package idresolver import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" ) // DevDirectory wraps a BaseDirectory and resolves .test TLD handles via a // PDS's com.atproto.identity.resolveHandle endpoint instead of the standard // DNS TXT / HTTP well-known methods which cannot work for non-routable TLDs. // // For all other handles and DIDs, it delegates to the underlying BaseDirectory. // This allows local development with real handle resolution (no handle.invalid) // while keeping normal resolution for production handles. type DevDirectory struct { inner *identity.BaseDirectory pdsURL string } var _ identity.Directory = (*DevDirectory)(nil) func (d *DevDirectory) LookupHandle(ctx context.Context, h syntax.Handle) (*identity.Identity, error) { h = h.Normalize() did, err := d.resolveHandle(ctx, h) if err != nil { return nil, err } doc, err := d.inner.ResolveDID(ctx, did) if err != nil { return nil, err } ident := identity.ParseIdentity(doc) declared, err := ident.DeclaredHandle() if err != nil { return nil, fmt.Errorf("could not verify handle/DID match: %w", err) } if declared != h { return nil, fmt.Errorf("handle mismatch: %s != %s", declared, h) } ident.Handle = declared return &ident, nil } func (d *DevDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) { doc, err := d.inner.ResolveDID(ctx, did) if err != nil { return nil, err } ident := identity.ParseIdentity(doc) declared, err := ident.DeclaredHandle() if err != nil { ident.Handle = syntax.HandleInvalid return &ident, nil } // Verify the declared handle resolves back to this DID. resolvedDID, err := d.resolveHandle(ctx, declared) if err != nil { ident.Handle = syntax.HandleInvalid } else if resolvedDID != did { ident.Handle = syntax.HandleInvalid } else { ident.Handle = declared } return &ident, nil } func (d *DevDirectory) Lookup(ctx context.Context, a syntax.AtIdentifier) (*identity.Identity, error) { handle, err := a.AsHandle() if nil == err { return d.LookupHandle(ctx, handle) } did, err := a.AsDID() if nil == err { return d.LookupDID(ctx, did) } return nil, fmt.Errorf("at-identifier neither a Handle nor a DID") } func (d *DevDirectory) Purge(ctx context.Context, atid syntax.AtIdentifier) error { return nil } // resolveHandle resolves a handle to a DID. For .test TLD handles, it queries // the configured PDS's com.atproto.identity.resolveHandle endpoint. For all // other handles, it delegates to the standard BaseDirectory resolution. func (d *DevDirectory) resolveHandle(ctx context.Context, h syntax.Handle) (syntax.DID, error) { if strings.HasSuffix(h.String(), ".test") { return d.resolveHandleViaPDS(ctx, h) } return d.inner.ResolveHandle(ctx, h) } // resolveHandleViaPDS calls com.atproto.identity.resolveHandle on the // configured PDS to resolve a handle that can't be resolved via DNS/HTTP. func (d *DevDirectory) resolveHandleViaPDS(ctx context.Context, h syntax.Handle) (syntax.DID, error) { url := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", d.pdsURL, h) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", fmt.Errorf("constructing PDS resolve request: %w", err) } resp, err := d.inner.HTTPClient.Do(req) if err != nil { return "", fmt.Errorf("PDS handle resolution failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("PDS handle resolution returned HTTP %d for %s", resp.StatusCode, h) } var body struct { DID string `json:"did"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", fmt.Errorf("invalid PDS resolve response: %w", err) } did, err := syntax.ParseDID(body.DID) if err != nil { return "", fmt.Errorf("invalid DID in PDS resolve response: %w", err) } return did, nil } // DefaultDevResolver returns a resolver for local development that resolves // .test TLD handles via a PDS endpoint. This enables proper handle resolution // for non-routable TLDs without skipping verification entirely. func DefaultDevResolver(plcUrl, pdsUrl string) *Resolver { base := BaseDirectory(plcUrl).(*identity.BaseDirectory) dir := &DevDirectory{inner: base, pdsURL: pdsUrl} cached := identity.NewCacheDirectory(dir, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) return &Resolver{ directory: &cached, } }