From c5198eff5bf8ea637f6a8f26b587d31dec2cbbf6 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Thu, 17 Sep 2026 04:24:54 +0900 Subject: [PATCH] deliberi,lexicons,web: remove in-app knot invite acceptance flow Signed-off-by: Seongmin Lee --- api/org_tangled/actordefs.go | 23 +++ api/org_tangled/actorredeemInviteCode.go | 30 ++++ api/org_tangled/admincreateInviteLink.go | 35 +++++ .../notificationlistNotifications.go | 2 - deliberi/bobbin.go | 30 ++-- deliberi/bobbin_test.go | 55 +++---- deliberi/db/db.go | 6 + deliberi/db/notifications_test.go | 5 +- deliberi/invites.go | 32 +---- deliberi/invites_test.go | 113 +++++---------- deliberi/models/notifications.go | 6 +- deliberi/xrpc/notifications.go | 3 - deliberi/xrpc/xrpc_test.go | 38 ----- .../temp/notification/listNotifications.json | 5 - web/src/lib/api/accept.test.ts | 134 +++++++----------- web/src/lib/api/accept.ts | 15 +- .../temp/notification/listNotifications.ts | 4 - web/src/lib/api/notifications.test.ts | 63 ++------ web/src/lib/api/notifications.ts | 12 -- web/src/lib/auth/scopes.test.ts | 29 ++-- .../NotificationItem.stories.svelte | 21 +-- .../notifications/NotificationItem.test.ts | 14 +- web/src/lib/components/notifications/mock.ts | 13 +- .../notifications/notification.test.ts | 19 +-- .../components/notifications/notification.ts | 8 +- web/src/lib/components/notifications/types.ts | 9 +- web/src/lib/oauth-client-metadata.ts | 2 - 27 files changed, 275 insertions(+), 451 deletions(-) create mode 100644 api/org_tangled/actordefs.go create mode 100644 api/org_tangled/actorredeemInviteCode.go create mode 100644 api/org_tangled/admincreateInviteLink.go diff --git a/api/org_tangled/actordefs.go b/api/org_tangled/actordefs.go new file mode 100644 index 000000000..84b42a578 --- /dev/null +++ b/api/org_tangled/actordefs.go @@ -0,0 +1,23 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package org_tangled + +// schema: org.tangled.temp.actor.defs + +const () + +// TempActorDefs_SavedKnotPref is a "savedKnotPref" in the org.tangled.temp.actor.defs schema. +type TempActorDefs_SavedKnotPref struct { + Items []*TempActorDefs_SavedService `json:"items" cborgen:"items"` +} + +// TempActorDefs_SavedService is a "savedService" in the org.tangled.temp.actor.defs schema. +type TempActorDefs_SavedService struct { + Id string `json:"id" cborgen:"id"` + Value string `json:"value" cborgen:"value"` +} + +// TempActorDefs_SavedSpindlePref is a "savedSpindlePref" in the org.tangled.temp.actor.defs schema. +type TempActorDefs_SavedSpindlePref struct { + Items []*TempActorDefs_SavedService `json:"items" cborgen:"items"` +} diff --git a/api/org_tangled/actorredeemInviteCode.go b/api/org_tangled/actorredeemInviteCode.go new file mode 100644 index 000000000..25403d982 --- /dev/null +++ b/api/org_tangled/actorredeemInviteCode.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package org_tangled + +// schema: org.tangled.temp.actor.redeemInviteCode + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempActorRedeemInviteCodeNSID = "org.tangled.temp.actor.redeemInviteCode" +) + +// TempActorRedeemInviteCode_Input is the input argument to a org.tangled.temp.actor.redeemInviteCode call. +type TempActorRedeemInviteCode_Input struct { + // code: The code from an invite link + Code string `json:"code" cborgen:"code"` +} + +// TempActorRedeemInviteCode calls the XRPC method "org.tangled.temp.actor.redeemInviteCode". +func TempActorRedeemInviteCode(ctx context.Context, c util.LexClient, input *TempActorRedeemInviteCode_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.actor.redeemInviteCode", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/org_tangled/admincreateInviteLink.go b/api/org_tangled/admincreateInviteLink.go new file mode 100644 index 000000000..453dd2319 --- /dev/null +++ b/api/org_tangled/admincreateInviteLink.go @@ -0,0 +1,35 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package org_tangled + +// schema: org.tangled.knot.admin.createInviteLink + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + KnotAdminCreateInviteLinkNSID = "org.tangled.knot.admin.createInviteLink" +) + +// KnotAdminCreateInviteLink_Output is the output of a org.tangled.knot.admin.createInviteLink call. +type KnotAdminCreateInviteLink_Output struct { + // code: The opaque code on its own, for an operator assembling a link some other way + Code string `json:"code" cborgen:"code"` + // expiresAt: When the code stops being redeemable + ExpiresAt string `json:"expiresAt" cborgen:"expiresAt"` + // url: The link to hand out. Opening it redirects to the knot's configured frontend to sign in + Url string `json:"url" cborgen:"url"` +} + +// KnotAdminCreateInviteLink calls the XRPC method "org.tangled.knot.admin.createInviteLink". +func KnotAdminCreateInviteLink(ctx context.Context, c util.LexClient) (*KnotAdminCreateInviteLink_Output, error) { + var out KnotAdminCreateInviteLink_Output + if err := c.LexDo(ctx, util.Procedure, "", "org.tangled.knot.admin.createInviteLink", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/org_tangled/notificationlistNotifications.go b/api/org_tangled/notificationlistNotifications.go index 4649fd404..c838796c0 100644 --- a/api/org_tangled/notificationlistNotifications.go +++ b/api/org_tangled/notificationlistNotifications.go @@ -23,8 +23,6 @@ type TempNotificationListNotifications_Notification struct { CreatedAt string `json:"createdAt" cborgen:"createdAt"` // issueAt: AT-URI of the related org.tangled.issue.issue record, if applicable. IssueAt *string `json:"issueAt,omitempty" cborgen:"issueAt,omitempty"` - // knotDid: did:web of knot that offered membership. Only knot_invited has it for now! Though in future more notifs may use it. - KnotDid *string `json:"knotDid,omitempty" cborgen:"knotDid,omitempty"` // pullAt: AT-URI of the related org.tangled.pulls.pull record, if applicable. PullAt *string `json:"pullAt,omitempty" cborgen:"pullAt,omitempty"` Read bool `json:"read" cborgen:"read"` diff --git a/deliberi/bobbin.go b/deliberi/bobbin.go index 835b03a63..778adc7cc 100644 --- a/deliberi/bobbin.go +++ b/deliberi/bobbin.go @@ -21,7 +21,6 @@ type recipientResolver interface { } type inviteLister interface { - ListMemberInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) } @@ -91,33 +90,26 @@ func (c *bobbinClient) RepoOwner(ctx context.Context, repoDid string) (string, s return owner, rec.Name, nil } -const ( - knotListMemberInvitesByNSID = "sh.tangled.knot.listMemberInvitesBy" - repoListCollaboratorInvitesByNSID = "sh.tangled.repo.listCollaboratorInvitesBy" -) - -func (c *bobbinClient) ListMemberInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { - return c.listInvitesBy(ctx, knotListMemberInvitesByNSID, subjectDid) -} +const repoListCollaboratorInvitesByNSID = "sh.tangled.repo.listCollaboratorInvitesBy" func (c *bobbinClient) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { - return c.listInvitesBy(ctx, repoListCollaboratorInvitesByNSID, subjectDid) -} - -func (c *bobbinClient) listInvitesBy(ctx context.Context, nsid, subjectDid string) (InviteAnswer, error) { var out InviteAnswer params := map[string]any{"subject": subjectDid} - if err := c.xc.Do(ctx, indigoxrpc.Query, "", nsid, params, nil, &out); err != nil { - return InviteAnswer{}, fmt.Errorf("calling %s: %w", nsid, err) + if err := c.xc.Do(ctx, indigoxrpc.Query, "", repoListCollaboratorInvitesByNSID, params, nil, &out); err != nil { + return InviteAnswer{}, fmt.Errorf("calling %s: %w", repoListCollaboratorInvitesByNSID, err) } if blank, found := lo.Find(out.Offers, func(offer InviteOffer) bool { - return offer.Uri == "" || offer.KnotDid == "" + return offer.Uri == "" || offer.KnotDid == "" || offer.RepoDid == "" }); found { + missing := "record" + if blank.Uri != "" { + missing = lo.Ternary(blank.KnotDid == "", "knot", "repo") + } return InviteAnswer{}, fmt.Errorf( - "%s served offer missing its %s, so later we couldn't tell whether it got withdrawn", - nsid, - lo.Ternary(blank.Uri == "", "record", "knot"), + "%s served offer missing its %s, so we couldn't tell it apart from a withdrawal", + repoListCollaboratorInvitesByNSID, + missing, ) } diff --git a/deliberi/bobbin_test.go b/deliberi/bobbin_test.go index a58d263f6..1089ba577 100644 --- a/deliberi/bobbin_test.go +++ b/deliberi/bobbin_test.go @@ -9,8 +9,6 @@ import ( "time" ) -const memberInvitesByBody = `{"items":[{"uri":"at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet","knot":"did:web:knot.oyster.cafe","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` - const collaboratorInvitesByBody = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.oyster.cafe","repo":"did:plc:scallop","addedBy":"did:plc:boltless","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` func serving(t *testing.T, path, body string) *bobbinClient { @@ -31,30 +29,6 @@ func serving(t *testing.T, path, body string) *bobbinClient { return newBobbinClient(srv.URL) } -func TestListMemberInvitesByDecodesBobbinsBody(t *testing.T) { - c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, memberInvitesByBody) - - answer, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet") - if err != nil { - t.Fatalf("ListMemberInvitesBy: %v", err) - } - if len(answer.Offers) != 1 { - t.Fatalf("got %d offers, want 1", len(answer.Offers)) - } - want := InviteOffer{ - Uri: "at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet", - KnotDid: "did:web:knot.oyster.cafe", - AddedBy: "did:plc:akshay", - CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), - } - if answer.Offers[0] != want { - t.Fatalf("offer = %+v, want %+v", answer.Offers[0], want) - } - if len(answer.Pending) != 0 || answer.Truncated { - t.Fatalf("pending = %v, truncated = %v; complete answers leave both alone", answer.Pending, answer.Truncated) - } -} - func TestListCollaboratorInvitesByDecodesBobbinsBody(t *testing.T) { c := serving(t, "/xrpc/"+repoListCollaboratorInvitesByNSID, collaboratorInvitesByBody) @@ -76,11 +50,11 @@ func TestListCollaboratorInvitesByDecodesBobbinsBody(t *testing.T) { func TestPartialAnswerListsWhatBobbinCouldntRead(t *testing.T) { const body = `{"items":[],"pending":["did:web:cold.knot","did:web:other.knot"],"truncated":true}` - c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, body) + c := serving(t, "/xrpc/"+repoListCollaboratorInvitesByNSID, body) - answer, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet") + answer, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet") if err != nil { - t.Fatalf("ListMemberInvitesBy: %v", err) + t.Fatalf("ListCollaboratorInvitesBy: %v", err) } if len(answer.Pending) != 2 { t.Fatalf("pending = %v, want both knots", answer.Pending) @@ -91,23 +65,32 @@ func TestPartialAnswerListsWhatBobbinCouldntRead(t *testing.T) { } func TestUnreadableCreatedAtFailsListing(t *testing.T) { - const body = `{"items":[{"uri":"at://did:web:knot.example/sh.tangled.knot.memberInvite/did:plc:limpet","knot":"did:web:knot.example","addedBy":"did:plc:akshay","createdAt":"whenever"}]}` - c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, body) + const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.example","repo":"did:plc:scallop","addedBy":"did:plc:akshay","createdAt":"whenever"}]}` + c := serving(t, "/xrpc/"+repoListCollaboratorInvitesByNSID, body) - if _, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil { t.Fatal("we can't stamp rows from timestamps that won't read") } } func TestOfferMissingItsKnotFailsListing(t *testing.T) { - const body = `{"items":[{"uri":"at://did:web:knot.example/sh.tangled.knot.memberInvite/did:plc:limpet","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` - c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, body) + const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","repo":"did:plc:scallop","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` + c := serving(t, "/xrpc/"+repoListCollaboratorInvitesByNSID, body) - if _, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil { t.Fatal("offers missing their knot can't be checked against pending, so accepting one would make its absence look like withdrawal") } } +func TestOfferMissingItsRepoFailsListing(t *testing.T) { + const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.example","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` + c := serving(t, "/xrpc/"+repoListCollaboratorInvitesByNSID, body) + + if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + t.Fatal("a repo-less offer has nothing to link or accept against, so the row would be unusable") + } +} + func TestWarming503FailsListing(t *testing.T) { const warming = `{"error":"Warming","message":"invite index is still warming, so it can't list offers yet"}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -118,7 +101,7 @@ func TestWarming503FailsListing(t *testing.T) { t.Cleanup(srv.Close) c := newBobbinClient(srv.URL) - if _, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil { t.Fatal("503 decoded as empty listing, so reconcile would read every offer as withdrawn") } } diff --git a/deliberi/db/db.go b/deliberi/db/db.go index 3885104a2..87ffec3a6 100644 --- a/deliberi/db/db.go +++ b/deliberi/db/db.go @@ -219,6 +219,12 @@ func runMigrations(conn *sql.Conn, logger *slog.Logger) error { }); err != nil { return err } + if err := orm.RunMigration(conn, logger, "drop-knot-invited-notifications", func(tx *sql.Tx) error { + _, err := tx.Exec(`delete from notifications where type = 'knot_invited'`) + return err + }); err != nil { + return err + } return nil } diff --git a/deliberi/db/notifications_test.go b/deliberi/db/notifications_test.go index afd138727..0c19c73bd 100644 --- a/deliberi/db/notifications_test.go +++ b/deliberi/db/notifications_test.go @@ -75,14 +75,15 @@ func TestCreateNotificationDedupe(t *testing.T) { } } -const inviteUri = "at://did:web:knot.example/sh.tangled.knot.memberInvite/did:plc:alice" +const inviteUri = "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:alice" func inviteOffer(recipientDid string) *models.Notification { return &models.Notification{ RecipientDid: recipientDid, AtUri: inviteUri, - Type: models.NotificationTypeKnotInvited, + Type: models.NotificationTypeCollaboratorInvited, ActorDid: "did:plc:admin", + RepoDid: "did:plc:scallop", KnotDid: "did:web:knot.example", } } diff --git a/deliberi/invites.go b/deliberi/invites.go index 5bc7c02ef..464427ba3 100644 --- a/deliberi/invites.go +++ b/deliberi/invites.go @@ -8,7 +8,6 @@ import ( "time" "github.com/samber/lo" - "golang.org/x/sync/errgroup" deldb "tangled.org/core/deliberi/db" "tangled.org/core/deliberi/models" "tangled.org/core/orm" @@ -20,7 +19,6 @@ const ( ) var inviteNotificationTypes = []models.NotificationType{ - models.NotificationTypeKnotInvited, models.NotificationTypeCollaboratorInvited, } @@ -131,27 +129,7 @@ func (s *InviteSync) fetch(ctx context.Context, recipientDid string) (InviteAnsw ctx, cancel := context.WithTimeout(ctx, inviteFetchTimeout) defer cancel() - var members, collabs InviteAnswer - g, gctx := errgroup.WithContext(ctx) - g.Go(func() error { - var err error - members, err = s.invites.ListMemberInvitesBy(gctx, recipientDid) - return err - }) - g.Go(func() error { - var err error - collabs, err = s.invites.ListCollaboratorInvitesBy(gctx, recipientDid) - return err - }) - if err := g.Wait(); err != nil { - return InviteAnswer{}, err - } - - return InviteAnswer{ - Offers: append(members.Offers, collabs.Offers...), - Pending: lo.Union(members.Pending, collabs.Pending), - Truncated: members.Truncated || collabs.Truncated, - }, nil + return s.invites.ListCollaboratorInvitesBy(ctx, recipientDid) } func keySet[T any, K comparable](items []T, key func(T) K) map[K]struct{} { @@ -170,17 +148,13 @@ func firstErr[T any](items []T, act func(T) error) error { } func inviteNotification(recipientDid string, o InviteOffer) *models.Notification { - kind, repo := models.NotificationTypeKnotInvited, "" - if o.RepoDid != "" { - kind, repo = models.NotificationTypeCollaboratorInvited, o.RepoDid - } return &models.Notification{ RecipientDid: recipientDid, AtUri: o.Uri, - Type: kind, + Type: models.NotificationTypeCollaboratorInvited, ActorDid: o.AddedBy, KnotDid: o.KnotDid, - RepoDid: repo, + RepoDid: o.RepoDid, Created: o.CreatedAt, } } diff --git a/deliberi/invites_test.go b/deliberi/invites_test.go index 5594fbe6f..78fb69491 100644 --- a/deliberi/invites_test.go +++ b/deliberi/invites_test.go @@ -20,15 +20,13 @@ const ( invitee = "did:plc:invitee" knotDid = "did:web:knot.example" repoDid = "did:plc:repo" - memberUri = "at://" + knotDid + "/sh.tangled.knot.memberInvite/" + invitee - otherUri = "at://" + knotDid + "/sh.tangled.knot.memberInvite/did:plc:olaren" collabUri = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/" + invitee + otherUri = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/did:plc:olaren" ) var offerMade = time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) type fakeLister struct { - members []InviteOffer collabs []InviteOffer pending []string truncated bool @@ -36,31 +34,19 @@ type fakeLister struct { calls *atomic.Int64 } -func (f *fakeLister) answer(offers []InviteOffer) (InviteAnswer, error) { +func (f *fakeLister) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { if f.calls != nil { f.calls.Add(1) } if f.err != nil { return InviteAnswer{}, f.err } - return InviteAnswer{Offers: offers, Pending: f.pending, Truncated: f.truncated}, nil -} - -func (f *fakeLister) ListMemberInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { - return f.answer(f.members) -} - -func (f *fakeLister) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { - return f.answer(f.collabs) + return InviteAnswer{Offers: f.collabs, Pending: f.pending, Truncated: f.truncated}, nil } func offerAt(uri, admin string) InviteOffer { - return InviteOffer{Uri: uri, KnotDid: knotDid, AddedBy: admin, CreatedAt: offerMade} -} - -func collabOffer(admin string) InviteOffer { return InviteOffer{ - Uri: collabUri, + Uri: uri, KnotDid: knotDid, RepoDid: repoDid, AddedBy: admin, @@ -87,17 +73,6 @@ func inviteRows(t *testing.T, s *InviteSync, did string) []*models.Notification return rows } -func rowAt(t *testing.T, rows []*models.Notification, uri string) *models.Notification { - t.Helper() - for _, row := range rows { - if row.AtUri == uri { - return row - } - } - t.Fatalf("no row at %q", uri) - return nil -} - func expire(s *InviteSync, did string) { s.mu.Lock() defer s.mu.Unlock() @@ -106,44 +81,34 @@ func expire(s *InviteSync, did string) { func TestReconcileCreatesRowFromOffer(t *testing.T) { s := newTestInviteSync(t, &fakeLister{ - members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}, - collabs: []InviteOffer{collabOffer("did:plc:boltless")}, + collabs: []InviteOffer{offerAt(collabUri, "did:plc:boltless")}, }) s.Reconcile(context.Background(), invitee) rows := inviteRows(t, s, invitee) - if len(rows) != 2 { - t.Fatalf("row count = %d, want 2 (one per offer)", len(rows)) + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1 (one per offer)", len(rows)) } - cases := []struct { - uri string - kind models.NotificationType - actor string - repo string - }{ - {memberUri, models.NotificationTypeKnotInvited, "did:plc:akshay", ""}, - {collabUri, models.NotificationTypeCollaboratorInvited, "did:plc:boltless", repoDid}, + row := rows[0] + if row.AtUri != collabUri { + t.Errorf("atUri = %q, want the offer's record %q", row.AtUri, collabUri) } - - for _, tc := range cases { - row := rowAt(t, rows, tc.uri) - if row.Type != tc.kind { - t.Errorf("%s: type = %q, want %q", tc.uri, row.Type, tc.kind) - } - if row.ActorDid != tc.actor { - t.Errorf("%s: actorDid = %q, want inviting admin %q", tc.uri, row.ActorDid, tc.actor) - } - if row.RepoDid != tc.repo { - t.Errorf("%s: repoDid = %q, want %q", tc.uri, row.RepoDid, tc.repo) - } - if row.KnotDid != knotDid { - t.Errorf("%s: knotDid = %q, want knot serving this offer", tc.uri, row.KnotDid) - } - if !row.Created.Equal(offerMade) { - t.Errorf("%s: created = %s, want offer's own %s", tc.uri, row.Created, offerMade) - } + if row.Type != models.NotificationTypeCollaboratorInvited { + t.Errorf("type = %q, want %q", row.Type, models.NotificationTypeCollaboratorInvited) + } + if row.ActorDid != "did:plc:boltless" { + t.Errorf("actorDid = %q, want inviting admin", row.ActorDid) + } + if row.RepoDid != repoDid { + t.Errorf("repoDid = %q, want %q", row.RepoDid, repoDid) + } + if row.KnotDid != knotDid { + t.Errorf("knotDid = %q, want knot serving this offer", row.KnotDid) + } + if !row.Created.Equal(offerMade) { + t.Errorf("created = %s, want offer's own %s", row.Created, offerMade) } } @@ -153,16 +118,16 @@ func TestReconcileDeletesOnlyWhatBobbinVouchesFor(t *testing.T) { then func(l *fakeLister) want int }{ - {"offer withdrawn", func(l *fakeLister) { l.members = nil }, 0}, + {"offer withdrawn", func(l *fakeLister) { l.collabs = nil }, 0}, { "offering knot pending", - func(l *fakeLister) { l.members, l.pending = nil, []string{knotDid} }, + func(l *fakeLister) { l.collabs, l.pending = nil, []string{knotDid} }, 1, }, { "answer truncated", func(l *fakeLister) { - l.members, l.truncated = []InviteOffer{offerAt(otherUri, "did:plc:akshay")}, true + l.collabs, l.truncated = []InviteOffer{offerAt(otherUri, "did:plc:akshay")}, true }, 2, }, @@ -171,7 +136,7 @@ func TestReconcileDeletesOnlyWhatBobbinVouchesFor(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - lister := &fakeLister{members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}} + lister := &fakeLister{collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}} s := newTestInviteSync(t, lister) s.Reconcile(context.Background(), invitee) if got := len(inviteRows(t, s, invitee)); got != 1 { @@ -191,13 +156,13 @@ func TestReconcileDeletesOnlyWhatBobbinVouchesFor(t *testing.T) { func TestReconcileDeletesOncePendingKnotIsRead(t *testing.T) { lister := &fakeLister{ - members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}, + collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}, pending: []string{knotDid}, } s := newTestInviteSync(t, lister) s.Reconcile(context.Background(), invitee) - lister.members, lister.pending = nil, nil + lister.collabs, lister.pending = nil, nil expire(s, invitee) s.Reconcile(context.Background(), invitee) @@ -207,17 +172,17 @@ func TestReconcileDeletesOncePendingKnotIsRead(t *testing.T) { } func TestReconcileReofferAfterWithdrawal(t *testing.T) { - lister := &fakeLister{members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}} + lister := &fakeLister{collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}} s := newTestInviteSync(t, lister) s.Reconcile(context.Background(), invitee) - lister.members = nil + lister.collabs = nil expire(s, invitee) s.Reconcile(context.Background(), invitee) - reoffer := offerAt(memberUri, "did:plc:akshay") + reoffer := offerAt(collabUri, "did:plc:akshay") reoffer.CreatedAt = time.Date(2026, 8, 31, 9, 15, 0, 0, time.UTC) - lister.members = []InviteOffer{reoffer} + lister.collabs = []InviteOffer{reoffer} expire(s, invitee) s.Reconcile(context.Background(), invitee) @@ -231,7 +196,7 @@ func TestReconcileReofferAfterWithdrawal(t *testing.T) { } func TestReconcileSkipsSelfInvite(t *testing.T) { - s := newTestInviteSync(t, &fakeLister{members: []InviteOffer{offerAt(memberUri, invitee)}}) + s := newTestInviteSync(t, &fakeLister{collabs: []InviteOffer{offerAt(collabUri, invitee)}}) s.Reconcile(context.Background(), invitee) @@ -253,14 +218,14 @@ func TestReconcileAsksBobbinOncePerWindow(t *testing.T) { }() } racers.Wait() - if got := calls.Load(); got != 2 { - t.Fatalf("listings across 16 concurrent bells = %d, want 2 (one pass, knot and repo)", got) + if got := calls.Load(); got != 1 { + t.Fatalf("listings across 16 concurrent bells = %d, want 1 (one pass)", got) } expire(s, invitee) s.Reconcile(context.Background(), invitee) - if got := calls.Load(); got != 4 { - t.Fatalf("listings once window passes = %d, want 4", got) + if got := calls.Load(); got != 2 { + t.Fatalf("listings once window passes = %d, want 2", got) } } diff --git a/deliberi/models/notifications.go b/deliberi/models/notifications.go index 648d16c30..75c87de1c 100644 --- a/deliberi/models/notifications.go +++ b/deliberi/models/notifications.go @@ -28,7 +28,6 @@ const ( NotificationTypeIssueUnassigned NotificationType = "issue_unassigned" NotificationTypePullAssigned NotificationType = "pull_assigned" NotificationTypePullUnassigned NotificationType = "pull_unassigned" - NotificationTypeKnotInvited NotificationType = "knot_invited" NotificationTypeCollaboratorInvited NotificationType = "collaborator_invited" ) @@ -52,7 +51,6 @@ var WorkNotificationTypes = []NotificationType{ NotificationTypeIssueUnassigned, NotificationTypePullAssigned, NotificationTypePullUnassigned, - NotificationTypeKnotInvited, NotificationTypeCollaboratorInvited, } @@ -114,8 +112,6 @@ func (n *Notification) Icon() string { return "user-round-arrow-forward" case NotificationTypeIssueUnassigned, NotificationTypePullUnassigned: return "user-round-minus" - case NotificationTypeKnotInvited: - return "server" case NotificationTypeCollaboratorInvited: return "users-round" default: @@ -132,7 +128,7 @@ func (n *Notification) URL(res *idresolver.Resolver) string { } switch n.Type { - case NotificationTypeFollowed, NotificationTypeKnotInvited: + case NotificationTypeFollowed: return "/" + resolve(n.ActorDid) } if n.RepoDid == "" || n.RepoName == "" { diff --git a/deliberi/xrpc/notifications.go b/deliberi/xrpc/notifications.go index 7c4d90c6b..8e33e5fb1 100644 --- a/deliberi/xrpc/notifications.go +++ b/deliberi/xrpc/notifications.go @@ -69,9 +69,6 @@ func (x *Xrpc) NotificationList(w http.ResponseWriter, r *http.Request) { if n.RepoDid != "" { item.RepoDid = &n.RepoDid } - if n.KnotDid != "" && n.Type == models.NotificationTypeKnotInvited { - item.KnotDid = &n.KnotDid - } if n.EntityAt != "" { switch syntax.ATURI(n.EntityAt).Collection().String() { case "sh.tangled.repo.issue": diff --git a/deliberi/xrpc/xrpc_test.go b/deliberi/xrpc/xrpc_test.go index 89c2eec97..26b7da6a7 100644 --- a/deliberi/xrpc/xrpc_test.go +++ b/deliberi/xrpc/xrpc_test.go @@ -326,44 +326,6 @@ func onlyNotification( return out.Notifications[0], out } -func TestNotificationListEmitsKnotDid(t *testing.T) { - router, d, sign := newTestXrpc(t) - - uri := "at://did:web:knot.example/sh.tangled.knot.memberInvite/" + testActor - if err := db.CreateNotification(d, &models.Notification{ - RecipientDid: testActor, - AtUri: uri, - Type: models.NotificationTypeKnotInvited, - ActorDid: "did:plc:akshay", - KnotDid: "did:web:knot.example", - }); err != nil { - t.Fatalf("CreateNotification: %v", err) - } - - n, out := onlyNotification(t, router, sign) - if n.Type != string(models.NotificationTypeKnotInvited) { - t.Fatalf("type = %q, want knot_invited", n.Type) - } - if n.Uri != uri { - t.Fatalf("uri = %q, want invite record %q", n.Uri, uri) - } - if n.ActorDid != "did:plc:akshay" { - t.Fatalf("actorDid = %q, want inviting admin", n.ActorDid) - } - if n.KnotDid == nil || *n.KnotDid != "did:web:knot.example" { - t.Fatalf("knotDid = %v, want did:web:knot.example", n.KnotDid) - } - if n.RepoDid != nil { - t.Fatalf("repoDid = %v on a knot offer, want absent", *n.RepoDid) - } - if n.Category != "work" { - t.Fatalf("category = %q, want work", n.Category) - } - if out.WorkUnreadCount != 1 { - t.Fatalf("workUnreadCount = %d, want 1", out.WorkUnreadCount) - } -} - func TestNotificationListHidesKnotOnCollaboratorOffer(t *testing.T) { router, d, sign := newTestXrpc(t) diff --git a/lexicons/temp/notification/listNotifications.json b/lexicons/temp/notification/listNotifications.json index dc2f427b7..dfdbca00c 100644 --- a/lexicons/temp/notification/listNotifications.json +++ b/lexicons/temp/notification/listNotifications.json @@ -86,11 +86,6 @@ "format": "did", "description": "DID of the related repository, if applicable." }, - "knotDid": { - "type": "string", - "format": "did", - "description": "did:web of knot that offered membership. Only knot_invited has it for now! Though in future more notifs may use it." - }, "issueAt": { "type": "string", "format": "at-uri", diff --git a/web/src/lib/api/accept.test.ts b/web/src/lib/api/accept.test.ts index 9d66164ad..06d85b107 100644 --- a/web/src/lib/api/accept.test.ts +++ b/web/src/lib/api/accept.test.ts @@ -8,33 +8,18 @@ import type * as Accept from "$lib/api/accept"; const agent = { sub: "did:plc:limpet" } as unknown as OAuthUserAgent; const KNOT = "knot.oyster.cafe"; -const KNOT_DID = "did:web:knot.oyster.cafe"; const REPO_DID = "did:plc:scallop"; -const offers: Record<"membership" | "collaboration", NotificationOffer> = { - membership: { - kind: "membership", - knot: new URL(`https://${KNOT}`), - subject: KNOT_DID as DidRkey - }, - collaboration: { - kind: "collaboration", - knot: new URL(`https://${KNOT}`), - subject: REPO_DID as DidRkey - } +const offer: NotificationOffer = { + kind: "collaboration", + knot: new URL(`https://${KNOT}`), + subject: REPO_DID as DidRkey }; -const WRITES = { - membership: { - collection: "sh.tangled.knot.memberAcceptance", - procedure: "sh.tangled.knot.acceptMembership", - key: KNOT_DID - }, - collaboration: { - collection: "sh.tangled.repo.collaboratorAcceptance", - procedure: "sh.tangled.repo.acceptCollaboration", - key: REPO_DID - } +const WRITE = { + collection: "sh.tangled.repo.collaboratorAcceptance", + procedure: "sh.tangled.repo.acceptCollaboration", + key: REPO_DID }; interface Write { @@ -91,61 +76,58 @@ const load = async (faults: Faults = {}) => { const refusing = async (faults: Faults = {}, knot?: typeof globalThis.fetch) => { const harness = await load(faults); const thrown = await harness.accept - .acceptOffer(agent, offers.membership, knot ?? harness.knot) + .acceptOffer(agent, offer, knot ?? harness.knot) .catch((cause: unknown) => cause); expect(thrown).toBeInstanceOf(harness.accept.OfferRefused); return { refused: thrown as Accept.OfferRefused, ...harness }; }; describe("accepting an offer", () => { - it.each(["membership", "collaboration"] as const)( - "a %s acceptance lands under its own subject, and the knot takes the uri under a fresh token", - async (kind) => { - const { accept, writes, minted, calls, steps, knot } = await load(); - const { collection, procedure, key } = WRITES[kind]; - - await accept.acceptOffer(agent, offers[kind], knot); - - expect(steps).toEqual(["record", "token", "call"]); - expect(writes[0]).toMatchObject({ - repo: agent.sub, - collection, - rkey: key, - record: { $type: collection } - }); - expect(Number.isFinite(Date.parse(writes[0].record.createdAt))).toBe(true); - expect(minted[0]).toMatchObject({ aud: key, lxm: procedure }); - expect(minted[0].signal).toBeInstanceOf(AbortSignal); - expect(calls[0].url).toBe(`https://${KNOT}/xrpc/${procedure}`); - expect(JSON.parse(String(calls[0].init.body))).toEqual({ - acceptance: `at://${agent.sub}/${collection}/${key}` - }); - expect(new Headers(calls[0].init.headers).get("authorization")).toBe( - `Bearer token-for-${key}` - ); - expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); - } - ); + it("the acceptance lands under its own subject, and the knot takes the uri under a fresh token", async () => { + const { accept, writes, minted, calls, steps, knot } = await load(); + const { collection, procedure, key } = WRITE; + + await accept.acceptOffer(agent, offer, knot); + + expect(steps).toEqual(["record", "token", "call"]); + expect(writes[0]).toMatchObject({ + repo: agent.sub, + collection, + rkey: key, + record: { $type: collection } + }); + expect(Number.isFinite(Date.parse(writes[0].record.createdAt))).toBe(true); + expect(minted[0]).toMatchObject({ aud: key, lxm: procedure }); + expect(minted[0].signal).toBeInstanceOf(AbortSignal); + expect(calls[0].url).toBe(`https://${KNOT}/xrpc/${procedure}`); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + acceptance: `at://${agent.sub}/${collection}/${key}` + }); + expect(new Headers(calls[0].init.headers).get("authorization")).toBe( + `Bearer token-for-${key}` + ); + expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); + }); it("xrpc url keeps the knot's port but drops its path", async () => { const { accept, calls, knot } = await load(); await accept.acceptOffer( agent, - { ...offers.membership, knot: new URL(`https://${KNOT}:8443/ignored`) }, + { ...offer, knot: new URL(`https://${KNOT}:8443/ignored`) }, knot ); - expect(calls[0].url).toBe(`https://${KNOT}:8443/xrpc/sh.tangled.knot.acceptMembership`); + expect(calls[0].url).toBe(`https://${KNOT}:8443/xrpc/${WRITE.procedure}`); }); it("a second accept rewrites the same record key", async () => { const { accept, writes, knot } = await load(); - await accept.acceptOffer(agent, offers.membership, knot); - await accept.acceptOffer(agent, offers.membership, knot); + await accept.acceptOffer(agent, offer, knot); + await accept.acceptOffer(agent, offer, knot); - expect(writes.map((write) => write.rkey)).toEqual([KNOT_DID, KNOT_DID]); + expect(writes.map((write) => write.rkey)).toEqual([REPO_DID, REPO_DID]); }); }); @@ -166,7 +148,7 @@ describe("OfferRefused owns the step that failed", () => { }); it("the knot's 403 arrives as the cause when the call fails", async () => { - const message = "no membership offer for you on this knot"; + const message = "no collaboration offer for you on this repo"; const knot = vi.fn( async () => new Response(JSON.stringify({ error: "Forbidden", message }), { @@ -220,8 +202,8 @@ describe("OfferRefused's sentence, stage by stage", () => { ["call", null, `${KNOT} didn't answer. Your acceptance is stored, so try again.`], [ "call", - "no membership offer for you on this knot", - "no membership offer for you on this knot" + "no collaboration offer for you on this repo", + "no collaboration offer for you on this repo" ] ] as const)("%s stage against %s", async (stage, description, expected) => { const { accept } = await load(); @@ -233,33 +215,23 @@ describe("OfferRefused's sentence, stage by stage", () => { data: { error: "Forbidden", message: description } }); - expect(new accept.OfferRefused(stage, cause).sentence(offers.membership.knot)).toBe( - expected - ); + expect(new accept.OfferRefused(stage, cause).sentence(offer.knot)).toBe(expected); }); }); describe("permissions an offer needs", () => { - it("all live in the client metadata, so fresh logins can accept either kind", async () => { + it("all live in the client metadata, so fresh logins can accept", async () => { const { accept } = await load(); - expect( - missingPermissions(oauthMetadata.scope, accept.permissionsFor(offers.membership)) - ).toEqual([]); - expect( - missingPermissions(oauthMetadata.scope, accept.permissionsFor(offers.collaboration)) - ).toEqual([]); + expect(missingPermissions(oauthMetadata.scope, accept.permissionsFor(offer))).toEqual([]); }); - it.each(["membership", "collaboration"] as const)( - "a %s writes one collection and calls one procedure", - async (kind) => { - const { accept } = await load(); - const { collection, procedure, key } = WRITES[kind]; + it("an offer writes one collection and calls one procedure", async () => { + const { accept } = await load(); + const { collection, procedure, key } = WRITE; - expect(accept.permissionsFor(offers[kind])).toEqual([ - { resource: "repo", collection, actions: ["create", "update"] }, - { resource: "rpc", lxm: procedure, aud: key } - ]); - } - ); + expect(accept.permissionsFor(offer)).toEqual([ + { resource: "repo", collection, actions: ["create", "update"] }, + { resource: "rpc", lxm: procedure, aud: key } + ]); + }); }); diff --git a/web/src/lib/api/accept.ts b/web/src/lib/api/accept.ts index 25b4b3ef9..d236f1235 100644 --- a/web/src/lib/api/accept.ts +++ b/web/src/lib/api/accept.ts @@ -7,29 +7,16 @@ import { mainSchema as acceptCollaborationSchema, type $input as CollaborationInput } from "$lib/api/lexicons/types/sh/tangled/repo/acceptCollaboration"; -import { - mainSchema as acceptMembershipSchema, - type $input as MembershipInput -} from "$lib/api/lexicons/types/sh/tangled/knot/acceptMembership"; import type * as CollaboratorAcceptance from "$lib/api/lexicons/types/sh/tangled/repo/collaboratorAcceptance"; -import type * as MemberAcceptance from "$lib/api/lexicons/types/sh/tangled/knot/memberAcceptance"; import { toResponseError } from "$lib/api/_request"; import { putRecord } from "$lib/api/write"; const ACCEPTANCES: { - membership: { - collection: MemberAcceptance.Main["$type"]; - procedure: typeof acceptMembershipSchema.nsid; - }; collaboration: { collection: CollaboratorAcceptance.Main["$type"]; procedure: typeof acceptCollaborationSchema.nsid; }; } = { - membership: { - collection: "sh.tangled.knot.memberAcceptance", - procedure: acceptMembershipSchema.nsid - }, collaboration: { collection: "sh.tangled.repo.collaboratorAcceptance", procedure: acceptCollaborationSchema.nsid @@ -116,7 +103,7 @@ export const acceptOffer = async ( signal: AbortSignal.timeout(MINT_DEADLINE_MS) }) ); - const body: MembershipInput & CollaborationInput = { acceptance: uri }; + const body: CollaborationInput = { acceptance: uri }; const response = await refusing("call", () => fetch(new URL(`/xrpc/${procedure}`, offer.knot), { method: "POST", diff --git a/web/src/lib/api/lexicons/types/org/tangled/temp/notification/listNotifications.ts b/web/src/lib/api/lexicons/types/org/tangled/temp/notification/listNotifications.ts index dee13f586..db3680414 100644 --- a/web/src/lib/api/lexicons/types/org/tangled/temp/notification/listNotifications.ts +++ b/web/src/lib/api/lexicons/types/org/tangled/temp/notification/listNotifications.ts @@ -58,10 +58,6 @@ const _notificationSchema = /*#__PURE__*/ v.object({ * AT-URI of the related org.tangled.issue.issue record, if applicable. */ issueAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), - /** - * did:web of knot that offered membership. Only knot_invited has it for now! Though in future more notifs may use it. - */ - knotDid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), /** * AT-URI of the related org.tangled.pulls.pull record, if applicable. */ diff --git a/web/src/lib/api/notifications.test.ts b/web/src/lib/api/notifications.test.ts index ca3397f48..ad31507d9 100644 --- a/web/src/lib/api/notifications.test.ts +++ b/web/src/lib/api/notifications.test.ts @@ -2,16 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { hydrateNotifications, type NotificationItem } from "$lib/api/notifications"; import { createBobbinClient, type BobbinContext } from "$lib/api/client"; -const knotOffer: NotificationItem = { - uri: "at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet", - type: "knot_invited", - category: "work", - actorDid: "did:plc:akshay", - read: false, - createdAt: "2026-06-01T00:00:00.000Z", - knotDid: "did:web:knot.oyster.cafe" -}; - const collaborationOffer: NotificationItem = { uri: "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet", type: "collaborator_invited", @@ -46,48 +36,17 @@ const ctxWithRepo = (value: Record) => const ctxWithoutBobbin = () => ({ xrpc: { call: vi.fn() } }) as unknown as BobbinContext; -describe("hydrateNotifications", () => { - it.each([ - ["did:web:knot.oyster.cafe", "knot.oyster.cafe"], - ["did:web:knot.oyster.cafe%3A8443", "knot.oyster.cafe:8443"], - ["did:web:knot.oyster.cafe%zz", "did:web:knot.oyster.cafe%zz"] - ])("builds knot summary from row's own %s", async (knotDid, domain) => { - const rows = await hydrateNotifications(ctxWithoutBobbin(), [{ ...knotOffer, knotDid }]); - expect(rows[0].knot).toEqual({ did: knotDid, domain }); - }); - - it("leaves knot unset where row omits knotDid", async () => { - const { knotDid: _knotDid, ...withoutKnot } = knotOffer; - const rows = await hydrateNotifications(ctxWithoutBobbin(), [ - { ...withoutKnot, type: "followed" } - ]); - expect(rows[0].knot).toBeUndefined(); - }); -}); - describe("an offer the row can act on", () => { - it("membership offers take their key from the knot that made them", async () => { - const rows = await hydrateNotifications(ctxWithoutBobbin(), [knotOffer]); - - expect(rows[0].offer).toEqual({ - kind: "membership", - knot: new URL("https://knot.oyster.cafe"), - subject: "did:web:knot.oyster.cafe" - }); - }); + it.each(["did:plc:scallop/path", "scallop", "..", ""])( + "offers nothing to accept where %s can't be an acceptance record key", + async (repoDid) => { + const rows = await hydrateNotifications(ctxWithRepo(repoRecord("knot.nel.pet")), [ + { ...collaborationOffer, repoDid } + ]); - it.each([ - "did:web:knot.oyster.cafe%3A8443", - "did:web:knot.oyster.cafe%zz", - "did:web:knot.oyster.cafe/path", - "knot.oyster.cafe", - "..", - "" - ])("offers nothing to accept where %s can't be an acceptance record key", async (knotDid) => { - const rows = await hydrateNotifications(ctxWithoutBobbin(), [{ ...knotOffer, knotDid }]); - - expect(rows[0].offer).toBeUndefined(); - }); + expect(rows[0].offer).toBeUndefined(); + } + ); it("a collaboration offer takes its knot from the repo record, never from the row", async () => { const rows = await hydrateNotifications(ctxWithRepo(repoRecord("knot.nel.pet")), [ @@ -117,8 +76,8 @@ describe("an offer the row can act on", () => { }); it("leaves every other notification type alone", async () => { - const rows = await hydrateNotifications(ctxWithoutBobbin(), [ - { ...knotOffer, type: "followed" } + const rows = await hydrateNotifications(ctxWithRepo(repoRecord("knot.nel.pet")), [ + { ...collaborationOffer, type: "followed" } ]); expect(rows[0].offer).toBeUndefined(); diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index 6d164cb52..d9ebd94c2 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -6,12 +6,10 @@ import { getIssues } from "$lib/api/issue"; import { getPulls } from "$lib/api/records"; import { repoNameOf } from "$lib/api/repo"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; -import { hostForServiceDid } from "$lib/auth/agent"; import { isDid, type Did } from "@atcute/lexicons/syntax"; import { parseDidRkey, type NotificationOffer } from "$lib/components/notifications/types"; import type { NotificationActor, - NotificationKnot, NotificationRepo, NotificationSubject, NotificationSummary, @@ -57,7 +55,6 @@ export interface NotificationItem { read: boolean; createdAt: string; repoDid?: string; - knotDid?: string; issueAt?: string; pullAt?: string; } @@ -123,11 +120,6 @@ const OFFERS: Partial< (item: NotificationItem, knotOfRepo: ReadonlyMap) => NotificationOffer | undefined > > = { - knot_invited: (item) => { - const subject = parseDidRkey(item.knotDid); - const knot = subject ? asKnot(hostForServiceDid(subject)) : null; - return subject && knot ? { kind: "membership", knot, subject } : undefined; - }, collaborator_invited: (item, knotOfRepo) => { const subject = parseDidRkey(item.repoDid); const knot = subject ? knotOfRepo.get(subject) : undefined; @@ -211,9 +203,6 @@ export const hydrateNotifications = async ( handle: item.actorDid }; const repo = item.repoDid ? repoMap.get(item.repoDid) : undefined; - const knot: NotificationKnot | undefined = item.knotDid - ? { did: item.knotDid, domain: hostForServiceDid(item.knotDid) ?? item.knotDid } - : undefined; let subject: NotificationSubject | undefined; if (item.issueAt) { subject = subjectFromUri(item.issueAt, "issue", titleMap.get(item.issueAt)); @@ -228,7 +217,6 @@ export const hydrateNotifications = async ( createdAt: item.createdAt, actor, repo, - knot, subject, offer: OFFERS[item.type as NotificationType]?.(item, knotOfRepo) }; diff --git a/web/src/lib/auth/scopes.test.ts b/web/src/lib/auth/scopes.test.ts index b54bdb391..87432bf4f 100644 --- a/web/src/lib/auth/scopes.test.ts +++ b/web/src/lib/auth/scopes.test.ts @@ -5,27 +5,30 @@ const KNOT = "did:web:knot.oyster.cafe"; const write: Permission = { resource: "repo", - collection: "sh.tangled.knot.memberAcceptance", + collection: "sh.tangled.repo.collaboratorAcceptance", actions: ["create", "update"] }; const call: Permission = { resource: "rpc", - lxm: "sh.tangled.knot.acceptMembership", + lxm: "sh.tangled.repo.acceptCollaboration", aud: KNOT }; const wanted = [write, call]; const covering: [string, readonly Permission[]][] = [ - ["repo:sh.tangled.knot.memberAcceptance", [write]], - ["repo?collection=sh.tangled.knot.memberAcceptance", [write]], + ["repo:sh.tangled.repo.collaboratorAcceptance", [write]], + ["repo?collection=sh.tangled.repo.collaboratorAcceptance", [write]], ["repo:*", [write]], - ["repo:sh.tangled.knot.memberAcceptance?action=create&action=update&action=delete", [write]], - ["rpc:sh.tangled.knot.acceptMembership?aud=*", [call]], - [`rpc?lxm=sh.tangled.knot.acceptMembership&aud=${KNOT}`, [call]], [ - `rpc:sh.tangled.knot.acceptMembership?aud=${KNOT}%23tangled_knot`, + "repo:sh.tangled.repo.collaboratorAcceptance?action=create&action=update&action=delete", + [write] + ], + ["rpc:sh.tangled.repo.acceptCollaboration?aud=*", [call]], + [`rpc?lxm=sh.tangled.repo.acceptCollaboration&aud=${KNOT}`, [call]], + [ + `rpc:sh.tangled.repo.acceptCollaboration?aud=${KNOT}%23tangled_knot`, [{ ...call, aud: `${KNOT}#tangled_knot` }] ], [`rpc:*?aud=${KNOT}`, [call]], @@ -38,9 +41,9 @@ const coveringNothing: [string, readonly Permission[]][] = [ ["repo:", [write]], ["repo?action=create", [write]], ["repo:sh.tangled.knot.member", [write]], - ["repo:sh.tangled.knot.memberAcceptance?action=create", [write]], - ["rpc:sh.tangled.knot.acceptMembership", [call]], - ["rpc?lxm=sh.tangled.knot.acceptMembership", [call]], + ["repo:sh.tangled.repo.collaboratorAcceptance?action=create", [write]], + ["rpc:sh.tangled.repo.acceptCollaboration", [call]], + ["rpc?lxm=sh.tangled.repo.acceptCollaboration", [call]], ["rpc:sh.tangled.knot.*?aud=*", [call]], ["rpc:sh.tangled.knot.addMember?aud=*", wanted], ["", wanted], @@ -57,8 +60,8 @@ describe("a granted scope", () => { }); it("covers its own audience and nobody else's", () => { - const mine = `rpc?lxm=sh.tangled.knot.acceptMembership&aud=${KNOT}`; - const theirs = "rpc?lxm=sh.tangled.knot.acceptMembership&aud=did:web:other.example"; + const mine = `rpc?lxm=sh.tangled.repo.acceptCollaboration&aud=${KNOT}`; + const theirs = "rpc?lxm=sh.tangled.repo.acceptCollaboration&aud=did:web:other.example"; expect(missingPermissions(mine, [call])).toEqual([]); expect(missingPermissions(theirs, [call])).toEqual([call]); diff --git a/web/src/lib/components/notifications/NotificationItem.stories.svelte b/web/src/lib/components/notifications/NotificationItem.stories.svelte index c187ef3ac..85c7d4b53 100644 --- a/web/src/lib/components/notifications/NotificationItem.stories.svelte +++ b/web/src/lib/components/notifications/NotificationItem.stories.svelte @@ -9,8 +9,7 @@ const starred = notifications[2]; const merged = notifications[6]; const followed = notifications[4]; - const knotInvite = notifications[7]; - const collaboratorInvite = notifications[8]; + const collaboratorInvite = notifications[7]; const { Story } = defineMeta({ title: "Notifications/NotificationItem", @@ -24,7 +23,7 @@ const frame = "grid grid-cols-[auto_1fr_auto] gap-x-4 divide-y divide-border-default overflow-hidden rounded-sm border border-border-default"; - const withdrawn = "no membership offer for you on this knot"; + const withdrawn = "no collaboration offer for you on this repo"; const acceptancePublished = (pathname: string, init: RequestInit): Response => { if (pathname.includes("getServiceAuth")) { @@ -94,12 +93,6 @@ {/snippet} - - {#snippet template(args)} -
- {/snippet} -
- {#snippet template(args)} @@ -122,7 +115,7 @@ {#snippet template(args)} @@ -131,7 +124,7 @@ withKnot(new Response(null, { status: 200 }), async (calls) => { @@ -139,7 +132,7 @@ await userEvent.click(canvas.getByRole("button", { name: "Accept" })); await waitFor(() => expect(canvas.getByText("Accepted")).toBeVisible()); expect(calls).toEqual([ - "https://knot.tangled.sh/xrpc/sh.tangled.knot.acceptMembership" + "https://knot.tangled.sh/xrpc/sh.tangled.repo.acceptCollaboration" ]); })} > @@ -150,7 +143,7 @@ withKnot( diff --git a/web/src/lib/components/notifications/NotificationItem.test.ts b/web/src/lib/components/notifications/NotificationItem.test.ts index 463258ab4..08419629e 100644 --- a/web/src/lib/components/notifications/NotificationItem.test.ts +++ b/web/src/lib/components/notifications/NotificationItem.test.ts @@ -39,7 +39,7 @@ const insideTheLink = (html: string): string => { describe("a notification row", () => { it("no control sits inside the link, since anchors can't wrap buttons", () => { - const html = markup(rowOf("knot_invited"), signedIn(oauthMetadata.scope)); + const html = markup(rowOf("collaborator_invited"), signedIn(oauthMetadata.scope)); expect(insideTheLink(html)).not.toContain(" { expect(insideTheLink(markup(rowOf("user_mentioned")))).toContain("after:inset-0"); }); - it("either invite kind gets an accept button", () => { - expect(markup(rowOf("knot_invited"), signedIn(oauthMetadata.scope))).toContain("Accept"); + it("an invite gets an accept button", () => { expect(markup(rowOf("collaborator_invited"), signedIn(oauthMetadata.scope))).toContain( "Accept" ); }); it("a session minted before the scopes goes back through authorization", () => { - const html = markup(rowOf("knot_invited"), signedIn("atproto repo:sh.tangled.knot")); + const html = markup( + rowOf("collaborator_invited"), + signedIn("atproto repo:sh.tangled.repo") + ); expect(html).toContain("Sign in again to accept"); expect(html).not.toContain(">Accept<"); @@ -65,13 +67,13 @@ describe("a notification row", () => { it("no session, no auth context, no accept button", () => { const signedOut = markup( - rowOf("knot_invited"), + rowOf("collaborator_invited"), new Map([[AUTH_KEY, { agent: null, currentDid: null } as unknown as Auth]]) ); expect(signedOut).not.toContain("Accept"); expect(signedOut.split(" { diff --git a/web/src/lib/components/notifications/mock.ts b/web/src/lib/components/notifications/mock.ts index ea3525902..cc859dec2 100644 --- a/web/src/lib/components/notifications/mock.ts +++ b/web/src/lib/components/notifications/mock.ts @@ -17,8 +17,7 @@ const dave = { did: "did:plc:kx4mzvhqrjpwqmvlbdxdlbnn", handle: "dave.tngl.sh" } const core = { ownerHandle: "tangled.org", name: "core" }; const knot = { ownerHandle: "tangled.org", name: "knot" }; -const knotHost = { did: "did:web:knot.tangled.sh", domain: "knot.tangled.sh" }; -const knotUrl = new URL(`https://${knotHost.domain}`); +const knotUrl = new URL("https://knot.tangled.sh"); const subjectUri = (kind: "issue" | "pull", number: string) => `at://did:plc:mock/sh.tangled.repo.${kind}/${number}`; @@ -116,16 +115,6 @@ export const notifications: NotificationSummary[] = [ title: "appview: resolve handles inside the timeline feed" } }, - { - uri: `at://mock/notif/${16}`, - id: 16, - type: "knot_invited", - read: false, - createdAt: ago(60 * 33), - actor: alice, - knot: knotHost, - offer: { kind: "membership", knot: knotUrl, subject: "did:web:knot.tangled.sh" as DidRkey } - }, { uri: `at://mock/notif/${17}`, id: 17, diff --git a/web/src/lib/components/notifications/notification.test.ts b/web/src/lib/components/notifications/notification.test.ts index b54f5f112..53bdea808 100644 --- a/web/src/lib/components/notifications/notification.test.ts +++ b/web/src/lib/components/notifications/notification.test.ts @@ -18,13 +18,6 @@ const summary = (overrides: Partial = {}): NotificationSumm ...overrides }); -const knotOffer = summary({ - type: "knot_invited", - knot: { did: "did:web:knot.test", domain: "knot.test" }, - repo: undefined, - subject: undefined -}); - describe("notificationHref", () => { it("links a named repo issue to its owner/repo path", () => { expect(notificationHref(summary())).toBe( @@ -48,10 +41,6 @@ describe("notificationHref", () => { expect(notificationHref(summary({ repo: undefined }))).toBe("/alice.test"); }); - it("links knot offer at admin who made it, because knot pages don't exist yet", () => { - expect(notificationHref(knotOffer)).toBe("/alice.test"); - }); - it("links collaboration offer to its repository", () => { expect( notificationHref(summary({ type: "collaborator_invited", subject: undefined })) @@ -60,7 +49,11 @@ describe("notificationHref", () => { }); describe("notificationTarget", () => { - it("titles knot offer with knot's domain", () => { - expect(notificationTarget(knotOffer)).toBe("knot.test"); + it("titles a row with owner/repo", () => { + expect(notificationTarget(summary())).toBe("alice.test/core"); + }); + + it("leaves a repo-less row untitled", () => { + expect(notificationTarget(summary({ repo: undefined }))).toBe(""); }); }); diff --git a/web/src/lib/components/notifications/notification.ts b/web/src/lib/components/notifications/notification.ts index 715e1816d..87fdff210 100644 --- a/web/src/lib/components/notifications/notification.ts +++ b/web/src/lib/components/notifications/notification.ts @@ -8,7 +8,6 @@ import GitMerge from "$icon/git-merge"; import GitPullRequestClosed from "$icon/git-pull-request-closed"; import GitPullRequestCreate from "$icon/git-pull-request-create"; import MessageSquare from "$icon/message-square"; -import Server from "$icon/server"; import Star from "$icon/star"; import UserPlus from "$icon/user-plus"; import UserRoundMinus from "$icon/user-round-minus"; @@ -42,7 +41,6 @@ const CATEGORIES: Record = { pull_assigned: "work", pull_unassigned: "work", user_mentioned: "work", - knot_invited: "work", collaborator_invited: "work" }; @@ -63,7 +61,6 @@ const ICONS: Record = { pull_assigned: UserRoundArrowForward, pull_unassigned: UserRoundMinus, user_mentioned: AtSign, - knot_invited: Server, collaborator_invited: UsersRound }; @@ -86,7 +83,6 @@ const TONES: Record = { pull_assigned: "text-foreground-info", pull_unassigned: "text-foreground-muted", user_mentioned: "text-foreground-info", - knot_invited: "text-foreground-info", collaborator_invited: "text-foreground-info" }; @@ -107,7 +103,6 @@ const PHRASES: Record = { pull_assigned: "assigned you to a PR on", pull_unassigned: "unassigned you from a PR on", user_mentioned: "mentioned you in", - knot_invited: "invited you to", collaborator_invited: "invited you to collaborate on" }; @@ -128,8 +123,7 @@ export const notificationPhrase = (notification: NotificationSummary): string => }; export const notificationTarget = (notification: NotificationSummary): string => - notification.knot?.domain ?? - (notification.repo ? `${notification.repo.ownerHandle}/${notification.repo.name}` : ""); + notification.repo ? `${notification.repo.ownerHandle}/${notification.repo.name}` : ""; export const notificationHref = (notification: NotificationSummary): string => { if (notification.type === "followed") return `/${notification.actor.handle}`; diff --git a/web/src/lib/components/notifications/types.ts b/web/src/lib/components/notifications/types.ts index d081ca25c..39194bfd2 100644 --- a/web/src/lib/components/notifications/types.ts +++ b/web/src/lib/components/notifications/types.ts @@ -24,7 +24,6 @@ export type NotificationType = | "pull_assigned" | "pull_unassigned" | "user_mentioned" - | "knot_invited" | "collaborator_invited"; export type NotificationCategory = "work" | "social"; @@ -43,13 +42,8 @@ export interface NotificationRepo { name: string; } -export interface NotificationKnot { - did: string; - domain: string; -} - export interface NotificationOffer { - kind: "membership" | "collaboration"; + kind: "collaboration"; knot: URL; subject: DidRkey; } @@ -69,7 +63,6 @@ export interface NotificationSummary { createdAt: string; actor: NotificationActor; repo?: NotificationRepo; - knot?: NotificationKnot; subject?: NotificationSubject; offer?: NotificationOffer; } diff --git a/web/src/lib/oauth-client-metadata.ts b/web/src/lib/oauth-client-metadata.ts index bd1520cce..35e0530de 100644 --- a/web/src/lib/oauth-client-metadata.ts +++ b/web/src/lib/oauth-client-metadata.ts @@ -10,7 +10,6 @@ const scopes = [ "repo:sh.tangled.graph.vouch", "repo:sh.tangled.knot", "repo:sh.tangled.knot.member", - "repo:sh.tangled.knot.memberAcceptance", "repo:sh.tangled.label.definition", "repo:sh.tangled.label.op", "repo:sh.tangled.publicKey", @@ -63,7 +62,6 @@ const scopes = [ "rpc:sh.tangled.git.keepCommit?aud=*", "rpc:sh.tangled.git.mergeCommit?aud=*", "rpc:sh.tangled.graph.listNetworkVouches?aud=*", - "rpc:sh.tangled.knot.acceptMembership?aud=*", "rpc:sh.tangled.knot.addMember?aud=*", "rpc:sh.tangled.knot.removeMember?aud=*", "rpc:sh.tangled.repo.acceptCollaboration?aud=*", -- 2.51.2