From 9aaa180e06ec869546ea7da94dfa063bfe984a3c Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 18 Aug 2026 11:53:31 +0300 Subject: [PATCH] lexicons,appview,knot2: model member and collaborator invites Lewis: May this revision serve well! --- api/tangled/knotacceptMembership.go | 30 ++ api/tangled/knotlistMemberInvites.go | 59 +++ api/tangled/knotlistMembers.go | 20 +- api/tangled/knotmember.go | 2 + api/tangled/repoacceptCollaboration.go | 30 ++ api/tangled/repocollaborator.go | 2 + api/tangled/repolistCollaboratorInvites.go | 59 +++ api/tangled/repolistCollaborators.go | 8 +- appview/knotacl/client.go | 4 +- appview/knots/knots.go | 6 + appview/oauth/handler.go | 42 +- appview/oauth/handler_test.go | 24 +- appview/oauth/oauth.go | 13 +- appview/oauth/scopes.go | 4 + .../knots/fragments/addMemberModal.html | 1 + .../pages/templates/repo/settings/access.html | 1 + appview/repo/repo.go | 7 + knot2/crates/knot-acl/Cargo.toml | 2 + knot2/crates/knot-acl/src/lib.rs | 293 +++++----- knot2/crates/knot-cob/src/lib.rs | 4 +- knot2/crates/knot-cobs/src/blocklist.rs | 17 +- knot2/crates/knot-cobs/src/collaborators.rs | 20 +- knot2/crates/knot-cobs/src/grant.rs | 501 +++++++++++++++--- knot2/crates/knot-cobs/src/lib.rs | 11 +- knot2/crates/knot-cobs/src/members.rs | 59 +-- knot2/crates/knot-cobs/tests/cobs.rs | 4 +- knot2/crates/knot-cobs/tests/invariants.rs | 111 +++- knot2/crates/knot-lexicons/src/lib.rs | 119 +++++ knot2/crates/knot-types/src/ids.rs | 44 ++ knot2/crates/knot-types/src/lib.rs | 8 +- lexicons/knot/acceptMembership.json | 26 + lexicons/knot/listMemberInvites.json | 70 +++ lexicons/knot/listMembers.json | 45 +- lexicons/knot/member.json | 2 + lexicons/repo/acceptCollaboration.json | 26 + lexicons/repo/collaborator.json | 2 + lexicons/repo/listCollaboratorInvites.json | 70 +++ lexicons/repo/listCollaborators.json | 16 +- spindle/knotstream_test.go | 70 +++ web/src/lib/api/lexicons/index.ts | 4 + .../types/sh/tangled/knot/acceptMembership.ts | 35 ++ .../sh/tangled/knot/listMemberInvites.ts | 81 +++ .../types/sh/tangled/knot/listMembers.ts | 32 +- .../sh/tangled/repo/acceptCollaboration.ts | 35 ++ .../tangled/repo/listCollaboratorInvites.ts | 86 +++ .../sh/tangled/repo/listCollaborators.ts | 12 +- web/src/lib/api/repoCreationTargets.ts | 12 +- xrpc/serviceauth/service_auth.go | 22 +- 48 files changed, 1771 insertions(+), 380 deletions(-) create mode 100644 api/tangled/knotacceptMembership.go create mode 100644 api/tangled/knotlistMemberInvites.go create mode 100644 api/tangled/repoacceptCollaboration.go create mode 100644 api/tangled/repolistCollaboratorInvites.go create mode 100644 lexicons/knot/acceptMembership.json create mode 100644 lexicons/knot/listMemberInvites.json create mode 100644 lexicons/repo/acceptCollaboration.json create mode 100644 lexicons/repo/listCollaboratorInvites.json create mode 100644 spindle/knotstream_test.go create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/knot/acceptMembership.ts create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/knot/listMemberInvites.ts create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/repo/acceptCollaboration.ts create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaboratorInvites.ts diff --git a/api/tangled/knotacceptMembership.go b/api/tangled/knotacceptMembership.go new file mode 100644 index 000000000..1d4296186 --- /dev/null +++ b/api/tangled/knotacceptMembership.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.knot.acceptMembership + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + KnotAcceptMembershipNSID = "sh.tangled.knot.acceptMembership" +) + +// KnotAcceptMembership_Input is the input argument to a sh.tangled.knot.acceptMembership call. +type KnotAcceptMembership_Input struct { + // acceptance: AT-URI of the sh.tangled.knot.memberAcceptance record, w/ knot-DID as rkey + Acceptance string `json:"acceptance" cborgen:"acceptance"` +} + +// KnotAcceptMembership calls the XRPC method "sh.tangled.knot.acceptMembership". +func KnotAcceptMembership(ctx context.Context, c util.LexClient, input *KnotAcceptMembership_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.knot.acceptMembership", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/knotlistMemberInvites.go b/api/tangled/knotlistMemberInvites.go new file mode 100644 index 000000000..bd498efd4 --- /dev/null +++ b/api/tangled/knotlistMemberInvites.go @@ -0,0 +1,59 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.knot.listMemberInvites + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + KnotListMemberInvitesNSID = "sh.tangled.knot.listMemberInvites" +) + +// KnotListMemberInvites_InviteItem is a "inviteItem" in the sh.tangled.knot.listMemberInvites schema. +// +// An offer awaiting the subject's acceptance. effectiveSince and access both arrive once the acceptance resolves. +type KnotListMemberInvites_InviteItem struct { + // addedBy: DID that made the offer + AddedBy string `json:"addedBy" cborgen:"addedBy"` + // createdAt: When the knot made the offer. This listing sorts and pages on it. + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // subject: DID the knot offered membership to + Subject string `json:"subject" cborgen:"subject"` +} + +// KnotListMemberInvites_Output is the output of a sh.tangled.knot.listMemberInvites call. +type KnotListMemberInvites_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Items []*KnotListMemberInvites_InviteItem `json:"items" cborgen:"items"` +} + +// KnotListMemberInvites calls the XRPC method "sh.tangled.knot.listMemberInvites". +// +// cursor: Pagination cursor +// order: Sort direction by createdAt. +// subject: Knot identifier whose outstanding membership offers to list. +func KnotListMemberInvites(ctx context.Context, c util.LexClient, cursor string, limit int64, order string, subject string) (*KnotListMemberInvites_Output, error) { + var out KnotListMemberInvites_Output + + params := map[string]interface{}{} + if cursor != "" { + params["cursor"] = cursor + } + if limit != 0 { + params["limit"] = limit + } + if order != "" { + params["order"] = order + } + params["subject"] = subject + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listMemberInvites", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/knotlistMembers.go b/api/tangled/knotlistMembers.go index 7aace791d..33624ffd1 100644 --- a/api/tangled/knotlistMembers.go +++ b/api/tangled/knotlistMembers.go @@ -16,10 +16,20 @@ const ( // KnotListMembers_ListItem is a "listItem" in the sh.tangled.knot.listMembers schema. type KnotListMembers_ListItem struct { + // addedBy: DID that added this member + AddedBy string `json:"addedBy" cborgen:"addedBy"` + // cid: Optional record CID for record-backed indexers Cid *string `json:"cid,omitempty" cborgen:"cid,omitempty"` - Uri string `json:"uri" cborgen:"uri"` - // value: Embedded sh.tangled.knot.member record - Value *util.LexiconTypeDecoder `json:"value" cborgen:"value"` + // createdAt: When the knot offered membership + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // effectiveSince: When the membership took effect. This listing sorts and pages on it. + EffectiveSince string `json:"effectiveSince" cborgen:"effectiveSince"` + // subject: DID of the member + Subject string `json:"subject" cborgen:"subject"` + // uri: Optional record AT-URI for record-backed indexers + Uri *string `json:"uri,omitempty" cborgen:"uri,omitempty"` + // verifiedAt: When the knot resolved the member's own acceptance record. Absent on a grant that predates the consent model. + VerifiedAt *string `json:"verifiedAt,omitempty" cborgen:"verifiedAt,omitempty"` } // KnotListMembers_Output is the output of a sh.tangled.knot.listMembers call. @@ -34,8 +44,8 @@ type KnotListMembers_Output struct { // // cursor: Pagination cursor // offset: Absolute offset for random-access pagination. Mutually exclusive with cursor; offsets drift under concurrent writes, so follow up with the returned cursor. -// order: Sort direction by createdAt. -// subject: Actor DID whose knot memberships to list. +// order: Sort direction by effectiveSince. +// subject: Knot identifier whose member records to list. func KnotListMembers(ctx context.Context, c util.LexClient, cursor string, limit int64, offset int64, order string, subject string) (*KnotListMembers_Output, error) { var out KnotListMembers_Output diff --git a/api/tangled/knotmember.go b/api/tangled/knotmember.go index dcb6b285c..a65d83732 100644 --- a/api/tangled/knotmember.go +++ b/api/tangled/knotmember.go @@ -15,6 +15,8 @@ const ( func init() { util.RegisterType("sh.tangled.knot.member", &KnotMember{}) } // +// DEPRECATED: use sh.tangled.knot.memberInvite plus sh.tangled.knot.memberAcceptance instead. Existing records keep their meaning and this NSID is never reused, so indexers shouldn't migrate what they have already stored. +// // RECORDTYPE: KnotMember type KnotMember struct { LexiconTypeID string `json:"$type,const=sh.tangled.knot.member" cborgen:"$type,const=sh.tangled.knot.member"` diff --git a/api/tangled/repoacceptCollaboration.go b/api/tangled/repoacceptCollaboration.go new file mode 100644 index 000000000..2f4a15d09 --- /dev/null +++ b/api/tangled/repoacceptCollaboration.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.repo.acceptCollaboration + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + RepoAcceptCollaborationNSID = "sh.tangled.repo.acceptCollaboration" +) + +// RepoAcceptCollaboration_Input is the input argument to a sh.tangled.repo.acceptCollaboration call. +type RepoAcceptCollaboration_Input struct { + // acceptance: AT-URI of the sh.tangled.repo.collaboratorAcceptance record, w/ repo-DID as rkey + Acceptance string `json:"acceptance" cborgen:"acceptance"` +} + +// RepoAcceptCollaboration calls the XRPC method "sh.tangled.repo.acceptCollaboration". +func RepoAcceptCollaboration(ctx context.Context, c util.LexClient, input *RepoAcceptCollaboration_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.acceptCollaboration", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/repocollaborator.go b/api/tangled/repocollaborator.go index c13bdc9cc..4bccad88a 100644 --- a/api/tangled/repocollaborator.go +++ b/api/tangled/repocollaborator.go @@ -15,6 +15,8 @@ const ( func init() { util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) } // +// DEPRECATED: use sh.tangled.repo.collaboratorInvite plus sh.tangled.repo.collaboratorAcceptance instead. Existing records keep their meaning and this NSID is never reused, so indexers shouldn't migrate what they have already stored. +// // RECORDTYPE: RepoCollaborator type RepoCollaborator struct { LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` diff --git a/api/tangled/repolistCollaboratorInvites.go b/api/tangled/repolistCollaboratorInvites.go new file mode 100644 index 000000000..7a2ac0320 --- /dev/null +++ b/api/tangled/repolistCollaboratorInvites.go @@ -0,0 +1,59 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.repo.listCollaboratorInvites + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + RepoListCollaboratorInvitesNSID = "sh.tangled.repo.listCollaboratorInvites" +) + +// RepoListCollaboratorInvites_InviteItem is a "inviteItem" in the sh.tangled.repo.listCollaboratorInvites schema. +// +// An offer awaiting the subject's acceptance. effectiveSince and access both arrive once the acceptance resolves. +type RepoListCollaboratorInvites_InviteItem struct { + // addedBy: DID that made the offer + AddedBy string `json:"addedBy" cborgen:"addedBy"` + // createdAt: When the repository made the offer. This listing sorts and pages on it. + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // subject: DID the repository offered collaboration to + Subject string `json:"subject" cborgen:"subject"` +} + +// RepoListCollaboratorInvites_Output is the output of a sh.tangled.repo.listCollaboratorInvites call. +type RepoListCollaboratorInvites_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Items []*RepoListCollaboratorInvites_InviteItem `json:"items" cborgen:"items"` +} + +// RepoListCollaboratorInvites calls the XRPC method "sh.tangled.repo.listCollaboratorInvites". +// +// cursor: Pagination cursor +// order: Sort direction by createdAt. +// subject: Repo DID whose outstanding collaboration offers to list. +func RepoListCollaboratorInvites(ctx context.Context, c util.LexClient, cursor string, limit int64, order string, subject string) (*RepoListCollaboratorInvites_Output, error) { + var out RepoListCollaboratorInvites_Output + + params := map[string]interface{}{} + if cursor != "" { + params["cursor"] = cursor + } + if limit != 0 { + params["limit"] = limit + } + if order != "" { + params["order"] = order + } + params["subject"] = subject + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listCollaboratorInvites", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/repolistCollaborators.go b/api/tangled/repolistCollaborators.go index f2eb55617..2f83ecd24 100644 --- a/api/tangled/repolistCollaborators.go +++ b/api/tangled/repolistCollaborators.go @@ -20,12 +20,16 @@ type RepoListCollaborators_ListItem struct { AddedBy string `json:"addedBy" cborgen:"addedBy"` // cid: Optional record CID for record-backed indexers Cid *string `json:"cid,omitempty" cborgen:"cid,omitempty"` - // createdAt: When the collaborator was added + // createdAt: When the repository offered collaboration CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // effectiveSince: When the collaboration took effect. This listing sorts and pages on it. + EffectiveSince string `json:"effectiveSince" cborgen:"effectiveSince"` // subject: DID of the collaborator Subject string `json:"subject" cborgen:"subject"` // uri: Optional record AT-URI for record-backed indexers Uri *string `json:"uri,omitempty" cborgen:"uri,omitempty"` + // verifiedAt: When the knot resolved the collaborator's own acceptance record. Absent on a grant that predates the consent model. + VerifiedAt *string `json:"verifiedAt,omitempty" cborgen:"verifiedAt,omitempty"` } // RepoListCollaborators_Output is the output of a sh.tangled.repo.listCollaborators call. @@ -40,7 +44,7 @@ type RepoListCollaborators_Output struct { // // cursor: Pagination cursor // offset: Absolute offset for random-access pagination. Mutually exclusive with cursor; offsets drift under concurrent writes, so follow up with the returned cursor. -// order: Sort direction by createdAt. +// order: Sort direction by effectiveSince. // subject: Repo DID whose collaborator records to list. func RepoListCollaborators(ctx context.Context, c util.LexClient, cursor string, limit int64, offset int64, order string, subject string) (*RepoListCollaborators_Output, error) { var out RepoListCollaborators_Output diff --git a/appview/knotacl/client.go b/appview/knotacl/client.go index fd63c2de2..db26a4346 100644 --- a/appview/knotacl/client.go +++ b/appview/knotacl/client.go @@ -49,7 +49,7 @@ func (c *Client) GetKnotMembers(ctx context.Context, host string) ([]string, err "", make(map[string]struct{}), func(cursor string) ([]*tangled.KnotListMembers_ListItem, *string, error) { - out, err := tangled.KnotListMembers(ctx, xc, cursor, listPageLimit, "", host) + out, err := tangled.KnotListMembers(ctx, xc, cursor, listPageLimit, 0, "", host) if err != nil { return nil, nil, err } @@ -75,7 +75,7 @@ func (c *Client) GetRepoCollaborators(ctx context.Context, host, repoDid string) "", make(map[string]struct{}), func(cursor string) ([]*tangled.RepoListCollaborators_ListItem, *string, error) { - out, err := tangled.RepoListCollaborators(ctx, xc, cursor, listPageLimit, "", repoDid) + out, err := tangled.RepoListCollaborators(ctx, xc, cursor, listPageLimit, 0, "", repoDid) if err != nil { return nil, nil, err } diff --git a/appview/knots/knots.go b/appview/knots/knots.go index b73573894..bfdfbbd96 100644 --- a/appview/knots/knots.go +++ b/appview/knots/knots.go @@ -533,6 +533,7 @@ func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { registration := registrations[0] noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) + offerId := fmt.Sprintf("add-member-notice-%d", registration.Id) defaultErr := "Failed to add member. Try again later." fail := func() { k.Pages.Notice(w, noticeId, defaultErr) @@ -589,6 +590,11 @@ func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { k.Acl.InvalidateMembers(domain) + if !k.Acl.IsKnotMember(r.Context(), domain, memberId.DID.String()) { + k.Pages.Notice(w, offerId, fmt.Sprintf("Invited %s. The member list won't change until they accept from their own account.", memberId.Handle)) + return + } + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) return } diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index 04f2a77f8..66e55223f 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -27,6 +27,7 @@ import ( "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/tid" + "tangled.org/core/xrpc/serviceauth" ) const knotAdminTimeout = 30 * time.Second @@ -118,7 +119,7 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { o.ensureProfileRecord(sessData.AccountDID, sessData.SessionID) - go o.addToDefaultKnot(sessData.AccountDID) + go o.addToDefaultKnot(sessData.AccountDID, sessData.SessionID) go o.addToDefaultSpindle(sessData.AccountDID.String()) go o.autoClaimTnglShDomain(sessData.AccountDID.String()) @@ -236,7 +237,7 @@ func onboardActionFor(s defaultKnotState) onboardAction { } } -func (o *OAuth) addToDefaultKnot(did syntax.DID) { +func (o *OAuth) addToDefaultKnot(did syntax.DID, sessionId string) { l := o.Logger.With("subject", did) ctx := context.Background() @@ -254,7 +255,12 @@ func (o *OAuth) addToDefaultKnot(did syntax.DID) { l.Error("failed to add to default knot via admin api", "err", err) return } + err := o.acceptDefaultKnotMembership(ctx, did, sessionId) o.Acl.InvalidateMembers(o.Config.Knot.Default) + if err != nil { + l.Error("failed to accept default knot invite", "err", err) + return + } l.Debug("successfully added to default knot via admin api") case onboardBlockedMissingSecret: @@ -292,6 +298,38 @@ func (o *OAuth) addToDefaultKnot(did syntax.DID) { } } +func (o *OAuth) acceptDefaultKnotMembership(ctx context.Context, did syntax.DID, sessionId string) error { + rkey, err := serviceauth.RkeyForService(o.Config.Knot.Default) + if err != nil { + return fmt.Errorf("failed to derive the default knot's acceptance record key: %w", err) + } + session, err := o.resumeSession(ctx, did, sessionId) + if err != nil { + return fmt.Errorf("failed to resume session: %w", err) + } + acceptance, err := ensureAcceptanceRecord(ctx, session.APIClient(), did, rkey) + if err != nil { + return err + } + client, err := o.SessionServiceClient(ctx, did, sessionId, WithService(o.Config.Knot.Default), WithLxm(tangled.KnotAcceptMembershipNSID), WithDev(o.Config.Core.Dev), WithTimeout(knotAdminTimeout)) + if err != nil { + return fmt.Errorf("failed to build knot service client: %w", err) + } + return tangled.KnotAcceptMembership(ctx, client, &tangled.KnotAcceptMembership_Input{Acceptance: acceptance}) +} + +func ensureAcceptanceRecord(ctx context.Context, client lexutil.LexClient, did syntax.DID, rkey serviceauth.ServiceRkey) (string, error) { + if held, err := comatproto.RepoGetRecord(ctx, client, "", tangled.KnotMemberAcceptanceNSID, did.String(), rkey.String()); err == nil && held != nil { + return held.Uri, nil + } + acceptance := &tangled.KnotMemberAcceptance{LexiconTypeID: tangled.KnotMemberAcceptanceNSID, CreatedAt: time.Now().Format(time.RFC3339)} + written, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{Collection: tangled.KnotMemberAcceptanceNSID, Repo: did.String(), Rkey: rkey.String(), Record: &lexutil.LexiconTypeDecoder{Val: acceptance}}) + if err != nil { + return "", fmt.Errorf("failed to write acceptance record: %w", err) + } + return written.Uri, nil +} + func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error { ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout) defer cancel() diff --git a/appview/oauth/handler_test.go b/appview/oauth/handler_test.go index 7970f6ed0..990c4905a 100644 --- a/appview/oauth/handler_test.go +++ b/appview/oauth/handler_test.go @@ -11,8 +11,10 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/samber/lo" "tangled.org/core/appview/config" "tangled.org/core/consts" + "tangled.org/core/xrpc/serviceauth" ) type fakeAcl struct { @@ -40,7 +42,7 @@ func TestAddToDefaultKnot_ShortCircuitsWhenAlreadyMember(t *testing.T) { }, } - o.addToDefaultKnot(syntax.DID("did:plc:akshay")) + o.addToDefaultKnot(syntax.DID("did:plc:akshay"), "session-1") if acl.gotDid != "did:plc:akshay" { t.Fatalf("IsKnotMember did = %q, want did:plc:akshay", acl.gotDid) @@ -100,3 +102,23 @@ func TestOnboardActionFor(t *testing.T) { }) } } + +func TestTheConsentHandshakeIsScopedAndKeyedByTheKnotsDidWeb(t *testing.T) { + if missing := lo.Without([]string{"repo:sh.tangled.knot.memberAcceptance", "repo:sh.tangled.repo.collaboratorAcceptance", "rpc:sh.tangled.knot.acceptMembership?aud=*", "rpc:sh.tangled.repo.acceptCollaboration?aud=*"}, TangledScopes...); len(missing) != 0 { + t.Errorf("TangledScopes is missing %v; a user can't accept an invite without them", missing) + } + if audience := serviceauth.DidWeb("knot.example:3000").String(); audience != "did:web:knot.example%3A3000" { + t.Errorf("service auth audience = %q, want the colon percent-encoded", audience) + } + lo.ForEach([]string{"knot.example:3000", "knot.example/path", ""}, func(host string, _ int) { + if _, err := serviceauth.RkeyForService(host); err == nil { + t.Errorf("RkeyForService(%q) succeeded; a record key must be the bare host", host) + } + }) + switch rkey, err := serviceauth.RkeyForService("knot.example"); { + case err != nil: + t.Fatalf("RkeyForService(bare host) = %v, want a record key", err) + case rkey.String() != "did:web:knot.example" || rkey.String() != serviceauth.DidWeb("knot.example").String(): + t.Errorf("acceptance rkey = %q, want the unencoded did:web DidWeb returns", rkey) + } +} diff --git a/appview/oauth/oauth.go b/appview/oauth/oauth.go index 48428d3af..733b9d77e 100644 --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -414,11 +414,20 @@ func (s *ServiceClientOpts) Host() string { } func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { - client, err := o.AuthorizedClient(r) + session, err := o.ResumeSession(r) if err != nil { return nil, err } + return o.SessionServiceClient(r.Context(), session.Data.AccountDID, session.Data.SessionID, os...) +} + +func (o *OAuth) SessionServiceClient(ctx context.Context, did syntax.DID, sessionId string, os ...ServiceClientOpt) (*xrpc.Client, error) { + session, err := o.resumeSession(ctx, did, sessionId) + if err != nil { + return nil, fmt.Errorf("error getting session: %w", err) + } + opts := DefaultServiceClientOpts() for _, o := range os { o(&opts) @@ -430,7 +439,7 @@ func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Cl opts.exp = sixty } - resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) + resp, err := comatproto.ServerGetServiceAuth(ctx, session.APIClient(), opts.Audience(), opts.exp, opts.lxm) if err != nil { return nil, err } diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go index 0fc9ab3f3..ff0a55910 100644 --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -11,12 +11,14 @@ var TangledScopes = []string{ "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", "repo:sh.tangled.repo", "repo:sh.tangled.repo.artifact", "repo:sh.tangled.repo.collaborator", + "repo:sh.tangled.repo.collaboratorAcceptance", "repo:sh.tangled.repo.issue", "repo:sh.tangled.repo.issue.comment", "repo:sh.tangled.repo.issue.state", @@ -29,6 +31,7 @@ var TangledScopes = []string{ "blob:*/*", + "rpc:sh.tangled.knot.acceptMembership?aud=*", "rpc:sh.tangled.knot.addMember?aud=*", "rpc:sh.tangled.knot.removeMember?aud=*", "rpc:sh.tangled.ci.triggerPipeline?aud=*", @@ -40,6 +43,7 @@ var TangledScopes = []string{ "rpc:org.tangled.temp.webhook.listWebhooks?aud=*", "rpc:org.tangled.temp.webhook.retryDelivery?aud=*", "rpc:sh.tangled.git.keepCommit?aud=*", + "rpc:sh.tangled.repo.acceptCollaboration?aud=*", "rpc:sh.tangled.repo.addCollaborator?aud=*", "rpc:sh.tangled.repo.addSecret?aud=*", "rpc:sh.tangled.repo.create?aud=*", diff --git a/appview/pages/templates/knots/fragments/addMemberModal.html b/appview/pages/templates/knots/fragments/addMemberModal.html index ee24489a4..9e00c97ec 100644 --- a/appview/pages/templates/knots/fragments/addMemberModal.html +++ b/appview/pages/templates/knots/fragments/addMemberModal.html @@ -61,5 +61,6 @@
+
{{ end }} diff --git a/appview/pages/templates/repo/settings/access.html b/appview/pages/templates/repo/settings/access.html index 611bc6aa5..a2a9a1dbd 100644 --- a/appview/pages/templates/repo/settings/access.html +++ b/appview/pages/templates/repo/settings/access.html @@ -126,5 +126,6 @@
+
{{ end }} diff --git a/appview/repo/repo.go b/appview/repo/repo.go index adec0e6ea..f29512b45 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -717,6 +717,7 @@ func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { } errorId := "add-collaborator-error" + noticeId := "add-collaborator-notice" fail := func(msg string, err error) { l.Error(msg, "err", err) rp.pages.Notice(w, errorId, msg) @@ -778,6 +779,12 @@ func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { rp.acl.InvalidateCollaborators(f.Knot, f.RepoDid) + granting := func(held pages.Collaborator) bool { return held.Did == collaboratorIdent.DID.String() } + if !slices.ContainsFunc(rp.acl.Collaborators(r.Context(), f), granting) { + rp.pages.Notice(w, noticeId, fmt.Sprintf("Invited %s. This list won't change until they accept from their own account.", collaboratorIdent.Handle)) + return + } + rp.pages.HxRefresh(w) return } diff --git a/knot2/crates/knot-acl/Cargo.toml b/knot2/crates/knot-acl/Cargo.toml index a90e01c15..b8b8fe20a 100644 --- a/knot2/crates/knot-acl/Cargo.toml +++ b/knot2/crates/knot-acl/Cargo.toml @@ -10,8 +10,10 @@ knot-types = { workspace = true } knot-index = { workspace = true } [dev-dependencies] +knot-index = { workspace = true, features = ["test-support"] } knot-cob = { workspace = true } knot-cobs = { workspace = true } knot-git = { workspace = true } knot-runtime = { workspace = true } +tokio = { workspace = true } tempfile = { workspace = true } diff --git a/knot2/crates/knot-acl/src/lib.rs b/knot2/crates/knot-acl/src/lib.rs index 8a13a78b9..4e4c3edb0 100644 --- a/knot2/crates/knot-acl/src/lib.rs +++ b/knot2/crates/knot-acl/src/lib.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use knot_index::{Index, Resolved}; +use knot_index::{CollaborationConsent, Index, MemberConsent, Resolved}; use knot_types::{AccountDid, AdmissionPolicy, OwnerDid, RepoDid}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,9 +27,7 @@ impl Decision { pub trait Acl { fn is_admin(&self, who: &AccountDid) -> bool; fn admission(&self) -> AdmissionPolicy; - fn is_member(&self, who: &AccountDid) -> Resolved; fn is_blocked(&self, who: &AccountDid) -> Resolved; - fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved; fn repo_owner(&self, repo: &RepoDid) -> Resolved>; } @@ -52,21 +50,28 @@ pub fn can_admin_knot(acl: &impl Acl, who: &AccountDid) -> Decision { Decision::allow_if(acl.is_admin(who)) } -pub fn can_create_repo(acl: &impl Acl, who: &AccountDid) -> Decision { - Decision::allow_if( - acl.is_admin(who) - || (not_blocked(acl, who) - && match acl.admission() { - AdmissionPolicy::Open => true, - AdmissionPolicy::Closed => confirmed(acl.is_member(who)), - }), - ) +fn closed_to_outsiders(acl: &impl Acl) -> bool { + matches!(acl.admission(), AdmissionPolicy::Closed) +} + +pub fn needs_membership(acl: &impl Acl, who: &AccountDid) -> bool { + !acl.is_admin(who) && closed_to_outsiders(acl) +} + +pub fn can_create_repo(acl: &impl Acl, consent: &MemberConsent<'_>) -> Decision { + let who = consent.subject(); + match acl.is_admin(who) { + true => Decision::Allow, + false => Decision::allow_if( + not_blocked(acl, who) && (!closed_to_outsiders(acl) || consent.granted()), + ), + } } -pub fn can_push(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { +pub fn can_push(acl: &impl Acl, consent: &CollaborationConsent<'_>) -> Decision { + let who = consent.subject(); Decision::allow_if( - not_blocked(acl, who) - && (owns_repo(acl, who, repo) || confirmed(acl.is_collaborator(repo, who))), + not_blocked(acl, who) && (owns_repo(acl, who, consent.repo()) || consent.granted()), ) } @@ -107,18 +112,10 @@ impl Acl for KnotAcl<'_> { self.policy } - fn is_member(&self, who: &AccountDid) -> Resolved { - self.index.is_member(who) - } - fn is_blocked(&self, who: &AccountDid) -> Resolved { self.index.is_blocked(who) } - fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved { - self.index.is_collaborator(repo, who) - } - fn repo_owner(&self, repo: &RepoDid) -> Resolved> { self.index.owner_of(repo) } @@ -140,12 +137,26 @@ mod tests { RepoDid::new(format!("did:plc:{suffix}")).unwrap() } + fn joined(who: &AccountDid) -> MemberConsent<'_> { + MemberConsent::assumed((), who, true) + } + + fn outside(who: &AccountDid) -> MemberConsent<'_> { + MemberConsent::assumed((), who, false) + } + + fn collaborating<'a>(repo: &'a RepoDid, who: &'a AccountDid) -> CollaborationConsent<'a> { + CollaborationConsent::assumed(repo, who, true) + } + + fn bystanding<'a>(repo: &'a RepoDid, who: &'a AccountDid) -> CollaborationConsent<'a> { + CollaborationConsent::assumed(repo, who, false) + } + struct Fake { admins: BTreeSet, admission: AdmissionPolicy, - member: Resolved, blocked: Resolved, - collaborator: Resolved, owner: Resolved>, } @@ -154,9 +165,7 @@ mod tests { Self { admins: BTreeSet::new(), admission: AdmissionPolicy::Closed, - member: Resolved::Warming, blocked: Resolved::Ready(false), - collaborator: Resolved::Warming, owner: Resolved::Warming, } } @@ -171,21 +180,11 @@ mod tests { self } - fn member(mut self, resolved: Resolved) -> Self { - self.member = resolved; - self - } - fn blocked(mut self, resolved: Resolved) -> Self { self.blocked = resolved; self } - fn collaborator(mut self, resolved: Resolved) -> Self { - self.collaborator = resolved; - self - } - fn owner(mut self, resolved: Resolved>) -> Self { self.owner = resolved; self @@ -201,18 +200,10 @@ mod tests { self.admission } - fn is_member(&self, _who: &AccountDid) -> Resolved { - self.member.clone() - } - fn is_blocked(&self, _who: &AccountDid) -> Resolved { self.blocked.clone() } - fn is_collaborator(&self, _repo: &RepoDid, _who: &AccountDid) -> Resolved { - self.collaborator.clone() - } - fn repo_owner(&self, _repo: &RepoDid) -> Resolved> { self.owner.clone() } @@ -222,12 +213,12 @@ mod tests { fn an_admin_administers_and_creates_but_does_not_push_arbitrary_repos() { let acl = Fake::new() .admin("nel") - .owner(Resolved::Ready(Some(owner("olaren")))) - .collaborator(Resolved::Ready(false)); - assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); - assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); + .owner(Resolved::Ready(Some(owner("olaren")))); + let (nel, squid) = (acc("nel"), repo("squid")); + assert_eq!(can_admin_knot(&acl, &nel), Decision::Allow); + assert_eq!(can_create_repo(&acl, &outside(&nel)), Decision::Allow); assert_eq!( - can_push(&acl, &acc("nel"), &repo("squid")), + can_push(&acl, &bystanding(&squid, &nel)), Decision::Deny, "knot admin has no push on repo it neither owns nor collaborates on" ); @@ -239,38 +230,34 @@ mod tests { let cases: Vec = vec![ ( "a_member_creates_repos", - Fake::new().member(Resolved::Ready(true)), - |acl| can_create_repo(acl, &acc("olaren")), + Fake::new(), + |acl| can_create_repo(acl, &joined(&acc("olaren"))), Decision::Allow, ), ( "a_member_cannot_administer_the_knot", - Fake::new().member(Resolved::Ready(true)), + Fake::new(), |acl| can_admin_knot(acl, &acc("olaren")), Decision::Deny, ), ( "an_open_knot_admits_a_non_member", - Fake::new().open().member(Resolved::Ready(false)), - |acl| can_create_repo(acl, &acc("teq")), + Fake::new().open(), + |acl| can_create_repo(acl, &outside(&acc("teq"))), Decision::Allow, ), ( "an_open_knot_does_not_widen_push", Fake::new() .open() - .owner(Resolved::Ready(Some(owner("nel")))) - .collaborator(Resolved::Ready(false)), - |acl| can_push(acl, &acc("teq"), &repo("squid")), + .owner(Resolved::Ready(Some(owner("nel")))), + |acl| can_push(acl, &bystanding(&repo("squid"), &acc("teq"))), Decision::Deny, ), ( "a_blocked_account_cannot_create", - Fake::new() - .open() - .member(Resolved::Ready(true)) - .blocked(Resolved::Ready(true)), - |acl| can_create_repo(acl, &acc("squid")), + Fake::new().open().blocked(Resolved::Ready(true)), + |acl| can_create_repo(acl, &joined(&acc("squid"))), Decision::Deny, ), ( @@ -279,83 +266,68 @@ mod tests { .open() .admin("nel") .blocked(Resolved::Ready(true)), - |acl| can_create_repo(acl, &acc("nel")), + |acl| can_create_repo(acl, &outside(&acc("nel"))), Decision::Allow, ), ( "the_repo_owner_pushes", - Fake::new() - .owner(Resolved::Ready(Some(owner("nel")))) - .collaborator(Resolved::Ready(false)), - |acl| can_push(acl, &acc("nel"), &repo("squid")), + Fake::new().owner(Resolved::Ready(Some(owner("nel")))), + |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), Decision::Allow, ), ( "a_collaborator_pushes_without_owning", - Fake::new() - .owner(Resolved::Ready(Some(owner("nel")))) - .collaborator(Resolved::Ready(true)), - |acl| can_push(acl, &acc("olaren"), &repo("squid")), + Fake::new().owner(Resolved::Ready(Some(owner("nel")))), + |acl| can_push(acl, &collaborating(&repo("squid"), &acc("olaren"))), Decision::Allow, ), ( "push_allows_on_a_confirmed_collaborator_while_the_registry_warms", - Fake::new() - .owner(Resolved::Warming) - .collaborator(Resolved::Ready(true)), - |acl| can_push(acl, &acc("olaren"), &repo("squid")), + Fake::new().owner(Resolved::Warming), + |acl| can_push(acl, &collaborating(&repo("squid"), &acc("olaren"))), Decision::Allow, ), ( - "push_allows_a_confirmed_owner_while_collaborators_warm", - Fake::new() - .owner(Resolved::Ready(Some(owner("nel")))) - .collaborator(Resolved::Warming), - |acl| can_push(acl, &acc("nel"), &repo("squid")), + "push_allows_a_confirmed_owner_with_no_acceptance_behind_them", + Fake::new().owner(Resolved::Ready(Some(owner("nel")))), + |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), Decision::Allow, ), ( "push_denies_when_ownership_is_warming_and_not_a_collaborator", - Fake::new() - .owner(Resolved::Warming) - .collaborator(Resolved::Ready(false)), - |acl| can_push(acl, &acc("nel"), &repo("squid")), + Fake::new().owner(Resolved::Warming), + |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), Decision::Deny, ), ( "push_denies_an_unregistered_repo", - Fake::new() - .owner(Resolved::Ready(None)) - .collaborator(Resolved::Ready(false)), - |acl| can_push(acl, &acc("nel"), &repo("squid")), + Fake::new().owner(Resolved::Ready(None)), + |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), Decision::Deny, ), ( "push_matches_a_did_web_owner_across_authority_case", - Fake::new() - .owner(Resolved::Ready(Some( - OwnerDid::new("did:web:OYSTER.cafe").unwrap(), - ))) - .collaborator(Resolved::Ready(false)), + Fake::new().owner(Resolved::Ready(Some( + OwnerDid::new("did:web:OYSTER.cafe").unwrap(), + ))), |acl| { can_push( acl, - &AccountDid::new("did:web:oyster.cafe").unwrap(), - &repo("squid"), + &bystanding( + &repo("squid"), + &AccountDid::new("did:web:oyster.cafe").unwrap(), + ), ) }, Decision::Allow, ), ( "push_denies_a_did_plc_owner_whose_case_differs", - Fake::new() - .owner(Resolved::Ready(Some(OwnerDid::new("did:plc:ABC").unwrap()))) - .collaborator(Resolved::Ready(false)), + Fake::new().owner(Resolved::Ready(Some(OwnerDid::new("did:plc:ABC").unwrap()))), |acl| { can_push( acl, - &AccountDid::new("did:plc:abc").unwrap(), - &repo("squid"), + &bystanding(&repo("squid"), &AccountDid::new("did:plc:abc").unwrap()), ) }, Decision::Deny, @@ -375,7 +347,7 @@ mod tests { ( "an_admin_creates_before_the_projection_warms", Fake::new().admin("nel"), - |acl| can_create_repo(acl, &acc("nel")), + |acl| can_create_repo(acl, &outside(&acc("nel"))), Decision::Allow, ), ( @@ -394,15 +366,15 @@ mod tests { fn a_blocked_owner_cannot_push_or_invite() { let acl = Fake::new() .owner(Resolved::Ready(Some(owner("squid")))) - .collaborator(Resolved::Ready(false)) .blocked(Resolved::Ready(true)); + let (squid, anemone) = (acc("squid"), repo("anemone")); assert_eq!( - can_push(&acl, &acc("squid"), &repo("anemone")), + can_push(&acl, &bystanding(&anemone, &squid)), Decision::Deny, "ban overrides ownership on write path" ); assert_eq!( - can_manage_collaborators(&acl, &acc("squid"), &repo("anemone")), + can_manage_collaborators(&acl, &squid, &anemone), Decision::Deny ); } @@ -413,33 +385,33 @@ mod tests { .open() .blocked(Resolved::Warming) .owner(Resolved::Ready(Some(owner("squid")))); + let (squid, anemone) = (acc("squid"), repo("anemone")); assert_eq!( - can_create_repo(&acl, &acc("squid")), + can_create_repo(&acl, &joined(&squid)), Decision::Deny, - "unresolved blocklist must not admit, ban could be hiding in it" + "an unresolved blocklist could contain the ban, so create must fail closed" ); assert_eq!( - can_push(&acl, &acc("squid"), &repo("anemone")), + can_push(&acl, &bystanding(&anemone, &squid)), Decision::Deny ); } #[test] fn a_stranger_is_denied_everything() { - let acl = Fake::new() - .member(Resolved::Ready(false)) - .collaborator(Resolved::Ready(false)) - .owner(Resolved::Ready(Some(owner("nel")))); - assert_eq!(can_admin_knot(&acl, &acc("teq")), Decision::Deny); - assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); - assert_eq!(can_push(&acl, &acc("teq"), &repo("squid")), Decision::Deny); + let acl = Fake::new().owner(Resolved::Ready(Some(owner("nel")))); + let (teq, squid) = (acc("teq"), repo("squid")); + assert_eq!(can_admin_knot(&acl, &teq), Decision::Deny); + assert_eq!(can_create_repo(&acl, &outside(&teq)), Decision::Deny); + assert_eq!(can_push(&acl, &bystanding(&squid, &teq)), Decision::Deny); } #[test] fn a_fully_warming_index_denies_every_index_backed_decision() { let acl = Fake::new(); - assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Deny); - assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny); + let (olaren, nel, squid) = (acc("olaren"), acc("nel"), repo("squid")); + assert_eq!(can_create_repo(&acl, &outside(&olaren)), Decision::Deny); + assert_eq!(can_push(&acl, &bystanding(&squid, &nel)), Decision::Deny); } #[test] @@ -452,8 +424,7 @@ mod tests { fn only_the_repo_owner_manages_collaborators() { let acl = Fake::new() .admin("nel") - .owner(Resolved::Ready(Some(owner("olaren")))) - .collaborator(Resolved::Ready(true)); + .owner(Resolved::Ready(Some(owner("olaren")))); assert_eq!( can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")), Decision::Allow, @@ -467,7 +438,7 @@ mod tests { assert_eq!( can_manage_collaborators(&acl, &acc("nel"), &repo("squid")), Decision::Deny, - "knot admin has no collaborator-invite right on repo it does not own" + "a knot admin's authority stops at the knot, and this repo answers to olaren" ); } @@ -475,8 +446,7 @@ mod tests { fn repo_deletion_is_the_owner_or_a_knot_admin() { let acl = Fake::new() .admin("nel") - .owner(Resolved::Ready(Some(owner("olaren")))) - .collaborator(Resolved::Ready(true)); + .owner(Resolved::Ready(Some(owner("olaren")))); assert_eq!( can_delete_repo(&acl, &acc("olaren"), &repo("squid")), Decision::Allow, @@ -503,6 +473,20 @@ mod tests { use knot_types::{KnotId, RepoName, RepoRkey, UnixSeconds}; use std::path::PathBuf; + use knot_index::{Acceptance, Acceptances, Read}; + + struct NoPds; + + impl Acceptances for NoPds { + fn read<'a>( + &'a self, + _scope: Acceptance<'a>, + subject: &'a AccountDid, + ) -> std::pin::Pin + Send + 'a>> { + panic!("the acl read {subject}'s repository for a grant the knot signed itself") + } + } + fn knot_home() -> CobHome { CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap()) } @@ -549,8 +533,29 @@ mod tests { store.create(home, change, signer, at).unwrap(); } - #[test] - fn the_enforcer_decides_over_a_real_rebuilt_index() { + async fn creates(index: &Index, acl: &KnotAcl<'_>, who: &AccountDid) -> Decision { + let consent = index + .consents() + .of_membership(&NoPds, who, UnixSeconds::new(2)) + .await; + can_create_repo(acl, &consent) + } + + async fn pushes( + index: &Index, + acl: &KnotAcl<'_>, + repo: &RepoDid, + who: &AccountDid, + ) -> Decision { + let consent = index + .consents() + .of_collaboration(&NoPds, repo, who, UnixSeconds::new(2)) + .await; + can_push(acl, &consent) + } + + #[tokio::test] + async fn the_enforcer_decides_over_a_real_rebuilt_index() { let (_dir, meta_path, layout, signer) = world(); let at = UnixSeconds::new; @@ -587,30 +592,33 @@ mod tests { let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); - assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); + assert_eq!(creates(&index, &acl, &acc("nel")).await, Decision::Allow); assert_eq!( - can_push(&acl, &acc("nel"), &squid), + pushes(&index, &acl, &squid, &acc("nel")).await, Decision::Allow, "nel owns squid in the registry" ); assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny); - assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow); + assert_eq!(creates(&index, &acl, &acc("olaren")).await, Decision::Allow); assert_eq!( - can_push(&acl, &acc("olaren"), &squid), + pushes(&index, &acl, &squid, &acc("olaren")).await, Decision::Deny, "member who is neither owner nor collaborator cannot push" ); assert_eq!( - can_push(&acl, &acc("lyna"), &squid), + pushes(&index, &acl, &squid, &acc("lyna")).await, Decision::Allow, "lyna collaborates on squid" ); - assert_eq!(can_create_repo(&acl, &acc("lyna")), Decision::Deny); + assert_eq!(creates(&index, &acl, &acc("lyna")).await, Decision::Deny); - assert_eq!(can_push(&acl, &acc("teq"), &squid), Decision::Deny); - assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); + assert_eq!( + pushes(&index, &acl, &squid, &acc("teq")).await, + Decision::Deny + ); + assert_eq!(creates(&index, &acl, &acc("teq")).await, Decision::Deny); let cold = Index::new(&meta_path, layout); let cold_acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &cold); @@ -620,15 +628,18 @@ mod tests { "admin is config, answered before any rebuild" ); assert_eq!( - can_push(&cold_acl, &acc("nel"), &squid), + pushes(&cold, &cold_acl, &squid, &acc("nel")).await, Decision::Deny, "before rebuild owner lookup is warming, so push fails closed" ); - assert_eq!(can_create_repo(&cold_acl, &acc("olaren")), Decision::Deny); + assert_eq!( + creates(&cold, &cold_acl, &acc("olaren")).await, + Decision::Deny + ); } - #[test] - fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() { + #[tokio::test] + async fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() { let (_dir, meta_path, layout, signer) = world(); let at = UnixSeconds::new; @@ -661,19 +672,19 @@ mod tests { let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); assert_eq!( - can_push(&acl, &acc("nel"), &squid), + pushes(&index, &acl, &squid, &acc("nel")).await, Decision::Deny, "re-register moves repo wholesale, so displaced owner loses push" ); assert_eq!( - can_push(&acl, &acc("olaren"), &squid), + pushes(&index, &acl, &squid, &acc("olaren")).await, Decision::Allow, "linear causal order gives later registrant deterministic ownership" ); } - #[test] - fn a_collaborator_on_an_unregistered_repo_cannot_push_after_a_real_rebuild() { + #[tokio::test] + async fn a_collaborator_on_an_unregistered_repo_cant_push_after_a_real_rebuild() { let (_dir, meta_path, layout, signer) = world(); let at = UnixSeconds::new; @@ -692,7 +703,7 @@ mod tests { let admins = BTreeSet::new(); let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); assert_eq!( - can_push(&acl, &acc("lyna"), &squid), + pushes(&index, &acl, &squid, &acc("lyna")).await, Decision::Deny, "rebuild folds collaborators only for registered repos, so collaborator COB on unregistered repo never warms and grants no push" ); diff --git a/knot2/crates/knot-cob/src/lib.rs b/knot2/crates/knot-cob/src/lib.rs index 82fde95c5..4ab638753 100644 --- a/knot2/crates/knot-cob/src/lib.rs +++ b/knot2/crates/knot-cob/src/lib.rs @@ -43,7 +43,7 @@ pub struct Delta { pub tip: ChangeId, } -const CHECKPOINT_FORMAT: u16 = 3; +const CHECKPOINT_FORMAT: u16 = 4; #[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct CheckpointDigest(Oid); @@ -586,7 +586,7 @@ mod tests { let bytes = encode_checkpoint(object, tip, &state).unwrap(); assert_eq!( knot_types::lowercase_hex(&bytes), - "850378286161616161616161616161616161616161616161616161616161616161616161616161616161616178286262626262626262626262626262626262626262626262626262626262626262626262626262626278283861623531376164633234616530313138383962643634343930663836633562646333333632333782646b656c70657371756964" + "850478286161616161616161616161616161616161616161616161616161616161616161616161616161616178286262626262626262626262626262626262626262626262626262626262626262626262626262626278283861623531376164633234616530313138383962643634343930663836633562646333333632333782646b656c70657371756964" ); } diff --git a/knot2/crates/knot-cobs/src/blocklist.rs b/knot2/crates/knot-cobs/src/blocklist.rs index 9c6f60329..61190c045 100644 --- a/knot2/crates/knot-cobs/src/blocklist.rs +++ b/knot2/crates/knot-cobs/src/blocklist.rs @@ -1,20 +1,9 @@ -use crate::grant::grant_set_cob; +use crate::grant::roster_cob; -grant_set_cob! { +roster_cob! { change = BlocklistChange, + ops = { Add(Grant), Remove(Removal) }, cob = BlocklistCob, state = Blocklist, type_name = "sh.tangled.knot.block", - add = block_account, - remove = unblock_account, -} - -#[cfg(test)] -mod tests { - use knot_cob::ChangePayload; - - #[test] - fn type_name_is_stable() { - assert_eq!(super::BlocklistChange::TYPE, "sh.tangled.knot.block"); - } } diff --git a/knot2/crates/knot-cobs/src/collaborators.rs b/knot2/crates/knot-cobs/src/collaborators.rs index dfed9e2a5..fc7ebbdbd 100644 --- a/knot2/crates/knot-cobs/src/collaborators.rs +++ b/knot2/crates/knot-cobs/src/collaborators.rs @@ -1,23 +1,9 @@ -use crate::grant::grant_set_cob; +use crate::grant::roster_cob; -grant_set_cob! { +roster_cob! { change = CollaboratorsChange, + ops = { Add(Grant), Invite(Invite), Accept(Accept), Remove(Removal) }, cob = CollaboratorsCob, state = Collaborators, type_name = "sh.tangled.repo.collaborator", - add = add_collaborator, - remove = remove_collaborator, -} - -#[cfg(test)] -mod tests { - use knot_cob::ChangePayload; - - #[test] - fn type_name_is_stable() { - assert_eq!( - super::CollaboratorsChange::TYPE, - "sh.tangled.repo.collaborator" - ); - } } diff --git a/knot2/crates/knot-cobs/src/grant.rs b/knot2/crates/knot-cobs/src/grant.rs index 010e0f3bc..672c715cb 100644 --- a/knot2/crates/knot-cobs/src/grant.rs +++ b/knot2/crates/knot-cobs/src/grant.rs @@ -10,15 +10,64 @@ pub struct Grant { pub created_at: UnixSeconds, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Invite(pub Grant); + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Removal { pub subject: AccountDid, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Accept { + pub subject: AccountDid, + pub verified_at: UnixSeconds, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Offer { + #[default] + Granted, + Invited, +} + +impl Offer { + fn is_granted(&self) -> bool { + matches!(self, Self::Granted) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Entry { + #[serde(default, skip_serializing_if = "Offer::is_granted")] + pub offer: Offer, pub added_by: AccountDid, pub created_at: UnixSeconds, + #[serde(skip_serializing_if = "Option::is_none")] + pub verified_at: Option, +} + +impl Entry { + pub fn standing(&self) -> Standing { + Standing::of(self.offer, self.created_at, self.verified_at) + } + + pub fn effective_since(&self) -> Option { + match (self.offer, self.verified_at) { + (Offer::Granted, _) => Some(EffectiveSince(self.created_at)), + (Offer::Invited, verified_at) => verified_at.map(EffectiveSince), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct EffectiveSince(UnixSeconds); + +impl EffectiveSince { + pub fn seconds(self) -> UnixSeconds { + self.0 + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -33,25 +82,47 @@ impl Roster { } } - pub fn admit(mut self, grant: Grant) -> Self { - self.entries.entry(grant.subject).or_insert(Entry { - added_by: grant.added_by, - created_at: grant.created_at, - }); + pub fn apply_change(mut self, change: &impl GrantChange) -> Self { + match change.effect() { + Effect::Admit(offer, grant) => { + self.entries + .entry(grant.subject.clone()) + .or_insert_with(|| Entry { + offer, + added_by: grant.added_by.clone(), + created_at: grant.created_at, + verified_at: None, + }); + } + Effect::Accept(accept) => { + if let Some(entry) = self.entries.get_mut(&accept.subject) { + entry.verified_at.get_or_insert(accept.verified_at); + } + } + Effect::Revoke(subject) => { + self.entries.remove(subject); + } + } self } - pub fn revoke(mut self, removal: Removal) -> Self { - self.entries.remove(&removal.subject); - self + pub fn would_change(&self, change: &impl GrantChange) -> bool { + match change.effect() { + Effect::Admit(_, grant) => !self.entries.contains_key(&grant.subject), + Effect::Accept(accept) => self + .entries + .get(&accept.subject) + .is_some_and(|entry| entry.verified_at.is_none()), + Effect::Revoke(subject) => self.entries.contains_key(subject), + } } - pub fn get(&self, subject: &AccountDid) -> Option<&Entry> { - self.entries.get(subject) + pub fn standing(&self, subject: &AccountDid) -> Option { + self.entries.get(subject).map(Entry::standing) } - pub fn contains(&self, subject: &AccountDid) -> bool { - self.entries.contains_key(subject) + pub fn get(&self, subject: &AccountDid) -> Option<&Entry> { + self.entries.get(subject) } pub fn len(&self) -> usize { @@ -67,50 +138,109 @@ impl Roster { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Standing { + Invited, + Accepted { verified_at: UnixSeconds }, + Grandfathered { granted_at: UnixSeconds }, +} + +impl Standing { + pub fn of(offer: Offer, created_at: UnixSeconds, verified_at: Option) -> Self { + match (verified_at, offer) { + (Some(verified_at), _) => Self::Accepted { verified_at }, + (None, Offer::Invited) => Self::Invited, + (None, Offer::Granted) => Self::Grandfathered { + granted_at: created_at, + }, + } + } + + pub fn is_effective(self) -> bool { + match self { + Self::Invited => false, + Self::Accepted { .. } | Self::Grandfathered { .. } => true, + } + } +} + +#[derive(Debug)] +pub enum Effect<'a> { + Admit(Offer, &'a Grant), + Accept(&'a Accept), + Revoke(&'a AccountDid), +} + +impl Effect<'_> { + pub fn announcement(&self) -> Option { + match self { + Self::Admit(Offer::Granted, _) | Self::Accept(_) => Some(Announcement::Effective), + Self::Admit(Offer::Invited, _) => None, + Self::Revoke(_) => Some(Announcement::Cleared), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Announcement { + Effective, + Cleared, +} + pub trait GrantChange { - fn subject(&self) -> &AccountDid; - fn adds(&self) -> bool; - fn as_grant(&self) -> Option<&Grant>; + fn effect(&self) -> Effect<'_>; + + fn subject(&self) -> &AccountDid { + match self.effect() { + Effect::Admit(_, grant) => &grant.subject, + Effect::Accept(accept) => &accept.subject, + Effect::Revoke(subject) => subject, + } + } } -macro_rules! grant_set_cob { +macro_rules! grant_change { + ($($payload:ident($binding:ident) => $effect:expr),+ $(,)?) => { + $(impl GrantChange for $payload { + fn effect(&self) -> Effect<'_> { + let $binding = self; + $effect + } + })+ + }; +} + +grant_change! { + Grant(grant) => Effect::Admit(Offer::Granted, grant), + Invite(invite) => Effect::Admit(Offer::Invited, &invite.0), + Accept(accept) => Effect::Accept(accept), + Removal(removal) => Effect::Revoke(&removal.subject), +} + +macro_rules! roster_cob { ( change = $change:ident, + ops = { $($variant:ident($payload:ident)),+ $(,)? }, cob = $cob:ident, state = $state:ident, - type_name = $type_name:literal, - add = $add:ident, - remove = $remove:ident $(,)? + type_name = $type_name:literal $(,)? ) => { #[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)] #[serde(tag = "op", content = "data", rename_all = "snake_case")] pub enum $change { - Add($crate::grant::Grant), - Remove($crate::grant::Removal), - } - - impl ::knot_cob::ChangePayload for $change { - const TYPE: &'static str = $type_name; + $($variant($crate::grant::$payload)),+ } impl $crate::grant::GrantChange for $change { - fn subject(&self) -> &::knot_types::AccountDid { + fn effect(&self) -> $crate::grant::Effect<'_> { match self { - $change::Add(grant) => &grant.subject, - $change::Remove(removal) => &removal.subject, + $($change::$variant(op) => $crate::grant::GrantChange::effect(op)),+ } } + } - fn adds(&self) -> bool { - ::core::matches!(self, $change::Add(_)) - } - - fn as_grant(&self) -> ::core::option::Option<&$crate::grant::Grant> { - match self { - $change::Add(grant) => ::core::option::Option::Some(grant), - $change::Remove(_) => ::core::option::Option::None, - } - } + impl ::knot_cob::ChangePayload for $change { + const TYPE: &'static str = $type_name; } pub type $state = $crate::grant::Roster; @@ -132,10 +262,7 @@ macro_rules! grant_set_cob { change: Self::Change, _author: &::knot_types::ActorId, ) -> Self::State { - match change { - $change::Add(grant) => state.admit(grant), - $change::Remove(removal) => state.revoke(removal), - } + state.apply_change(&change) } } @@ -147,68 +274,272 @@ macro_rules! grant_set_cob { } } - pub fn $add( - store: &::knot_cob::CobStore, - home: &::knot_cob::CobHome, - object: ::knot_cob::CobId, - grant: $crate::grant::Grant, - signer: &dyn ::knot_runtime::Signer, - timestamp: ::knot_types::UnixSeconds, - ) -> ::core::result::Result<::knot_cob::ChangeId, ::knot_cob::CobError> { - store.update_with_checkpointed::<$cob, ::knot_cob::CobError>( - home, - object, - signer, - timestamp, - |_state| ::core::result::Result::Ok($change::Add(grant.clone())), - ) - } - - pub fn $remove( - store: &::knot_cob::CobStore, - home: &::knot_cob::CobHome, - object: ::knot_cob::CobId, - removal: $crate::grant::Removal, - signer: &dyn ::knot_runtime::Signer, - timestamp: ::knot_types::UnixSeconds, - ) -> ::core::result::Result<::knot_cob::ChangeId, ::knot_cob::CobError> { - store.update_with_checkpointed::<$cob, ::knot_cob::CobError>( - home, - object, - signer, - timestamp, - |_state| ::core::result::Result::Ok($change::Remove(removal.clone())), - ) + #[cfg(test)] + #[test] + fn the_change_declares_its_lexicon_type_and_folds_through_the_cob() { + use ::knot_cob::{ChangePayload, Evaluate}; + + let did = |value: &str| ::knot_types::AccountDid::new(value).unwrap(); + let change = $change::Add($crate::grant::Grant { + subject: did("did:plc:nel"), + added_by: did("did:plc:olaren"), + created_at: ::knot_types::UnixSeconds::new(9), + }); + let actor = ::knot_types::ActorId::from_secp256k1(&[0x02; 33]); + assert_eq!($change::TYPE, $type_name); + assert_eq!( + $change::decode(&change.encode().unwrap()).unwrap(), + change, + "a change must decode from dag-cbor as itself, or replay folds a different roster" + ); + assert_eq!( + $cob::apply($cob::initial(), change, &actor).len(), + 1, + "the cob's fold has to write the change's subject into the roster" + ); } }; } -pub(crate) use grant_set_cob; +pub(crate) use roster_cob; #[cfg(test)] mod tests { + use proptest::prelude::*; + + use super::Offer::{Granted, Invited}; use super::*; + use crate::MembersChange; + + const ALPHABET: &[u8; 4] = b"giar"; fn did(suffix: &str) -> AccountDid { AccountDid::new(format!("did:plc:{suffix}")).unwrap() } - fn grant(subject: &str, added_by: &str, at: i64) -> Grant { - Grant { + fn change(op: u8, subject: &str, added_by: &str, at: i64) -> MembersChange { + let grant = Grant { subject: did(subject), added_by: did(added_by), created_at: UnixSeconds::new(at), + }; + match op { + b'g' => MembersChange::Add(grant), + b'i' => MembersChange::Invite(Invite(grant)), + b'a' => MembersChange::Accept(Accept { + subject: grant.subject, + verified_at: grant.created_at, + }), + _ => MembersChange::Remove(Removal { + subject: grant.subject, + }), } } + fn fold(changes: Vec) -> Roster { + changes.iter().fold(Roster::empty(), Roster::apply_change) + } + + fn entry(offer: Offer, added_by: &str, at: i64, verified: Option) -> Option { + Some(Entry { + offer, + added_by: did(added_by), + created_at: UnixSeconds::new(at), + verified_at: verified.map(UnixSeconds::new), + }) + } + + fn grandfathered(at: i64) -> Standing { + Standing::Grandfathered { + granted_at: UnixSeconds::new(at), + } + } + + fn accepted(at: i64) -> Standing { + Standing::Accepted { + verified_at: UnixSeconds::new(at), + } + } + + fn history() -> Roster { + fold(vec![ + change(b'g', "granted", "olaren", 1), + change(b'i', "invited", "olaren", 1), + change(b'g', "regranted", "olaren", 1), + change(b'g', "regranted", "teq", 5), + change(b'i', "reinvited", "olaren", 1), + change(b'i', "reinvited", "teq", 5), + change(b'i', "granted_over_invite", "olaren", 1), + change(b'g', "granted_over_invite", "teq", 5), + change(b'g', "invited_over_grant", "olaren", 1), + change(b'i', "invited_over_grant", "teq", 5), + change(b'a', "unoffered", "olaren", 7), + change(b'a', "accepted_early", "olaren", 7), + change(b'i', "accepted_early", "olaren", 9), + change(b'i', "invite_then_accept", "olaren", 1), + change(b'a', "invite_then_accept", "olaren", 7), + change(b'g', "grant_then_accept", "olaren", 1), + change(b'a', "grant_then_accept", "olaren", 7), + change(b'i', "accepted_twice", "olaren", 1), + change(b'a', "accepted_twice", "olaren", 7), + change(b'a', "accepted_twice", "olaren", 9), + change(b'i', "removed", "olaren", 1), + change(b'a', "removed", "olaren", 7), + change(b'r', "removed", "olaren", 0), + change(b'i', "reinvite_after_remove", "olaren", 1), + change(b'a', "reinvite_after_remove", "olaren", 7), + change(b'r', "reinvite_after_remove", "olaren", 0), + change(b'i', "reinvite_after_remove", "teq", 11), + change(b'g', "readd_after_remove", "olaren", 1), + change(b'a', "readd_after_remove", "olaren", 7), + change(b'r', "readd_after_remove", "olaren", 0), + change(b'g', "readd_after_remove", "olaren", 11), + ]) + } + + #[test] + fn a_fold_keeps_the_first_offer_and_the_first_acceptance_until_a_removal_clears_them() { + let roster = history(); + [ + ("nobody", None), + ("granted", entry(Granted, "olaren", 1, None)), + ("invited", entry(Invited, "olaren", 1, None)), + ("regranted", entry(Granted, "olaren", 1, None)), + ("reinvited", entry(Invited, "olaren", 1, None)), + ("granted_over_invite", entry(Invited, "olaren", 1, None)), + ("invited_over_grant", entry(Granted, "olaren", 1, None)), + ("unoffered", None), + ("accepted_early", entry(Invited, "olaren", 9, None)), + ("invite_then_accept", entry(Invited, "olaren", 1, Some(7))), + ("grant_then_accept", entry(Granted, "olaren", 1, Some(7))), + ("accepted_twice", entry(Invited, "olaren", 1, Some(7))), + ("removed", None), + ("reinvite_after_remove", entry(Invited, "teq", 11, None)), + ("readd_after_remove", entry(Granted, "olaren", 11, None)), + ] + .into_iter() + .for_each(|(subject, expected)| { + assert_eq!( + roster.get(&did(subject)).cloned(), + expected, + "the first offer sets an entry's provenance, and only a removal clears it: \ + {subject}" + ); + }); + } + + #[test] + fn a_standing_is_effective_exactly_when_it_has_a_since_key_to_page_on() { + let roster = history(); + [ + ("granted", grandfathered(1), Some(1)), + ("invited", Standing::Invited, None), + ("grant_then_accept", accepted(7), Some(1)), + ("invite_then_accept", accepted(7), Some(7)), + ] + .into_iter() + .for_each(|(subject, standing, since)| { + let entry = roster.get(&did(subject)).unwrap(); + assert_eq!( + (entry.standing(), entry.effective_since().map(EffectiveSince::seconds)), + (standing, since.map(UnixSeconds::new)), + "effective_since comes from the offer, so accepting mustn't shift {subject} in \ + a page a reader is already walking" + ); + }); + roster.entries().for_each(|(subject, entry)| { + assert_eq!( + entry.effective_since().is_some(), + entry.standing().is_effective(), + "a listing that pages on the since-key must serve exactly the rows the acl \ + grants on, so the two readings of {subject} have to agree" + ); + }); + assert_eq!( + fold(vec![]).standing(&did("nel")), + None, + "a standing for nel out of an empty change log" + ); + } + + #[derive(Serialize)] + struct EntryBeforeConsent { + added_by: AccountDid, + created_at: UnixSeconds, + } + #[test] - fn admit_keeps_the_first_provenance() { - let roster = Roster::empty() - .admit(grant("nel", "olaren", 1)) - .admit(grant("nel", "teq", 5)); - let entry = roster.get(&did("nel")).unwrap(); - assert_eq!(entry.added_by, did("olaren")); - assert_eq!(entry.created_at, UnixSeconds::new(1)); - assert_eq!(roster.len(), 1); + fn a_checkpoint_keeps_the_old_entries_byte_stable_and_reads_them_as_grandfathered() { + let before = serde_ipld_dagcbor::to_vec(&EntryBeforeConsent { + added_by: did("olaren"), + created_at: UnixSeconds::new(7), + }) + .unwrap(); + assert_eq!( + before, + serde_ipld_dagcbor::to_vec(&entry(Granted, "olaren", 7, None).unwrap()).unwrap(), + "a checkpoint written before the consent model must still match its own digest" + ); + let decoded: Entry = serde_ipld_dagcbor::from_slice(&before).unwrap(); + assert_eq!( + decoded.standing(), + grandfathered(7), + "an entry from before invites existed must decode as grandfathered, not an acceptance" + ); + let mixed = history(); + let reread: Roster = + serde_ipld_dagcbor::from_slice(&serde_ipld_dagcbor::to_vec(&mixed).unwrap()).unwrap(); + assert_eq!( + reread, mixed, + "a checkpoint is the roster's own encoding, so an outstanding invite has to come \ + back out of one or the knot reads it as the grant it never was" + ); + } + + #[test] + fn an_invite_emits_no_announcement_and_the_other_three_set_a_direction() { + [ + (b'g', Some(Announcement::Effective)), + (b'a', Some(Announcement::Effective)), + (b'r', Some(Announcement::Cleared)), + (b'i', None), + ] + .into_iter() + .for_each(|(op, announcement)| { + let signed = change(op, "nel", "olaren", 1); + assert_eq!( + GrantChange::effect(&signed).announcement(), + announcement, + "spindle opens a repository off an announcement, so {signed:?} would let in an \ + account that never signed" + ); + }); + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] + + #[test] + fn would_change_agrees_with_the_fold_over_the_whole_alphabet( + ops in prop::collection::vec((0u8..4, 0u8..3), 0..12), + (op, who) in (0u8..4, 0u8..3), + ) { + let roster = fold( + ops.iter() + .enumerate() + .map(|(index, (op, who))| { + let at = index as i64 + 1; + change(ALPHABET[*op as usize], &format!("s{who}"), "olaren", at) + }) + .collect(), + ); + let next = change(ALPHABET[op as usize], &format!("s{who}"), "olaren", 99); + prop_assert_eq!( + roster.would_change(&next), + roster.clone().apply_change(&next) != roster, + "the append-time predicate and the fold disagree on {:?}", + next + ); + } } } diff --git a/knot2/crates/knot-cobs/src/lib.rs b/knot2/crates/knot-cobs/src/lib.rs index 386c944d1..3abf90593 100644 --- a/knot2/crates/knot-cobs/src/lib.rs +++ b/knot2/crates/knot-cobs/src/lib.rs @@ -5,13 +5,14 @@ mod import; mod members; mod registry; -pub use blocklist::{Blocklist, BlocklistChange, BlocklistCob, block_account, unblock_account}; -pub use collaborators::{ - Collaborators, CollaboratorsChange, CollaboratorsCob, add_collaborator, remove_collaborator, +pub use blocklist::{Blocklist, BlocklistChange, BlocklistCob}; +pub use collaborators::{Collaborators, CollaboratorsChange, CollaboratorsCob}; +pub use grant::{ + Accept, Announcement, Effect, EffectiveSince, Entry, Grant, GrantChange, Invite, Offer, + Removal, Roster, Standing, }; -pub use grant::{Entry, Grant, GrantChange, Removal, Roster}; pub use import::{ImportError, verify_cob_ref}; -pub use members::{Members, MembersChange, MembersCob, add_member, remove_member}; +pub use members::{Members, MembersChange, MembersCob}; pub use registry::{ Registration, Registry, RegistryChange, RegistryError, Rename, RepoRecord, RepoRef, RepoRegistryCob, deregister_repo, register_repo, rename_repo, diff --git a/knot2/crates/knot-cobs/src/members.rs b/knot2/crates/knot-cobs/src/members.rs index 31ab5c59a..cb7b06a32 100644 --- a/knot2/crates/knot-cobs/src/members.rs +++ b/knot2/crates/knot-cobs/src/members.rs @@ -1,62 +1,9 @@ -use crate::grant::grant_set_cob; +use crate::grant::roster_cob; -grant_set_cob! { +roster_cob! { change = MembersChange, + ops = { Add(Grant), Invite(Invite), Accept(Accept), Remove(Removal) }, cob = MembersCob, state = Members, type_name = "sh.tangled.knot.member", - add = add_member, - remove = remove_member, -} - -#[cfg(test)] -mod tests { - use knot_cob::{ChangePayload, Evaluate}; - use knot_types::{AccountDid, ActorId, UnixSeconds}; - - use super::*; - use crate::grant::{Grant, Removal}; - - fn did(suffix: &str) -> AccountDid { - AccountDid::new(format!("did:plc:{suffix}")).unwrap() - } - - fn grant(subject: &str, added_by: &str, at: i64) -> Grant { - Grant { - subject: did(subject), - added_by: did(added_by), - created_at: UnixSeconds::new(at), - } - } - - fn fold(changes: Vec) -> Members { - let author = ActorId::from_secp256k1(&[0x02; 33]); - changes - .into_iter() - .fold(MembersCob::initial(), |state, change| { - MembersCob::apply(state, change, &author) - }) - } - - #[test] - fn members_fold_add_then_remove() { - let state = fold(vec![ - MembersChange::Add(grant("nel", "nel", 1)), - MembersChange::Add(grant("olaren", "nel", 2)), - MembersChange::Remove(Removal { - subject: did("nel"), - }), - ]); - assert!(state.contains(&did("olaren"))); - assert!(!state.contains(&did("nel"))); - assert_eq!(state.len(), 1); - } - - #[test] - fn change_payload_roundtrips_through_dag_cbor() { - assert_eq!(MembersChange::TYPE, "sh.tangled.knot.member"); - let change = MembersChange::Add(grant("nel", "olaren", 9)); - let bytes = change.encode().unwrap(); - assert_eq!(MembersChange::decode(&bytes).unwrap(), change); - } } diff --git a/knot2/crates/knot-cobs/tests/cobs.rs b/knot2/crates/knot-cobs/tests/cobs.rs index d6b91007c..523d9cc7d 100644 --- a/knot2/crates/knot-cobs/tests/cobs.rs +++ b/knot2/crates/knot-cobs/tests/cobs.rs @@ -54,12 +54,12 @@ fn members_roundtrip_and_reload_is_identical() { let object = store.get::(created.object).unwrap(); let state = object.state(); - assert!(state.contains(&account("nel"))); + assert!(state.standing(&account("nel")).is_some()); assert_eq!( state.get(&account("olaren")).unwrap().added_by, account("nel") ); - assert!(!state.contains(&account("teq"))); + assert!(state.standing(&account("teq")).is_none()); assert_eq!(state.len(), 2); let listed: Vec<&AccountDid> = state.entries().map(|(subject, _)| subject).collect(); diff --git a/knot2/crates/knot-cobs/tests/invariants.rs b/knot2/crates/knot-cobs/tests/invariants.rs index 8ba1fc1da..d52766825 100644 --- a/knot2/crates/knot-cobs/tests/invariants.rs +++ b/knot2/crates/knot-cobs/tests/invariants.rs @@ -4,14 +4,16 @@ use common::{ account, at, build_members, cob_ref, fixture, forked_members, forked_members_object, grant, home, members_store, owner_of, registration, registry_with, rkey, signer, write_cob_commit, }; -use knot_cob::{ChangePayload, CobError, CobHome, CobId, CobStore}; +use knot_cob::{ChangePayload, CobError, CobHome, CobId, CobStore, Evaluate}; use knot_cobs::{ - CollaboratorsChange, ImportError, MembersChange, MembersCob, RegistryChange, RegistryError, - Removal, RepoRef, RepoRegistryCob, add_member, deregister_repo, register_repo, verify_cob_ref, + Accept, CollaboratorsChange, ImportError, Invite, MembersChange, MembersCob, RegistryChange, + RegistryError, Removal, RepoRef, RepoRegistryCob, Standing, deregister_repo, register_repo, + verify_cob_ref, }; use knot_git::RefUpdate; use knot_runtime::Signer; use knot_types::{ActorId, OwnerDid, RepoDid, TypeName}; +use proptest::prelude::*; use serde::Serialize; #[test] @@ -34,6 +36,42 @@ fn forked_acl_is_rejected_not_merged() { ); } +fn accepted(subject: &str, seconds: i64) -> MembersChange { + MembersChange::Accept(Accept { + subject: account(subject), + verified_at: at(seconds), + }) +} + +#[test] +fn only_invited_accounts_gain_standing_and_first_verification_wins() { + let uninvited = build_members( + 3, + &[ + (MembersChange::Add(grant("olaren", "olaren", 1)), 1), + (accepted("nel", 2), 2), + ], + ); + assert!( + uninvited.standing(&account("nel")).is_none(), + "nel has a standing from an acceptance the knot never offered" + ); + + let twice = build_members( + 4, + &[ + (MembersChange::Add(grant("nel", "olaren", 1)), 1), + (accepted("nel", 2), 2), + (accepted("nel", 9), 9), + ], + ); + assert_eq!( + twice.get(&account("nel")).unwrap().verified_at, + Some(at(2)), + "a refold keeps the first verification time, and a later acceptance can't move it" + ); +} + #[test] fn linear_member_semantics() { let readd = build_members( @@ -50,7 +88,7 @@ fn linear_member_semantics() { ], ); assert!( - readd.contains(&account("nel")), + readd.standing(&account("nel")).is_some(), "linear re-add after a remove is a legitimate decision and takes effect" ); @@ -67,7 +105,7 @@ fn linear_member_semantics() { ], ); assert!( - !stale_remove.contains(&account("nel")), + stale_remove.standing(&account("nel")).is_none(), "in a linear chain Remove is Add's child, so it applies last even with an older timestamp" ); @@ -343,21 +381,21 @@ fn add_member_handler_lands_a_grant() { ) .unwrap(); - add_member( - &store, - &home(), - created.object, - grant("olaren", "nel", 2), - &key, - at(2), - ) - .unwrap(); + store + .update_with_checkpointed::( + &home(), + created.object, + &key, + at(2), + |_| Ok(MembersChange::Add(grant("olaren", "nel", 2))), + ) + .unwrap(); let members = store .get::(created.object) .unwrap() .into_state(); - assert!(members.contains(&account("olaren"))); + assert!(members.standing(&account("olaren")).is_some()); } #[test] @@ -458,3 +496,46 @@ fn verify_cob_ref_boundary_cases() { "forked linear history is refused at import alongside the signature check" ); } + +fn knot_signed(op: u8, subject: &str, when: i64) -> MembersChange { + match op { + 0 => MembersChange::Invite(Invite(grant(subject, "olaren", when))), + 1 => MembersChange::Remove(Removal { + subject: account(subject), + }), + _ => MembersChange::Add(grant(subject, "olaren", when)), + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 96, ..ProptestConfig::default() })] + + #[test] + fn only_a_grant_grandfathers_and_only_an_acceptance_verifies( + ops in prop::collection::vec((0u8..3, 0u8..3), 1..24) + ) { + let author = ActorId::from_secp256k1(&[0x02; 33]); + let folded = ops + .iter() + .enumerate() + .fold(MembersCob::initial(), |state, (step, (op, who))| { + let change = knot_signed(*op, &format!("s{who}"), step as i64 + 1); + MembersCob::apply(state, change, &author) + }); + + let unearned: Vec<(u8, Option)> = (0u8..3) + .map(|who| (who, folded.standing(&account(&format!("s{who}"))))) + .filter(|(who, standing)| match standing { + Some(Standing::Accepted { .. }) => true, + Some(Standing::Grandfathered { .. }) => { + !ops.iter().any(|(op, subject)| *op == 2 && subject == who) + } + _ => false, + }) + .collect(); + prop_assert!( + unearned.is_empty(), + "standings the knot signed for on its own: {unearned:?}" + ); + } +} diff --git a/knot2/crates/knot-lexicons/src/lib.rs b/knot2/crates/knot-lexicons/src/lib.rs index 2d5fe003b..fca2a58e4 100644 --- a/knot2/crates/knot-lexicons/src/lib.rs +++ b/knot2/crates/knot-lexicons/src/lib.rs @@ -7,3 +7,122 @@ extern crate alloc; mod _lex; pub use _lex::*; + +mod scan { + const RECORD_OBJECT: &str = "record: LexRecordRecord::Object(LexObject {"; + + const fn run(hay: &str, pat: &str, at: usize, lo: usize, hi: usize) -> bool { + match hi - lo { + 0 | 1 => hi == lo || hay.as_bytes()[at + lo] == pat.as_bytes()[lo], + span => run(hay, pat, at, lo, lo + span / 2) && run(hay, pat, at, lo + span / 2, hi), + } + } + + const fn find(hay: &str, pat: &str, lo: usize, hi: usize) -> Option { + match hi - lo { + 1 if lo + pat.len() <= hay.len() && run(hay, pat, lo, 0, pat.len()) => Some(lo), + 0 | 1 => None, + span => match find(hay, pat, lo, lo + span / 2) { + Some(at) => Some(at), + None => find(hay, pat, lo + span / 2, hi), + }, + } + } + + pub const fn count(hay: &str, pat: &str, from: usize) -> usize { + match find(hay, pat, from, hay.len()) { + None => 0, + Some(at) => 1 + count(hay, pat, at + 1), + } + } + + pub const fn in_record(hay: &str, pat: &str) -> bool { + match find(hay, RECORD_OBJECT, 0, hay.len()) { + None => false, + Some(at) => find(hay, pat, at + RECORD_OBJECT.len(), hay.len()).is_some(), + } + } +} + +macro_rules! deprecated_collection { + ($nsid:literal, $bindings:literal) => { + const _: () = { + let bindings = include_str!($bindings); + assert!( + scan::count(bindings, concat!("/// DEPRECATED: use ", $nsid, "Invite"), 0) > 0, + concat!($nsid, " lost defs.main.description, so no Rust doc has the notice") + ); + assert!( + scan::in_record(bindings, concat!("DEPRECATED: use ", $nsid, "Invite")), + concat!($nsid, " lost record.description, so no Go doc has the notice") + ); + assert!( + scan::count(bindings, "key: Some(CowStr::new_static(\"tid\"))", 0) == 1, + concat!($nsid, " no longer keys records by tid, so every existing rkey is wrong") + ); + }; + }; +} +deprecated_collection!("sh.tangled.knot.member", "_lex/sh_tangled/knot/member.rs"); +deprecated_collection!("sh.tangled.repo.collaborator", "_lex/sh_tangled/repo/collaborator.rs"); + +#[cfg(test)] +mod tests { + use jacquard_common::DefaultStr; + use jacquard_common::types::{collection::Collection, string::{Datetime, Did}}; + + use crate::{scan::count, sh_tangled::{knot, repo}}; + + const WHEN: &str = "2026-06-11T00:00:00Z"; + const NSIDS: [&str; 6] = [ + ::NSID, + ::NSID, + ::NSID, + ::NSID, + ::NSID, + ::NSID, + ]; + const _: () = assert!( + count(include_str!("_lex/sh_tangled/knot.rs"), "\npub mod member", 0) + + count(include_str!("_lex/sh_tangled/repo.rs"), "\npub mod collaborator", 0) + == NSIDS.len(), + "a generated member or collaborator collection is missing from NSIDS" + ); + + #[test] + fn deprecating_a_collection_leaves_the_shape_a_consumer_already_indexed() { + let member = knot::member::Member:: { + created_at: Datetime::raw_str(WHEN), + domain: "knot.nel.pet".into(), + subject: Did::new_owned("did:plc:lyna").expect("literal did parses"), + extra_data: None, + }; + let collaborator = repo::collaborator::Collaborator:: { + created_at: Datetime::raw_str(WHEN), + repo: Did::new_owned("did:plc:repo").expect("literal did parses"), + subject: Did::new_owned("did:plc:lyna").expect("literal did parses"), + extra_data: None, + }; + + assert_eq!( + serde_json::json!([member, collaborator]), + serde_json::json!([ + { "$type": "sh.tangled.knot.member", "createdAt": WHEN, + "domain": "knot.nel.pet", "subject": "did:plc:lyna" }, + { "$type": "sh.tangled.repo.collaborator", "createdAt": WHEN, + "repo": "did:plc:repo", "subject": "did:plc:lyna" } + ]), + "the notice is prose on a description, so a member or collaborator already in \ + somebody's index still decodes as the fact it always was" + ); + } + + #[test] + fn every_consent_collection_owns_its_own_nsid() { + assert_eq!( + NSIDS.into_iter().collect::>().len(), + NSIDS.len(), + "two consent collections have the same NSID, so their records are stored together" + ); + } +} diff --git a/knot2/crates/knot-types/src/ids.rs b/knot2/crates/knot-types/src/ids.rs index 316f84c72..e3ab540f3 100644 --- a/knot2/crates/knot-types/src/ids.rs +++ b/knot2/crates/knot-types/src/ids.rs @@ -240,6 +240,13 @@ fn parse_rkey(value: &str) -> Option> { Rkey::new_owned(value).ok() } +fn parse_did_rkey(value: &str) -> Option { + let did = parse_did(value)?; + Rkey::::new_owned(did.as_str()).ok()?; + let ported = matches!(did.as_str().strip_prefix("did:web:"), Some(host) if host.contains(':')); + (!ported).then_some(did) +} + fn parse_ref_name(value: &str) -> Option { is_ref_name(value).then(|| RefName(value.to_string())) } @@ -357,6 +364,7 @@ string_id!(AccountDid, "account DID", via parse_did => Did); string_id!(ServiceDid, "service DID", via parse_did => Did); string_id!(RepoName, "repo name", parse_repo_name); string_id!(RepoRkey, "repo record key", via parse_rkey => Rkey); +string_id!(DidRkey, "DID record key", via parse_did_rkey => Did); string_id!(RefName, "ref name", parse_ref_name); string_id!(TypeName, "COB type name", via parse_nsid => Nsid); string_id!(ActorId, "actor public key", parse_multikey); @@ -369,6 +377,12 @@ string_id!(KnotServiceUrl, "knot service URL", parse_service_url); string_id!(LogsHost, "ci logs host", parse_logs_host); string_id!(PushOption, "push option", parse_push_option); +impl From<&DidRkey> for RepoDid { + fn from(rkey: &DidRkey) -> Self { + Self(rkey.0.clone()) + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] #[serde(transparent)] pub struct PushOptions(Vec); @@ -1256,6 +1270,36 @@ mod tests { assert!(KnotHostname::new("knot.nel.pet/path").is_err()); } + #[test] + fn a_did_record_key_canonicalizes_production_dids_and_refuses_the_rest() { + [ + ( + "did:plc:3fwecdnvtcscjnrx2p4n7alz", + Some("did:plc:3fwecdnvtcscjnrx2p4n7alz"), + ), + ("did:web:KNOT.oyster.cafe", Some("did:web:knot.oyster.cafe")), + ("did:web:localhost%3A3000", None), + ("did:web:oyster.cafe:knots:one", None), + ("not-a-did", None), + ("", None), + (&format!("did:plc:{}", "a".repeat(512)), None), + ] + .into_iter() + .for_each(|(value, canonical)| { + assert_eq!( + DidRkey::new(value).ok().as_ref().map(DidRkey::as_str), + canonical, + "{value:?}" + ); + }); + } + + #[test] + fn every_did_record_key_is_already_a_repository_did() { + let rkey = DidRkey::new("did:web:knot.oyster.cafe").unwrap(); + assert_eq!(RepoDid::from(&rkey).as_str(), rkey.as_str()); + } + #[test] fn a_ci_logs_address_splits_into_a_bare_host_and_a_nonzero_port() { let addr = CiLogsAddr::new("logs.oyster.cafe:3333").unwrap(); diff --git a/knot2/crates/knot-types/src/lib.rs b/knot2/crates/knot-types/src/lib.rs index 8ab937007..40afde127 100644 --- a/knot2/crates/knot-types/src/lib.rs +++ b/knot2/crates/knot-types/src/lib.rs @@ -7,10 +7,10 @@ pub use changes::{ChangedFiles, ChangedFilesBudget, Listing}; mod ids; pub use ids::{ AccountDid, ActorId, AppviewEndpoint, AuthorName, BranchName, ChangeId, CiLogsAddr, ClonePath, - CobId, Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, LanguageName, - LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OriginUrl, OwnerDid, OwnerRef, - ParseError, PushOption, PushOptions, RefName, RefTransition, RepoDid, RepoName, RepoPath, - RepoRkey, ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, + CobId, DidRkey, Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, + LanguageName, LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OriginUrl, + OwnerDid, OwnerRef, ParseError, PushOption, PushOptions, RefName, RefTransition, RepoDid, + RepoName, RepoPath, RepoRkey, ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, }; mod policy; diff --git a/lexicons/knot/acceptMembership.json b/lexicons/knot/acceptMembership.json new file mode 100644 index 000000000..3a6183a20 --- /dev/null +++ b/lexicons/knot/acceptMembership.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "sh.tangled.knot.acceptMembership", + "defs": { + "main": { + "type": "procedure", + "description": "Accept this knot's membership offer. The knot refuses the call unless the acceptance record is already in the actor's own repository.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "acceptance" + ], + "properties": { + "acceptance": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the sh.tangled.knot.memberAcceptance record, w/ knot-DID as rkey" + } + } + } + } + } + } +} diff --git a/lexicons/knot/listMemberInvites.json b/lexicons/knot/listMemberInvites.json new file mode 100644 index 000000000..f0295383b --- /dev/null +++ b/lexicons/knot/listMemberInvites.json @@ -0,0 +1,70 @@ +{ + "lexicon": 1, + "id": "sh.tangled.knot.listMemberInvites", + "defs": { + "main": { + "type": "query", + "description": "List this knot's outstanding membership offers. An offer leaves this listing once acceptMembership resolves the subject's record, or removeMember withdraws it.", + "parameters": { + "type": "params", + "required": ["subject"], + "properties": { + "subject": { + "type": "string", + "format": "did", + "description": "Knot identifier whose outstanding membership offers to list." + }, + "cursor": { "type": "string", "description": "Pagination cursor" }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + }, + "order": { + "type": "string", + "knownValues": ["asc", "desc"], + "default": "desc", + "description": "Sort direction by createdAt." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "ref", "ref": "#inviteItem" } + }, + "cursor": { "type": "string" } + } + } + } + }, + "inviteItem": { + "type": "object", + "description": "An offer awaiting the subject's acceptance. effectiveSince and access both arrive once the acceptance resolves.", + "required": ["subject", "addedBy", "createdAt"], + "properties": { + "subject": { + "type": "string", + "format": "did", + "description": "DID the knot offered membership to" + }, + "addedBy": { + "type": "string", + "format": "did", + "description": "DID that made the offer" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "When the knot made the offer. This listing sorts and pages on it." + } + } + } + } +} diff --git a/lexicons/knot/listMembers.json b/lexicons/knot/listMembers.json index 755e52f55..38e6c1c5e 100644 --- a/lexicons/knot/listMembers.json +++ b/lexicons/knot/listMembers.json @@ -10,7 +10,7 @@ "properties": { "subject": { "type": "string", - "description": "Actor DID whose knot memberships to list." + "description": "Knot identifier whose member records to list." }, "cursor": { "type": "string", @@ -31,7 +31,7 @@ "type": "string", "knownValues": ["asc", "desc"], "default": "desc", - "description": "Sort direction by createdAt." + "description": "Sort direction by effectiveSince." } } }, @@ -56,13 +56,42 @@ }, "listItem": { "type": "object", - "required": ["uri", "value"], + "required": ["subject", "addedBy", "createdAt", "effectiveSince"], "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" }, - "value": { - "type": "unknown", - "description": "Embedded sh.tangled.knot.member record" + "subject": { + "type": "string", + "format": "did", + "description": "DID of the member" + }, + "addedBy": { + "type": "string", + "format": "did", + "description": "DID that added this member" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "When the knot offered membership" + }, + "verifiedAt": { + "type": "string", + "format": "datetime", + "description": "When the knot resolved the member's own acceptance record. Absent on a grant that predates the consent model." + }, + "effectiveSince": { + "type": "string", + "format": "datetime", + "description": "When the membership took effect. This listing sorts and pages on it." + }, + "uri": { + "type": "string", + "format": "at-uri", + "description": "Optional record AT-URI for record-backed indexers" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Optional record CID for record-backed indexers" } } } diff --git a/lexicons/knot/member.json b/lexicons/knot/member.json index 10c2d4375..9a6dad04c 100644 --- a/lexicons/knot/member.json +++ b/lexicons/knot/member.json @@ -6,9 +6,11 @@ "defs": { "main": { "type": "record", + "description": "DEPRECATED: use sh.tangled.knot.memberInvite plus sh.tangled.knot.memberAcceptance instead. Existing records keep their meaning and this NSID is never reused, so indexers shouldn't migrate what they have already stored.", "key": "tid", "record": { "type": "object", + "description": "DEPRECATED: use sh.tangled.knot.memberInvite plus sh.tangled.knot.memberAcceptance instead. Existing records keep their meaning and this NSID is never reused, so indexers shouldn't migrate what they have already stored.", "required": [ "subject", "domain", diff --git a/lexicons/repo/acceptCollaboration.json b/lexicons/repo/acceptCollaboration.json new file mode 100644 index 000000000..58bcb0c17 --- /dev/null +++ b/lexicons/repo/acceptCollaboration.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.acceptCollaboration", + "defs": { + "main": { + "type": "procedure", + "description": "Accept a repository's collaboration offer. The knot refuses the call unless the acceptance record is already in the actor's own repository.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "acceptance" + ], + "properties": { + "acceptance": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the sh.tangled.repo.collaboratorAcceptance record, w/ repo-DID as rkey" + } + } + } + } + } + } +} diff --git a/lexicons/repo/collaborator.json b/lexicons/repo/collaborator.json index 727c14d56..2d81f0e22 100644 --- a/lexicons/repo/collaborator.json +++ b/lexicons/repo/collaborator.json @@ -6,9 +6,11 @@ "defs": { "main": { "type": "record", + "description": "DEPRECATED: use sh.tangled.repo.collaboratorInvite plus sh.tangled.repo.collaboratorAcceptance instead. Existing records keep their meaning and this NSID is never reused, so indexers shouldn't migrate what they have already stored.", "key": "tid", "record": { "type": "object", + "description": "DEPRECATED: use sh.tangled.repo.collaboratorInvite plus sh.tangled.repo.collaboratorAcceptance instead. Existing records keep their meaning and this NSID is never reused, so indexers shouldn't migrate what they have already stored.", "required": [ "subject", "repo", diff --git a/lexicons/repo/listCollaboratorInvites.json b/lexicons/repo/listCollaboratorInvites.json new file mode 100644 index 000000000..40592572f --- /dev/null +++ b/lexicons/repo/listCollaboratorInvites.json @@ -0,0 +1,70 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.listCollaboratorInvites", + "defs": { + "main": { + "type": "query", + "description": "List this repository's outstanding collaboration offers. An offer leaves this listing once acceptCollaboration resolves the subject's record, or removeCollaborator withdraws it.", + "parameters": { + "type": "params", + "required": ["subject"], + "properties": { + "subject": { + "type": "string", + "format": "did", + "description": "Repo DID whose outstanding collaboration offers to list." + }, + "cursor": { "type": "string", "description": "Pagination cursor" }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + }, + "order": { + "type": "string", + "knownValues": ["asc", "desc"], + "default": "desc", + "description": "Sort direction by createdAt." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "ref", "ref": "#inviteItem" } + }, + "cursor": { "type": "string" } + } + } + } + }, + "inviteItem": { + "type": "object", + "description": "An offer awaiting the subject's acceptance. effectiveSince and access both arrive once the acceptance resolves.", + "required": ["subject", "addedBy", "createdAt"], + "properties": { + "subject": { + "type": "string", + "format": "did", + "description": "DID the repository offered collaboration to" + }, + "addedBy": { + "type": "string", + "format": "did", + "description": "DID that made the offer" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "When the repository made the offer. This listing sorts and pages on it." + } + } + } + } +} diff --git a/lexicons/repo/listCollaborators.json b/lexicons/repo/listCollaborators.json index 8e7f2501d..7f3b04877 100644 --- a/lexicons/repo/listCollaborators.json +++ b/lexicons/repo/listCollaborators.json @@ -32,7 +32,7 @@ "type": "string", "knownValues": ["asc", "desc"], "default": "desc", - "description": "Sort direction by createdAt." + "description": "Sort direction by effectiveSince." } } }, @@ -57,7 +57,7 @@ }, "listItem": { "type": "object", - "required": ["subject", "addedBy", "createdAt"], + "required": ["subject", "addedBy", "createdAt", "effectiveSince"], "properties": { "subject": { "type": "string", @@ -72,7 +72,17 @@ "createdAt": { "type": "string", "format": "datetime", - "description": "When the collaborator was added" + "description": "When the repository offered collaboration" + }, + "verifiedAt": { + "type": "string", + "format": "datetime", + "description": "When the knot resolved the collaborator's own acceptance record. Absent on a grant that predates the consent model." + }, + "effectiveSince": { + "type": "string", + "format": "datetime", + "description": "When the collaboration took effect. This listing sorts and pages on it." }, "uri": { "type": "string", diff --git a/spindle/knotstream_test.go b/spindle/knotstream_test.go new file mode 100644 index 000000000..9beab00f6 --- /dev/null +++ b/spindle/knotstream_test.go @@ -0,0 +1,70 @@ +package spindle + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "slices" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/samber/lo" + "tangled.org/core/eventconsumer" + "tangled.org/core/eventstream" + knotdb "tangled.org/core/knotserver/db" + "tangled.org/core/log" + "tangled.org/core/rbac" + "tangled.org/core/spindle/db" +) + +func TestKnotStreamTracksCollaboratorsOnlyFromTheKnotHostingTheRepo(t *testing.T) { + const knot, foreignKnot = "knot.nel.pet", "barnacle.nel.pet" + const repoDid, subject = syntax.DID("did:plc:limpet"), syntax.DID("did:plc:boltless") + type step struct { + host string + op knotdb.AclOp + member bool + } + event := func(st step) eventstream.Event { + record := lo.Ternary[any](st.member, knotdb.KnotMemberUpdate{Op: st.op, Subject: subject.String()}, knotdb.RepoCollaboratorUpdate{Op: st.op, Subject: subject.String(), Repo: repoDid.String()}) + return eventstream.Event{Rkey: "evt", Nsid: lo.Ternary(st.member, knotdb.KnotMemberUpdateNSID, knotdb.RepoCollaboratorUpdateNSID), EventJson: lo.Must(json.Marshal(record))} + } + + for _, tc := range []struct { + name string + steps []step + wantErr, granted bool + }{ + {"an add grants the policy and writes the row", []step{{knot, knotdb.AclOpAdd, false}}, false, true}, + {"a remove revokes the policy and drops the row", []step{{knot, knotdb.AclOpAdd, false}, {knot, knotdb.AclOpRemove, false}}, false, false}, + {"an add from a knot that doesn't host the repo is dropped quietly", []step{{foreignKnot, knotdb.AclOpAdd, false}}, false, false}, + {"a remove from a knot that doesn't host the repo can't revoke the grant", []step{{knot, knotdb.AclOpAdd, false}, {foreignKnot, knotdb.AclOpRemove, false}}, false, true}, + {"an unrecognized op is an error rather than a silent drop", []step{{knot, knotdb.AclOp("bogus"), false}}, true, false}, + {"a knot memberUpdate is ignored because knot membership is not repo access", []step{{knot, knotdb.AclOpAdd, true}}, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + d, e := newTestSpindleDB(t) + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx, s := log.IntoContext(context.Background(), quiet), &Spindle{db: d, e: e, l: quiet} + lo.Must0(d.AddRepo(db.Repo{Knot: knot, Owner: "did:plc:akshay", Rkey: "3kqrstuvwxyz", RepoDid: repoDid})) + + err := lo.Reduce(tc.steps, func(acc error, st step, _ int) error { + return errors.Join(acc, s.processKnotStream(ctx, eventconsumer.Source{Kind: eventconsumer.KindKnot, Host: st.host}, event(st))) + }, nil) + if (err != nil) != tc.wantErr { + t.Fatalf("processKnotStream err = %v, want an error: %v", err, tc.wantErr) + } + + wantPerms := lo.Ternary(tc.granted, []string{"repo:collaborator", "repo:push", "repo:settings"}, nil) + perms := slices.Sorted(slices.Values(s.e.GetPermissionsInRepo(subject.String(), rbac.ThisServer, repoDid.String()))) + if !slices.Equal(perms, wantPerms) { + t.Errorf("permissions = %v, want %v", perms, wantPerms) + } + if held := slices.ContainsFunc(lo.Must(s.db.ListCollaboratorsByRepoDid(repoDid)), func(c db.RepoCollaborator) bool { return c.Subject == subject }); held != tc.granted { + t.Errorf("repo_collaborators row present = %v, want %v", held, tc.granted) + } + }) + } +} diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index 84f1baf73..53bb25964 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -124,12 +124,14 @@ export * as ShTangledGraphListVouches from "./types/sh/tangled/graph/listVouches export * as ShTangledGraphListVouchesBy from "./types/sh/tangled/graph/listVouchesBy.js"; export * as ShTangledGraphVouch from "./types/sh/tangled/graph/vouch.js"; export * as ShTangledKnot from "./types/sh/tangled/knot.js"; +export * as ShTangledKnotAcceptMembership from "./types/sh/tangled/knot/acceptMembership.js"; export * as ShTangledKnotAddMember from "./types/sh/tangled/knot/addMember.js"; export * as ShTangledKnotCountKnots from "./types/sh/tangled/knot/countKnots.js"; export * as ShTangledKnotCountMembers from "./types/sh/tangled/knot/countMembers.js"; export * as ShTangledKnotCountMembersBy from "./types/sh/tangled/knot/countMembersBy.js"; export * as ShTangledKnotListKeys from "./types/sh/tangled/knot/listKeys.js"; export * as ShTangledKnotListKnots from "./types/sh/tangled/knot/listKnots.js"; +export * as ShTangledKnotListMemberInvites from "./types/sh/tangled/knot/listMemberInvites.js"; export * as ShTangledKnotListMembers from "./types/sh/tangled/knot/listMembers.js"; export * as ShTangledKnotListMembersBy from "./types/sh/tangled/knot/listMembersBy.js"; export * as ShTangledKnotMember from "./types/sh/tangled/knot/member.js"; @@ -168,6 +170,7 @@ export * as ShTangledPullGetPullView from "./types/sh/tangled/pull/getPullView.j export * as ShTangledPullListPullViews from "./types/sh/tangled/pull/listPullViews.js"; export * as ShTangledQueryEnrichResponse from "./types/sh/tangled/query/enrichResponse.js"; export * as ShTangledRepo from "./types/sh/tangled/repo.js"; +export * as ShTangledRepoAcceptCollaboration from "./types/sh/tangled/repo/acceptCollaboration.js"; export * as ShTangledRepoAddCollaborator from "./types/sh/tangled/repo/addCollaborator.js"; export * as ShTangledRepoAddSecret from "./types/sh/tangled/repo/addSecret.js"; export * as ShTangledRepoArchive from "./types/sh/tangled/repo/archive.js"; @@ -220,6 +223,7 @@ export * as ShTangledRepoIssueStateOpen from "./types/sh/tangled/repo/issue/stat export * as ShTangledRepoLanguages from "./types/sh/tangled/repo/languages.js"; export * as ShTangledRepoListArtifacts from "./types/sh/tangled/repo/listArtifacts.js"; export * as ShTangledRepoListArtifactsBy from "./types/sh/tangled/repo/listArtifactsBy.js"; +export * as ShTangledRepoListCollaboratorInvites from "./types/sh/tangled/repo/listCollaboratorInvites.js"; export * as ShTangledRepoListCollaborators from "./types/sh/tangled/repo/listCollaborators.js"; export * as ShTangledRepoListCollaboratorsBy from "./types/sh/tangled/repo/listCollaboratorsBy.js"; export * as ShTangledRepoListIssues from "./types/sh/tangled/repo/listIssues.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/knot/acceptMembership.ts b/web/src/lib/api/lexicons/types/sh/tangled/knot/acceptMembership.ts new file mode 100644 index 000000000..8a3427a64 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/knot/acceptMembership.ts @@ -0,0 +1,35 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure( + "sh.tangled.knot.acceptMembership", + { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * AT-URI of the sh.tangled.knot.memberAcceptance record, w/ knot-DID as rkey + */ + acceptance: /*#__PURE__*/ v.resourceUriString(), + }), + }, + output: null, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "sh.tangled.knot.acceptMembership": mainSchema; + } +} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/knot/listMemberInvites.ts b/web/src/lib/api/lexicons/types/sh/tangled/knot/listMemberInvites.ts new file mode 100644 index 000000000..aad9644c9 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/knot/listMemberInvites.ts @@ -0,0 +1,81 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _inviteItemSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.knot.listMemberInvites#inviteItem"), + ), + /** + * DID that made the offer + */ + addedBy: /*#__PURE__*/ v.didString(), + /** + * When the knot made the offer. This listing sorts and pages on it. + */ + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * DID the knot offered membership to + */ + subject: /*#__PURE__*/ v.didString(), +}); +const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.knot.listMemberInvites", { + params: /*#__PURE__*/ v.object({ + /** + * Pagination cursor + */ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * @minimum 1 + * @maximum 1000 + * @default 50 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 1000), + ]), + 50, + ), + /** + * Sort direction by createdAt. + * @default "desc" + */ + order: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), + "desc", + ), + /** + * Knot identifier whose outstanding membership offers to list. + */ + subject: /*#__PURE__*/ v.didString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get items() { + return /*#__PURE__*/ v.array(inviteItemSchema); + }, + }), + }, +}); + +type inviteItem$schematype = typeof _inviteItemSchema; +type main$schematype = typeof _mainSchema; + +export interface inviteItemSchema extends inviteItem$schematype {} +export interface mainSchema extends main$schematype {} + +export const inviteItemSchema = _inviteItemSchema as inviteItemSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface InviteItem extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.knot.listMemberInvites": mainSchema; + } +} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/knot/listMembers.ts b/web/src/lib/api/lexicons/types/sh/tangled/knot/listMembers.ts index 029703873..f06d26078 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/knot/listMembers.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/knot/listMembers.ts @@ -6,12 +6,34 @@ const _listItemSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal("sh.tangled.knot.listMembers#listItem"), ), + /** + * DID that added this member + */ + addedBy: /*#__PURE__*/ v.didString(), + /** + * Optional record CID for record-backed indexers + */ cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), - uri: /*#__PURE__*/ v.resourceUriString(), /** - * Embedded sh.tangled.knot.member record + * When the knot offered membership + */ + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * When the membership took effect. This listing sorts and pages on it. + */ + effectiveSince: /*#__PURE__*/ v.datetimeString(), + /** + * DID of the member + */ + subject: /*#__PURE__*/ v.didString(), + /** + * Optional record AT-URI for record-backed indexers + */ + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + /** + * When the knot resolved the member's own acceptance record. Absent on a grant that predates the consent model. */ - value: /*#__PURE__*/ v.unknown(), + verifiedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), }); const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.knot.listMembers", { params: /*#__PURE__*/ v.object({ @@ -36,7 +58,7 @@ const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.knot.listMembers", { */ offset: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), /** - * Sort direction by createdAt. + * Sort direction by effectiveSince. * @default "desc" */ order: /*#__PURE__*/ v.optional( @@ -44,7 +66,7 @@ const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.knot.listMembers", { "desc", ), /** - * Actor DID whose knot memberships to list. + * Knot identifier whose member records to list. */ subject: /*#__PURE__*/ v.string(), }), diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/acceptCollaboration.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/acceptCollaboration.ts new file mode 100644 index 000000000..8835ac63c --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/acceptCollaboration.ts @@ -0,0 +1,35 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure( + "sh.tangled.repo.acceptCollaboration", + { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * AT-URI of the sh.tangled.repo.collaboratorAcceptance record, w/ repo-DID as rkey + */ + acceptance: /*#__PURE__*/ v.resourceUriString(), + }), + }, + output: null, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "sh.tangled.repo.acceptCollaboration": mainSchema; + } +} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaboratorInvites.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaboratorInvites.ts new file mode 100644 index 000000000..3e57f9675 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaboratorInvites.ts @@ -0,0 +1,86 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _inviteItemSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "sh.tangled.repo.listCollaboratorInvites#inviteItem", + ), + ), + /** + * DID that made the offer + */ + addedBy: /*#__PURE__*/ v.didString(), + /** + * When the repository made the offer. This listing sorts and pages on it. + */ + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * DID the repository offered collaboration to + */ + subject: /*#__PURE__*/ v.didString(), +}); +const _mainSchema = /*#__PURE__*/ v.query( + "sh.tangled.repo.listCollaboratorInvites", + { + params: /*#__PURE__*/ v.object({ + /** + * Pagination cursor + */ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * @minimum 1 + * @maximum 1000 + * @default 50 + */ + limit: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ + /*#__PURE__*/ v.integerRange(1, 1000), + ]), + 50, + ), + /** + * Sort direction by createdAt. + * @default "desc" + */ + order: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), + "desc", + ), + /** + * Repo DID whose outstanding collaboration offers to list. + */ + subject: /*#__PURE__*/ v.didString(), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + get items() { + return /*#__PURE__*/ v.array(inviteItemSchema); + }, + }), + }, + }, +); + +type inviteItem$schematype = typeof _inviteItemSchema; +type main$schematype = typeof _mainSchema; + +export interface inviteItemSchema extends inviteItem$schematype {} +export interface mainSchema extends main$schematype {} + +export const inviteItemSchema = _inviteItemSchema as inviteItemSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface InviteItem extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.repo.listCollaboratorInvites": mainSchema; + } +} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaborators.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaborators.ts index 73709f5bf..fd175be3e 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaborators.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/listCollaborators.ts @@ -15,9 +15,13 @@ const _listItemSchema = /*#__PURE__*/ v.object({ */ cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), /** - * When the collaborator was added + * When the repository offered collaboration */ createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * When the collaboration took effect. This listing sorts and pages on it. + */ + effectiveSince: /*#__PURE__*/ v.datetimeString(), /** * DID of the collaborator */ @@ -26,6 +30,10 @@ const _listItemSchema = /*#__PURE__*/ v.object({ * Optional record AT-URI for record-backed indexers */ uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + /** + * When the knot resolved the collaborator's own acceptance record. Absent on a grant that predates the consent model. + */ + verifiedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), }); const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.repo.listCollaborators", { params: /*#__PURE__*/ v.object({ @@ -50,7 +58,7 @@ const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.repo.listCollaborators", { */ offset: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), /** - * Sort direction by createdAt. + * Sort direction by effectiveSince. * @default "desc" */ order: /*#__PURE__*/ v.optional( diff --git a/web/src/lib/api/repoCreationTargets.ts b/web/src/lib/api/repoCreationTargets.ts index 6f65cdc37..012c2eb1c 100644 --- a/web/src/lib/api/repoCreationTargets.ts +++ b/web/src/lib/api/repoCreationTargets.ts @@ -1,7 +1,7 @@ import { ok } from "@atcute/client"; import type { Did } from "@atcute/lexicons/syntax"; import type { BobbinContext } from "./client"; -import { mainSchema as listKnotMembersSchema } from "./lexicons/types/sh/tangled/knot/listMembers"; +import { jsonGet } from "./_request"; import { mainSchema as listKnotsSchema } from "./lexicons/types/sh/tangled/knot/listKnots"; import { mainSchema as listSpindleMembersSchema } from "./lexicons/types/sh/tangled/spindle/listMembers"; import { mainSchema as listSpindlesSchema } from "./lexicons/types/sh/tangled/spindle/listSpindles"; @@ -61,11 +61,11 @@ export const availableKnots = async (ctx: BobbinContext, did: Did): Promise - ok( - ctx.xrpc.call(listKnotMembersSchema, { - params: { subject: did, limit: 1_000, cursor } - }) - ) + jsonGet>(ctx, "sh.tangled.knot.listMembers", { + subject: did, + limit: 1_000, + cursor + }) ) ]); return targetNames(owned, memberships, knotDomain); diff --git a/xrpc/serviceauth/service_auth.go b/xrpc/serviceauth/service_auth.go index 709012591..42560a95c 100644 --- a/xrpc/serviceauth/service_auth.go +++ b/xrpc/serviceauth/service_auth.go @@ -3,6 +3,8 @@ package serviceauth import ( "context" "encoding/json" + "errors" + "fmt" "log/slog" "net/http" "path" @@ -23,6 +25,24 @@ func DidWeb(hostname string) syntax.DID { return syntax.DID("did:web:" + strings.ReplaceAll(hostname, ":", "%3A")) } +type ServiceRkey string + +func (r ServiceRkey) String() string { return string(r) } + +func RkeyForService(hostname string) (ServiceRkey, error) { + const maxRkeyLength = 512 + switch rkey, stray := ServiceRkey(DidWeb(hostname)), strings.IndexAny(hostname, ":/%"); { + case hostname == "": + return "", errors.New("a service record key comes from a hostname, and this one is empty") + case stray >= 0: + return "", fmt.Errorf("a service record key comes from the bare host, and %q contains %q", hostname, hostname[stray]) + case len(rkey) > maxRkeyLength: + return "", fmt.Errorf("a record key from %q is %d bytes, over the %d a repository allows", hostname, len(rkey), maxRkeyLength) + default: + return rkey, nil + } +} + type ServiceAuth struct { logger *slog.Logger dir identity.Directory @@ -44,7 +64,7 @@ func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { lxm, err := syntax.ParseNSID(path.Base(r.URL.Path)) if err != nil { - sa.logger.Error("could not derive lexicon method from request path", "path", r.URL.Path, "err", err) + sa.logger.Error("couldn't derive lexicon method from request path", "path", r.URL.Path, "err", err) writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) return } -- 2.51.2