Remove platform tenancy from SXCHNGE customer BFF (#34)
This commit is contained in:
1 parent
7062672a87
commit
981e84a4b4
244 files changed
+651
-17130
No files matched your search
@@ -6,7 +6,6 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http"
|
||||
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
@@ -58,10 +57,6 @@ func (a *APIImp) Register() api.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) Permissions() auth.Provider {
|
||||
return a.db.Permissions()
|
||||
}
|
||||
|
||||
func (a *APIImp) RuntimeComponent(name string) any {
|
||||
if a == nil || a.components == nil {
|
||||
return nil
|
||||
|
||||
@@ -79,11 +79,9 @@ func (a *APIImp) installRuntimeBasics(
|
||||
if a.mw, err = httptransport.CreateMiddleware(
|
||||
logger,
|
||||
db,
|
||||
a.db.Permissions().Enforcer(),
|
||||
apiRouter,
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
a.runtime.EnableWebSocket,
|
||||
debug,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime"
|
||||
"git.sendico.io/tech/sxchnge/bff/internal/profile"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
endpoint, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
query := endpoint.Query()
|
||||
query.Set("directConnection", "true")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
uri = endpoint.String()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_runtime"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
data, err := os.ReadFile("../../../../config.example.yml")
|
||||
require.NoError(t, err)
|
||||
var example struct {
|
||||
API api.Config `yaml:"api"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &example))
|
||||
t.Setenv("SXCHNGE_DOMAIN", "localhost")
|
||||
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
||||
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
||||
root.Mount("/", routes)
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
// Runtime auth routes are installed even with zero customers and no seed.
|
||||
result = httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
||||
}
|
||||
@@ -9,14 +9,11 @@ import (
|
||||
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
|
||||
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router"
|
||||
mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics"
|
||||
ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
@@ -34,7 +31,6 @@ type Middleware struct {
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
wshandler ws.Router
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
@@ -50,14 +46,6 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
|
||||
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
|
||||
if mw.wshandler == nil {
|
||||
mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType))
|
||||
return
|
||||
}
|
||||
mw.wshandler.InstallHandler(messageType, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
mw.metrics.Finish()
|
||||
}
|
||||
@@ -116,11 +104,9 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
||||
func CreateMiddleware(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
enforcer auth.Enforcer,
|
||||
apiRouter *chi.Mux,
|
||||
config *middleware.Config,
|
||||
authStorage *edgeapi.AuthStorageConfig,
|
||||
installWebSocket bool,
|
||||
debug bool,
|
||||
) (*Middleware, error) {
|
||||
if config == nil {
|
||||
@@ -161,12 +147,8 @@ func CreateMiddleware(
|
||||
stores.Verification,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
enforcer,
|
||||
config,
|
||||
loginRateLimits,
|
||||
)
|
||||
if installWebSocket {
|
||||
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type customerDB struct {
|
||||
account.DB
|
||||
customer model.Account
|
||||
requested bson.ObjectID
|
||||
}
|
||||
|
||||
func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Account) error {
|
||||
d.requested = id
|
||||
if id != d.customer.ID {
|
||||
return merrors.NoData("customer not found")
|
||||
}
|
||||
*result = d.customer
|
||||
return nil
|
||||
}
|
||||
func (d *sessionDB) GetClient(context.Context, string) (*model.Client, error) {
|
||||
return &model.Client{ClientID: "web"}, nil
|
||||
}
|
||||
|
||||
func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
||||
for _, status := range []model.AccountStatus{model.AccountActive, model.AccountBlocked, model.AccountPendingVerification} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
accounts := &customerDB{}
|
||||
accounts.customer.ID = bson.NewObjectID()
|
||||
accounts.customer.Status = status
|
||||
routes := chi.NewRouter()
|
||||
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
|
||||
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &model.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
called := false
|
||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
called = true
|
||||
require.Equal(t, accounts.customer.ID, c.ID)
|
||||
return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
})
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil)
|
||||
_, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}))
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
routes.ServeHTTP(recorder, req)
|
||||
require.Equal(t, accounts.customer.ID, accounts.requested)
|
||||
if status == model.AccountActive {
|
||||
require.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
require.True(t, called)
|
||||
} else {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
require.False(t, called)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin
|
||||
}
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
|
||||
}
|
||||
|
||||
accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier)
|
||||
if err != nil {
|
||||
ar.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||
@@ -148,6 +152,9 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin
|
||||
}
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
if a.Status == model.AccountBlocked || a.IsArchived() {
|
||||
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
|
||||
}
|
||||
return handler(r, &a, t)
|
||||
}
|
||||
ar.tokenHandler(service, endpoint, method, hndlr)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
@@ -28,7 +27,7 @@ type AuthorizedRouter struct {
|
||||
service mservice.Type
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||
router.Use(jwtauth.Verifier(ja))
|
||||
router.Use(jwtauth.Authenticator(ja))
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
@@ -44,7 +43,6 @@ func NewDispatcher(
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
enforcer auth.Enforcer,
|
||||
config *middleware.Config,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
) *Dispatcher {
|
||||
@@ -59,7 +57,7 @@ func NewDispatcher(
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
|
||||
@@ -46,7 +46,7 @@ func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.L
|
||||
return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials")
|
||||
}
|
||||
|
||||
if !account.IsActive() {
|
||||
if !account.IsActive() || account.IsArchived() {
|
||||
return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,6 @@ func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.A
|
||||
return db.result, db.err
|
||||
}
|
||||
|
||||
func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
|
||||
return nil, merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
@@ -135,3 +135,17 @@ func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) {
|
||||
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
|
||||
require.Empty(t, response.Header().Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) {
|
||||
for _, status := range []model.AccountStatus{model.AccountBlocked, model.AccountPendingVerification} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
|
||||
router.db.(*testAccountDB).result.Status = status
|
||||
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
|
||||
})
|
||||
}
|
||||
router, sessions := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
|
||||
other := testActiveAccount(t)
|
||||
sessions.client.AccountRef = &other.ID
|
||||
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
|
||||
}
|
||||
@@ -88,6 +88,13 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Reques
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if !account.IsActive() || account.IsArchived() {
|
||||
return nil, nil, nil, merrors.Unauthorized("customer is inactive")
|
||||
}
|
||||
if client.AccountRef != nil && *client.AccountRef != account.ID {
|
||||
return nil, nil, nil, merrors.Unauthorized("client account mismatch")
|
||||
}
|
||||
|
||||
accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type DispatcherImpl struct {
|
||||
logger mlogger.Logger
|
||||
handlers map[string]ws.HandlerFunc
|
||||
timeout int
|
||||
}
|
||||
|
||||
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
|
||||
d.handlers[messageType] = handler
|
||||
d.logger.Info("Handler installed", zap.String("message_type", messageType))
|
||||
}
|
||||
|
||||
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
|
||||
var msg ws.Message
|
||||
err := websocket.JSON.Receive(conn, &msg)
|
||||
if err != nil {
|
||||
d.logger.Warn("Failed to read websocket message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if handler, exists := d.handlers[msg.MessageType]; exists {
|
||||
responseHandler := handler(ctx, msg)
|
||||
responseHandler(msg.MessageType, conn)
|
||||
} else {
|
||||
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
|
||||
//nolint:contextcheck // websocket.Handler callback signature does not carry request context.
|
||||
websocket.Handler(func(conn *websocket.Conn) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
|
||||
defer cancel()
|
||||
d.dispatchMessage(ctx, conn)
|
||||
}).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl {
|
||||
d := &DispatcherImpl{
|
||||
logger: logger.Named("websocket"),
|
||||
handlers: make(map[string]ws.HandlerFunc),
|
||||
timeout: config.Timeout,
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing websocket middleware...")
|
||||
router.Group(func(r chi.Router) {
|
||||
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
|
||||
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
|
||||
r.Get(ep, d.handle)
|
||||
})
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Router interface {
|
||||
InstallHandler(messageType string, handler ws.HandlerFunc)
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
|
||||
return NewDispatcher(logger, router, config, apiEndpoint)
|
||||
}
|
||||
Reference in new issue
Block a user