diff --git a/api/tangled/repodelete.go b/api/tangled/repodelete.go index 696b94fd..3cd59bac 100644 --- a/api/tangled/repodelete.go +++ b/api/tangled/repodelete.go @@ -16,14 +16,16 @@ const ( // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. type RepoDelete_Input struct { - // did: DID of the repository owner - Did string `json:"did" cborgen:"did"` + // did: DID of the repository owner. A knot without the repo-did-input capability reads this and name in place of repo. + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` // force: Admin-only. Delete even though the repository record still exists on the owner's PDS. Force *bool `json:"force,omitempty" cborgen:"force,omitempty"` - // name: Name of the repository to delete - Name string `json:"name" cborgen:"name"` - // rkey: Rkey of the repository record - Rkey string `json:"rkey" cborgen:"rkey"` + // name: Name of the repository to delete. A knot without the repo-did-input capability reads this and DID in place of repo. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` + // repo: DID of the repository to delete + Repo string `json:"repo" cborgen:"repo"` + // rkey: Rkey of the repository record. A knot without the repo-did-input capability checks this against the owner's PDS. + Rkey *string `json:"rkey,omitempty" cborgen:"rkey,omitempty"` } // RepoDelete calls the XRPC method "sh.tangled.repo.delete". diff --git a/api/tangled/repodeleteBranch.go b/api/tangled/repodeleteBranch.go index d861878b..155934de 100644 --- a/api/tangled/repodeleteBranch.go +++ b/api/tangled/repodeleteBranch.go @@ -17,7 +17,8 @@ const ( // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. type RepoDeleteBranch_Input struct { Branch string `json:"branch" cborgen:"branch"` - Repo string `json:"repo" cborgen:"repo"` + // repo: DID of the repository + Repo string `json:"repo" cborgen:"repo"` } // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". diff --git a/api/tangled/repoforkStatus.go b/api/tangled/repoforkStatus.go deleted file mode 100644 index 67f969dd..00000000 --- a/api/tangled/repoforkStatus.go +++ /dev/null @@ -1,45 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: sh.tangled.repo.forkStatus - -import ( - "context" - - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - RepoForkStatusNSID = "sh.tangled.repo.forkStatus" -) - -// RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. -type RepoForkStatus_Input struct { - // branch: Branch to check status for - Branch string `json:"branch" cborgen:"branch"` - // did: DID of the fork owner - Did string `json:"did" cborgen:"did"` - // hiddenRef: Hidden ref to use for comparison - HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` - // name: Name of the forked repository - Name string `json:"name" cborgen:"name"` - // source: Source repository URL - Source string `json:"source" cborgen:"source"` -} - -// RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. -type RepoForkStatus_Output struct { - // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch - Status int64 `json:"status" cborgen:"status"` -} - -// RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". -func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { - var out RepoForkStatus_Output - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} diff --git a/api/tangled/repoforkSync.go b/api/tangled/repoforkSync.go index 7072e100..89999374 100644 --- a/api/tangled/repoforkSync.go +++ b/api/tangled/repoforkSync.go @@ -18,14 +18,14 @@ const ( type RepoForkSync_Input struct { // branch: Branch to sync Branch string `json:"branch" cborgen:"branch"` - // did: DID of the fork owner - Did string `json:"did" cborgen:"did"` - // name: Name of the forked repository - Name string `json:"name" cborgen:"name"` - // repo: DID of the repository - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` - // source: AT-URI of the source repository - Source string `json:"source" cborgen:"source"` + // did: DID of the fork owner. A knot without the repo-did-input capability reads this and name in place of repo. + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` + // name: Name of the forked repository. A knot without the repo-did-input capability reads this and DID in place of repo. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` + // repo: DID of the fork to sync + Repo string `json:"repo" cborgen:"repo"` + // source: AT-URI of the source repository. A knot without the repo-did-input capability requires this field without reading it. + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` } // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". diff --git a/api/tangled/repohiddenRef.go b/api/tangled/repohiddenRef.go index 62e3b3e5..6c30c12a 100644 --- a/api/tangled/repohiddenRef.go +++ b/api/tangled/repohiddenRef.go @@ -20,7 +20,7 @@ type RepoHiddenRef_Input struct { ForkRef string `json:"forkRef" cborgen:"forkRef"` // remoteRef: Remote reference name RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` - // repo: AT-URI of the repository + // repo: DID of the fork that the hidden ref belongs to Repo string `json:"repo" cborgen:"repo"` } diff --git a/api/tangled/repomerge.go b/api/tangled/repomerge.go index b99d1985..8cb2a252 100644 --- a/api/tangled/repomerge.go +++ b/api/tangled/repomerge.go @@ -26,14 +26,14 @@ type RepoMerge_Input struct { CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` // commitMessage: Merge commit message CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` - // did: DID of the repository owner - Did string `json:"did" cborgen:"did"` - // name: Name of the repository - Name string `json:"name" cborgen:"name"` + // did: DID of the repository owner. A knot without the repo-did-input capability reads this and name in place of repo. + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` + // name: Name of the repository. A knot without the repo-did-input capability reads this and DID in place of repo. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` // patch: Patch content to merge Patch string `json:"patch" cborgen:"patch"` // repo: DID of the repository - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` + Repo string `json:"repo" cborgen:"repo"` } // RepoMerge calls the XRPC method "sh.tangled.repo.merge". diff --git a/api/tangled/repomergeCheck.go b/api/tangled/repomergeCheck.go index 31d71dc7..c1aaee17 100644 --- a/api/tangled/repomergeCheck.go +++ b/api/tangled/repomergeCheck.go @@ -26,14 +26,14 @@ type RepoMergeCheck_ConflictInfo struct { type RepoMergeCheck_Input struct { // branch: Target branch to merge into Branch string `json:"branch" cborgen:"branch"` - // did: DID of the repository owner - Did string `json:"did" cborgen:"did"` - // name: Name of the repository - Name string `json:"name" cborgen:"name"` + // did: DID of the repository owner. A knot without the repo-did-input capability reads this and name in place of repo. + Did *string `json:"did,omitempty" cborgen:"did,omitempty"` + // name: Name of the repository. A knot without the repo-did-input capability reads this and DID in place of repo. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` // patch: Patch or pull request to check for merge conflicts Patch string `json:"patch" cborgen:"patch"` // repo: DID of the repository - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` + Repo string `json:"repo" cborgen:"repo"` } // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. diff --git a/api/tangled/reposetDefaultBranch.go b/api/tangled/reposetDefaultBranch.go index 3373d9a2..bf7b05de 100644 --- a/api/tangled/reposetDefaultBranch.go +++ b/api/tangled/reposetDefaultBranch.go @@ -17,7 +17,8 @@ const ( // RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call. type RepoSetDefaultBranch_Input struct { DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"` - Repo string `json:"repo" cborgen:"repo"` + // repo: DID of the repository + Repo string `json:"repo" cborgen:"repo"` } // RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch". diff --git a/appview/knotcompat/version.go b/appview/knotcompat/version.go index f05629a3..c8b88542 100644 --- a/appview/knotcompat/version.go +++ b/appview/knotcompat/version.go @@ -11,9 +11,11 @@ import ( "sync/atomic" "time" + "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/consts" + "tangled.org/core/repoident" ) const ( @@ -23,8 +25,13 @@ const ( versionProbeCacheMax = 4096 ) +type knotIdentity struct { + version string + capabilities []string +} + type versionProbeEntry struct { - version string + identity knotIdentity probedAt time.Time } @@ -40,13 +47,13 @@ func (c *versionProbeCache) get(host string) (versionProbeEntry, bool) { return e, ok } -func (c *versionProbeCache) put(host, version string, at time.Time) { +func (c *versionProbeCache) put(host string, identity knotIdentity, at time.Time) { c.mu.Lock() defer c.mu.Unlock() if _, exists := c.entries[host]; !exists && len(c.entries) >= versionProbeCacheMax { c.evictLocked(at) } - c.entries[host] = versionProbeEntry{version: version, probedAt: at} + c.entries[host] = versionProbeEntry{identity: identity, probedAt: at} } func (c *versionProbeCache) evictLocked(now time.Time) { @@ -66,18 +73,26 @@ func (c *versionProbeCache) evictLocked(now time.Time) { } } -func (c *versionProbeCache) supports(now time.Time, host string, minMajor, minMinor int, failOpen bool, probe func() (string, bool)) bool { +func (c *versionProbeCache) resolve(now time.Time, host string, probe func() (knotIdentity, bool)) (knotIdentity, bool) { if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeFresh { - return atLeast(e.version, minMajor, minMinor) + return e.identity, true } - if version, ok := probe(); ok { - c.put(host, version, now) - return atLeast(version, minMajor, minMinor) + if identity, ok := probe(); ok { + c.put(host, identity, now) + return identity, true } if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeTrust { - return atLeast(e.version, minMajor, minMinor) + return e.identity, true + } + return knotIdentity{}, false +} + +func (c *versionProbeCache) supports(now time.Time, host string, minMajor, minMinor int, failOpen bool, probe func() (knotIdentity, bool)) bool { + identity, ok := c.resolve(now, host, probe) + if !ok { + return failOpen } - return failOpen + return atLeast(identity.version, minMajor, minMinor) } var probeCache = &versionProbeCache{entries: map[string]versionProbeEntry{}} @@ -166,6 +181,20 @@ func KnotSupports114(ctx context.Context, host string, dev bool) bool { return knotSupportsVersion(ctx, host, dev, 1, 14, true) } +func RepoArg(ctx context.Context, host string, dev bool, repoDid string, legacyAtUri syntax.ATURI) string { + rd, err := repoident.NewRepoDid(repoDid) + if err != nil { + return legacyAtUri.String() + } + identity, ok := probeCache.resolve(time.Now(), host, func() (knotIdentity, bool) { + return probeIdentity(ctx, host, dev) + }) + if !ok || !slices.Contains(identity.capabilities, string(consts.CapRepoDidInput)) { + return legacyAtUri.String() + } + return rd.String() +} + func KnotCapability(ctx context.Context, host string, dev bool, capability consts.Capability) CapStatus { return nativeProbeGate.status(host, func() CapStatus { return knotDeclares(ctx, host, dev, capability) @@ -200,12 +229,12 @@ func knotDeclares(ctx context.Context, host string, dev bool, capability consts. } func knotSupportsVersion(ctx context.Context, host string, dev bool, minMajor, minMinor int, failOpen bool) bool { - return probeCache.supports(time.Now(), host, minMajor, minMinor, failOpen, func() (string, bool) { - return probeVersion(ctx, host, dev) + return probeCache.supports(time.Now(), host, minMajor, minMinor, failOpen, func() (knotIdentity, bool) { + return probeIdentity(ctx, host, dev) }) } -func probeVersion(ctx context.Context, host string, dev bool) (string, bool) { +func probeIdentity(ctx context.Context, host string, dev bool) (knotIdentity, bool) { scheme := "https" if dev { scheme = "http" @@ -220,9 +249,9 @@ func probeVersion(ctx context.Context, host string, dev bool) (string, bool) { resp, err := tangled.KnotVersion(ctx, client) if err != nil || resp == nil { - return "", false + return knotIdentity{}, false } - return resp.Version, true + return knotIdentity{version: resp.Version, capabilities: resp.Capabilities}, true } func atLeast(v string, minMajor, minMinor int) bool { diff --git a/appview/knotcompat/version_test.go b/appview/knotcompat/version_test.go index 98db29a1..008e2e48 100644 --- a/appview/knotcompat/version_test.go +++ b/appview/knotcompat/version_test.go @@ -1,10 +1,16 @@ package knotcompat import ( + "context" + "slices" "strconv" "sync" "testing" "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/samber/lo" + "tangled.org/core/consts" ) type fakeLatch struct { @@ -275,7 +281,7 @@ func TestVersionProbeCacheFreshSkipsProbe(t *testing.T) { c := newProbeCache() now := time.Unix(1_000_000, 0) calls := 0 - probe := func() (string, bool) { calls++; return "v1.15.0", true } + probe := func() (knotIdentity, bool) { calls++; return ident("v1.15.0"), true } if !c.supports(now, "knot.nel.pet", 1, 15, false, probe) { t.Fatal("cold probe: want supported") @@ -294,11 +300,11 @@ func TestVersionProbeCacheFreshSkipsProbe(t *testing.T) { func TestVersionProbeCacheServesStaleOnFailure(t *testing.T) { c := newProbeCache() now := time.Unix(1_000_000, 0) - if !c.supports(now, "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.15.0", true }) { + if !c.supports(now, "knot.nel.pet", 1, 15, false, func() (knotIdentity, bool) { return ident("v1.15.0"), true }) { t.Fatal("seed: want supported") } - failProbe := func() (string, bool) { return "", false } + failProbe := func() (knotIdentity, bool) { return knotIdentity{}, false } if !c.supports(now.Add(10*time.Minute), "knot.nel.pet", 1, 15, false, failProbe) { t.Error("a probe failure within the trust window must serve the last-known version, not fail closed") } @@ -307,7 +313,7 @@ func TestVersionProbeCacheServesStaleOnFailure(t *testing.T) { func TestVersionProbeCacheFailsClosedWhenUntrusted(t *testing.T) { c := newProbeCache() now := time.Unix(1_000_000, 0) - failProbe := func() (string, bool) { return "", false } + failProbe := func() (knotIdentity, bool) { return knotIdentity{}, false } if c.supports(now, "knot.nel.pet", 1, 15, false, failProbe) { t.Error("a cold probe failure on a fail-closed gate must return false") } @@ -319,10 +325,10 @@ func TestVersionProbeCacheFailsClosedWhenUntrusted(t *testing.T) { func TestVersionProbeCacheExpiresTrust(t *testing.T) { c := newProbeCache() now := time.Unix(1_000_000, 0) - if !c.supports(now, "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.15.0", true }) { + if !c.supports(now, "knot.nel.pet", 1, 15, false, func() (knotIdentity, bool) { return ident("v1.15.0"), true }) { t.Fatal("seed: want supported") } - if c.supports(now.Add(2*time.Hour), "knot.nel.pet", 1, 15, false, func() (string, bool) { return "", false }) { + if c.supports(now.Add(2*time.Hour), "knot.nel.pet", 1, 15, false, func() (knotIdentity, bool) { return knotIdentity{}, false }) { t.Error("a probe failure past the trust window must fail closed") } } @@ -330,10 +336,10 @@ func TestVersionProbeCacheExpiresTrust(t *testing.T) { func TestVersionProbeCacheRefreshesAfterFresh(t *testing.T) { c := newProbeCache() now := time.Unix(1_000_000, 0) - if c.supports(now, "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.14.0", true }) { + if c.supports(now, "knot.nel.pet", 1, 15, false, func() (knotIdentity, bool) { return ident("v1.14.0"), true }) { t.Fatal("seed: 1.14 must not satisfy 1.15") } - if !c.supports(now.Add(10*time.Minute), "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.15.0", true }) { + if !c.supports(now.Add(10*time.Minute), "knot.nel.pet", 1, 15, false, func() (knotIdentity, bool) { return ident("v1.15.0"), true }) { t.Error("a re-probe past the fresh window must pick up the upgraded version") } } @@ -341,7 +347,7 @@ func TestVersionProbeCacheRefreshesAfterFresh(t *testing.T) { func TestVersionProbeCacheEnforcesHardCap(t *testing.T) { c := newProbeCache() now := time.Unix(1_000_000, 0) - probe := func() (string, bool) { return "v1.15.0", true } + probe := func() (knotIdentity, bool) { return ident("v1.15.0"), true } for i := 0; i < versionProbeCacheMax+200; i++ { c.supports(now, "knot"+strconv.Itoa(i)+".nel.pet", 1, 15, false, probe) } @@ -353,7 +359,7 @@ func TestVersionProbeCacheEnforcesHardCap(t *testing.T) { func TestVersionProbeCacheEvictsOldestWhenFull(t *testing.T) { c := newProbeCache() base := time.Unix(1_000_000, 0) - probe := func() (string, bool) { return "v1.15.0", true } + probe := func() (knotIdentity, bool) { return ident("v1.15.0"), true } for i := 0; i < versionProbeCacheMax; i++ { c.supports(base.Add(time.Duration(i)*time.Millisecond), "knot"+strconv.Itoa(i)+".nel.pet", 1, 15, false, probe) } @@ -369,3 +375,56 @@ func TestVersionProbeCacheEvictsOldestWhenFull(t *testing.T) { t.Error("newcomer must be retained") } } + +func ident(version string, capabilities ...consts.Capability) knotIdentity { + return knotIdentity{ + version: version, + capabilities: lo.Map(capabilities, func(c consts.Capability, _ int) string { return string(c) }), + } +} + +func TestVersionProbeCacheResolveServesOneProbeToEveryReader(t *testing.T) { + c, calls := newProbeCache(), 0 + now := time.Unix(1_000_000, 0) + probe := func() (knotIdentity, bool) { calls++; return ident("v1.15.0", consts.CapRepoDidInput), true } + if !c.supports(now, "knot.nel.pet", 1, 15, false, probe) { + t.Fatal("version check: want supported") + } + if identity, ok := c.resolve(now.Add(time.Minute), "knot.nel.pet", probe); !ok || + !slices.Contains(identity.capabilities, string(consts.CapRepoDidInput)) { + t.Fatalf("capability read = %v (resolved=%v), want the advertised capability", identity.capabilities, ok) + } + if calls != 1 { + t.Errorf("calls = %d, want 1, since both reads must come from the one probe", calls) + } +} + +func TestRepoArgSendsTheDidOnlyToAKnotThatAdvertisesIt(t *testing.T) { + const atUri = syntax.ATURI("at://did:plc:nel/sh.tangled.repo/periwinkle") + const did = "did:plc:periwinkle" + takesDid := []consts.Capability{consts.CapRepoDidInput} + + cases := map[string]struct { + host string + advertised []consts.Capability + repoDid string + want string + }{ + "we'll send the DID to a knot that advertises repo-did-input": {"a.nel.pet", takesDid, did, did}, + "we'll still send the DID to a knot advertising repo-did-input and knot-acl": {"b.nel.pet", []consts.Capability{consts.CapKnotACL, consts.CapRepoDidInput}, did, did}, + "we'll send the AT-URI to a knot that predates repo-did-input": {"c.nel.pet", []consts.Capability{consts.CapKnotACL}, did, atUri.String()}, + "we'll fall back to the AT-URI for an empty capability list": {"d.nel.pet", nil, did, atUri.String()}, + "we'll address a repo that we don't have a DID for by AT-URI": {"e.nel.pet", takesDid, "", atUri.String()}, + "we don't assume a knot whose probe fails will read the repo DID": {"127.0.0.1:1", nil, did, atUri.String()}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if tc.host != "127.0.0.1:1" { + probeCache.put(tc.host, ident("v1.15.0", tc.advertised...), time.Now()) + } + if got := RepoArg(context.Background(), tc.host, true, tc.repoDid, atUri); got != tc.want { + t.Errorf("RepoArg = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go index efd39900..05eb68e8 100644 --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -38,7 +38,6 @@ var TangledScopes = []string{ "rpc:sh.tangled.repo.create?aud=*", "rpc:sh.tangled.repo.delete?aud=*", "rpc:sh.tangled.repo.deleteBranch?aud=*", - "rpc:sh.tangled.repo.forkStatus?aud=*", "rpc:sh.tangled.repo.forkSync?aud=*", "rpc:sh.tangled.repo.hiddenRef?aud=*", "rpc:sh.tangled.repo.listSecrets?aud=*", diff --git a/appview/pulls/compose.go b/appview/pulls/compose.go index 1dc6da18..b6685814 100644 --- a/appview/pulls/compose.go +++ b/appview/pulls/compose.go @@ -14,6 +14,7 @@ import ( "tangled.org/core/api/tangled" "tangled.org/core/appview/db" + "tangled.org/core/appview/knotcompat" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" @@ -461,9 +462,9 @@ func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, target xrpcc := s.knotClient(repo.Knot) resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ - Did: repo.Did, - Name: repo.Name, - Repo: repo.RepoDidPtr(), + Repo: repo.RepoDid, + Did: &repo.Did, + Name: &repo.Name, Branch: targetBranch, Patch: patch, }) @@ -595,7 +596,7 @@ func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, &tangled.RepoHiddenRef_Input{ ForkRef: sourceBranch, RemoteRef: targetBranch, - Repo: fork.RepoAt().String(), + Repo: knotcompat.RepoArg(r.Context(), fork.Knot, s.config.Core.Dev, fork.RepoDid, fork.RepoAt()), }, ) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { diff --git a/appview/pulls/create.go b/appview/pulls/create.go index b29d1ec7..2416c561 100644 --- a/appview/pulls/create.go +++ b/appview/pulls/create.go @@ -118,6 +118,11 @@ func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo oauth.WithLxm(tangled.RepoHiddenRefNSID), oauth.WithDev(s.config.Core.Dev), ) + if err != nil { + l.Error("failed to create service client for the fork's knot", "err", err) + s.pages.Notice(w, "pull", "Failed to connect to knot server.") + return + } resp, err := tangled.RepoHiddenRef( r.Context(), @@ -125,7 +130,7 @@ func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo &tangled.RepoHiddenRef_Input{ ForkRef: sourceBranch, RemoteRef: targetBranch, - Repo: fork.RepoAt().String(), + Repo: knotcompat.RepoArg(r.Context(), fork.Knot, s.config.Core.Dev, fork.RepoDid, fork.RepoAt()), }, ) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { diff --git a/appview/pulls/merge.go b/appview/pulls/merge.go index 400de134..997efcd3 100644 --- a/appview/pulls/merge.go +++ b/appview/pulls/merge.go @@ -72,9 +72,9 @@ func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { authorName := ident.Handle.String() mergeInput := &tangled.RepoMerge_Input{ - Did: f.Did, - Name: f.Name, - Repo: f.RepoDidPtr(), + Repo: f.RepoDid, + Did: &f.Did, + Name: &f.Name, Branch: pull.TargetBranch, Patch: patch, CommitMessage: &pull.Title, diff --git a/appview/pulls/resubmit.go b/appview/pulls/resubmit.go index 623e6571..d62abb4f 100644 --- a/appview/pulls/resubmit.go +++ b/appview/pulls/resubmit.go @@ -215,7 +215,7 @@ func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { &tangled.RepoHiddenRef_Input{ ForkRef: pull.PullSource.Branch, RemoteRef: pull.TargetBranch, - Repo: forkRepo.RepoAt().String(), + Repo: knotcompat.RepoArg(r.Context(), forkRepo.Knot, s.config.Core.Dev, forkRepo.RepoDid, forkRepo.RepoAt()), }, ) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { diff --git a/appview/pulls/single.go b/appview/pulls/single.go index e9c0a82f..5a3d7ce8 100644 --- a/appview/pulls/single.go +++ b/appview/pulls/single.go @@ -410,9 +410,9 @@ func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, s r.Context(), xrpcc, &tangled.RepoMergeCheck_Input{ - Did: f.Did, - Name: f.Name, - Repo: f.RepoDidPtr(), + Repo: f.RepoDid, + Did: &f.Did, + Name: &f.Name, Branch: pull.TargetBranch, Patch: patch, }, diff --git a/appview/repo/branches.go b/appview/repo/branches.go index ac3c3179..72736555 100644 --- a/appview/repo/branches.go +++ b/appview/repo/branches.go @@ -6,6 +6,7 @@ import ( "net/http" "tangled.org/core/api/tangled" + "tangled.org/core/appview/knotcompat" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/types" @@ -71,7 +72,7 @@ func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { client, &tangled.RepoDeleteBranch_Input{ Branch: branch, - Repo: f.RepoAt().String(), + Repo: knotcompat.RepoArg(r.Context(), f.Knot, rp.config.Core.Dev, f.RepoDid, f.RepoAt()), }, ) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { diff --git a/appview/repo/repo.go b/appview/repo/repo.go index f18037ab..adec0e6e 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -1281,9 +1281,10 @@ func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { r.Context(), client, &tangled.RepoDelete_Input{ - Did: f.Did, - Name: f.Name, - Rkey: f.Rkey, + Repo: f.RepoDid, + Did: &f.Did, + Name: &f.Name, + Rkey: &f.Rkey, }, ) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { @@ -1358,7 +1359,6 @@ func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ref := chi.URLParam(r, "ref") ref, _ = url.PathUnescape(ref) - user := rp.oauth.GetMultiAccountUser(r) f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to resolve source repo", "err", err) @@ -1387,10 +1387,10 @@ func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { r.Context(), client, &tangled.RepoForkSync_Input{ - Did: user.Did, - Name: f.Name, - Repo: f.RepoDidPtr(), - Source: f.Source, + Repo: f.RepoDid, + Did: &f.Did, + Name: &f.Name, + Source: &f.Source, Branch: ref, }, ) @@ -1577,9 +1577,10 @@ func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) if dErr := tangled.RepoDelete(ctx, deleteClient, &tangled.RepoDelete_Input{ - Did: user.Did, - Name: forkName, - Rkey: rkey, + Repo: repoDid, + Did: &user.Did, + Name: &rkey, + Rkey: &rkey, }); dErr != nil { cancel() l.Error("failed to clean up fork on knot after rollback", "attempt", attempt+1, "err", dErr) diff --git a/appview/repo/settings.go b/appview/repo/settings.go index d93f9aaf..2abae65b 100644 --- a/appview/repo/settings.go +++ b/appview/repo/settings.go @@ -64,7 +64,7 @@ func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { r.Context(), client, &tangled.RepoSetDefaultBranch_Input{ - Repo: f.RepoAt().String(), + Repo: knotcompat.RepoArg(r.Context(), f.Knot, rp.config.Core.Dev, f.RepoDid, f.RepoAt()), DefaultBranch: branch, }, ) diff --git a/appview/state/state.go b/appview/state/state.go index dad7623b..9d4e041c 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -617,9 +617,10 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) if dErr := tangled.RepoDelete(ctx, deleteClient, &tangled.RepoDelete_Input{ - Did: user.Did, - Name: rkey, - Rkey: rkey, + Repo: repoDid, + Did: &user.Did, + Name: &rkey, + Rkey: &rkey, }); dErr != nil { cancel() l.Error("failed to clean up repo on knot after rollback", "attempt", attempt+1, "err", dErr) diff --git a/consts/consts.go b/consts/consts.go index 5ffdbe1c..1c25f661 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -10,4 +10,7 @@ const ( type Capability string -const CapKnotACL Capability = "knot-acl" +const ( + CapKnotACL Capability = "knot-acl" + CapRepoDidInput Capability = "repo-did-input" +) diff --git a/knotserver/git_test.go b/knotserver/git_test.go index 15bf2b55..2c0b3610 100644 --- a/knotserver/git_test.go +++ b/knotserver/git_test.go @@ -30,6 +30,9 @@ func TestAliasResolvesOriginalName(t *testing.T) { if got != "did:plc:repo1" { t.Errorf("repoDid = %q, want did:plc:repo1", got) } + if _, err := d.GetRepoDid("did:plc:akshay", "Foo"); err == nil { + t.Error("GetRepoDid with a mismatched-case rkey: got nil error, want failure") + } } func TestAliasUpsertRespectsRevOrdering(t *testing.T) { diff --git a/knotserver/xrpc/delete_branch.go b/knotserver/xrpc/delete_branch.go index f4cd3c82..09e98070 100644 --- a/knotserver/xrpc/delete_branch.go +++ b/knotserver/xrpc/delete_branch.go @@ -2,90 +2,46 @@ package xrpc import ( "encoding/json" - "fmt" "net/http" - comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/knotserver/git" - "tangled.org/core/rbac" xrpcerr "tangled.org/core/xrpc/errors" ) func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { l := x.Logger - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { - fail(xrpcerr.MissingActorDidError) + badRequest(xrpcerr.MissingActorDidError).send(l, w) return } var data tangled.RepoDeleteBranch_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - // unfortunately we have to resolve repo-at here - repoAt, err := syntax.ParseATURI(data.Repo) - if err != nil { - fail(xrpcerr.InvalidRepoError(data.Repo)) - return - } - - // resolve this aturi to extract the repo record - ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) - if err != nil || ident.Handle.IsInvalidHandle() { - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) - return - } - - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} - resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) - if err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - if _, ok := resp.Value.Val.(*tangled.Repo); !ok { - fail(xrpcerr.RepoNotFoundError) - return - } - repoDid, err := x.Db.GetRepoDid(ident.DID.String(), repoAt.RecordKey().String()) - if err != nil { - fail(xrpcerr.RepoNotFoundError) - return - } - repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid) - if err != nil { - fail(xrpcerr.RepoNotFoundError) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { - l.Error("insufficient permissions", "did", actorDid.String(), "repo", repoDid) - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + repo, denial := x.pushableRepoDID(actorDid, data.Repo) + if denial != nil { + denial.send(l, w) return } - gr, err := git.PlainOpen(repoPath) + gr, err := git.PlainOpen(repo.path) if err != nil { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } err = gr.DeleteBranch(data.Branch) if err != nil { l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) + serverError(xrpcerr.GitError(err)).send(l, w) return } diff --git a/knotserver/xrpc/delete_repo.go b/knotserver/xrpc/delete_repo.go index 7e27b4e9..8d447744 100644 --- a/knotserver/xrpc/delete_repo.go +++ b/knotserver/xrpc/delete_repo.go @@ -4,7 +4,6 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "net/http" "os" @@ -14,40 +13,47 @@ import ( securejoin "github.com/cyphar/filepath-securejoin" "tangled.org/core/api/tangled" "tangled.org/core/rbac" + "tangled.org/core/repoident" xrpcerr "tangled.org/core/xrpc/errors" ) func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "DeleteRepo") - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { - fail(xrpcerr.MissingActorDidError) + badRequest(xrpcerr.MissingActorDidError).send(l, w) return } var data tangled.RepoDelete_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } - did := data.Did - name := data.Name - rkey := data.Rkey + repoDid, err := repoident.NewRepoDid(data.Repo) + if err != nil { + badRequest(xrpcerr.InvalidRepoError(data.Repo)).send(l, w) + return + } + repo := repoDid.String() - if did == "" || name == "" { - fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) + ownerDid, rkey, err := x.Db.GetRepoKeyOwner(repo) + if errors.Is(err, sql.ErrNoRows) { + l.Info("repo already torn down or not found", "repo", repo) + w.WriteHeader(http.StatusOK) + return + } + if err != nil { + l.Error("failed to look up repo", "error", err.Error()) + serverError(xrpcerr.GenericError(err)).send(l, w) return } - ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) + ident, err := x.Resolver.ResolveIdent(r.Context(), ownerDid) if err != nil || ident.Handle.IsInvalidHandle() { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } @@ -56,58 +62,43 @@ func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { } // ensure that the record does not exists - _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, ownerDid, rkey) if err == nil { - fail(xrpcerr.RecordExistsError(rkey)) - return - } - - repoDid, err := x.Db.GetRepoDid(did, name) - if errors.Is(err, sql.ErrNoRows) { - repoDid, err = x.Db.GetRepoDidByName(did, name) - if errors.Is(err, sql.ErrNoRows) { - l.Info("repo already torn down or not found", "did", did, "name", name) - w.WriteHeader(http.StatusOK) - return - } - } - if err != nil { - l.Error("failed to look up repo", "error", err.Error()) - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + badRequest(xrpcerr.RecordExistsError(rkey)).send(l, w) return } - repoPath, joinErr := securejoin.SecureJoin(x.Config.Repo.ScanPath, repoDid) + repoPath, joinErr := securejoin.SecureJoin(x.Config.Repo.ScanPath, repo) if joinErr != nil { - fail(xrpcerr.GenericError(joinErr)) + badRequest(xrpcerr.GenericError(joinErr)).send(l, w) return } - isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, repoDid) + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, repo) if err != nil { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } if !isDeleteAllowed { - fail(xrpcerr.AccessControlError(actorDid.String())) + badRequest(xrpcerr.AccessControlError(actorDid.String())).send(l, w) return } if rmErr := os.RemoveAll(repoPath); rmErr != nil { l.Error("deleting repo", "error", rmErr.Error()) - writeError(w, xrpcerr.GenericError(rmErr), http.StatusInternalServerError) + serverError(xrpcerr.GenericError(rmErr)).send(l, w) return } - if rbacErr := x.Enforcer.WipeRepoPolicies(rbac.ThisServer, repoDid); rbacErr != nil { + if rbacErr := x.Enforcer.WipeRepoPolicies(rbac.ThisServer, repo); rbacErr != nil { l.Error("failed to delete repo from enforcer", "error", rbacErr.Error()) - writeError(w, xrpcerr.GenericError(rbacErr), http.StatusInternalServerError) + serverError(xrpcerr.GenericError(rbacErr)).send(l, w) return } - if delErr := x.Db.DeleteRepoKey(repoDid); delErr != nil { + if delErr := x.Db.DeleteRepoKey(repo); delErr != nil { l.Error("failed to delete repo key", "error", delErr.Error()) - writeError(w, xrpcerr.GenericError(delErr), http.StatusInternalServerError) + serverError(xrpcerr.GenericError(delErr)).send(l, w) return } diff --git a/knotserver/xrpc/fork_status.go b/knotserver/xrpc/fork_status.go deleted file mode 100644 index f4ac9e5b..00000000 --- a/knotserver/xrpc/fork_status.go +++ /dev/null @@ -1,113 +0,0 @@ -package xrpc - -import ( - "encoding/json" - "fmt" - "net/http" - "path/filepath" - - "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/api/tangled" - "tangled.org/core/knotserver/git" - "tangled.org/core/rbac" - "tangled.org/core/types" - xrpcerr "tangled.org/core/xrpc/errors" -) - -func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { - l := x.Logger.With("handler", "ForkStatus") - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } - - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) - if !ok { - fail(xrpcerr.MissingActorDidError) - return - } - - var data tangled.RepoForkStatus_Input - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - did := data.Did - source := data.Source - branch := data.Branch - hiddenRef := data.HiddenRef - - if did == "" || source == "" || branch == "" || hiddenRef == "" { - fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) - return - } - - var name string - if data.Name != "" { - name = data.Name - } else { - name = filepath.Base(source) - } - - repoDid, err := x.Db.GetRepoDid(did, name) - if err != nil { - fail(xrpcerr.RepoNotFoundError) - return - } - repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid) - if err != nil { - fail(xrpcerr.RepoNotFoundError) - return - } - - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { - l.Error("insufficient permissions", "did", actorDid.String(), "repo", repoDid) - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) - return - } - - gr, err := git.PlainOpen(repoPath) - if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) - return - } - - forkCommit, err := gr.ResolveRevision(branch) - if err != nil { - l.Error("error resolving ref revision", "msg", err.Error()) - fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) - return - } - - sourceCommit, err := gr.ResolveRevision(hiddenRef) - if err != nil { - l.Error("error resolving hidden ref revision", "msg", err.Error()) - fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) - return - } - - status := types.UpToDate - if forkCommit.Hash.String() != sourceCommit.Hash.String() { - isAncestor, err := forkCommit.IsAncestor(sourceCommit) - if err != nil { - l.Error("error checking ancestor relationship", "error", err.Error()) - fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) - return - } - - if isAncestor { - status = types.FastForwardable - } else { - status = types.Conflict - } - } - - response := tangled.RepoForkStatus_Output{ - Status: int64(status), - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) -} diff --git a/knotserver/xrpc/fork_sync.go b/knotserver/xrpc/fork_sync.go index 625c5d42..ac18c83e 100644 --- a/knotserver/xrpc/fork_sync.go +++ b/knotserver/xrpc/fork_sync.go @@ -8,61 +8,40 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/knotserver/git" - "tangled.org/core/rbac" xrpcerr "tangled.org/core/xrpc/errors" ) func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "ForkSync") - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { - fail(xrpcerr.MissingActorDidError) + badRequest(xrpcerr.MissingActorDidError).send(l, w) return } var data tangled.RepoForkSync_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - did := data.Did - name := data.Name - branch := data.Branch - - if did == "" || name == "" { - fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) - return - } - - repoDid, repoPath, err := x.resolveRepoDID(data.Repo, did, name) - if err != nil { - l.Error("failed to resolve repo", "err", err) - fail(xrpcerr.RepoNotFoundError) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid.String()); !ok || err != nil { - l.Error("insufficient permissions", "did", actorDid.String(), "repo", repoDid.String()) - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + repo, denial := x.pushableRepoDID(actorDid, data.Repo) + if denial != nil { + denial.send(l, w) return } - gr, err := git.Open(repoPath, branch) + gr, err := git.Open(repo.path, data.Branch) if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + badRequest(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))).send(l, w) return } err = gr.Sync() if err != nil { l.Error("error syncing repo fork", "error", err.Error()) - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + serverError(xrpcerr.GenericError(err)).send(l, w) return } diff --git a/knotserver/xrpc/hidden_ref.go b/knotserver/xrpc/hidden_ref.go index 2522cdd5..678dec5d 100644 --- a/knotserver/xrpc/hidden_ref.go +++ b/knotserver/xrpc/hidden_ref.go @@ -5,93 +5,51 @@ import ( "fmt" "net/http" - comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/knotserver/git" - "tangled.org/core/rbac" xrpcerr "tangled.org/core/xrpc/errors" ) func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "HiddenRef") - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { - fail(xrpcerr.MissingActorDidError) + badRequest(xrpcerr.MissingActorDidError).send(l, w) return } var data tangled.RepoHiddenRef_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } forkRef := data.ForkRef remoteRef := data.RemoteRef - repoAtUri := data.Repo - - if forkRef == "" || remoteRef == "" || repoAtUri == "" { - fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) - return - } - - repoAt, err := syntax.ParseATURI(repoAtUri) - if err != nil { - fail(xrpcerr.InvalidRepoError(repoAtUri)) - return - } - - ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) - if err != nil || ident.Handle.IsInvalidHandle() { - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) - return - } - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} - resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) - if err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - if _, ok := resp.Value.Val.(*tangled.Repo); !ok { - fail(xrpcerr.RepoNotFoundError) - return - } - repoDid, err := x.Db.GetRepoDid(actorDid.String(), repoAt.RecordKey().String()) - if err != nil { - fail(xrpcerr.RepoNotFoundError) - return - } - repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid) - if err != nil { - fail(xrpcerr.RepoNotFoundError) + if forkRef == "" || remoteRef == "" { + badRequest(xrpcerr.GenericError(fmt.Errorf("forkRef and remoteRef are required"))).send(l, w) return } - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { - l.Error("insufficient permissions", "did", actorDid.String(), "repo", repoDid) - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + repo, denial := x.pushableRepoDID(actorDid, data.Repo) + if denial != nil { + denial.send(l, w) return } - gr, err := git.PlainOpen(repoPath) + gr, err := git.PlainOpen(repo.path) if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + badRequest(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))).send(l, w) return } err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) if err != nil { l.Error("error tracking hidden remote ref", "error", err.Error()) - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) + serverError(xrpcerr.GitError(err)).send(l, w) return } diff --git a/knotserver/xrpc/merge.go b/knotserver/xrpc/merge.go index df86be89..683d0795 100644 --- a/knotserver/xrpc/merge.go +++ b/knotserver/xrpc/merge.go @@ -10,54 +10,34 @@ import ( "tangled.org/core/api/tangled" "tangled.org/core/knotserver/git" "tangled.org/core/patchutil" - "tangled.org/core/rbac" "tangled.org/core/types" xrpcerr "tangled.org/core/xrpc/errors" ) func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "Merge") - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { - fail(xrpcerr.MissingActorDidError) + badRequest(xrpcerr.MissingActorDidError).send(l, w) return } var data tangled.RepoMerge_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - did := data.Did - name := data.Name - - if did == "" || name == "" { - fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) - return - } - - repoDid, repoPath, err := x.resolveRepoDID(data.Repo, did, name) - if err != nil { - l.Error("failed to resolve repo", "err", err) - fail(xrpcerr.RepoNotFoundError) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid.String()); !ok || err != nil { - l.Error("insufficient permissions", "did", actorDid.String(), "repo", repoDid.String()) - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + repo, denial := x.pushableRepoDID(actorDid, data.Repo) + if denial != nil { + denial.send(l, w) return } - gr, err := git.Open(repoPath, data.Branch) + gr, err := git.Open(repo.path, data.Branch) if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + badRequest(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))).send(l, w) return } if x.Sandbox != nil { @@ -98,11 +78,11 @@ func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { xrpcerr.WithTag("MergeConflict"), xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), ) - writeError(w, conflictErr, http.StatusConflict) + conflicted(conflictErr).send(l, w) return } else { l.Error("failed to merge", "error", err.Error()) - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) + serverError(xrpcerr.GitError(err)).send(l, w) return } } diff --git a/knotserver/xrpc/merge_check.go b/knotserver/xrpc/merge_check.go index 3a94e21e..6b6d1167 100644 --- a/knotserver/xrpc/merge_check.go +++ b/knotserver/xrpc/merge_check.go @@ -14,35 +14,23 @@ import ( func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "MergeCheck") - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } var data tangled.RepoMergeCheck_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - did := data.Did - name := data.Name - - if did == "" || name == "" { - fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } - _, repoPath, err := x.resolveRepoDID(data.Repo, did, name) + repo, err := x.resolveRepoDID(data.Repo) if err != nil { l.Error("failed to resolve repo", "err", err) - fail(xrpcerr.RepoNotFoundError) + badRequest(xrpcerr.RepoNotFoundError).send(l, w) return } - gr, err := git.Open(repoPath, data.Branch) + gr, err := git.Open(repo.path, data.Branch) if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + badRequest(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))).send(l, w) return } if x.Sandbox != nil { diff --git a/knotserver/xrpc/resolve_repo_did_test.go b/knotserver/xrpc/resolve_repo_did_test.go index 8d82149f..2b0bdf3b 100644 --- a/knotserver/xrpc/resolve_repo_did_test.go +++ b/knotserver/xrpc/resolve_repo_did_test.go @@ -1,83 +1,80 @@ package xrpc import ( + "net/http" "os" "path/filepath" "testing" -) -const ( - resolveOwnerDid = "did:plc:akshay" - resolveRepoDid = "did:plc:squid" - resolveStoredKey = "squidbot" + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/rbac" ) func setupResolveRepo(t *testing.T) (*Xrpc, string) { t.Helper() - x := newTestXrpc(t) + x, _ := newACLXrpc(t) scanPath := t.TempDir() x.Config.Repo.ScanPath = scanPath - - if err := x.Db.StoreRepoKey(resolveRepoDid, []byte("k256"), resolveOwnerDid, resolveStoredKey); err != nil { - t.Fatalf("StoreRepoKey: %v", err) - } - if err := os.MkdirAll(filepath.Join(scanPath, resolveRepoDid), 0o755); err != nil { + seedRepo(t, x) + if err := os.MkdirAll(filepath.Join(scanPath, aclRepoDid), 0o755); err != nil { t.Fatalf("mkdir repo dir: %v", err) } return x, scanPath } -func TestResolveRepoDID_PrefersRepoOverName(t *testing.T) { +func TestResolveRepoDID(t *testing.T) { x, scanPath := setupResolveRepo(t) - repo := resolveRepoDid - gotDid, gotPath, err := x.resolveRepoDID(&repo, resolveOwnerDid, "SquidBot") + got, err := x.resolveRepoDID(aclRepoDid) if err != nil { - t.Fatalf("resolveRepoDID with repo set: %v", err) + t.Fatalf("resolveRepoDID: %v", err) } - if gotDid != resolveRepoDid { - t.Errorf("repoDid = %q, want %q", gotDid, resolveRepoDid) + if got.did != aclRepoDid || got.owner != aclOwner { + t.Errorf("resolved %q owned by %q, want %q owned by %q", got.did, got.owner, aclRepoDid, aclOwner) } - if want := filepath.Join(scanPath, resolveRepoDid); gotPath != want { - t.Errorf("repoPath = %q, want %q", gotPath, want) + if want := filepath.Join(scanPath, aclRepoDid); got.path != want { + t.Errorf("repoPath = %q, want %q", got.path, want) } -} - -func TestResolveRepoDID_RejectsMalformedRepoDid(t *testing.T) { - x, _ := setupResolveRepo(t) - malformed := "not-a-did" - if _, _, err := x.resolveRepoDID(&malformed, resolveOwnerDid, resolveStoredKey); err == nil { - t.Fatal("resolveRepoDID with malformed repo DID: got nil error, want failure") + rejected := map[string]string{ + "a malformed repo DID": "not-a-did", + "an empty repo DID": "", + "a repo DID that this knot doesn't host": "did:plc:conch", + "an owner and name instead of a DID": aclOwner + "/reponame", } -} - -func TestResolveRepoDID_UnknownRepoDidDoesNotFallBackToName(t *testing.T) { - x, _ := setupResolveRepo(t) - - unknown := "did:plc:limpet" - if _, _, err := x.resolveRepoDID(&unknown, resolveOwnerDid, resolveStoredKey); err == nil { - t.Fatal("resolveRepoDID with unknown repo DID and resolvable name: got nil error, want failure") + for name, repo := range rejected { + t.Run(name, func(t *testing.T) { + if _, err := x.resolveRepoDID(repo); err == nil { + t.Fatalf("resolveRepoDID(%q): got nil error, want failure", repo) + } + }) } } -func TestResolveRepoDID_NameFallbackIsCaseSensitive(t *testing.T) { +func TestPushableRepoDID(t *testing.T) { x, _ := setupResolveRepo(t) - - if _, _, err := x.resolveRepoDID(nil, resolveOwnerDid, "SquidBot"); err == nil { - t.Fatal("resolveRepoDID with mismatched-case name: got nil error, want failure") + if err := x.Enforcer.AddRepo(aclOwner, rbac.ThisServer, "did:plc:conch"); err != nil { + t.Fatalf("AddRepo: %v", err) } - empty := "" - if _, _, err := x.resolveRepoDID(&empty, resolveOwnerDid, "SquidBot"); err == nil { - t.Fatal("resolveRepoDID with empty repo and mismatched-case name: got nil error, want failure") + if _, denial := x.pushableRepoDID(aclOwner, aclRepoDid); denial != nil { + t.Fatalf("owner denied: %v (status %d), want the repo", denial.err, denial.status) } - gotDid, _, err := x.resolveRepoDID(nil, resolveOwnerDid, resolveStoredKey) - if err != nil { - t.Fatalf("resolveRepoDID with exact-case name: %v", err) + cases := map[string]struct { + actor syntax.DID + repo string + want int + }{ + "we'll 401 a stranger": {aclSubject, aclRepoDid, http.StatusUnauthorized}, + "we'll 400 a repo that this knot doesn't host before the ACL read": {aclOwner, "did:plc:conch", http.StatusBadRequest}, } - if gotDid != resolveRepoDid { - t.Errorf("repoDid = %q, want %q", gotDid, resolveRepoDid) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, denial := x.pushableRepoDID(tc.actor, tc.repo) + if denial == nil || denial.status != tc.want { + t.Fatalf("denial = %v, want status %d", denial, tc.want) + } + }) } } diff --git a/knotserver/xrpc/set_default_branch.go b/knotserver/xrpc/set_default_branch.go index 9af514d5..35f3f1e8 100644 --- a/knotserver/xrpc/set_default_branch.go +++ b/knotserver/xrpc/set_default_branch.go @@ -2,16 +2,12 @@ package xrpc import ( "encoding/json" - "fmt" "net/http" - comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/eventstream" "tangled.org/core/knotserver/git" - "tangled.org/core/rbac" "tangled.org/core/tid" xrpcerr "tangled.org/core/xrpc/errors" @@ -19,87 +15,47 @@ import ( func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { l := x.Logger - fail := func(e xrpcerr.XrpcError) { - l.Error("failed", "kind", e.Tag, "error", e.Message) - writeError(w, e, http.StatusBadRequest) - } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { - fail(xrpcerr.MissingActorDidError) + badRequest(xrpcerr.MissingActorDidError).send(l, w) return } var data tangled.RepoSetDefaultBranch_Input if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - // unfortunately we have to resolve repo-at here - repoAt, err := syntax.ParseATURI(data.Repo) - if err != nil { - fail(xrpcerr.InvalidRepoError(data.Repo)) - return - } - - // resolve this aturi to extract the repo record - ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) - if err != nil || ident.Handle.IsInvalidHandle() { - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) - return - } - - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} - resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) - if err != nil { - fail(xrpcerr.GenericError(err)) - return - } - - if _, ok := resp.Value.Val.(*tangled.Repo); !ok { - fail(xrpcerr.RepoNotFoundError) - return - } - repoDid, err := x.Db.GetRepoDid(actorDid.String(), repoAt.RecordKey().String()) - if err != nil { - fail(xrpcerr.RepoNotFoundError) - return - } - repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid) - if err != nil { - fail(xrpcerr.RepoNotFoundError) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid); !ok || err != nil { - l.Error("insufficient permissions", "did", actorDid.String()) - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + repo, denial := x.pushableRepoDID(actorDid, data.Repo) + if denial != nil { + denial.send(l, w) return } - gr, err := git.PlainOpen(repoPath) + gr, err := git.PlainOpen(repo.path) if err != nil { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } err = gr.SetDefaultBranch(data.DefaultBranch) if err != nil { l.Error("setting default branch", "error", err.Error()) - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) + serverError(xrpcerr.GitError(err)).send(l, w) return } - ownerDid := ident.DID.String() + ownerDid := repo.owner.String() refUpdate := tangled.GitRefUpdate{ - Repo: repoDid, + Repo: repo.did.String(), OwnerDid: &ownerDid, CommitterDid: actorDid.String(), } eventJson, err := json.Marshal(refUpdate) if err != nil { - fail(xrpcerr.GenericError(err)) + badRequest(xrpcerr.GenericError(err)).send(l, w) return } @@ -109,7 +65,7 @@ func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { EventJson: eventJson, }, x.Notifier); err != nil { l.Error("failed to insert event", "error", err) - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + serverError(xrpcerr.GenericError(err)).send(l, w) return } diff --git a/knotserver/xrpc/version.go b/knotserver/xrpc/version.go index 6589d6a4..4bc54f7e 100644 --- a/knotserver/xrpc/version.go +++ b/knotserver/xrpc/version.go @@ -12,7 +12,10 @@ import ( // version is set during build time. var version string -var knotCapabilities = []string{string(consts.CapKnotACL)} +var knotCapabilities = []string{ + string(consts.CapKnotACL), + string(consts.CapRepoDidInput), +} func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { if version == "" { diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go index b87be0d4..de8f1e2d 100644 --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" + "github.com/bluesky-social/indigo/atproto/syntax" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "tangled.org/core/api/tangled" @@ -54,7 +55,6 @@ func (x *Xrpc) Router() http.Handler { r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) - r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) r.Post("/"+tangled.RepoMergeNSID, x.Merge) @@ -145,29 +145,64 @@ func (x *Xrpc) resolveRepo(repo string) (resolvedRepo, error) { return resolvedRepo{path: repoPath, name: gitutil.RepoName(repoName)}, nil } -func (x *Xrpc) resolveRepoDID(repo *string, ownerDid, name string) (repoident.RepoDid, string, error) { - raw, err := x.selectRepoDID(repo, ownerDid, name) +type resolvedRepoDID struct { + did repoident.RepoDid + path string + owner repoident.OwnerDid +} + +func (x *Xrpc) resolveRepoDID(repo string) (resolvedRepoDID, error) { + repoDid, err := repoident.NewRepoDid(repo) if err != nil { - return "", "", err + return resolvedRepoDID{}, err } - repoDid, err := repoident.NewRepoDid(raw) + repoPath, storedOwner, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid.String()) if err != nil { - return "", "", err + return resolvedRepoDID{}, err } - - repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid.String()) + ownerDid, err := repoident.NewOwnerDid(storedOwner) if err != nil { - return "", "", err + return resolvedRepoDID{}, err } - return repoDid, repoPath, nil + return resolvedRepoDID{did: repoDid, path: repoPath, owner: ownerDid}, nil +} + +type failure struct { + err xrpcerr.XrpcError + status int +} + +func (f *failure) send(l *slog.Logger, w http.ResponseWriter) { + l.Error("failed", "kind", f.err.Tag, "error", f.err.Message) + writeError(w, f.err, f.status) +} + +func badRequest(e xrpcerr.XrpcError) *failure { + return &failure{err: e, status: http.StatusBadRequest} } -func (x *Xrpc) selectRepoDID(repo *string, ownerDid, name string) (string, error) { - if repo != nil && *repo != "" { - return *repo, nil +func unauthorized(e xrpcerr.XrpcError) *failure { + return &failure{err: e, status: http.StatusUnauthorized} +} + +func conflicted(e xrpcerr.XrpcError) *failure { + return &failure{err: e, status: http.StatusConflict} +} + +func serverError(e xrpcerr.XrpcError) *failure { + return &failure{err: e, status: http.StatusInternalServerError} +} + +func (x *Xrpc) pushableRepoDID(actor syntax.DID, repo string) (resolvedRepoDID, *failure) { + resolved, err := x.resolveRepoDID(repo) + if err != nil { + return resolvedRepoDID{}, badRequest(xrpcerr.RepoNotFoundError) + } + if ok, err := x.Enforcer.IsPushAllowed(actor.String(), rbac.ThisServer, resolved.did.String()); !ok || err != nil { + return resolvedRepoDID{}, unauthorized(xrpcerr.AccessControlError(actor.String())) } - return x.Db.GetRepoDid(ownerDid, name) + return resolved, nil } func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { diff --git a/lexicons/repo/defaultBranch.json b/lexicons/repo/defaultBranch.json index a0a4ae30..db8cc06d 100644 --- a/lexicons/repo/defaultBranch.json +++ b/lexicons/repo/defaultBranch.json @@ -16,7 +16,8 @@ "properties": { "repo": { "type": "string", - "format": "at-uri" + "format": "did", + "description": "DID of the repository" }, "defaultBranch": { "type": "string" diff --git a/lexicons/repo/delete.json b/lexicons/repo/delete.json index 26329591..4ea69362 100644 --- a/lexicons/repo/delete.json +++ b/lexicons/repo/delete.json @@ -9,21 +9,26 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["did", "name", "rkey"], + "required": ["repo"], "properties": { + "repo": { + "type": "string", + "format": "did", + "description": "DID of the repository to delete" + }, "did": { "type": "string", "format": "did", - "description": "DID of the repository owner" + "description": "DID of the repository owner. A knot without the repo-did-input capability reads this and name in place of repo." }, "name": { "type": "string", - "description": "Name of the repository to delete" + "description": "Name of the repository to delete. A knot without the repo-did-input capability reads this and DID in place of repo." }, "rkey": { "type": "string", "format": "record-key", - "description": "Rkey of the repository record" + "description": "Rkey of the repository record. A knot without the repo-did-input capability checks this against the owner's PDS." }, "force": { "type": "boolean", diff --git a/lexicons/repo/deleteBranch.json b/lexicons/repo/deleteBranch.json index f1687167..1fd9d1e7 100644 --- a/lexicons/repo/deleteBranch.json +++ b/lexicons/repo/deleteBranch.json @@ -16,7 +16,8 @@ "properties": { "repo": { "type": "string", - "format": "at-uri" + "format": "did", + "description": "DID of the repository" }, "branch": { "type": "string" diff --git a/lexicons/repo/forkStatus.json b/lexicons/repo/forkStatus.json deleted file mode 100644 index 720cdadd..00000000 --- a/lexicons/repo/forkStatus.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "lexicon": 1, - "id": "sh.tangled.repo.forkStatus", - "defs": { - "main": { - "type": "procedure", - "description": "Check fork status relative to upstream source", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["did", "name", "source", "branch", "hiddenRef"], - "properties": { - "did": { - "type": "string", - "format": "did", - "description": "DID of the fork owner" - }, - "name": { - "type": "string", - "description": "Name of the forked repository" - }, - "source": { - "type": "string", - "description": "Source repository URL" - }, - "branch": { - "type": "string", - "description": "Branch to check status for" - }, - "hiddenRef": { - "type": "string", - "description": "Hidden ref to use for comparison" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["status"], - "properties": { - "status": { - "type": "integer", - "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" - } - } - } - } - } - } -} diff --git a/lexicons/repo/forkSync.json b/lexicons/repo/forkSync.json index f3c22ca0..8ca502da 100644 --- a/lexicons/repo/forkSync.json +++ b/lexicons/repo/forkSync.json @@ -10,30 +10,28 @@ "schema": { "type": "object", "required": [ - "did", - "source", - "name", + "repo", "branch" ], "properties": { - "did": { + "repo": { "type": "string", "format": "did", - "description": "DID of the fork owner" + "description": "DID of the fork to sync" }, - "source": { + "did": { "type": "string", - "format": "at-uri", - "description": "AT-URI of the source repository" + "format": "did", + "description": "DID of the fork owner. A knot without the repo-did-input capability reads this and name in place of repo." }, "name": { "type": "string", - "description": "Name of the forked repository" + "description": "Name of the forked repository. A knot without the repo-did-input capability reads this and DID in place of repo." }, - "repo": { + "source": { "type": "string", - "format": "did", - "description": "DID of the repository" + "format": "at-uri", + "description": "AT-URI of the source repository. A knot without the repo-did-input capability requires this field without reading it." }, "branch": { "type": "string", diff --git a/lexicons/repo/hiddenRef.json b/lexicons/repo/hiddenRef.json index 28541e75..a0f8d7b8 100644 --- a/lexicons/repo/hiddenRef.json +++ b/lexicons/repo/hiddenRef.json @@ -17,8 +17,8 @@ "properties": { "repo": { "type": "string", - "format": "at-uri", - "description": "AT-URI of the repository" + "format": "did", + "description": "DID of the fork that the hidden ref belongs to" }, "forkRef": { "type": "string", diff --git a/lexicons/repo/merge.json b/lexicons/repo/merge.json index ffd2104d..b157bf15 100644 --- a/lexicons/repo/merge.json +++ b/lexicons/repo/merge.json @@ -9,21 +9,21 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["did", "name", "patch", "branch"], + "required": ["repo", "patch", "branch"], "properties": { - "did": { + "repo": { "type": "string", "format": "did", - "description": "DID of the repository owner" + "description": "DID of the repository" }, - "name": { + "did": { "type": "string", - "description": "Name of the repository" + "format": "did", + "description": "DID of the repository owner. A knot without the repo-did-input capability reads this and name in place of repo." }, - "repo": { + "name": { "type": "string", - "format": "did", - "description": "DID of the repository" + "description": "Name of the repository. A knot without the repo-did-input capability reads this and DID in place of repo." }, "patch": { "type": "string", diff --git a/lexicons/repo/mergeCheck.json b/lexicons/repo/mergeCheck.json index 7a0ee6e0..419851fd 100644 --- a/lexicons/repo/mergeCheck.json +++ b/lexicons/repo/mergeCheck.json @@ -9,21 +9,21 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["did", "name", "patch", "branch"], + "required": ["repo", "patch", "branch"], "properties": { - "did": { + "repo": { "type": "string", "format": "did", - "description": "DID of the repository owner" + "description": "DID of the repository" }, - "name": { + "did": { "type": "string", - "description": "Name of the repository" + "format": "did", + "description": "DID of the repository owner. A knot without the repo-did-input capability reads this and name in place of repo." }, - "repo": { + "name": { "type": "string", - "format": "did", - "description": "DID of the repository" + "description": "Name of the repository. A knot without the repo-did-input capability reads this and DID in place of repo." }, "patch": { "type": "string", diff --git a/types/repo.go b/types/repo.go index e1f2936c..cf71bba0 100644 --- a/types/repo.go +++ b/types/repo.go @@ -93,10 +93,6 @@ type ForkInfo struct { Status ForkStatus } -type AncestorCheckResponse struct { - Status ForkStatus `json:"status"` -} - type RepoLanguageDetails struct { Name string Percentage float32 diff --git a/xrpc/errors/errors.go b/xrpc/errors/errors.go index 9d5ace82..2fb65cd8 100644 --- a/xrpc/errors/errors.go +++ b/xrpc/errors/errors.go @@ -81,7 +81,7 @@ var AuthError = func(err error) XrpcError { var InvalidRepoError = func(r string) XrpcError { return NewXrpcError( WithTag("InvalidRepo"), - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), + WithError(fmt.Errorf("supplied repo identifier is invalid: %s", r)), ) }