Files
at-container-registry/pkg/atproto/resolver.go
T
Evan JarrettandClaude Opus 5 27ce122db0 auth: stop logging an unresolvable hold DID at ERROR
A hold DID that can never resolve is a property of stored user data, not a
fault on our side. The value comes from a user's own sailor profile
defaultHold, so any account can choose the appview's ERROR volume, and
nothing is cached on the failure path, so it re-logs on every request for
that user.

On production this was not a rounding error: two accounts pointing at
did:web:localhost%3A8080 produced 2956 of 2958 ERROR lines over seven days,
99.9%. The genuine rate underneath was about two a day, which made
level=ERROR useless as a signal or an alert threshold.

Classify at the resolution boundary instead of string-matching prose.
ErrHoldDIDPermanent marks a malformed identifier or a missing DID document;
those log at DEBUG while everything an operator could act on stays at ERROR.
didWebHostUnusable is conservative on purpose: it only claims the cases we
are sure about (percent-encoded ports, bare IPs, localhost), so an
unfamiliar failure stays loud rather than being quietly swallowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
2026-09-02 12:44:48 -05:00

314 lines
11 KiB
Go

package atproto
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// ErrHoldDIDPermanent marks a hold-DID resolution failure that no retry can
// fix: a malformed identifier, or a DID whose document does not exist. It is a
// fact about stored data — typically a stale or hand-edited sailor profile
// defaultHold — rather than a fault on our side, so callers can log it quietly
// instead of at ERROR and avoid burying real faults. Transient failures (a PLC
// blip, a network error) deliberately do not carry it.
var ErrHoldDIDPermanent = errors.New("hold DID is not resolvable")
// didWebHostUnusable reports whether a did:web identifier names something the
// identity directory can never resolve. The directory requires a plain
// hostname and rejects ports (percent-encoded as %3A), bare IPs, and
// localhost. Detecting those here, rather than string-matching the directory's
// error prose, is what lets ResolveHoldDIDToURL classify the failure as
// permanent. Conservative by design: anything not clearly hopeless is left to
// the transient path, so an unfamiliar failure still surfaces loudly.
func didWebHostUnusable(did string) bool {
host, ok := strings.CutPrefix(did, "did:web:")
if !ok {
return false
}
if i := strings.IndexByte(host, ':'); i != -1 { // path-qualified did:web
host = host[:i]
}
if strings.Contains(host, "%3A") || strings.Contains(host, "%3a") {
return true // a port survived encoding, e.g. localhost%3A8080
}
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
return net.ParseIP(host) != nil
}
// holdDIDResolveClient bounds the /.well-known/atproto-did fetch so an
// unreachable or slow hold can't stall /auth/token (this resolution runs on the
// hot gate + fetch path) past Docker's token-fetch timeout. It's a plain
// idempotent GET, so a hard timeout is safe.
var holdDIDResolveClient = &http.Client{Timeout: 10 * time.Second}
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL.
// For DIDs (both did:web and did:plc), resolves via the indigo identity directory
// which caches results (24h TTL). Prefers the #atcr_hold service endpoint,
// falls back to #atproto_pds.
//
// Supported formats:
// - URL: https://hold.example.com → passthrough
// - DID: did:web:hold01.atcr.io → resolved via /.well-known/did.json
// - DID: did:plc:abc123 → resolved via PLC directory
func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error) {
// If it's already a URL (has scheme), return as-is
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
return holdIdentifier, nil
}
// If it's a DID, resolve via identity directory
if strings.HasPrefix(holdIdentifier, "did:") {
return ResolveHoldDIDToURL(ctx, holdIdentifier)
}
// Fallback: assume it's a hostname and use HTTPS
return "https://" + holdIdentifier, nil
}
// ResolveHoldDID resolves a hold identifier (DID, URL, or hostname) to its actual DID.
// If the input is already a DID, it is returned as-is.
// If the input is a URL or hostname, the hold's /.well-known/atproto-did endpoint is
// fetched to discover the real DID (which may be did:web or did:plc).
func ResolveHoldDID(ctx context.Context, holdIdentifier string) (string, error) {
if holdIdentifier == "" {
return "", fmt.Errorf("empty hold identifier")
}
// If already a DID, return as-is
if _, err := syntax.ParseDID(holdIdentifier); err == nil {
return holdIdentifier, nil
}
// Normalize to a full URL
holdURL := holdIdentifier
if !strings.HasPrefix(holdURL, "http://") && !strings.HasPrefix(holdURL, "https://") {
holdURL = "https://" + holdURL
}
holdURL = strings.TrimSuffix(holdURL, "/")
// Fetch /.well-known/atproto-did to discover the hold's actual DID
req, err := http.NewRequestWithContext(ctx, "GET", holdURL+"/.well-known/atproto-did", nil)
if err != nil {
return "", fmt.Errorf("failed to create request for hold DID resolution: %w", err)
}
resp, err := holdDIDResolveClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch hold DID from %s: %w", holdURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("hold at %s returned status %d for DID resolution", holdURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
if err != nil {
return "", fmt.Errorf("failed to read hold DID response: %w", err)
}
did := strings.TrimSpace(string(body))
if _, err := syntax.ParseDID(did); err != nil {
return "", fmt.Errorf("hold at %s returned invalid DID: %q", holdURL, did)
}
return did, nil
}
// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
// Prefers the #atcr_hold service endpoint, falls back to #atproto_pds.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) {
directory := GetDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid hold DID %q: %w: %w", did, ErrHoldDIDPermanent, err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
// In test mode, fall back to deriving URL directly from did:web.
// The indigo directory hardcodes HTTPS and rejects IPs/ports,
// so local dev (HTTP, IP:port) always needs this fallback.
if testMode && strings.HasPrefix(did, "did:web:") {
return DIDWebToURL(did), nil
}
// A missing DID document or a structurally unusable did:web will fail
// identically on every retry, so mark it permanent and let callers pick
// a quieter log level.
if errors.Is(err, identity.ErrDIDNotFound) || didWebHostUnusable(did) {
return "", fmt.Errorf("failed to resolve hold DID %s: %w: %w", did, ErrHoldDIDPermanent, err)
}
return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err)
}
// Prefer #atcr_hold service (hold-specific endpoint)
if url := ident.GetServiceEndpoint("atcr_hold"); url != "" {
return url, nil
}
// Fall back to #atproto_pds (hold publishes both with same URL)
if url := ident.PDSEndpoint(); url != "" {
return url, nil
}
return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
}
// HasHoldService reports whether a DID's identity document advertises an
// #atcr_hold service endpoint, i.e. whether the DID actually runs a hold
// service. Any ATProto account can publish io.atcr.hold.captain records, but
// only real holds publish the atcr_hold service in their DID document — use
// this to verify captain records discovered on the network before caching.
// Resolution goes through the shared identity directory (cached, 24h TTL).
func HasHoldService(ctx context.Context, did string) (bool, error) {
didParsed, err := syntax.ParseDID(did)
if err != nil {
return false, fmt.Errorf("invalid hold DID %q: %w", did, err)
}
ident, err := GetDirectory().LookupDID(ctx, didParsed)
if err != nil {
// In test mode, local did:web identifiers (HTTP, IP:port) are not
// resolvable by the indigo directory at all — trust them, matching
// the ResolveHoldDIDToURL fallback.
if testMode && strings.HasPrefix(did, "did:web:") {
return true, nil
}
return false, fmt.Errorf("failed to resolve DID %s: %w", did, err)
}
return ident.GetServiceEndpoint("atcr_hold") != "", nil
}
// NormalizeDID ensures did:web DIDs use %3A encoding for port separators
// per the did:web spec. Other DID methods are returned as-is.
// e.g., "did:web:172.28.0.3:8080" → "did:web:172.28.0.3%3A8080"
func NormalizeDID(did string) string {
if !strings.HasPrefix(did, "did:web:") {
return did
}
host := strings.TrimPrefix(did, "did:web:")
// Only fix bare colons — skip if already percent-encoded
if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
host = strings.Replace(host, ":", "%3A", 1)
}
return "did:web:" + host
}
// DIDWebToURL converts a did:web DID to its base URL.
// did:web:example.com → https://example.com
// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
func DIDWebToURL(did string) string {
host := strings.TrimPrefix(did, "did:web:")
host = strings.ReplaceAll(host, "%3A", ":")
scheme := "https"
if strings.Contains(host, ":") {
scheme = "http"
}
return scheme + "://" + host
}
// ResolveDIDToPDS resolves a DID to its PDS endpoint.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
directory := GetDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return "", fmt.Errorf("failed to resolve DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return "", fmt.Errorf("no PDS endpoint found for DID")
}
return pdsEndpoint, nil
}
// ResolveIdentity resolves an ATProto identifier (handle or DID) to DID, handle, and PDS endpoint.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
//
// If the handle is invalid (handle.invalid), it returns the DID as the handle for display purposes.
// Returns: did, handle, pdsEndpoint, error
func ResolveIdentity(ctx context.Context, identifier string) (string, string, string, error) {
directory := GetDirectory()
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
}
ident, err := directory.Lookup(ctx, atID)
if err != nil {
return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
}
did := ident.DID.String()
handle := ident.Handle.String()
pdsEndpoint := ident.PDSEndpoint()
// If handle is invalid, use DID as display name
if handle == "handle.invalid" || handle == "" {
handle = did
}
// PDS endpoint is required for XRPC calls
if pdsEndpoint == "" {
return "", "", "", fmt.Errorf("no PDS endpoint found for identifier %q", identifier)
}
return did, handle, pdsEndpoint, nil
}
// ResolveHandleToDID resolves a handle or DID to just the DID.
// Uses the shared identity directory with cache TTL and event-driven invalidation.
// This is useful when you only need the DID and don't care about handle/PDS.
func ResolveHandleToDID(ctx context.Context, identifier string) (string, error) {
directory := GetDirectory()
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return "", fmt.Errorf("invalid identifier: %w", err)
}
ident, err := directory.Lookup(ctx, atID)
if err != nil {
return "", err
}
return ident.DID.String(), nil
}
// InvalidateIdentity purges cached identity data for a DID or handle.
// This should be called when identity changes are detected (e.g., via Jetstream events)
// to ensure the cache is refreshed on the next lookup.
//
// Use cases:
// - Handle changes (identity events from Jetstream)
// - Account deactivation/migration (account events from Jetstream)
// - PDS migrations (deactivation followed by reactivation at new PDS)
func InvalidateIdentity(ctx context.Context, identifier string) error {
directory := GetDirectory()
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return fmt.Errorf("invalid identifier for cache invalidation: %w", err)
}
return directory.Purge(ctx, atID)
}