This repository has no description
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 := ®istrations[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: ®istration,
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}