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. // // A `-tags testmode` build resolves exactly these identifiers over plain HTTP // (see indigo_local.go), so there a failure means the local service is down // rather than unresolvable; the quieter classification is harmless in dev. 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 { // 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 { 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 } // 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) }