[DEPRECATED] Go implementation of plcbundle

handle resolver

Changed files
+298 -57
bundle
cmd
plcbundle
internal
handleresolver
+78 -13
bundle/manager.go
··· 9 9 "path/filepath" 10 10 "runtime" 11 11 "sort" 12 + "strings" 12 13 "sync" 13 14 "time" 14 15 15 16 "tangled.org/atscan.net/plcbundle/internal/bundleindex" 16 17 "tangled.org/atscan.net/plcbundle/internal/didindex" 18 + "tangled.org/atscan.net/plcbundle/internal/handleresolver" 17 19 "tangled.org/atscan.net/plcbundle/internal/mempool" 18 20 "tangled.org/atscan.net/plcbundle/internal/plcclient" 19 21 "tangled.org/atscan.net/plcbundle/internal/storage" ··· 38 40 operations *storage.Operations 39 41 index *bundleindex.Index 40 42 indexPath string 41 - plcClient *plcclient.Client 42 43 logger types.Logger 43 44 mempool *mempool.Mempool 44 45 didIndex *didindex.Manager 46 + 47 + plcClient *plcclient.Client 48 + handleResolver *handleresolver.Client 45 49 46 50 syncer *internalsync.Fetcher 47 51 cloner *internalsync.Cloner ··· 291 295 fetcher := internalsync.NewFetcher(plcClient, ops, config.Logger) 292 296 cloner := internalsync.NewCloner(ops, config.BundleDir, config.Logger) 293 297 298 + // Initialize handle resolver if configured 299 + var handleResolver *handleresolver.Client 300 + if config.HandleResolverURL != "" { 301 + handleResolver = handleresolver.NewClient(config.HandleResolverURL) 302 + config.Logger.Printf("Handle resolver configured: %s", config.HandleResolverURL) 303 + } 304 + 294 305 return &Manager{ 295 - config: config, 296 - operations: ops, 297 - index: index, 298 - indexPath: indexPath, 299 - plcClient: plcClient, 300 - logger: config.Logger, 301 - mempool: mempool, 302 - didIndex: didIndex, // Updated type 303 - bundleCache: make(map[int]*Bundle), 304 - maxCacheSize: 10, 305 - syncer: fetcher, 306 - cloner: cloner, 306 + config: config, 307 + operations: ops, 308 + index: index, 309 + indexPath: indexPath, 310 + logger: config.Logger, 311 + mempool: mempool, 312 + didIndex: didIndex, // Updated type 313 + bundleCache: make(map[int]*Bundle), 314 + maxCacheSize: 10, 315 + syncer: fetcher, 316 + cloner: cloner, 317 + plcClient: plcClient, 318 + handleResolver: handleResolver, 307 319 }, nil 308 320 } 309 321 ··· 1471 1483 1472 1484 return len(bundleFiles) > 0 1473 1485 } 1486 + 1487 + // ResolveHandleOrDID resolves input that can be either a handle or DID 1488 + // Returns: (did, handleResolveTime, error) 1489 + func (m *Manager) ResolveHandleOrDID(ctx context.Context, input string) (string, time.Duration, error) { 1490 + input = strings.TrimSpace(input) 1491 + 1492 + // Normalize handle format (remove at://, @ prefixes) 1493 + if !strings.HasPrefix(input, "did:") { 1494 + input = strings.TrimPrefix(input, "at://") 1495 + input = strings.TrimPrefix(input, "@") 1496 + } 1497 + 1498 + // If already a DID, validate and return 1499 + if strings.HasPrefix(input, "did:plc:") { 1500 + if err := plcclient.ValidateDIDFormat(input); err != nil { 1501 + return "", 0, err 1502 + } 1503 + return input, 0, nil // ✅ No resolution needed 1504 + } 1505 + 1506 + // Support did:web too 1507 + if strings.HasPrefix(input, "did:web:") { 1508 + return input, 0, nil 1509 + } 1510 + 1511 + // It's a handle - need resolver 1512 + if m.handleResolver == nil { 1513 + return "", 0, fmt.Errorf( 1514 + "input '%s' appears to be a handle, but handle resolver is not configured\n\n"+ 1515 + "Configure resolver with:\n"+ 1516 + " plcbundle --handle-resolver https://quickdid.smokesignal.tools did resolve %s\n\n"+ 1517 + "Or set default in config", 1518 + input, input) 1519 + } 1520 + 1521 + // ✨ TIME THE RESOLUTION 1522 + resolveStart := time.Now() 1523 + m.logger.Printf("Resolving handle: %s", input) 1524 + did, err := m.handleResolver.ResolveHandle(ctx, input) 1525 + resolveTime := time.Since(resolveStart) 1526 + 1527 + if err != nil { 1528 + return "", resolveTime, fmt.Errorf("failed to resolve handle '%s': %w", input, err) 1529 + } 1530 + 1531 + m.logger.Printf("Resolved: %s → %s (in %s)", input, did, resolveTime) 1532 + return did, resolveTime, nil 1533 + } 1534 + 1535 + // GetHandleResolver returns the handle resolver (can be nil) 1536 + func (m *Manager) GetHandleResolver() *handleresolver.Client { 1537 + return m.handleResolver 1538 + }
+25 -22
bundle/types.go
··· 119 119 120 120 // Config holds configuration for bundle operations 121 121 type Config struct { 122 - BundleDir string 123 - VerifyOnLoad bool 124 - AutoRebuild bool 125 - AutoInit bool // Allow auto-creating empty repository 126 - RebuildWorkers int // Number of workers for parallel rebuild (0 = auto-detect) 127 - RebuildProgress func(current, total int) // Progress callback for rebuild 128 - Logger types.Logger 122 + BundleDir string 123 + HandleResolverURL string 124 + VerifyOnLoad bool 125 + AutoRebuild bool 126 + AutoInit bool // Allow auto-creating empty repository 127 + RebuildWorkers int // Number of workers for parallel rebuild (0 = auto-detect) 128 + RebuildProgress func(current, total int) // Progress callback for rebuild 129 + Logger types.Logger 129 130 } 130 131 131 132 // DefaultConfig returns default configuration 132 133 func DefaultConfig(bundleDir string) *Config { 133 134 return &Config{ 134 - BundleDir: bundleDir, 135 - VerifyOnLoad: true, 136 - AutoRebuild: true, 137 - AutoInit: false, 138 - RebuildWorkers: 0, // 0 means auto-detect CPU count 139 - RebuildProgress: nil, // No progress callback by default 140 - Logger: nil, 135 + BundleDir: bundleDir, 136 + HandleResolverURL: "https://quickdid.atscan.net", 137 + VerifyOnLoad: true, 138 + AutoRebuild: true, 139 + AutoInit: false, 140 + RebuildWorkers: 0, // 0 means auto-detect CPU count 141 + RebuildProgress: nil, // No progress callback by default 142 + Logger: nil, 141 143 } 142 144 } 143 145 ··· 191 193 192 194 // ResolveDIDResult contains DID resolution with timing metrics 193 195 type ResolveDIDResult struct { 194 - Document *plcclient.DIDDocument 195 - MempoolTime time.Duration 196 - IndexTime time.Duration 197 - LoadOpTime time.Duration 198 - TotalTime time.Duration 199 - Source string // "mempool" or "bundle" 200 - BundleNumber int // if from bundle 201 - Position int // if from bundle 196 + Document *plcclient.DIDDocument 197 + MempoolTime time.Duration 198 + IndexTime time.Duration 199 + LoadOpTime time.Duration 200 + TotalTime time.Duration 201 + ResolvedHandle string 202 + Source string // "mempool" or "bundle" 203 + BundleNumber int // if from bundle 204 + Position int // if from bundle 202 205 }
+16 -4
cmd/plcbundle/commands/common.go
··· 47 47 GetBundleIndex() didindex.BundleIndexProvider 48 48 ScanDirectoryParallel(workers int, progressCallback func(current, total int, bytesProcessed int64)) (*bundle.DirectoryScanResult, error) 49 49 LoadBundleForDIDIndex(ctx context.Context, bundleNumber int) (*didindex.BundleData, error) 50 + ResolveHandleOrDID(ctx context.Context, input string) (string, time.Duration, error) 50 51 } 51 52 52 53 // PLCOperationWithLocation wraps operation with location info ··· 58 59 59 60 // ManagerOptions configures manager creation 60 61 type ManagerOptions struct { 61 - Cmd *cobra.Command // Optional: for reading --dir flag 62 - Dir string // Optional: explicit directory (overrides Cmd flag and cwd) 63 - PLCURL string // Optional: PLC directory URL 64 - AutoInit bool // Optional: allow creating new empty repository (default: false) 62 + Cmd *cobra.Command // Optional: for reading --dir flag 63 + Dir string // Optional: explicit directory (overrides Cmd flag and cwd) 64 + PLCURL string // Optional: PLC directory URL 65 + HandleResolverURL string // Optional: Handle resolver URL (XRPC) 66 + AutoInit bool // Optional: allow creating new empty repository (default: false) 65 67 } 66 68 67 69 // ============================================================================ ··· 118 120 var client *plcclient.Client 119 121 if opts.PLCURL != "" { 120 122 client = plcclient.NewClient(opts.PLCURL) 123 + } 124 + 125 + // Set handle resolver URL from flag or option 126 + handleResolverURL := opts.HandleResolverURL 127 + if handleResolverURL == "" && opts.Cmd != nil { 128 + handleResolverURL, _ = opts.Cmd.Root().PersistentFlags().GetString("handle-resolver") // ✅ Fixed flag name 129 + } 130 + // Only override default if explicitly provided 131 + if handleResolverURL != "" { 132 + config.HandleResolverURL = handleResolverURL 121 133 } 122 134 123 135 // Create manager
+52 -18
cmd/plcbundle/commands/did.go
··· 61 61 ) 62 62 63 63 cmd := &cobra.Command{ 64 - Use: "lookup <did>", 64 + Use: "lookup <did|handle>", 65 65 Aliases: []string{"find", "get"}, 66 - Short: "Find all operations for a DID", 67 - Long: `Find all operations for a DID 66 + Short: "Find all operations for a DID or handle", 67 + Long: `Find all operations for a DID or handle 68 68 69 - Retrieves all operations (both bundled and mempool) for a specific DID, 70 - showing bundle locations, timestamps, and nullification status. 69 + Retrieves all operations (both bundled and mempool) for a specific DID. 70 + Accepts either: 71 + • DID: did:plc:524tuhdhh3m7li5gycdn6boe 72 + • Handle: tree.fail (resolves via configured resolver) 71 73 72 74 Requires DID index to be built. If not available, will fall back to 73 75 full scan (slow).`, 74 76 75 - Example: ` # Lookup DID operations 77 + Example: ` # Lookup by DID 76 78 plcbundle did lookup did:plc:524tuhdhh3m7li5gycdn6boe 77 79 78 - # Verbose output with timing 79 - plcbundle did lookup did:plc:524tuhdhh3m7li5gycdn6boe -v 80 + # Lookup by handle (requires --resolver-url) 81 + plcbundle did lookup tree.fail 82 + plcbundle did lookup ngerakines.me 80 83 81 - # JSON output 82 - plcbundle did lookup did:plc:524tuhdhh3m7li5gycdn6boe --json 83 - 84 - # Using alias 85 - plcbundle did find did:plc:524tuhdhh3m7li5gycdn6boe`, 84 + # With non-default handle resolver configured 85 + plcbundle --handle-resolver https://quickdid.smokesignal.tools did lookup tree.fail`, 86 86 87 87 Args: cobra.ExactArgs(1), 88 88 89 89 RunE: func(cmd *cobra.Command, args []string) error { 90 - did := args[0] 90 + input := args[0] 91 91 92 92 mgr, _, err := getManager(&ManagerOptions{Cmd: cmd}) 93 93 if err != nil { ··· 95 95 } 96 96 defer mgr.Close() 97 97 98 + // ✨ Resolve handle to DID with timing 99 + ctx := context.Background() 100 + did, handleResolveTime, err := mgr.ResolveHandleOrDID(ctx, input) 101 + if err != nil { 102 + return err 103 + } 104 + 105 + // Show what we resolved to (if it was a handle) 106 + if input != did && !showJSON { 107 + fmt.Fprintf(os.Stderr, "Resolved handle '%s' → %s (in %s)\n\n", 108 + input, did, handleResolveTime) 109 + } 110 + 98 111 stats := mgr.GetDIDIndexStats() 99 112 if !stats["exists"].(bool) { 100 113 fmt.Fprintf(os.Stderr, "⚠️ DID index not found. Run: plcbundle index build\n") ··· 102 115 } 103 116 104 117 totalStart := time.Now() 105 - ctx := context.Background() 106 118 107 119 // Lookup operations 108 120 lookupStart := time.Now() ··· 178 190 plcbundle did resolve did:plc:524tuhdhh3m7li5gycdn6boe --raw 179 191 180 192 # Pipe to jq 181 - plcbundle did resolve did:plc:524tuhdhh3m7li5gycdn6boe | jq .service`, 193 + plcbundle did resolve did:plc:524tuhdhh3m7li5gycdn6boe | jq .service 194 + 195 + # Resolve by handle 196 + plcbundle did resolve tree.fail`, 182 197 183 198 Args: cobra.ExactArgs(1), 184 199 185 200 RunE: func(cmd *cobra.Command, args []string) error { 186 - did := args[0] 201 + input := args[0] 187 202 188 203 mgr, _, err := getManager(&ManagerOptions{Cmd: cmd}) 189 204 if err != nil { ··· 193 208 194 209 ctx := context.Background() 195 210 211 + // ✨ Resolve handle to DID with timing 212 + did, handleResolveTime, err := mgr.ResolveHandleOrDID(ctx, input) 213 + if err != nil { 214 + return err 215 + } 216 + 217 + // Show resolution timing if it was a handle 218 + if input != did { 219 + if showTiming { 220 + fmt.Fprintf(os.Stderr, "Handle resolution: %s → %s (%s)\n", 221 + input, did, handleResolveTime) 222 + } else { 223 + fmt.Fprintf(os.Stderr, "Resolved handle '%s' → %s\n", input, did) 224 + } 225 + } 226 + 196 227 if showTiming { 197 - fmt.Fprintf(os.Stderr, "Resolving: %s\n", did) 228 + fmt.Fprintf(os.Stderr, "Resolving DID: %s\n", did) 198 229 } 199 230 200 231 if verbose { ··· 208 239 209 240 // Display timing if requested 210 241 if showTiming { 242 + if handleResolveTime > 0 { 243 + fmt.Fprintf(os.Stderr, "Handle: %s | ", handleResolveTime) 244 + } 211 245 if result.Source == "mempool" { 212 246 fmt.Fprintf(os.Stderr, "Mempool check: %s (✓ found)\n", result.MempoolTime) 213 247 fmt.Fprintf(os.Stderr, "Total: %s\n\n", result.TotalTime)
+2
cmd/plcbundle/main.go
··· 41 41 cmd.PersistentFlags().StringP("dir", "C", "", "Repository directory (default: current directory)") 42 42 cmd.PersistentFlags().BoolP("verbose", "v", false, "Show detailed output and progress") 43 43 cmd.PersistentFlags().BoolP("quiet", "q", false, "Suppress non-error output") 44 + cmd.PersistentFlags().String("handle-resolver", "", 45 + "Handle resolver URL (e.g., https://quickdid.smokesignal.tools)") 44 46 45 47 // Bundle operations (root level - most common) 46 48 cmd.AddCommand(commands.NewSyncCommand())
+125
internal/handleresolver/resolver.go
··· 1 + package handleresolver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "regexp" 10 + "strings" 11 + "time" 12 + 13 + "github.com/goccy/go-json" 14 + ) 15 + 16 + // Client resolves AT Protocol handles to DIDs via XRPC 17 + type Client struct { 18 + baseURL string 19 + httpClient *http.Client 20 + } 21 + 22 + // NewClient creates a new handle resolver client 23 + func NewClient(baseURL string) *Client { 24 + return &Client{ 25 + baseURL: strings.TrimSuffix(baseURL, "/"), 26 + httpClient: &http.Client{ 27 + Timeout: 10 * time.Second, 28 + }, 29 + } 30 + } 31 + 32 + // ResolveHandle resolves a handle to a DID using com.atproto.identity.resolveHandle 33 + func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 34 + // Validate handle format 35 + if err := ValidateHandleFormat(handle); err != nil { 36 + return "", err 37 + } 38 + 39 + // Build XRPC URL 40 + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle", c.baseURL) 41 + 42 + // Add query parameter 43 + params := url.Values{} 44 + params.Add("handle", handle) 45 + fullURL := fmt.Sprintf("%s?%s", endpoint, params.Encode()) 46 + 47 + // Create request 48 + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) 49 + if err != nil { 50 + return "", fmt.Errorf("failed to create request: %w", err) 51 + } 52 + 53 + // Execute request 54 + resp, err := c.httpClient.Do(req) 55 + if err != nil { 56 + return "", fmt.Errorf("failed to resolve handle: %w", err) 57 + } 58 + defer resp.Body.Close() 59 + 60 + if resp.StatusCode != http.StatusOK { 61 + body, _ := io.ReadAll(resp.Body) 62 + return "", fmt.Errorf("resolver returned status %d: %s", resp.StatusCode, string(body)) 63 + } 64 + 65 + // Parse response 66 + var result struct { 67 + DID string `json:"did"` 68 + } 69 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 70 + return "", fmt.Errorf("failed to parse response: %w", err) 71 + } 72 + 73 + if result.DID == "" { 74 + return "", fmt.Errorf("resolver returned empty DID") 75 + } 76 + 77 + // Validate returned DID 78 + if !strings.HasPrefix(result.DID, "did:plc:") && !strings.HasPrefix(result.DID, "did:web:") { 79 + return "", fmt.Errorf("invalid DID format returned: %s", result.DID) 80 + } 81 + 82 + return result.DID, nil 83 + } 84 + 85 + // ValidateHandleFormat validates AT Protocol handle format 86 + func ValidateHandleFormat(handle string) error { 87 + if handle == "" { 88 + return fmt.Errorf("handle cannot be empty") 89 + } 90 + 91 + // Handle can't be a DID 92 + if strings.HasPrefix(handle, "did:") { 93 + return fmt.Errorf("input is already a DID, not a handle") 94 + } 95 + 96 + // Basic length check 97 + if len(handle) > 253 { 98 + return fmt.Errorf("handle too long (max 253 chars)") 99 + } 100 + 101 + // Must have at least one dot (domain.tld) 102 + if !strings.Contains(handle, ".") { 103 + return fmt.Errorf("handle must be a domain (e.g., user.bsky.social)") 104 + } 105 + 106 + // Valid handle pattern (simplified - matches AT Protocol spec) 107 + validPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 108 + if !validPattern.MatchString(handle) { 109 + return fmt.Errorf("invalid handle format") 110 + } 111 + 112 + return nil 113 + } 114 + 115 + // IsHandle checks if a string looks like a handle (not a DID) 116 + func IsHandle(input string) bool { 117 + return !strings.HasPrefix(input, "did:") 118 + } 119 + 120 + // NormalizeHandle normalizes handle format (removes at:// prefix if present) 121 + func NormalizeHandle(handle string) string { 122 + handle = strings.TrimPrefix(handle, "at://") 123 + handle = strings.TrimPrefix(handle, "@") 124 + return handle 125 + }