forked from tangled.org/core
this repo has no description

spindle/xrpc: use new top-level xrpc packages

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

authored by anirudh.fi and committed by oppi.li e48b561a 9cea15d1

+11 -7
spindle/server.go
··· 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 "tangled.sh/tangled.sh/core/spindle/secrets" 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 ) 29 30 //go:embed motd ··· 213 func (s *Spindle) XrpcRouter() http.Handler { 214 logger := s.l.With("route", "xrpc") 215 216 x := xrpc.Xrpc{ 217 - Logger: logger, 218 - Db: s.db, 219 - Enforcer: s.e, 220 - Engines: s.engs, 221 - Config: s.cfg, 222 - Resolver: s.res, 223 - Vault: s.vault, 224 } 225 226 return x.Router()
··· 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 "tangled.sh/tangled.sh/core/spindle/secrets" 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 29 ) 30 31 //go:embed motd ··· 214 func (s *Spindle) XrpcRouter() http.Handler { 215 logger := s.l.With("route", "xrpc") 216 217 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 + 219 x := xrpc.Xrpc{ 220 + Logger: logger, 221 + Db: s.db, 222 + Enforcer: s.e, 223 + Engines: s.engs, 224 + Config: s.cfg, 225 + Resolver: s.res, 226 + Vault: s.vault, 227 + ServiceAuth: serviceAuth, 228 } 229 230 return x.Router()
+11 -10
spindle/xrpc/add_secret.go
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 ) 17 18 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 - fail := func(e XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 - fail(MissingActorDidError) 28 return 29 } 30 31 var data tangled.RepoAddSecret_Input 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(GenericError(err)) 34 return 35 } 36 37 if err := secrets.ValidateKey(data.Key); err != nil { 38 - fail(GenericError(err)) 39 return 40 } 41 42 // unfortunately we have to resolve repo-at here 43 repoAt, err := syntax.ParseATURI(data.Repo) 44 if err != nil { 45 - fail(InvalidRepoError(data.Repo)) 46 return 47 } 48 49 // resolve this aturi to extract the repo record 50 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 if err != nil || ident.Handle.IsInvalidHandle() { 52 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 return 54 } 55 56 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 if err != nil { 59 - fail(GenericError(err)) 60 return 61 } 62 63 repo := resp.Value.Val.(*tangled.Repo) 64 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 if err != nil { 66 - fail(GenericError(err)) 67 return 68 } 69 70 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 l.Error("insufficent permissions", "did", actorDid.String()) 72 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 return 74 } 75 ··· 83 err = x.Vault.AddSecret(r.Context(), secret) 84 if err != nil { 85 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 - writeError(w, GenericError(err), http.StatusInternalServerError) 87 return 88 } 89
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 return 30 } 31 32 var data tangled.RepoAddSecret_Input 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 return 36 } 37 38 if err := secrets.ValidateKey(data.Key); err != nil { 39 + fail(xrpcerr.GenericError(err)) 40 return 41 } 42 43 // unfortunately we have to resolve repo-at here 44 repoAt, err := syntax.ParseATURI(data.Repo) 45 if err != nil { 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 47 return 48 } 49 50 // resolve this aturi to extract the repo record 51 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 if err != nil || ident.Handle.IsInvalidHandle() { 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 return 55 } 56 57 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 return 62 } 63 64 repo := resp.Value.Val.(*tangled.Repo) 65 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 return 69 } 70 71 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 l.Error("insufficent permissions", "did", actorDid.String()) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 return 75 } 76 ··· 84 err = x.Vault.AddSecret(r.Context(), secret) 85 if err != nil { 86 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 88 return 89 } 90
+10 -9
spindle/xrpc/list_secrets.go
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 ) 17 18 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 - fail := func(e XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 - fail(MissingActorDidError) 28 return 29 } 30 31 repoParam := r.URL.Query().Get("repo") 32 if repoParam == "" { 33 - fail(GenericError(fmt.Errorf("empty params"))) 34 return 35 } 36 37 // unfortunately we have to resolve repo-at here 38 repoAt, err := syntax.ParseATURI(repoParam) 39 if err != nil { 40 - fail(InvalidRepoError(repoParam)) 41 return 42 } 43 44 // resolve this aturi to extract the repo record 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 return 49 } 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 if err != nil { 54 - fail(GenericError(err)) 55 return 56 } 57 58 repo := resp.Value.Val.(*tangled.Repo) 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 if err != nil { 61 - fail(GenericError(err)) 62 return 63 } 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 return 69 } 70 71 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 if err != nil { 73 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 - writeError(w, GenericError(err), http.StatusInternalServerError) 75 return 76 } 77
··· 13 "tangled.sh/tangled.sh/core/api/tangled" 14 "tangled.sh/tangled.sh/core/rbac" 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 writeError(w, e, http.StatusBadRequest) 24 } 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 return 30 } 31 32 repoParam := r.URL.Query().Get("repo") 33 if repoParam == "" { 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 35 return 36 } 37 38 // unfortunately we have to resolve repo-at here 39 repoAt, err := syntax.ParseATURI(repoParam) 40 if err != nil { 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 42 return 43 } 44 45 // resolve this aturi to extract the repo record 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 return 50 } 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 return 57 } 58 59 repo := resp.Value.Val.(*tangled.Repo) 60 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 return 64 } 65 66 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 return 70 } 71 72 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 if err != nil { 74 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 76 return 77 } 78
+10 -9
spindle/xrpc/remove_secret.go
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 ) 16 17 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 l := x.Logger 19 - fail := func(e XrpcError) { 20 l.Error("failed", "kind", e.Tag, "error", e.Message) 21 writeError(w, e, http.StatusBadRequest) 22 } 23 24 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 if !ok { 26 - fail(MissingActorDidError) 27 return 28 } 29 30 var data tangled.RepoRemoveSecret_Input 31 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 - fail(GenericError(err)) 33 return 34 } 35 36 // unfortunately we have to resolve repo-at here 37 repoAt, err := syntax.ParseATURI(data.Repo) 38 if err != nil { 39 - fail(InvalidRepoError(data.Repo)) 40 return 41 } 42 43 // resolve this aturi to extract the repo record 44 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 if err != nil || ident.Handle.IsInvalidHandle() { 46 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 return 48 } 49 50 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 if err != nil { 53 - fail(GenericError(err)) 54 return 55 } 56 57 repo := resp.Value.Val.(*tangled.Repo) 58 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 if err != nil { 60 - fail(GenericError(err)) 61 return 62 } 63 64 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 l.Error("insufficent permissions", "did", actorDid.String()) 66 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 return 68 } 69 ··· 74 err = x.Vault.RemoveSecret(r.Context(), secret) 75 if err != nil { 76 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 - writeError(w, GenericError(err), http.StatusInternalServerError) 78 return 79 } 80
··· 12 "tangled.sh/tangled.sh/core/api/tangled" 13 "tangled.sh/tangled.sh/core/rbac" 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 ) 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 l := x.Logger 20 + fail := func(e xrpcerr.XrpcError) { 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 writeError(w, e, http.StatusBadRequest) 23 } 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 return 29 } 30 31 var data tangled.RepoRemoveSecret_Input 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 return 35 } 36 37 // unfortunately we have to resolve repo-at here 38 repoAt, err := syntax.ParseATURI(data.Repo) 39 if err != nil { 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 41 return 42 } 43 44 // resolve this aturi to extract the repo record 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 if err != nil || ident.Handle.IsInvalidHandle() { 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 return 49 } 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 if err != nil { 54 + fail(xrpcerr.GenericError(err)) 55 return 56 } 57 58 repo := resp.Value.Val.(*tangled.Repo) 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 return 63 } 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 l.Error("insufficent permissions", "did", actorDid.String()) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 return 69 } 70 ··· 75 err = x.Vault.RemoveSecret(r.Context(), secret) 76 if err != nil { 77 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 79 return 80 } 81
+14 -109
spindle/xrpc/xrpc.go
··· 1 package xrpc 2 3 import ( 4 - "context" 5 _ "embed" 6 "encoding/json" 7 - "fmt" 8 "log/slog" 9 "net/http" 10 - "strings" 11 12 - "github.com/bluesky-social/indigo/atproto/auth" 13 "github.com/go-chi/chi/v5" 14 15 "tangled.sh/tangled.sh/core/api/tangled" ··· 19 "tangled.sh/tangled.sh/core/spindle/db" 20 "tangled.sh/tangled.sh/core/spindle/models" 21 "tangled.sh/tangled.sh/core/spindle/secrets" 22 ) 23 24 const ActorDid string = "ActorDid" 25 26 type Xrpc struct { 27 - Logger *slog.Logger 28 - Db *db.DB 29 - Enforcer *rbac.Enforcer 30 - Engines map[string]models.Engine 31 - Config *config.Config 32 - Resolver *idresolver.Resolver 33 - Vault secrets.Manager 34 } 35 36 func (x *Xrpc) Router() http.Handler { 37 r := chi.NewRouter() 38 39 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 - r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 - r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 42 43 return r 44 } 45 46 - func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 - l := x.Logger.With("url", r.URL) 49 - 50 - token := r.Header.Get("Authorization") 51 - token = strings.TrimPrefix(token, "Bearer ") 52 - 53 - s := auth.ServiceAuthValidator{ 54 - Audience: x.Config.Server.Did().String(), 55 - Dir: x.Resolver.Directory(), 56 - } 57 - 58 - did, err := s.Validate(r.Context(), token, nil) 59 - if err != nil { 60 - l.Error("signature verification failed", "err", err) 61 - writeError(w, AuthError(err), http.StatusForbidden) 62 - return 63 - } 64 - 65 - r = r.WithContext( 66 - context.WithValue(r.Context(), ActorDid, did), 67 - ) 68 - 69 - next.ServeHTTP(w, r) 70 - }) 71 - } 72 - 73 - type XrpcError struct { 74 - Tag string `json:"error"` 75 - Message string `json:"message"` 76 - } 77 - 78 - func NewXrpcError(opts ...ErrOpt) XrpcError { 79 - x := XrpcError{} 80 - for _, o := range opts { 81 - o(&x) 82 - } 83 - 84 - return x 85 - } 86 - 87 - type ErrOpt = func(xerr *XrpcError) 88 - 89 - func WithTag(tag string) ErrOpt { 90 - return func(xerr *XrpcError) { 91 - xerr.Tag = tag 92 - } 93 - } 94 - 95 - func WithMessage[S ~string](s S) ErrOpt { 96 - return func(xerr *XrpcError) { 97 - xerr.Message = string(s) 98 - } 99 - } 100 - 101 - func WithError(e error) ErrOpt { 102 - return func(xerr *XrpcError) { 103 - xerr.Message = e.Error() 104 - } 105 - } 106 - 107 - var MissingActorDidError = NewXrpcError( 108 - WithTag("MissingActorDid"), 109 - WithMessage("actor DID not supplied"), 110 - ) 111 - 112 - var AuthError = func(err error) XrpcError { 113 - return NewXrpcError( 114 - WithTag("Auth"), 115 - WithError(fmt.Errorf("signature verification failed: %w", err)), 116 - ) 117 - } 118 - 119 - var InvalidRepoError = func(r string) XrpcError { 120 - return NewXrpcError( 121 - WithTag("InvalidRepo"), 122 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 - ) 124 - } 125 - 126 - func GenericError(err error) XrpcError { 127 - return NewXrpcError( 128 - WithTag("Generic"), 129 - WithError(err), 130 - ) 131 - } 132 - 133 - var AccessControlError = func(d string) XrpcError { 134 - return NewXrpcError( 135 - WithTag("AccessControl"), 136 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 - ) 138 - } 139 - 140 // this is slightly different from http_util::write_error to follow the spec: 141 // 142 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 144 w.Header().Set("Content-Type", "application/json") 145 w.WriteHeader(status) 146 json.NewEncoder(w).Encode(e)
··· 1 package xrpc 2 3 import ( 4 _ "embed" 5 "encoding/json" 6 "log/slog" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 15 "tangled.sh/tangled.sh/core/spindle/db" 16 "tangled.sh/tangled.sh/core/spindle/models" 17 "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 ) 21 22 const ActorDid string = "ActorDid" 23 24 type Xrpc struct { 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 33 } 34 35 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 37 38 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 + r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 + r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 41 42 return r 43 } 44 45 // this is slightly different from http_util::write_error to follow the spec: 46 // 47 // the json object returned must include an "error" and a "message" 48 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 49 w.Header().Set("Content-Type", "application/json") 50 w.WriteHeader(status) 51 json.NewEncoder(w).Encode(e)