Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

at icy/yovxsu 19 kB View raw
1package knots 2 3import ( 4 "context" 5 "errors" 6 "fmt" 7 "log/slog" 8 "net/http" 9 "strings" 10 "time" 11 12 "github.com/go-chi/chi/v5" 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/config" 15 "tangled.org/core/appview/db" 16 "tangled.org/core/appview/knotacl" 17 "tangled.org/core/appview/knotcompat" 18 "tangled.org/core/appview/middleware" 19 "tangled.org/core/appview/models" 20 "tangled.org/core/appview/oauth" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/serververify" 23 "tangled.org/core/appview/xrpcclient" 24 "tangled.org/core/consts" 25 "tangled.org/core/eventconsumer" 26 "tangled.org/core/idresolver" 27 "tangled.org/core/orm" 28 "tangled.org/core/rbac" 29 "tangled.org/core/tid" 30 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 "github.com/bluesky-social/indigo/atproto/atclient" 33 lexutil "github.com/bluesky-social/indigo/lex/util" 34) 35 36type Knots struct { 37 Db *db.DB 38 OAuth *oauth.OAuth 39 Pages *pages.Pages 40 Config *config.Config 41 Enforcer *rbac.Enforcer 42 Acl *knotacl.Service 43 IdResolver *idresolver.Resolver 44 Logger *slog.Logger 45 Knotstream *eventconsumer.Consumer 46} 47 48func (k *Knots) Router() http.Handler { 49 r := chi.NewRouter() 50 51 r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 52 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 53 54 r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 55 r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 56 57 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 58 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 59 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 60 61 return r 62} 63 64func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 65 user := k.OAuth.GetMultiAccountUser(r) 66 registrations, err := db.GetRegistrations( 67 k.Db, 68 orm.FilterEq("did", user.Did), 69 ) 70 if err != nil { 71 k.Logger.Error("failed to fetch knot registrations", "err", err) 72 w.WriteHeader(http.StatusInternalServerError) 73 return 74 } 75 76 knots := make([]pages.KnotListingParams, 0, len(registrations)) 77 for i := range registrations { 78 registration := &registrations[i] 79 count, err := db.CountRepos(k.Db, orm.FilterEq("knot", registration.Domain)) 80 if err != nil { 81 k.Logger.Error("failed to count knot repos", "err", err, "domain", registration.Domain) 82 w.WriteHeader(http.StatusInternalServerError) 83 return 84 } 85 knots = append(knots, pages.KnotListingParams{ 86 Registration: registration, 87 RepoCount: int(count), 88 }) 89 } 90 91 k.Pages.Knots(w, pages.KnotsParams{ 92 LoggedInUser: user, 93 Knots: knots, 94 }) 95} 96 97func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 98 l := k.Logger.With("handler", "dashboard") 99 100 user := k.OAuth.GetMultiAccountUser(r) 101 l = l.With("user", user.Did) 102 103 domain := chi.URLParam(r, "domain") 104 if domain == "" { 105 return 106 } 107 l = l.With("domain", domain) 108 109 registrations, err := db.GetRegistrations( 110 k.Db, 111 orm.FilterEq("did", user.Did), 112 orm.FilterEq("domain", domain), 113 ) 114 if err != nil { 115 l.Error("failed to get registrations", "err", err) 116 http.Error(w, "Not found", http.StatusNotFound) 117 return 118 } 119 if len(registrations) != 1 { 120 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 121 return 122 } 123 registration := registrations[0] 124 125 members := k.Acl.KnotMembers(r.Context(), domain) 126 127 repos, err := db.GetRepos( 128 k.Db, 129 orm.FilterEq("knot", domain), 130 ) 131 if err != nil { 132 l.Error("failed to get knot repos", "err", err) 133 http.Error(w, "Not found", http.StatusInternalServerError) 134 return 135 } 136 137 // organize repos by did 138 repoMap := make(map[string][]models.Repo) 139 for _, r := range repos { 140 repoMap[r.Did] = append(repoMap[r.Did], r) 141 } 142 143 k.Pages.Knot(w, pages.KnotParams{ 144 LoggedInUser: user, 145 Registration: &registration, 146 Members: members, 147 Repos: repoMap, 148 IsOwner: true, 149 RepoCount: len(repos), 150 }) 151} 152 153func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 154 user := k.OAuth.GetMultiAccountUser(r) 155 l := k.Logger.With("handler", "register") 156 157 noticeId := "register-error" 158 defaultErr := "Failed to register knot. Try again later." 159 fail := func() { 160 k.Pages.Notice(w, noticeId, defaultErr) 161 } 162 163 domain := r.FormValue("domain") 164 // Strip protocol, trailing slashes, and whitespace 165 // Rkey cannot contain slashes 166 domain = strings.TrimSpace(domain) 167 domain = strings.TrimPrefix(domain, "https://") 168 domain = strings.TrimPrefix(domain, "http://") 169 domain = strings.TrimSuffix(domain, "/") 170 if domain == "" { 171 k.Pages.Notice(w, noticeId, "Incomplete form.") 172 return 173 } 174 l = l.With("domain", domain) 175 l = l.With("user", user.Did) 176 177 tx, err := k.Db.Begin() 178 if err != nil { 179 l.Error("failed to start transaction", "err", err) 180 fail() 181 return 182 } 183 defer tx.Rollback() 184 185 if err := db.AddKnot(tx, domain, user.Did); err != nil { 186 l.Error("failed to insert", "err", err) 187 fail() 188 return 189 } 190 191 client, err := k.OAuth.AuthorizedClient(r) 192 if err != nil { 193 l.Error("failed to authorize client", "err", err) 194 fail() 195 return 196 } 197 198 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 199 var exCid *string 200 if ex != nil { 201 exCid = ex.Cid 202 } 203 204 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 205 Collection: tangled.KnotNSID, 206 Repo: user.Did, 207 Rkey: domain, 208 Record: &lexutil.LexiconTypeDecoder{ 209 Val: &tangled.Knot{ 210 CreatedAt: time.Now().Format(time.RFC3339), 211 }, 212 }, 213 SwapRecord: exCid, 214 }) 215 if err != nil { 216 l.Error("failed to put record", "err", err) 217 fail() 218 return 219 } 220 221 if err := tx.Commit(); err != nil { 222 l.Error("failed to commit transaction", "err", err) 223 fail() 224 return 225 } 226 227 go k.Knotstream.AddSource(r.Context(), eventconsumer.NewKnotSource(domain)) 228 229 k.Pages.HxRefresh(w) 230} 231 232func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 233 user := k.OAuth.GetMultiAccountUser(r) 234 l := k.Logger.With("handler", "delete") 235 236 noticeId := "operation-error" 237 defaultErr := "Failed to delete knot. Try again later." 238 fail := func() { 239 k.Pages.Notice(w, noticeId, defaultErr) 240 } 241 242 domain := chi.URLParam(r, "domain") 243 if domain == "" { 244 l.Error("empty domain") 245 fail() 246 return 247 } 248 249 // get record from db first 250 registrations, err := db.GetRegistrations( 251 k.Db, 252 orm.FilterEq("did", user.Did), 253 orm.FilterEq("domain", domain), 254 ) 255 if err != nil { 256 l.Error("failed to get registration", "err", err) 257 fail() 258 return 259 } 260 if len(registrations) != 1 { 261 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 262 fail() 263 return 264 } 265 registration := registrations[0] 266 267 tx, err := k.Db.Begin() 268 if err != nil { 269 l.Error("failed to start txn", "err", err) 270 fail() 271 return 272 } 273 defer func() { 274 tx.Rollback() 275 k.Enforcer.E.LoadPolicy() 276 }() 277 278 err = db.DeleteKnot( 279 tx, 280 orm.FilterEq("did", user.Did), 281 orm.FilterEq("domain", domain), 282 ) 283 if err != nil { 284 l.Error("failed to delete registration", "err", err) 285 fail() 286 return 287 } 288 289 err = db.RemoveReposByKnot(tx, domain) 290 if err != nil { 291 l.Error("failed to delete repos", "err", err) 292 fail() 293 return 294 } 295 296 // delete from enforcer if it was registered 297 if registration.Registered != nil { 298 err = k.Enforcer.RemoveKnot(domain) 299 if err != nil { 300 l.Error("failed to update ACL", "err", err) 301 fail() 302 return 303 } 304 } 305 306 client, err := k.OAuth.AuthorizedClient(r) 307 if err != nil { 308 l.Error("failed to authorize client", "err", err) 309 fail() 310 return 311 } 312 313 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 314 Collection: tangled.KnotNSID, 315 Repo: user.Did, 316 Rkey: domain, 317 }) 318 if err != nil { 319 // non-fatal 320 l.Error("failed to delete record", "err", err) 321 } 322 323 err = tx.Commit() 324 if err != nil { 325 l.Error("failed to delete knot", "err", err) 326 fail() 327 return 328 } 329 330 err = k.Enforcer.E.SavePolicy() 331 if err != nil { 332 l.Error("failed to update ACL", "err", err) 333 k.Pages.HxRefresh(w) 334 return 335 } 336 337 if registration.Registered != nil { 338 remaining, rErr := db.GetRegistrations(k.Db, 339 orm.FilterEq("domain", domain), 340 orm.FilterIsNot("registered", "null"), 341 ) 342 if rErr != nil { 343 l.Warn("failed to check remaining registrations after delete", "err", rErr) 344 } else if len(remaining) == 0 { 345 go k.Knotstream.RemoveSource(eventconsumer.NewKnotSource(domain)) 346 } 347 } 348 349 shouldRedirect := r.Header.Get("shouldRedirect") 350 if shouldRedirect == "true" { 351 k.Pages.HxRedirect(w, "/knots") 352 return 353 } 354 355 w.Write([]byte{}) 356} 357 358func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 359 user := k.OAuth.GetMultiAccountUser(r) 360 l := k.Logger.With("handler", "retry") 361 362 noticeId := "operation-error" 363 defaultErr := "Failed to verify knot. Try again later." 364 fail := func() { 365 k.Pages.Notice(w, noticeId, defaultErr) 366 } 367 368 domain := chi.URLParam(r, "domain") 369 if domain == "" { 370 l.Error("empty domain") 371 fail() 372 return 373 } 374 l = l.With("domain", domain) 375 l = l.With("user", user.Did) 376 377 // get record from db first 378 registrations, err := db.GetRegistrations( 379 k.Db, 380 orm.FilterEq("did", user.Did), 381 orm.FilterEq("domain", domain), 382 ) 383 if err != nil { 384 l.Error("failed to get registration", "err", err) 385 fail() 386 return 387 } 388 if len(registrations) != 1 { 389 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 390 fail() 391 return 392 } 393 registration := registrations[0] 394 395 // begin verification 396 err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 397 if err != nil { 398 l.Error("verification failed", "err", err) 399 400 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 401 k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 402 return 403 } 404 405 if e, ok := err.(*serververify.OwnerMismatch); ok { 406 k.Pages.Notice(w, noticeId, e.Error()) 407 return 408 } 409 410 fail() 411 return 412 } 413 414 err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 415 if err != nil { 416 l.Error("failed to mark verified", "err", err) 417 k.Pages.Notice(w, noticeId, err.Error()) 418 return 419 } 420 421 // if this knot requires upgrade, then emit a record too 422 // 423 // this is part of migrating from the old knot system to the new one 424 if registration.NeedsUpgrade { 425 // re-announce by registering under same rkey 426 client, err := k.OAuth.AuthorizedClient(r) 427 if err != nil { 428 l.Error("failed to authorize client", "err", err) 429 fail() 430 return 431 } 432 433 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 434 var exCid *string 435 if ex != nil { 436 exCid = ex.Cid 437 } 438 439 // ignore the error here 440 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 441 Collection: tangled.KnotNSID, 442 Repo: user.Did, 443 Rkey: domain, 444 Record: &lexutil.LexiconTypeDecoder{ 445 Val: &tangled.Knot{ 446 CreatedAt: time.Now().Format(time.RFC3339), 447 }, 448 }, 449 SwapRecord: exCid, 450 }) 451 if err != nil { 452 l.Error("non-fatal: failed to reannouce knot", "err", err) 453 } 454 } 455 456 // add this knot to knotstream 457 go k.Knotstream.AddSource( 458 r.Context(), 459 eventconsumer.NewKnotSource(domain), 460 ) 461 462 shouldRefresh := r.Header.Get("shouldRefresh") 463 if shouldRefresh == "true" { 464 k.Pages.HxRefresh(w) 465 return 466 } 467 468 // Get updated registration to show 469 registrations, err = db.GetRegistrations( 470 k.Db, 471 orm.FilterEq("did", user.Did), 472 orm.FilterEq("domain", domain), 473 ) 474 if err != nil { 475 l.Error("failed to get registration", "err", err) 476 fail() 477 return 478 } 479 if len(registrations) != 1 { 480 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 481 fail() 482 return 483 } 484 updatedRegistration := registrations[0] 485 486 count, err := db.CountRepos(k.Db, orm.FilterEq("knot", domain)) 487 if err != nil { 488 l.Error("failed to count knot repos", "err", err) 489 fail() 490 return 491 } 492 493 w.Header().Set("HX-Reswap", "outerHTML") 494 k.Pages.KnotListing(w, pages.KnotListingParams{ 495 Registration: &updatedRegistration, 496 RepoCount: int(count), 497 }) 498} 499 500func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 501 user := k.OAuth.GetMultiAccountUser(r) 502 l := k.Logger.With("handler", "addMember") 503 504 domain := chi.URLParam(r, "domain") 505 if domain == "" { 506 l.Error("empty domain") 507 http.Error(w, "Not found", http.StatusNotFound) 508 return 509 } 510 l = l.With("domain", domain) 511 l = l.With("user", user.Did) 512 513 registrations, err := db.GetRegistrations( 514 k.Db, 515 orm.FilterEq("did", user.Did), 516 orm.FilterEq("domain", domain), 517 orm.FilterIsNot("registered", "null"), 518 ) 519 if err != nil { 520 l.Error("failed to get registration", "err", err) 521 return 522 } 523 if len(registrations) != 1 { 524 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 525 return 526 } 527 registration := registrations[0] 528 529 noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 530 defaultErr := "Failed to add member. Try again later." 531 fail := func() { 532 k.Pages.Notice(w, noticeId, defaultErr) 533 } 534 535 member := r.FormValue("member") 536 member = strings.TrimPrefix(member, "@") 537 if member == "" { 538 l.Error("empty member") 539 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 540 return 541 } 542 l = l.With("member", member) 543 544 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 545 if err != nil { 546 l.Error("failed to resolve member identity to handle", "err", err) 547 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 548 return 549 } 550 if memberId.Handle.IsInvalidHandle() { 551 l.Error("failed to resolve member identity to handle") 552 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 553 return 554 } 555 556 if knotcompat.KnotHasCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) { 557 client, err := k.OAuth.ServiceClient( 558 r, 559 oauth.WithService(domain), 560 oauth.WithLxm(tangled.KnotAddMemberNSID), 561 oauth.WithDev(k.Config.Core.Dev), 562 ) 563 if err != nil { 564 l.Error("failed to create knot service client", "err", err) 565 fail() 566 return 567 } 568 569 err = tangled.KnotAddMember(r.Context(), client, &tangled.KnotAddMember_Input{ 570 Subject: memberId.DID.String(), 571 }) 572 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 573 l.Error("failed to call XRPC knot.addMember", "xrpcerr", xrpcerr, "err", err) 574 k.Pages.Notice(w, noticeId, xrpcerr.Error()) 575 return 576 } 577 578 k.Acl.InvalidateMembers(domain) 579 580 k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 581 return 582 } 583 584 client, err := k.OAuth.AuthorizedClient(r) 585 if err != nil { 586 l.Error("failed to authorize client", "err", err) 587 fail() 588 return 589 } 590 591 rkey := tid.TID() 592 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 593 Collection: tangled.KnotMemberNSID, 594 Repo: user.Did, 595 Rkey: rkey, 596 Record: &lexutil.LexiconTypeDecoder{ 597 Val: &tangled.KnotMember{ 598 CreatedAt: time.Now().Format(time.RFC3339), 599 Domain: domain, 600 Subject: memberId.DID.String(), 601 }, 602 }, 603 }) 604 if err != nil { 605 l.Error("failed to add record to PDS", "err", err) 606 k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 607 return 608 } 609 610 k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 611} 612 613func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 614 user := k.OAuth.GetMultiAccountUser(r) 615 l := k.Logger.With("handler", "removeMember") 616 617 noticeId := "operation-error" 618 defaultErr := "Failed to remove member. Try again later." 619 fail := func() { 620 k.Pages.Notice(w, noticeId, defaultErr) 621 } 622 623 domain := chi.URLParam(r, "domain") 624 if domain == "" { 625 l.Error("empty domain") 626 fail() 627 return 628 } 629 l = l.With("domain", domain) 630 l = l.With("user", user.Did) 631 632 registrations, err := db.GetRegistrations( 633 k.Db, 634 orm.FilterEq("did", user.Did), 635 orm.FilterEq("domain", domain), 636 orm.FilterIsNot("registered", "null"), 637 ) 638 if err != nil { 639 l.Error("failed to get registration", "err", err) 640 return 641 } 642 if len(registrations) != 1 { 643 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 644 return 645 } 646 647 member := r.FormValue("member") 648 member = strings.TrimPrefix(member, "@") 649 if member == "" { 650 l.Error("empty member") 651 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 652 return 653 } 654 l = l.With("member", member) 655 656 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 657 if err != nil { 658 l.Error("failed to resolve member identity to handle", "err", err) 659 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 660 return 661 } 662 663 if knotcompat.KnotHasCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) { 664 client, err := k.OAuth.ServiceClient( 665 r, 666 oauth.WithService(domain), 667 oauth.WithLxm(tangled.KnotRemoveMemberNSID), 668 oauth.WithDev(k.Config.Core.Dev), 669 ) 670 if err != nil { 671 l.Error("failed to create knot service client", "err", err) 672 fail() 673 return 674 } 675 676 err = tangled.KnotRemoveMember(r.Context(), client, &tangled.KnotRemoveMember_Input{ 677 Subject: memberId.DID.String(), 678 }) 679 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 680 l.Error("failed to call XRPC knot.removeMember", "xrpcerr", xrpcerr, "err", err) 681 k.Pages.Notice(w, noticeId, xrpcerr.Error()) 682 return 683 } 684 685 k.Acl.InvalidateMembers(domain) 686 687 k.Pages.HxRefresh(w) 688 return 689 } 690 691 client, err := k.OAuth.AuthorizedClient(r) 692 if err != nil { 693 l.Error("failed to authorize client", "err", err) 694 fail() 695 return 696 } 697 698 rkey, err := lookupKnotMemberRkey(r.Context(), k.Db, client, user.Did, domain, memberId.DID.String()) 699 if err != nil { 700 l.Warn("failed to look up member rkey", "err", err) 701 } 702 703 if rkey == "" { 704 l.Error("no member record found to remove") 705 fail() 706 return 707 } 708 709 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 710 Collection: tangled.KnotMemberNSID, 711 Repo: user.Did, 712 Rkey: rkey, 713 }) 714 if err != nil { 715 l.Error("failed to delete record from PDS", "err", err) 716 k.Pages.Notice(w, noticeId, "Failed to delete record from PDS, try again later.") 717 return 718 } 719 720 k.Pages.HxRefresh(w) 721} 722 723func lookupKnotMemberRkey(ctx context.Context, d *db.DB, client *atclient.APIClient, ownerDid, domain, subject string) (string, error) { 724 members, err := db.GetKnotMembers( 725 d, 726 orm.FilterEq("did", ownerDid), 727 orm.FilterEq("domain", domain), 728 orm.FilterEq("subject", subject), 729 ) 730 if err != nil { 731 return "", fmt.Errorf("db lookup: %w", err) 732 } 733 if len(members) >= 1 { 734 return members[0].Rkey, nil 735 } 736 return findKnotMemberRkey(ctx, client, ownerDid, domain, subject, "") 737} 738 739func findKnotMemberRkey(ctx context.Context, client *atclient.APIClient, repo, domain, subject, cursor string) (string, error) { 740 out, err := comatproto.RepoListRecords(ctx, client, tangled.KnotMemberNSID, cursor, 100, repo, false) 741 if err != nil { 742 return "", err 743 } 744 for _, rec := range out.Records { 745 m, ok := rec.Value.Val.(*tangled.KnotMember) 746 if !ok { 747 continue 748 } 749 if m.Domain != domain || m.Subject != subject { 750 continue 751 } 752 parts := strings.Split(rec.Uri, "/") 753 return parts[len(parts)-1], nil 754 } 755 if out.Cursor == nil || *out.Cursor == "" || *out.Cursor == cursor { 756 return "", nil 757 } 758 return findKnotMemberRkey(ctx, client, repo, domain, subject, *out.Cursor) 759}