+78
-13
bundle/manager.go
+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
+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
+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
+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
+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
+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
+
}