dev vouch dev on at. thats about it
atvouch.dev
1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net"
12 "net/http"
13 "net/url"
14 "os"
15 "os/exec"
16 "runtime"
17 "strings"
18 "time"
19
20 "github.com/bluesky-social/indigo/atproto/atclient"
21 "github.com/bluesky-social/indigo/atproto/auth/oauth"
22 "github.com/bluesky-social/indigo/atproto/syntax"
23 "github.com/spf13/cobra"
24)
25
26func main() {
27 rootCmd := &cobra.Command{
28 Use: "atvouch",
29 Short: "AT Protocol vouching tool",
30 }
31
32 loginCmd := &cobra.Command{
33 Use: "login <handle>",
34 Short: "Authenticate with your PDS via OAuth",
35 Args: cobra.ExactArgs(1),
36 RunE: func(cmd *cobra.Command, args []string) error {
37 return login(cmd.Context(), args[0])
38 },
39 }
40
41 meCmd := &cobra.Command{
42 Use: "me",
43 Short: "Show current authenticated session info",
44 Args: cobra.NoArgs,
45 RunE: func(cmd *cobra.Command, args []string) error {
46 return me(cmd.Context())
47 },
48 }
49
50 createCmd := &cobra.Command{
51 Use: "create <handle>",
52 Short: "Vouch for a user by their handle",
53 Args: cobra.ExactArgs(1),
54 RunE: func(cmd *cobra.Command, args []string) error {
55 return create(cmd.Context(), args[0])
56 },
57 }
58
59 checkCmd := &cobra.Command{
60 Use: "check <handle>",
61 Short: "Check vouch paths to a user",
62 Args: cobra.ExactArgs(1),
63 RunE: func(cmd *cobra.Command, args []string) error {
64 return check(cmd.Context(), args[0])
65 },
66 }
67
68 rootCmd.AddCommand(loginCmd, meCmd, createCmd, checkCmd)
69
70 if err := rootCmd.ExecuteContext(context.Background()); err != nil {
71 os.Exit(1)
72 }
73}
74
75func newStore() (*Store, error) {
76 return NewStore()
77}
78
79func newOAuthClient(store *Store, callbackURL string) *oauth.ClientApp {
80 config := oauth.NewLocalhostConfig(callbackURL, []string{
81 "atproto",
82 "repo:dev.atvouch.graph.vouch",
83 })
84 return oauth.NewClientApp(&config, store)
85}
86
87func login(ctx context.Context, handle string) error {
88 store, err := newStore()
89 if err != nil {
90 return err
91 }
92
93 // Start the callback server on a random available port
94 callbackCh := make(chan url.Values, 1)
95 port, server, err := listenForCallback(ctx, callbackCh)
96 if err != nil {
97 return err
98 }
99 defer server.Close()
100
101 callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
102 oauthClient := newOAuthClient(store, callbackURL)
103
104 // Start the OAuth flow
105 fmt.Printf("Logging in as %s...\n", handle)
106 authURL, err := oauthClient.StartAuthFlow(ctx, handle)
107 if err != nil {
108 return fmt.Errorf("starting auth flow: %w", err)
109 }
110
111 // Open the browser to the authorization URL
112 fmt.Printf("Opening browser...\n")
113 if !strings.HasPrefix(authURL, "https://") {
114 return fmt.Errorf("unexpected non-https auth URL")
115 }
116 if err := openBrowser(authURL); err != nil {
117 fmt.Printf("Could not open browser automatically.\nPlease visit: %s\n", authURL)
118 }
119
120 // Wait for the OAuth callback
121 fmt.Println("Waiting for authorization...")
122 params := <-callbackCh
123
124 // Exchange the authorization code for a session
125 sessData, err := oauthClient.ProcessCallback(ctx, params)
126 if err != nil {
127 return fmt.Errorf("processing callback: %w", err)
128 }
129
130 // Mark this as the active session
131 if err := store.SetActive(sessData.AccountDID, sessData.SessionID); err != nil {
132 return fmt.Errorf("saving active session: %w", err)
133 }
134
135 fmt.Printf("Logged in as %s (%s)\n", handle, sessData.AccountDID)
136 return nil
137}
138
139func resumeSession(ctx context.Context) (*oauth.ClientSession, error) {
140 store, err := newStore()
141 if err != nil {
142 return nil, err
143 }
144
145 active, err := store.GetActive()
146 if err != nil {
147 return nil, err
148 }
149
150 // We need a callback URL to construct the client config, but we won't
151 // actually start any auth flow here. Use a dummy port.
152 callbackURL := "http://127.0.0.1:0/callback"
153 oauthClient := newOAuthClient(store, callbackURL)
154
155 session, err := oauthClient.ResumeSession(ctx, active.DID, active.SessionID)
156 if err != nil {
157 return nil, fmt.Errorf("resuming session: %w", err)
158 }
159
160 return session, nil
161}
162
163func me(ctx context.Context) error {
164 session, err := resumeSession(ctx)
165 if err != nil {
166 return err
167 }
168
169 client := session.APIClient()
170 var resp json.RawMessage
171 if err := client.Get(ctx, "com.atproto.server.getSession", nil, &resp); err != nil {
172 return fmt.Errorf("fetching session: %w", err)
173 }
174
175 // Pretty-print the response
176 var pretty bytes.Buffer
177 json.Indent(&pretty, resp, "", " ")
178 fmt.Println(pretty.String())
179
180 return nil
181}
182
183func create(ctx context.Context, handle string) error {
184 session, err := resumeSession(ctx)
185 if err != nil {
186 return err
187 }
188
189 client := session.APIClient()
190
191 // Resolve handle to DID
192 var resolveResp struct {
193 DID string `json:"did"`
194 }
195 if err := client.Get(ctx, "com.atproto.identity.resolveHandle", map[string]any{
196 "handle": handle,
197 }, &resolveResp); err != nil {
198 return fmt.Errorf("resolving handle %q: %w", handle, err)
199 }
200
201 subjectDID, err := syntax.ParseDID(resolveResp.DID)
202 if err != nil {
203 return fmt.Errorf("invalid DID from resolution: %w", err)
204 }
205
206 // Check if a vouch already exists for this subject
207 var existingRecord struct {
208 URI string `json:"uri"`
209 Value any `json:"value"`
210 }
211 err = client.Get(ctx, "com.atproto.repo.getRecord", map[string]any{
212 "repo": session.Data.AccountDID,
213 "collection": "dev.atvouch.graph.vouch",
214 "rkey": subjectDID.String(),
215 }, &existingRecord)
216 if err == nil {
217 fmt.Printf("You have already vouched for %s (%s)\n", handle, subjectDID)
218 fmt.Printf("Record: %s\n", existingRecord.URI)
219 return nil
220 }
221
222 // Create the vouch record
223 record := map[string]any{
224 "$type": "dev.atvouch.graph.vouch",
225 "subject": subjectDID.String(),
226 "createdAt": time.Now().UTC().Format(time.RFC3339),
227 }
228
229 var createResp struct {
230 URI string `json:"uri"`
231 CID string `json:"cid"`
232 }
233 if err := client.Post(ctx, "com.atproto.repo.createRecord", map[string]any{
234 "repo": session.Data.AccountDID,
235 "collection": "dev.atvouch.graph.vouch",
236 "rkey": subjectDID.String(),
237 "record": record,
238 }, &createResp); err != nil {
239 return fmt.Errorf("creating vouch record: %w", err)
240 }
241
242 fmt.Printf("Vouched for %s (%s)\n", handle, subjectDID)
243 fmt.Printf("Record: %s\n", createResp.URI)
244
245 return nil
246}
247
248// checkDeps holds injectable dependencies for the check logic.
249type checkDeps struct {
250 myDID string
251 resolveHandle func(handle string) (string, error)
252 resolveDidToHandle func(did string) (string, error)
253 fetchVouchers func(targetDID string) ([]string, error)
254 listMyVouches func() ([]string, error)
255}
256
257// checkResult holds the output of a check operation.
258type checkResult struct {
259 targetDID string
260 paths [][]string // each path is a list of DIDs
261 handleMap map[string]string // DID -> handle
262}
263
264func check(ctx context.Context, handle string) error {
265 session, err := resumeSession(ctx)
266 if err != nil {
267 return err
268 }
269
270 client := session.APIClient()
271 myDID := session.Data.AccountDID.String()
272
273 deps := checkDeps{
274 myDID: myDID,
275 resolveHandle: slingshotResolveHandle,
276 resolveDidToHandle: slingshotResolveDidToHandle,
277 fetchVouchers: fetchVouchersFromMicrocosm,
278 listMyVouches: func() ([]string, error) {
279 return listVouchSubjects(ctx, client, myDID)
280 },
281 }
282
283 result, err := checkWithDeps(handle, deps)
284 if err != nil {
285 return err
286 }
287
288 fmt.Printf("Checking vouch paths to %s (%s)...\n", handle, result.targetDID)
289
290 if result.paths == nil {
291 fmt.Printf("\nyou -> %s\n", handle)
292 return nil
293 }
294
295 if len(result.paths) == 0 {
296 fmt.Println("no vouch routes found")
297 return nil
298 }
299
300 fmt.Printf("\nFound %d vouch route(s):\n", len(result.paths))
301 for _, path := range result.paths {
302 parts := make([]string, len(path))
303 for i, did := range path {
304 parts[i] = result.handleMap[did]
305 }
306 fmt.Println(strings.Join(parts, " -> "))
307 }
308
309 return nil
310}
311
312// checkWithDeps contains the core check logic with injected dependencies.
313// Returns a checkResult where paths == nil means direct vouch found,
314// paths == empty means no routes, otherwise contains discovered paths.
315func checkWithDeps(handle string, deps checkDeps) (*checkResult, error) {
316 targetDID, err := deps.resolveHandle(handle)
317 if err != nil {
318 return nil, fmt.Errorf("resolving handle %q: %w", handle, err)
319 }
320
321 myVouches, err := deps.listMyVouches()
322 if err != nil {
323 return nil, fmt.Errorf("fetching your vouches: %w", err)
324 }
325
326 // Direct vouch check (depth 1)
327 for _, did := range myVouches {
328 if did == targetDID {
329 return &checkResult{targetDID: targetDID, paths: nil}, nil
330 }
331 }
332
333 // Build reverse graph from target using microcosm (up to 3 levels back)
334 // reverseGraph[did] = set of DIDs that vouch for did
335 reverseGraph := make(map[string]map[string]bool)
336
337 // Level 1: who vouches for target
338 level1, err := deps.fetchVouchers(targetDID)
339 if err != nil {
340 return nil, fmt.Errorf("querying microcosm: %w", err)
341 }
342 reverseGraph[targetDID] = toSet(level1)
343
344 // Level 2: who vouches for each level-1 voucher
345 level2DIDs := []string{}
346 for _, did := range level1 {
347 vouchers, err := deps.fetchVouchers(did)
348 if err != nil {
349 return nil, fmt.Errorf("querying microcosm: %w", err)
350 }
351 reverseGraph[did] = toSet(vouchers)
352 level2DIDs = append(level2DIDs, vouchers...)
353 }
354
355 // Level 3: who vouches for each level-2 voucher
356 for _, did := range level2DIDs {
357 if _, exists := reverseGraph[did]; exists {
358 continue // already fetched
359 }
360 vouchers, err := deps.fetchVouchers(did)
361 if err != nil {
362 return nil, fmt.Errorf("querying microcosm: %w", err)
363 }
364 reverseGraph[did] = toSet(vouchers)
365 }
366
367 // Find all paths: me -> (someone I vouch for) -> ... -> target
368 myVouchSet := toSet(myVouches)
369 var paths [][]string
370
371 // Depth 2: me -> X -> target (X vouches for target, I vouch for X)
372 for voucher := range reverseGraph[targetDID] {
373 if myVouchSet[voucher] {
374 paths = append(paths, []string{deps.myDID, voucher, targetDID})
375 }
376 }
377
378 // Depth 3: me -> X -> Y -> target (Y vouches for target, X vouches for Y, I vouch for X)
379 for yDID := range reverseGraph[targetDID] {
380 for xDID := range reverseGraph[yDID] {
381 if myVouchSet[xDID] {
382 paths = append(paths, []string{deps.myDID, xDID, yDID, targetDID})
383 }
384 }
385 }
386
387 // Resolve all unique DIDs to handles for display
388 handleMap := make(map[string]string)
389 if len(paths) > 0 {
390 uniqueDIDs := make(map[string]bool)
391 for _, path := range paths {
392 for _, did := range path {
393 uniqueDIDs[did] = true
394 }
395 }
396
397 handleMap[targetDID] = handle // we already know this one
398 for did := range uniqueDIDs {
399 if _, exists := handleMap[did]; exists {
400 continue
401 }
402 resolved, err := deps.resolveDidToHandle(did)
403 if err != nil {
404 handleMap[did] = did // fallback to DID
405 } else {
406 handleMap[did] = resolved
407 }
408 }
409 }
410
411 return &checkResult{
412 targetDID: targetDID,
413 paths: paths,
414 handleMap: handleMap,
415 }, nil
416}
417
418// listVouchSubjects returns the DIDs that the given repo has vouched for.
419func listVouchSubjects(ctx context.Context, client *atclient.APIClient, repo string) ([]string, error) {
420 var subjects []string
421 var cursor string
422
423 for {
424 params := map[string]any{
425 "repo": repo,
426 "collection": "dev.atvouch.graph.vouch",
427 "limit": 100,
428 }
429 if cursor != "" {
430 params["cursor"] = cursor
431 }
432
433 var resp struct {
434 Records []struct {
435 Value struct {
436 Subject string `json:"subject"`
437 } `json:"value"`
438 } `json:"records"`
439 Cursor *string `json:"cursor"`
440 }
441
442 if err := client.Get(ctx, "com.atproto.repo.listRecords", params, &resp); err != nil {
443 return nil, err
444 }
445
446 for _, rec := range resp.Records {
447 if rec.Value.Subject != "" {
448 subjects = append(subjects, rec.Value.Subject)
449 }
450 }
451
452 if resp.Cursor == nil || *resp.Cursor == "" {
453 break
454 }
455 cursor = *resp.Cursor
456 }
457
458 return subjects, nil
459}
460
461// fetchVouchersFromMicrocosm returns DIDs that have vouched for the given target DID.
462func fetchVouchersFromMicrocosm(targetDID string) ([]string, error) {
463 u := "https://constellation.microcosm.blue/links/distinct-dids?" + url.Values{
464 "target": {targetDID},
465 "collection": {"dev.atvouch.graph.vouch"},
466 "path": {".subject"},
467 }.Encode()
468
469 req, err := http.NewRequest("GET", u, nil)
470 if err != nil {
471 return nil, err
472 }
473 req.Header.Set("Accept", "application/json")
474
475 resp, err := http.DefaultClient.Do(req)
476 if err != nil {
477 return nil, err
478 }
479 defer resp.Body.Close()
480
481 body, err := io.ReadAll(resp.Body)
482 if err != nil {
483 return nil, err
484 }
485
486 if resp.StatusCode != 200 {
487 return nil, fmt.Errorf("microcosm returned %d: %s", resp.StatusCode, string(body))
488 }
489
490 var result struct {
491 LinkingDIDs []string `json:"linking_dids"`
492 }
493 if err := json.Unmarshal(body, &result); err != nil {
494 return nil, fmt.Errorf("parsing microcosm response: %w", err)
495 }
496
497 return result.LinkingDIDs, nil
498}
499
500// slingshotResolveHandle resolves a handle to a DID via slingshot.
501func slingshotResolveHandle(handle string) (string, error) {
502 u := "https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle?" + url.Values{
503 "handle": {handle},
504 }.Encode()
505
506 resp, err := http.Get(u)
507 if err != nil {
508 return "", err
509 }
510 defer resp.Body.Close()
511
512 if resp.StatusCode != 200 {
513 return "", fmt.Errorf("slingshot returned %d", resp.StatusCode)
514 }
515
516 var result struct {
517 DID string `json:"did"`
518 }
519 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
520 return "", err
521 }
522 return result.DID, nil
523}
524
525// slingshotResolveDidToHandle resolves a DID to a handle via slingshot.
526func slingshotResolveDidToHandle(did string) (string, error) {
527 u := "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?" + url.Values{
528 "identifier": {did},
529 }.Encode()
530
531 resp, err := http.Get(u)
532 if err != nil {
533 return "", err
534 }
535 defer resp.Body.Close()
536
537 if resp.StatusCode != 200 {
538 return "", fmt.Errorf("slingshot returned %d", resp.StatusCode)
539 }
540
541 var result struct {
542 Handle string `json:"handle"`
543 }
544 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
545 return "", err
546 }
547 return result.Handle, nil
548}
549
550func toSet(items []string) map[string]bool {
551 s := make(map[string]bool, len(items))
552 for _, item := range items {
553 s[item] = true
554 }
555 return s
556}
557
558func listenForCallback(ctx context.Context, res chan url.Values) (int, *http.Server, error) {
559 listener, err := net.Listen("tcp", "127.0.0.1:0")
560 if err != nil {
561 return 0, nil, err
562 }
563
564 mux := http.NewServeMux()
565 server := &http.Server{Handler: mux}
566
567 mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
568 res <- r.URL.Query()
569 w.Header().Set("Content-Type", "text/html")
570 w.WriteHeader(200)
571 w.Write([]byte("<h1>Authorized! You can close this tab.</h1>"))
572 go server.Shutdown(ctx)
573 })
574
575 go func() {
576 if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
577 log.Fatal(err)
578 }
579 }()
580
581 return listener.Addr().(*net.TCPAddr).Port, server, nil
582}
583
584func openBrowser(url string) error {
585 switch runtime.GOOS {
586 case "darwin":
587 return exec.Command("open", url).Run()
588 case "windows":
589 return exec.Command("cmd", "/c", "start", url).Run()
590 default:
591 return exec.Command("xdg-open", url).Run()
592 }
593}