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