This repository has no description
0

Configure Feed

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

core / appview / knots / knots.go
20 kB 772 lines
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/consts" 24 "tangled.org/core/eventconsumer" 25 "tangled.org/core/idresolver" 26 "tangled.org/core/orm" 27 "tangled.org/core/rbac" 28 "tangled.org/core/tid" 29 "tangled.org/core/xrpc/xrpcclient" 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 BaseParams: pages.BaseParamsFromContext(r.Context()), 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 BaseParams: pages.BaseParamsFromContext(r.Context()), 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 l.Error("attempt to delete repos by knot", "knot", domain) 290 // err = db.RemoveReposByKnot(tx, domain) 291 // if err != nil { 292 // l.Error("failed to delete repos", "err", err) 293 // fail() 294 // return 295 // } 296 297 // delete from enforcer if it was registered 298 if registration.Registered != nil { 299 err = k.Enforcer.RemoveKnot(domain) 300 if err != nil { 301 l.Error("failed to update ACL", "err", err) 302 fail() 303 return 304 } 305 } 306 307 client, err := k.OAuth.AuthorizedClient(r) 308 if err != nil { 309 l.Error("failed to authorize client", "err", err) 310 fail() 311 return 312 } 313 314 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 315 Collection: tangled.KnotNSID, 316 Repo: user.Did, 317 Rkey: domain, 318 }) 319 if err != nil { 320 // non-fatal 321 l.Error("failed to delete record", "err", err) 322 } 323 324 err = tx.Commit() 325 if err != nil { 326 l.Error("failed to delete knot", "err", err) 327 fail() 328 return 329 } 330 331 err = k.Enforcer.E.SavePolicy() 332 if err != nil { 333 l.Error("failed to update ACL", "err", err) 334 k.Pages.HxRefresh(w) 335 return 336 } 337 338 if registration.Registered != nil { 339 remaining, rErr := db.GetRegistrations(k.Db, 340 orm.FilterEq("domain", domain), 341 orm.FilterIsNot("registered", "null"), 342 ) 343 if rErr != nil { 344 l.Warn("failed to check remaining registrations after delete", "err", rErr) 345 } else if len(remaining) == 0 { 346 go k.Knotstream.RemoveSource(eventconsumer.NewKnotSource(domain)) 347 } 348 } 349 350 shouldRedirect := r.Header.Get("shouldRedirect") 351 if shouldRedirect == "true" { 352 k.Pages.HxRedirect(w, "/knots") 353 return 354 } 355 356 w.Write([]byte{}) 357} 358 359func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 360 user := k.OAuth.GetMultiAccountUser(r) 361 l := k.Logger.With("handler", "retry") 362 363 noticeId := "operation-error" 364 defaultErr := "Failed to verify knot. Try again later." 365 fail := func() { 366 k.Pages.Notice(w, noticeId, defaultErr) 367 } 368 369 domain := chi.URLParam(r, "domain") 370 if domain == "" { 371 l.Error("empty domain") 372 fail() 373 return 374 } 375 l = l.With("domain", domain) 376 l = l.With("user", user.Did) 377 378 // get record from db first 379 registrations, err := db.GetRegistrations( 380 k.Db, 381 orm.FilterEq("did", user.Did), 382 orm.FilterEq("domain", domain), 383 ) 384 if err != nil { 385 l.Error("failed to get registration", "err", err) 386 fail() 387 return 388 } 389 if len(registrations) != 1 { 390 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 391 fail() 392 return 393 } 394 registration := registrations[0] 395 396 // begin verification 397 err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 398 if err != nil { 399 l.Error("verification failed", "err", err) 400 401 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 402 k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 403 return 404 } 405 406 if e, ok := err.(*serververify.OwnerMismatch); ok { 407 k.Pages.Notice(w, noticeId, e.Error()) 408 return 409 } 410 411 fail() 412 return 413 } 414 415 err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 416 if err != nil { 417 l.Error("failed to mark verified", "err", err) 418 k.Pages.Notice(w, noticeId, err.Error()) 419 return 420 } 421 422 // if this knot requires upgrade, then emit a record too 423 // 424 // this is part of migrating from the old knot system to the new one 425 if registration.NeedsUpgrade { 426 // re-announce by registering under same rkey 427 client, err := k.OAuth.AuthorizedClient(r) 428 if err != nil { 429 l.Error("failed to authorize client", "err", err) 430 fail() 431 return 432 } 433 434 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, 445 Record: &lexutil.LexiconTypeDecoder{ 446 Val: &tangled.Knot{ 447 CreatedAt: time.Now().Format(time.RFC3339), 448 }, 449 }, 450 SwapRecord: exCid, 451 }) 452 if err != nil { 453 l.Error("non-fatal: failed to reannouce knot", "err", err) 454 } 455 } 456 457 // add this knot to knotstream 458 go k.Knotstream.AddSource( 459 r.Context(), 460 eventconsumer.NewKnotSource(domain), 461 ) 462 463 shouldRefresh := r.Header.Get("shouldRefresh") 464 if shouldRefresh == "true" { 465 k.Pages.HxRefresh(w) 466 return 467 } 468 469 // Get updated registration to show 470 registrations, err = db.GetRegistrations( 471 k.Db, 472 orm.FilterEq("did", user.Did), 473 orm.FilterEq("domain", domain), 474 ) 475 if err != nil { 476 l.Error("failed to get registration", "err", err) 477 fail() 478 return 479 } 480 if len(registrations) != 1 { 481 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 482 fail() 483 return 484 } 485 updatedRegistration := registrations[0] 486 487 count, err := db.CountRepos(k.Db, orm.FilterEq("knot", domain)) 488 if err != nil { 489 l.Error("failed to count knot repos", "err", err) 490 fail() 491 return 492 } 493 494 w.Header().Set("HX-Reswap", "outerHTML") 495 k.Pages.KnotListing(w, pages.KnotListingParams{ 496 Registration: &updatedRegistration, 497 RepoCount: int(count), 498 }) 499} 500 501func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 502 user := k.OAuth.GetMultiAccountUser(r) 503 l := k.Logger.With("handler", "addMember") 504 505 domain := chi.URLParam(r, "domain") 506 if domain == "" { 507 l.Error("empty domain") 508 http.Error(w, "Not found", http.StatusNotFound) 509 return 510 } 511 l = l.With("domain", domain) 512 l = l.With("user", user.Did) 513 514 registrations, err := db.GetRegistrations( 515 k.Db, 516 orm.FilterEq("did", user.Did), 517 orm.FilterEq("domain", domain), 518 orm.FilterIsNot("registered", "null"), 519 ) 520 if err != nil { 521 l.Error("failed to get registration", "err", err) 522 return 523 } 524 if len(registrations) != 1 { 525 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 526 return 527 } 528 registration := registrations[0] 529 530 noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 531 defaultErr := "Failed to add member. Try again later." 532 fail := func() { 533 k.Pages.Notice(w, noticeId, defaultErr) 534 } 535 536 member := r.FormValue("member") 537 member = strings.TrimPrefix(member, "@") 538 if member == "" { 539 l.Error("empty member") 540 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 541 return 542 } 543 l = l.With("member", member) 544 545 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 546 if err != nil { 547 l.Error("failed to resolve member identity to handle", "err", err) 548 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 549 return 550 } 551 if memberId.Handle.IsInvalidHandle() { 552 l.Error("failed to resolve member identity to handle") 553 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 554 return 555 } 556 557 capStatus := knotcompat.KnotCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) 558 if capStatus == knotcompat.CapUnknown { 559 l.Error("knot capability probe failed") 560 k.Pages.Notice(w, noticeId, "Could not reach the knot to add the member. Try again later.") 561 return 562 } 563 if capStatus == knotcompat.CapPresent { 564 client, err := k.OAuth.ServiceClient( 565 r, 566 oauth.WithService(domain), 567 oauth.WithLxm(tangled.KnotAddMemberNSID), 568 oauth.WithDev(k.Config.Core.Dev), 569 ) 570 if err != nil { 571 l.Error("failed to create knot service client", "err", err) 572 fail() 573 return 574 } 575 576 err = tangled.KnotAddMember(r.Context(), client, &tangled.KnotAddMember_Input{ 577 Subject: memberId.DID.String(), 578 }) 579 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 580 l.Error("failed to call XRPC knot.addMember", "xrpcerr", xrpcerr, "err", err) 581 k.Pages.Notice(w, noticeId, xrpcerr.Error()) 582 return 583 } 584 585 k.Acl.InvalidateMembers(domain) 586 587 k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 588 return 589 } 590 591 client, err := k.OAuth.AuthorizedClient(r) 592 if err != nil { 593 l.Error("failed to authorize client", "err", err) 594 fail() 595 return 596 } 597 598 rkey := tid.TID() 599 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 600 Collection: tangled.KnotMemberNSID, 601 Repo: user.Did, 602 Rkey: rkey, 603 Record: &lexutil.LexiconTypeDecoder{ 604 Val: &tangled.KnotMember{ 605 CreatedAt: time.Now().Format(time.RFC3339), 606 Domain: domain, 607 Subject: memberId.DID.String(), 608 }, 609 }, 610 }) 611 if err != nil { 612 l.Error("failed to add record to PDS", "err", err) 613 k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 614 return 615 } 616 617 k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 618} 619 620func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 621 user := k.OAuth.GetMultiAccountUser(r) 622 l := k.Logger.With("handler", "removeMember") 623 624 noticeId := "operation-error" 625 defaultErr := "Failed to remove member. Try again later." 626 fail := func() { 627 k.Pages.Notice(w, noticeId, defaultErr) 628 } 629 630 domain := chi.URLParam(r, "domain") 631 if domain == "" { 632 l.Error("empty domain") 633 fail() 634 return 635 } 636 l = l.With("domain", domain) 637 l = l.With("user", user.Did) 638 639 registrations, err := db.GetRegistrations( 640 k.Db, 641 orm.FilterEq("did", user.Did), 642 orm.FilterEq("domain", domain), 643 orm.FilterIsNot("registered", "null"), 644 ) 645 if err != nil { 646 l.Error("failed to get registration", "err", err) 647 return 648 } 649 if len(registrations) != 1 { 650 l.Error("got incorrect number of registrations", "got", len(registrations), "expected", 1) 651 return 652 } 653 654 member := r.FormValue("member") 655 member = strings.TrimPrefix(member, "@") 656 if member == "" { 657 l.Error("empty member") 658 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 659 return 660 } 661 l = l.With("member", member) 662 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 664 if err != nil { 665 l.Error("failed to resolve member identity to handle", "err", err) 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 return 668 } 669 670 capStatus := knotcompat.KnotCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) 671 if capStatus == knotcompat.CapUnknown { 672 l.Error("knot capability probe failed") 673 k.Pages.Notice(w, noticeId, "Could not reach the knot to remove the member. Try again later.") 674 return 675 } 676 if capStatus == knotcompat.CapPresent { 677 client, err := k.OAuth.ServiceClient( 678 r, 679 oauth.WithService(domain), 680 oauth.WithLxm(tangled.KnotRemoveMemberNSID), 681 oauth.WithDev(k.Config.Core.Dev), 682 ) 683 if err != nil { 684 l.Error("failed to create knot service client", "err", err) 685 fail() 686 return 687 } 688 689 err = tangled.KnotRemoveMember(r.Context(), client, &tangled.KnotRemoveMember_Input{ 690 Subject: memberId.DID.String(), 691 }) 692 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 693 l.Error("failed to call XRPC knot.removeMember", "xrpcerr", xrpcerr, "err", err) 694 k.Pages.Notice(w, noticeId, xrpcerr.Error()) 695 return 696 } 697 698 k.Acl.InvalidateMembers(domain) 699 700 k.Pages.HxRefresh(w) 701 return 702 } 703 704 client, err := k.OAuth.AuthorizedClient(r) 705 if err != nil { 706 l.Error("failed to authorize client", "err", err) 707 fail() 708 return 709 } 710 711 rkey, err := lookupKnotMemberRkey(r.Context(), k.Db, client, user.Did, domain, memberId.DID.String()) 712 if err != nil { 713 l.Warn("failed to look up member rkey", "err", err) 714 } 715 716 if rkey == "" { 717 l.Error("no member record found to remove") 718 fail() 719 return 720 } 721 722 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 723 Collection: tangled.KnotMemberNSID, 724 Repo: user.Did, 725 Rkey: rkey, 726 }) 727 if err != nil { 728 l.Error("failed to delete record from PDS", "err", err) 729 k.Pages.Notice(w, noticeId, "Failed to delete record from PDS, try again later.") 730 return 731 } 732 733 k.Pages.HxRefresh(w) 734} 735 736func lookupKnotMemberRkey(ctx context.Context, d *db.DB, client *atclient.APIClient, ownerDid, domain, subject string) (string, error) { 737 members, err := db.GetKnotMembers( 738 d, 739 orm.FilterEq("did", ownerDid), 740 orm.FilterEq("domain", domain), 741 orm.FilterEq("subject", subject), 742 ) 743 if err != nil { 744 return "", fmt.Errorf("db lookup: %w", err) 745 } 746 if len(members) >= 1 { 747 return members[0].Rkey, nil 748 } 749 return findKnotMemberRkey(ctx, client, ownerDid, domain, subject, "") 750} 751 752func findKnotMemberRkey(ctx context.Context, client *atclient.APIClient, repo, domain, subject, cursor string) (string, error) { 753 out, err := comatproto.RepoListRecords(ctx, client, tangled.KnotMemberNSID, cursor, 100, repo, false) 754 if err != nil { 755 return "", err 756 } 757 for _, rec := range out.Records { 758 m, ok := rec.Value.Val.(*tangled.KnotMember) 759 if !ok { 760 continue 761 } 762 if m.Domain != domain || m.Subject != subject { 763 continue 764 } 765 parts := strings.Split(rec.Uri, "/") 766 return parts[len(parts)-1], nil 767 } 768 if out.Cursor == nil || *out.Cursor == "" || *out.Cursor == cursor { 769 return "", nil 770 } 771 return findKnotMemberRkey(ctx, client, repo, domain, subject, *out.Cursor) 772}