From d04c8b12abeb54c6b4d8a4d4058f262107f8ebc2 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sun, 13 Sep 2026 10:26:06 +0200 Subject: [PATCH] Make product API versions independently installable --- backend/product/README.md | 5 +- backend/product/app/api/api.go | 42 ++-- backend/product/app/api/config.go | 2 + backend/product/app/api/register.go | 13 -- backend/product/app/api/runtime.go | 30 ++- backend/product/app/api/v1/install.go | 26 +++ backend/product/app/http/middleware.go | 118 +---------- backend/product/app/profile/runtime.go | 17 +- backend/product/app/runtime/api.go | 66 +----- backend/product/app/runtime/bootstrap.go | 131 +++++------- backend/product/app/runtime/bootstrap_test.go | 2 +- backend/product/app/runtime/lifecycle.go | 32 ++- backend/product/app/runtime/mounts.go | 58 ++++++ backend/product/app/runtime/mounts_test.go | 190 ++++++++++++++++++ backend/product/app/runtime/services.go | 82 +++++--- .../app/server/internal/config_test.go | 32 +++ backend/product/app/server/internal/server.go | 2 +- backend/product/app/urls/domain_provider.go | 1 + .../app/urls/internal/domain_provider.go | 18 +- .../app/urls/internal/domain_provider_test.go | 27 +++ backend/product/architecture/versions_test.go | 41 ++++ .../http/{ => v1}/authorized/customer_test.go | 4 +- .../auth/http/{ => v1}/authorized/handler.go | 2 +- .../auth/http/{ => v1}/authorized/router.go | 4 +- .../http/{ => v1}/authorized/session_test.go | 0 .../product/auth/http/{ => v1}/dispatcher.go | 20 +- .../auth/http/{ => v1}/endpoint/endpoint.go | 2 +- .../auth/http/{ => v1}/endpoint/install.go | 4 +- .../auth/http/{ => v1}/endpoint/token.go | 2 +- .../auth/http/{ => v1}/public/login.go | 4 +- .../http/{ => v1}/public/login_rate_limit.go | 2 +- .../{ => v1}/public/login_rate_limit_test.go | 2 +- .../auth/http/{ => v1}/public/login_test.go | 4 +- .../public/login_test_support_test.go | 0 .../auth/http/{ => v1}/public/logout.go | 2 +- .../auth/http/{ => v1}/public/logout_test.go | 0 .../auth/http/{ => v1}/public/metrics.go | 2 +- .../auth/http/{ => v1}/public/refresh.go | 4 +- .../auth/http/{ => v1}/public/refresh_test.go | 2 +- .../auth/http/{ => v1}/public/respond.go | 2 +- .../auth/http/{ => v1}/public/rotate.go | 2 +- .../auth/http/{ => v1}/public/router.go | 6 +- .../auth/http/{ => v1}/public/validate.go | 4 +- .../auth/http/{ => v1}/public/verification.go | 2 +- .../public/verification_integration_test.go | 2 +- .../auth/{ => http/v1}/request/login.go | 0 .../auth/{ => http/v1}/request/refresh.go | 0 .../auth/{ => http/v1}/request/rotate.go | 0 .../{ => http/v1}/request/validateable.go | 0 .../{ => http/v1}/request/verification.go | 0 .../auth/{ => http/v1}/response/account.go | 0 .../auth/{ => http/v1}/response/authresp.go | 0 .../auth/{ => http/v1}/response/file.go | 0 .../auth/{ => http/v1}/response/login.go | 0 .../{ => http/v1}/response/login_pending.go | 0 .../auth/{ => http/v1}/response/login_test.go | 0 .../auth/{ => http/v1}/response/response.go | 0 .../auth/{ => http/v1}/response/result.go | 0 .../auth/{ => http/v1}/response/token.go | 0 .../{ => http/v1}/response/verification.go | 0 backend/product/auth/http/{ => v1}/router.go | 2 +- backend/product/config.example.yml | 4 + backend/product/customers/internal/service.go | 11 +- backend/product/customers/types.go | 9 +- backend/product/sessions/browser/browser.go | 19 +- .../product/sessions/browser/browser_test.go | 6 +- .../product/twofactor/http/{ => v1}/router.go | 2 +- .../twofactor/http/{ => v1}/router_test.go | 2 +- docs/api-versions.md | 91 +++++++++ 69 files changed, 755 insertions(+), 404 deletions(-) delete mode 100644 backend/product/app/api/register.go create mode 100644 backend/product/app/api/v1/install.go create mode 100644 backend/product/app/runtime/mounts.go create mode 100644 backend/product/app/runtime/mounts_test.go create mode 100644 backend/product/app/urls/internal/domain_provider_test.go create mode 100644 backend/product/architecture/versions_test.go rename backend/product/auth/http/{ => v1}/authorized/customer_test.go (91%) rename backend/product/auth/http/{ => v1}/authorized/handler.go (99%) rename backend/product/auth/http/{ => v1}/authorized/router.go (89%) rename backend/product/auth/http/{ => v1}/authorized/session_test.go (100%) rename backend/product/auth/http/{ => v1}/dispatcher.go (73%) rename backend/product/auth/http/{ => v1}/endpoint/endpoint.go (94%) rename backend/product/auth/http/{ => v1}/endpoint/install.go (93%) rename backend/product/auth/http/{ => v1}/endpoint/token.go (95%) rename backend/product/auth/http/{ => v1}/public/login.go (98%) rename backend/product/auth/http/{ => v1}/public/login_rate_limit.go (98%) rename backend/product/auth/http/{ => v1}/public/login_rate_limit_test.go (99%) rename backend/product/auth/http/{ => v1}/public/login_test.go (99%) rename backend/product/auth/http/{ => v1}/public/login_test_support_test.go (100%) rename backend/product/auth/http/{ => v1}/public/logout.go (97%) rename backend/product/auth/http/{ => v1}/public/logout_test.go (100%) rename backend/product/auth/http/{ => v1}/public/metrics.go (94%) rename backend/product/auth/http/{ => v1}/public/refresh.go (93%) rename backend/product/auth/http/{ => v1}/public/refresh_test.go (99%) rename backend/product/auth/http/{ => v1}/public/respond.go (96%) rename backend/product/auth/http/{ => v1}/public/rotate.go (95%) rename backend/product/auth/http/{ => v1}/public/router.go (95%) rename backend/product/auth/http/{ => v1}/public/validate.go (96%) rename backend/product/auth/http/{ => v1}/public/verification.go (99%) rename backend/product/auth/http/{ => v1}/public/verification_integration_test.go (95%) rename backend/product/auth/{ => http/v1}/request/login.go (100%) rename backend/product/auth/{ => http/v1}/request/refresh.go (100%) rename backend/product/auth/{ => http/v1}/request/rotate.go (100%) rename backend/product/auth/{ => http/v1}/request/validateable.go (100%) rename backend/product/auth/{ => http/v1}/request/verification.go (100%) rename backend/product/auth/{ => http/v1}/response/account.go (100%) rename backend/product/auth/{ => http/v1}/response/authresp.go (100%) rename backend/product/auth/{ => http/v1}/response/file.go (100%) rename backend/product/auth/{ => http/v1}/response/login.go (100%) rename backend/product/auth/{ => http/v1}/response/login_pending.go (100%) rename backend/product/auth/{ => http/v1}/response/login_test.go (100%) rename backend/product/auth/{ => http/v1}/response/response.go (100%) rename backend/product/auth/{ => http/v1}/response/result.go (100%) rename backend/product/auth/{ => http/v1}/response/token.go (100%) rename backend/product/auth/{ => http/v1}/response/verification.go (100%) rename backend/product/auth/http/{ => v1}/router.go (89%) rename backend/product/twofactor/http/{ => v1}/router.go (99%) rename backend/product/twofactor/http/{ => v1}/router_test.go (96%) create mode 100644 docs/api-versions.md diff --git a/backend/product/README.md b/backend/product/README.md index 8f45c07..da04be0 100644 --- a/backend/product/README.md +++ b/backend/product/README.md @@ -28,7 +28,10 @@ Signup and email verification delivery/completion are available when `api.notifications` is configured. Flutter selects the backend using `SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See [auth notification setup and contracts](../../docs/auth-notifications.md). -Profile HTTP wiring remains follow-up work. +API versions are installed explicitly by the product composition. See +[version installation and compatibility](../../docs/api-versions.md) for selection, +mounts, shared lifecycle and adding a future version. Customer profile endpoint +wiring remains follow-up work. Sendico integration is not wired. Its organization context belongs only to the future public HTTP adapter. There is no unused public API snapshot in this module; diff --git a/backend/product/app/api/api.go b/backend/product/app/api/api.go index 8e156a4..4af05aa 100644 --- a/backend/product/app/api/api.go +++ b/backend/product/app/api/api.go @@ -1,23 +1,37 @@ package api import ( - "context" - "git.sendico.io/tech/sxchnge/product/app/urls" - "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/flow" + "git.sendico.io/tech/sxchnge/product/auth/storage" + "git.sendico.io/tech/sxchnge/product/customers" + "git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/shared/logging" - "git.sendico.io/tech/sxchnge/shared/service" + "github.com/go-chi/chi/v5" ) -type API interface { - LifecycleContext() context.Context - Logger() mlogger.Logger - DomainProvider() domainprovider.DomainProvider - Config() *Config - ProfileConfig() ConfigProvider - DBFactory() db.Factory - RuntimeComponent(name string) any - Register() Register +// Dependencies are initialized once and shared by installed transports. +type Dependencies struct { + Stores *storage.Stores + Customers accountservice.AccountService + Auth *flow.Service + Factors *twofactor.Service + Token middleware.TokenConfig + Signature middleware.Signature + LoginLimits middleware.LoginRateLimits } -type MicroServiceFactoryT = func(API) (mservice.MicroService, error) +// Scope belongs to one installation. Router paths are relative to Mount. +type Scope struct { + Router chi.Router + Mount string + URLs domainprovider.DomainProvider + Logger mlogger.Logger +} +type Installer func(Scope, *Dependencies) error + +type Mount struct { + Version string `yaml:"version"` + Path string `yaml:"path"` +} diff --git a/backend/product/app/api/config.go b/backend/product/app/api/config.go index 5ce6ba6..338f69c 100644 --- a/backend/product/app/api/config.go +++ b/backend/product/app/api/config.go @@ -7,6 +7,8 @@ import ( ) type Config struct { + // nil preserves the legacy v1 mount; an explicit empty list installs no API. + Versions []Mount `yaml:"versions"` Notifications *notifications.Config `yaml:"notifications"` Mw *mwa.Config `yaml:"middleware"` AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"` diff --git a/backend/product/app/api/register.go b/backend/product/app/api/register.go deleted file mode 100644 index 4361edd..0000000 --- a/backend/product/app/api/register.go +++ /dev/null @@ -1,13 +0,0 @@ -package api - -import ( - "git.sendico.io/tech/sxchnge/product/auth/response" - api "git.sendico.io/tech/sxchnge/shared/http" - "git.sendico.io/tech/sxchnge/shared/service" -) - -type Register interface { - Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) - AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) - PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) -} diff --git a/backend/product/app/api/runtime.go b/backend/product/app/api/runtime.go index 1490caf..789418e 100644 --- a/backend/product/app/api/runtime.go +++ b/backend/product/app/api/runtime.go @@ -1,36 +1,32 @@ package api -import "git.sendico.io/tech/sxchnge/shared/service" +import ( + "context" -type ConfigProvider interface { - SharedConfig() *Config -} + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/logging" +) +type ConfigProvider interface{ SharedConfig() *Config } type ConfigFactory func() ConfigProvider -type RuntimeComponents map[string]any -type RuntimeBootstrapFunc func(API) (RuntimeComponents, error) -type RuntimeFinishFunc func(API) error +// Bootstrap opens process-owned resources. Its cleanup is called once even if +// installation fails. On error, bootstrap must return cleanup or clean its own +// resources before returning, never both. +type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error) type RuntimeConfig struct { Name string - Services []ServiceFactory ConfigFactory ConfigFactory - Bootstrap RuntimeBootstrapFunc - Finish RuntimeFinishFunc -} - -type ServiceFactory struct { - Name mservice.Type - Factory MicroServiceFactoryT + Bootstrap Bootstrap + Versions map[string]Installer } func (cfg RuntimeConfig) RuntimeName() string { if cfg.Name == "" { - return "edge" + return "product" } return cfg.Name } - func (cfg RuntimeConfig) NewConfig() ConfigProvider { if cfg.ConfigFactory != nil { return cfg.ConfigFactory() diff --git a/backend/product/app/api/v1/install.go b/backend/product/app/api/v1/install.go new file mode 100644 index 0000000..72cf227 --- /dev/null +++ b/backend/product/app/api/v1/install.go @@ -0,0 +1,26 @@ +// Package v1 composes the product's first public HTTP contract. +package v1 + +import ( + "git.sendico.io/tech/sxchnge/product/app/api" + auth "git.sendico.io/tech/sxchnge/product/auth/http/v1" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/public" + factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1" + "git.sendico.io/tech/sxchnge/shared/errors" +) + +func Install(scope api.Scope, deps *api.Dependencies) error { + if deps == nil || deps.Stores == nil { + return merrors.InvalidArgument("v1 auth dependencies are required") + } + flows := public.Flows{Onboarding: deps.Auth} + if deps.Factors != nil { + flows.Factors = deps.Factors + } + // Signature is resolved at bootstrap, not by each version from the environment. + routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows) + if deps.Factors != nil { + factors.Install(routes, deps.Factors, scope.Logger) + } + return nil +} diff --git a/backend/product/app/http/middleware.go b/backend/product/app/http/middleware.go index 207916e..5bdb2f9 100644 --- a/backend/product/app/http/middleware.go +++ b/backend/product/app/http/middleware.go @@ -2,26 +2,12 @@ package httptransport import ( "net/http" - "os" "path" mr "git.sendico.io/tech/sxchnge/product/app/metrics" "git.sendico.io/tech/sxchnge/product/auth/config" - "git.sendico.io/tech/sxchnge/product/auth/flow" - httprouter "git.sendico.io/tech/sxchnge/product/auth/http" - public "git.sendico.io/tech/sxchnge/product/auth/http/public" - "git.sendico.io/tech/sxchnge/product/auth/response" - authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" - accountservice "git.sendico.io/tech/sxchnge/product/customers" - "git.sendico.io/tech/sxchnge/product/notifications" "git.sendico.io/tech/sxchnge/product/sessions/browser" - "git.sendico.io/tech/sxchnge/product/twofactor" - delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery" - factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http" - factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store" - "git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/errors" - api "git.sendico.io/tech/sxchnge/shared/http" "git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/service" @@ -34,33 +20,17 @@ import ( ) type Middleware struct { - notifications *notifications.Client - logger mlogger.Logger - router *chi.Mux - apiEndpoint string - metrics mr.Metrics - epdispatcher *httprouter.Dispatcher -} - -func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) { - mw.epdispatcher.Handler(service, endpoint, method, handler) -} - -func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) { - mw.epdispatcher.AccountHandler(service, endpoint, method, handler) -} - -func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) { - mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler) + logger mlogger.Logger + router *chi.Mux + apiEndpoint string + metrics mr.Metrics } func (mw *Middleware) Finish() { - mw.metrics.Finish() - if mw.notifications != nil { - _ = mw.notifications.Close() + if mw.metrics != nil { + mw.metrics.Finish() } } - func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) { mw.logger.Debug("Installing middleware stack...") // Collect metrics for application traffic only. Scrapes and health probes are @@ -112,15 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool { return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics" } -func CreateMiddleware( - logger mlogger.Logger, - db db.Factory, - apiRouter *chi.Mux, - config *middleware.Config, - authStorage *middleware.AuthStorageConfig, - debug bool, - notificationConfig ...*notifications.Config, -) (*Middleware, error) { +func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) { if config == nil { return nil, merrors.InvalidArgument("middleware configuration is required", "middleware") } @@ -130,72 +92,12 @@ func CreateMiddleware( if err := config.CORS.Validate(); err != nil { return nil, err } - loginRateLimits, err := config.ResolveLoginRateLimits() - if err != nil { - logger.Error("Invalid login rate limit configuration", zap.Error(err)) - return nil, err - } - p := &Middleware{ - logger: logger.Named("middleware"), - router: apiRouter, - apiEndpoint: os.Getenv(config.EndPointEnv), - } - p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint)) + p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint} p.installMiddleware(config, debug) - if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil { - p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint)) - return nil, err - } - - stores, err := authstorage.OpenStores(p.logger, db, authStorage) + var err error + p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint) if err != nil { return nil, err } - - var authFlow *flow.Service - var factors *twofactor.Service - if len(notificationConfig) > 0 && notificationConfig[0] != nil { - c := notificationConfig[0] - customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage) - if err != nil { - return nil, err - } - authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL} - if err = authFlow.Validate(); err != nil { - return nil, err - } - p.notifications, err = notifications.Open(*c) - if err != nil { - return nil, err - } - authFlow.Sender = p.notifications - records, err := factorstore.Open(p.logger, db) - if err != nil { - _ = p.notifications.Close() - return nil, err - } - factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications}) - if err != nil { - _ = p.notifications.Close() - return nil, err - } - } - flows := public.Flows{Onboarding: authFlow} - if factors != nil { - flows.Factors = factors - } - p.epdispatcher = httprouter.NewDispatcher( - p.logger, - p.router, - stores.Accounts, - stores.RefreshTokens, - stores.LoginAttempts, - config, - loginRateLimits, - flows, - ) - if factors != nil { - factorhttp.Install(p.epdispatcher, factors, p.logger) - } return p, nil } diff --git a/backend/product/app/profile/runtime.go b/backend/product/app/profile/runtime.go index b8bbba1..f334156 100644 --- a/backend/product/app/profile/runtime.go +++ b/backend/product/app/profile/runtime.go @@ -1,21 +1,12 @@ -// Package profile selects the locally owned components of the copied BFF. +// Package profile selects the product's supported API implementations. package profile import ( "git.sendico.io/tech/sxchnge/product/app/api" - "git.sendico.io/tech/sxchnge/product/customers" + "git.sendico.io/tech/sxchnge/product/app/api/v1" + "git.sendico.io/tech/sxchnge/product/app/runtime" ) -const Accounts = "accounts" - func Runtime() api.RuntimeConfig { - return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap} -} - -func bootstrap(a api.API) (api.RuntimeComponents, error) { - accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage) - if err != nil { - return nil, err - } - return api.RuntimeComponents{Accounts: accounts}, nil + return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}} } diff --git a/backend/product/app/runtime/api.go b/backend/product/app/runtime/api.go index 9b82f60..be25731 100644 --- a/backend/product/app/runtime/api.go +++ b/backend/product/app/runtime/api.go @@ -2,68 +2,20 @@ package runtime import ( "context" + "sync" - "git.sendico.io/tech/sxchnge/product/app/api" amr "git.sendico.io/tech/sxchnge/product/app/health" httptransport "git.sendico.io/tech/sxchnge/product/app/http" - "git.sendico.io/tech/sxchnge/product/app/urls" - "git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/logging" - "git.sendico.io/tech/sxchnge/shared/service" ) -type Microservices = []mservice.MicroService - -// APIImp represents the structure of the APIImp -type APIImp struct { - lifecycleCtx context.Context - logger mlogger.Logger - db db.Factory - domain domainprovider.DomainProvider - config *api.Config - profile api.ConfigProvider - runtime api.RuntimeConfig - components api.RuntimeComponents - services Microservices - health amr.Health - mw *httptransport.Middleware +type instance struct { + logger mlogger.Logger + health amr.Health + mw *httptransport.Middleware + cleanup func(context.Context) error + once sync.Once + finishErr error } -func (a *APIImp) LifecycleContext() context.Context { - return a.lifecycleCtx -} - -func (a *APIImp) Logger() mlogger.Logger { - return a.logger -} - -func (a *APIImp) Config() *api.Config { - return a.config -} - -func (a *APIImp) ProfileConfig() api.ConfigProvider { - return a.profile -} - -func (a *APIImp) DBFactory() db.Factory { - return a.db -} - -func (a *APIImp) DomainProvider() domainprovider.DomainProvider { - return a.domain -} - -func (a *APIImp) Register() api.Register { - return a.mw -} - -func (a *APIImp) RuntimeComponent(name string) any { - if a == nil || a.components == nil { - return nil - } - return a.components[name] -} - -func (a *APIImp) Name() string { - return "api" -} +func (a *instance) Name() string { return "api" } diff --git a/backend/product/app/runtime/bootstrap.go b/backend/product/app/runtime/bootstrap.go index 8aa651d..4ed64f4 100644 --- a/backend/product/app/runtime/bootstrap.go +++ b/backend/product/app/runtime/bootstrap.go @@ -6,9 +6,12 @@ import ( "git.sendico.io/tech/sxchnge/product/app/api" amr "git.sendico.io/tech/sxchnge/product/app/health" + "git.sendico.io/tech/sxchnge/product/app/health/status" httptransport "git.sendico.io/tech/sxchnge/product/app/http" "git.sendico.io/tech/sxchnge/product/app/urls" + "git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" @@ -16,93 +19,67 @@ import ( ) func CreateAPI( - lifecycleCtx context.Context, + ctx context.Context, logger mlogger.Logger, config *api.Config, - profileConfig api.ConfigProvider, db db.Factory, - rootRouter *chi.Mux, - apiRouter *chi.Mux, + rootRouter, apiRouter *chi.Mux, debug bool, runtime api.RuntimeConfig, -) (mservice.MicroService, error) { - p := &APIImp{ - lifecycleCtx: lifecycleCtx, - logger: logger.Named("api"), - config: config, - profile: profileConfig, - db: db, - runtime: runtime, +) (_ mservice.MicroService, result error) { + if config == nil || config.Mw == nil { + return nil, merrors.InvalidArgument("API middleware configuration is required") } - - if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil { + endpoint := os.Getenv(config.Mw.EndPointEnv) + mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions) + if err != nil { return nil, err } - if err := p.bootstrapConfiguredRuntime(config, db); err != nil { - return nil, err + if runtime.Bootstrap == nil { + return nil, merrors.InvalidArgument("runtime bootstrap is required") } - - p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName())) - if err := p.installServices(); err != nil { - p.logger.Error("Failed to install a microservice", zap.Error(err)) - return nil, err - } - p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services))) - - return p, nil -} - -func (a *APIImp) installRuntimeBasics( - logger mlogger.Logger, - db db.Factory, - rootRouter *chi.Mux, - apiRouter *chi.Mux, - debug bool, -) error { - var err error - if a.domain, err = domainprovider.CreateDomainProvider( - a.logger, - a.config.Mw.DomainEnv, - a.config.Mw.APIProtocolEnv, - a.config.Mw.EndPointEnv, - ); err != nil { - a.logger.Error("Failed to initialize domain provider") - return err - } - a.logger.Info("Domain provider installed") - - apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv) - if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil { - a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint)) - return err - } - if a.mw, err = httptransport.CreateMiddleware( - logger, - db, - apiRouter, - a.config.Mw, - a.config.AuthStorage, - debug, - a.config.Notifications, - ); err != nil { - a.logger.Error("Failed to create middleware", zap.Error(err)) - return err - } - a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug)) - return nil -} - -func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error { - if a.runtime.Bootstrap != nil { - components, err := a.runtime.Bootstrap(a) + // Resolve every URL before opening resources or publishing routes. + scopes := make([]api.Scope, len(mounts)) + for i, mount := range mounts { + urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path) if err != nil { - a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName())) - return err + return nil, err } - a.components = components - a.logger.Info("Runtime profile bootstrap complete", - zap.String("runtime", a.runtime.RuntimeName()), - zap.Int("components", len(components))) + scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))} + scopes[i].Router.Use(browser.Scope(mount.Path)) } - return nil + a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))} + defer func() { + if result != nil { + if err := a.Finish(context.Background()); err != nil { + a.logger.Warn("Failed startup resource cleanup", zap.Error(err)) + } + } + }() + a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug) + if err != nil { + return nil, err + } + deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db) + a.cleanup = cleanup + if err != nil { + return nil, err + } + for i, mount := range mounts { + if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil { + return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version) + } + } + // Attach only after every installer succeeds. + for i, mount := range mounts { + apiRouter.Mount(mount.Path, scopes[i].Router) + scopes[i].Logger.Info("API version installed") + } + a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint) + if err != nil { + return nil, err + } + a.logger.Info("API installation complete", zap.Int("versions", len(mounts))) + a.health.SetStatus(health.SSRunning) + return a, nil } diff --git a/backend/product/app/runtime/bootstrap_test.go b/backend/product/app/runtime/bootstrap_test.go index 9b66a61..1c90b76 100644 --- a/backend/product/app/runtime/bootstrap_test.go +++ b/backend/product/app/runtime/bootstrap_test.go @@ -50,7 +50,7 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) { t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs. root, routes := chi.NewRouter(), chi.NewRouter() - runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime()) + runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &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) diff --git a/backend/product/app/runtime/lifecycle.go b/backend/product/app/runtime/lifecycle.go index 141c483..43a450a 100644 --- a/backend/product/app/runtime/lifecycle.go +++ b/backend/product/app/runtime/lifecycle.go @@ -4,28 +4,20 @@ import ( "context" "git.sendico.io/tech/sxchnge/product/app/health/status" - "go.uber.org/zap" ) -func (a *APIImp) Finish(ctx context.Context) error { - a.health.SetStatus(health.SSTerminating) - a.health.Finish() - a.mw.Finish() - var lastError error - for i := len(a.services) - 1; i >= 0; i-- { - if err := (a.services[i]).Finish(ctx); err != nil { - lastError = err - a.logger.Warn("Error occurred when finishing service", - zap.Error(err), zap.String("service_name", (a.services[i]).Name())) - } else { - a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name())) +func (a *instance) Finish(ctx context.Context) error { + a.once.Do(func() { + if a.health != nil { + a.health.SetStatus(health.SSTerminating) + a.health.Finish() } - } - if a.runtime.Finish != nil { - if err := a.runtime.Finish(a); err != nil { - lastError = err - a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err)) + if a.cleanup != nil { + a.finishErr = a.cleanup(ctx) } - } - return lastError + if a.mw != nil { + a.mw.Finish() + } + }) + return a.finishErr } diff --git a/backend/product/app/runtime/mounts.go b/backend/product/app/runtime/mounts.go new file mode 100644 index 0000000..2a6722c --- /dev/null +++ b/backend/product/app/runtime/mounts.go @@ -0,0 +1,58 @@ +package runtime + +import ( + "path" + "strings" + + "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/shared/errors" +) + +func validMount(value string) bool { + if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value { + return false + } + for _, c := range value { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) { + continue + } + return false + } + return true +} +func overlaps(a, b string) bool { + return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") +} + +func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) { + if !validMount(endpoint) { + return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint") + } + if configured == nil { + configured = []api.Mount{{Version: "v1", Path: endpoint}} + } + seen := map[string]bool{} + for i, mount := range configured { + if supported[mount.Version] == nil { + return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions") + } + if seen[mount.Version] { + return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions") + } + seen[mount.Version] = true + if !validMount(mount.Path) { + return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions") + } + for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} { + if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") { + return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions") + } + } + for _, previous := range configured[:i] { + if overlaps(mount.Path, previous.Path) { + return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions") + } + } + } + return configured, nil +} diff --git a/backend/product/app/runtime/mounts_test.go b/backend/product/app/runtime/mounts_test.go new file mode 100644 index 0000000..bb8a8e7 --- /dev/null +++ b/backend/product/app/runtime/mounts_test.go @@ -0,0 +1,190 @@ +package runtime + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "git.sendico.io/tech/sxchnge/product/app/api" + "git.sendico.io/tech/sxchnge/product/sessions/browser" + "git.sendico.io/tech/sxchnge/shared/database" + merrors "git.sendico.io/tech/sxchnge/shared/errors" + "git.sendico.io/tech/sxchnge/shared/logging" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +func testConfig(t *testing.T) *api.Config { + t.Helper() + data, err := os.ReadFile("../../config.example.yml") + require.NoError(t, err) + var raw struct { + API api.Config `yaml:"api"` + } + require.NoError(t, yaml.Unmarshal(data, &raw)) + t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1") + t.Setenv(raw.API.Mw.DomainEnv, "example.test") + t.Setenv(raw.API.Mw.APIProtocolEnv, "https") + return &raw.API +} + +func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) { + config := testConfig(t) + config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}} + initialized, closed := 0, 0 + deps := &api.Dependencies{} + installer := func(version string) api.Installer { + return func(scope api.Scope, got *api.Dependencies) error { + require.Same(t, deps, got) + link, err := scope.URLs.GetAPILink("accounts") + require.NoError(t, err) + require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link) + scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) }) + scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) { + browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour)) + }) + scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie) + return nil + } + } + runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) { + initialized++ + return deps, func(context.Context) error { closed++; return nil }, nil + }} + root, routes := chi.NewRouter(), chi.NewRouter() + srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime) + require.NoError(t, err) + root.Mount("/", routes) + for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} { + result := httptest.NewRecorder() + root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil)) + require.Equal(t, http.StatusOK, result.Code) + require.Equal(t, expected, result.Body.String()) + } + t.Setenv("API_ENDPOINT", "/wrong/global") + for _, mount := range config.Versions { + for _, method := range []string{http.MethodPost, http.MethodDelete} { + result := httptest.NewRecorder() + request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil) + request.Header.Set(browser.TransportHeader, browser.TransportCookie) + request.Header.Set(browser.CSRFHeader, browser.CSRFValue) + request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0]) + root.ServeHTTP(result, request) + require.Equal(t, http.StatusOK, result.Code) + cookies := result.Result().Cookies() + require.Len(t, cookies, 1) + require.Equal(t, mount.Path+"/accounts", cookies[0].Path) + if method == http.MethodDelete { + require.Equal(t, -1, cookies[0].MaxAge) + } + } + } + result := httptest.NewRecorder() + root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil)) + require.Equal(t, 404, result.Code) + require.Equal(t, 1, initialized) + var wg sync.WaitGroup + results := make(chan error, 3) + for range 3 { + wg.Go(func() { results <- srv.Finish(context.Background()) }) + } + wg.Wait() + close(results) + for err := range results { + require.NoError(t, err) + } + require.Equal(t, 1, closed) +} + +func TestInvalidMountsFailBeforeBootstrap(t *testing.T) { + for name, mounts := range map[string][]api.Mount{ + "unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}}, + "same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}}, + "nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}}, + "cookie delimiter": {{Version: "v1", Path: "/api;v1"}}, + "control": {{Version: "v1", Path: "/api/\x01"}}, + "relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}}, + "unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}}, + "health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}}, + } { + t.Run(name, func(t *testing.T) { + config := testConfig(t) + config.Versions = mounts + install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil } + _, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) { + t.Fatal("bootstrap must not run") + return nil, nil, nil + }}) + require.ErrorIs(t, err, merrors.ErrInvalidArg) + }) + } +} + +func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) { + config := testConfig(t) + config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}} + closed := 0 + cause := merrors.InvalidArgument("test installation failure") + routes := chi.NewRouter() + _, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{ + Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) { + return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil + }, + Versions: map[string]api.Installer{ + "v1": func(scope api.Scope, _ *api.Dependencies) error { + scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }) + return nil + }, + "v2": func(api.Scope, *api.Dependencies) error { return cause }, + }}) + require.True(t, errors.Is(err, cause)) + require.Equal(t, 1, closed) + result := httptest.NewRecorder() + routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil)) + require.Equal(t, 404, result.Code) +} + +func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) { + config := testConfig(t) + config.Versions = []api.Mount{} + root, routes := chi.NewRouter(), chi.NewRouter() + srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) { + return &api.Dependencies{}, nil, nil + }}) + require.NoError(t, err) + defer srv.Finish(context.Background()) + root.Mount("/", routes) + for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} { + result := httptest.NewRecorder() + root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil)) + require.Equal(t, status, result.Code, target) + } +} + +func TestBootstrapFailureClosesReturnedResources(t *testing.T) { + config := testConfig(t) + config.Versions = []api.Mount{} + closed := 0 + cause := merrors.Internal("bootstrap failure") + _, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) { + return nil, func(context.Context) error { closed++; return nil }, cause + }}) + require.ErrorIs(t, err, cause) + require.Equal(t, 1, closed) +} + +func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) { + cause := merrors.Internal("cleanup failure") + closed := 0 + srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }} + require.ErrorIs(t, srv.Finish(context.Background()), cause) + require.ErrorIs(t, srv.Finish(context.Background()), cause) + require.Equal(t, 1, closed) +} diff --git a/backend/product/app/runtime/services.go b/backend/product/app/runtime/services.go index d0d9ef7..af94b82 100644 --- a/backend/product/app/runtime/services.go +++ b/backend/product/app/runtime/services.go @@ -1,39 +1,69 @@ package runtime import ( + "context" + "git.sendico.io/tech/sxchnge/product/app/api" - "git.sendico.io/tech/sxchnge/product/app/health/status" - "git.sendico.io/tech/sxchnge/shared/errors" - "git.sendico.io/tech/sxchnge/shared/service" + "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/flow" + "git.sendico.io/tech/sxchnge/product/auth/storage" + "git.sendico.io/tech/sxchnge/product/customers" + "git.sendico.io/tech/sxchnge/product/notifications" + "git.sendico.io/tech/sxchnge/product/twofactor" + "git.sendico.io/tech/sxchnge/product/twofactor/delivery" + factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store" + "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/logging" "go.uber.org/zap" ) -func (a *APIImp) installMicroservice(srv mservice.MicroService) { - a.services = append(a.services, srv) - a.logger.Info("Microservice installed", zap.String("service", srv.Name())) -} - -func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error { - srv, err := srvf(a) +func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) { + limits, err := config.Mw.ResolveLoginRateLimits() if err != nil { - a.logger.Error("Failed to install a microservice", zap.Error(err)) - return err + return nil, nil, err } - - a.installMicroservice(srv) - return nil -} - -func (a *APIImp) installServices() error { - for _, service := range a.runtime.Services { - if service.Factory == nil { - return merrors.InvalidArgument("microservice factory is required", "runtime.services") + stores, err := storage.OpenStores(logger, db, config.AuthStorage) + if err != nil { + return nil, nil, err + } + customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory()) + if err != nil { + return nil, nil, err + } + deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits} + var client *notifications.Client + cleanup := func(context.Context) error { + if client != nil { + return client.Close() } - if err := a.addMicroservice(service.Factory); err != nil { - return err + return nil + } + defer func() { + if result != nil { + if err := cleanup(context.Background()); err != nil { + logger.Warn("Partial bootstrap cleanup failed", zap.Error(err)) + } + } + }() + if c := config.Notifications; c != nil { + deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL} + if err := deps.Auth.Validate(); err != nil { + return nil, nil, err + } + client, err = notifications.Open(*c) + if err != nil { + return nil, nil, err + } + deps.Auth.Sender = client + records, err := factorstore.Open(logger, db) + if err != nil { + return nil, nil, err + } + deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client}) + if err != nil { + return nil, nil, err } } - - a.health.SetStatus(health.SSRunning) - return nil + logger.Info("Product dependencies initialized") + return deps, cleanup, nil } diff --git a/backend/product/app/server/internal/config_test.go b/backend/product/app/server/internal/config_test.go index e2d4e8e..f42a869 100644 --- a/backend/product/app/server/internal/config_test.go +++ b/backend/product/app/server/internal/config_test.go @@ -5,6 +5,7 @@ import ( "git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/shared/errors" "os" + "strings" "testing" ) @@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) { t.Fatal(err) } } + +func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) { + data, err := os.ReadFile("../../../config.example.yml") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, setting string + wantNil bool + wantLength int + }{ + {name: "omitted", wantNil: true}, + {name: "disabled", setting: " versions: []\n"}, + {name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + instance := &Imp{runtime: api.RuntimeConfig{}} + configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1) + if err := instance.decodeConfig([]byte(configured)); err != nil { + t.Fatal(err) + } + versions := instance.config.API.SharedConfig().Versions + if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength { + t.Fatalf("unexpected versions: %#v", versions) + } + if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") { + t.Fatalf("lost version configuration: %#v", versions) + } + }) + } +} diff --git a/backend/product/app/server/internal/server.go b/backend/product/app/server/internal/server.go index a1821ae..2f121b3 100644 --- a/backend/product/app/server/internal/server.go +++ b/backend/product/app/server/internal/server.go @@ -112,7 +112,7 @@ func (i *Imp) Start() error { rootRouter := chi.NewRouter() apiRouter := chi.NewRouter() - if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil { + if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil { i.db.CloseConnection() return err } diff --git a/backend/product/app/urls/domain_provider.go b/backend/product/app/urls/domain_provider.go index ca5eab4..2edab81 100644 --- a/backend/product/app/urls/domain_provider.go +++ b/backend/product/app/urls/domain_provider.go @@ -10,6 +10,7 @@ type DomainProvider interface { GetAPILink(linkElem ...string) (string, error) } +// CreateDomainProvider accepts a resolved host, protocol and installation mount. func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) { return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint) } diff --git a/backend/product/app/urls/internal/domain_provider.go b/backend/product/app/urls/internal/domain_provider.go index 0c1bc70..ab7eceb 100644 --- a/backend/product/app/urls/internal/domain_provider.go +++ b/backend/product/app/urls/internal/domain_provider.go @@ -2,8 +2,8 @@ package domainproviderimp import ( "net/url" - "os" + "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/logging" "go.uber.org/zap" ) @@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) { return url.JoinPath(dp.apiEndpoint, linkElem...) } -func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) { +func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) { + if protocol != "http" && protocol != "https" { + return nil, merrors.InvalidArgument("API protocol must be http or https") + } + parsed, err := url.Parse(protocol + "://" + domain) + if err != nil { + return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin") + } + if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, merrors.InvalidArgument("API domain must be a host with optional port") + } p := new(DomainProviderImp) - p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain) - var err error - apiPath := os.Getenv(apiPointEnv) + p.domain = protocol + "://" + domain if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil { logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath)) return nil, err diff --git a/backend/product/app/urls/internal/domain_provider_test.go b/backend/product/app/urls/internal/domain_provider_test.go new file mode 100644 index 0000000..7d358f9 --- /dev/null +++ b/backend/product/app/urls/internal/domain_provider_test.go @@ -0,0 +1,27 @@ +package domainproviderimp + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestExplicitOriginAndMount(t *testing.T) { + provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2") + require.NoError(t, err) + link, err := provider.GetAPILink("accounts", "me") + require.NoError(t, err) + require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link) + link, err = provider.GetFullLink("signup") + require.NoError(t, err) + require.Equal(t, "https://localhost:8082/signup", link) +} +func TestInvalidOrigin(t *testing.T) { + for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} { + _, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1") + require.Error(t, err, domain) + } + _, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1") + require.Error(t, err) +} diff --git a/backend/product/architecture/versions_test.go b/backend/product/architecture/versions_test.go new file mode 100644 index 0000000..ad5480a --- /dev/null +++ b/backend/product/architecture/versions_test.go @@ -0,0 +1,41 @@ +package architecture + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) { + for _, dir := range []string{"../app/runtime", "../app/http"} { + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, im := range file.Imports { + name, err := strconv.Unquote(im.Path.Value) + if err != nil { + return err + } + if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") { + t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/backend/product/auth/http/authorized/customer_test.go b/backend/product/auth/http/v1/authorized/customer_test.go similarity index 91% rename from backend/product/auth/http/authorized/customer_test.go rename to backend/product/auth/http/v1/authorized/customer_test.go index 979ae33..63de01a 100644 --- a/backend/product/auth/http/authorized/customer_test.go +++ b/backend/product/auth/http/v1/authorized/customer_test.go @@ -9,8 +9,8 @@ import ( "testing" "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" emodel "git.sendico.io/tech/sxchnge/product/auth/model" - "git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/shared/errors" api "git.sendico.io/tech/sxchnge/shared/http" @@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) { 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: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature) + router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature) called := false router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc { called = true diff --git a/backend/product/auth/http/authorized/handler.go b/backend/product/auth/http/v1/authorized/handler.go similarity index 99% rename from backend/product/auth/http/authorized/handler.go rename to backend/product/auth/http/v1/authorized/handler.go index d670d26..a49b8dd 100644 --- a/backend/product/auth/http/authorized/handler.go +++ b/backend/product/auth/http/v1/authorized/handler.go @@ -6,9 +6,9 @@ import ( "net/http" "strings" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/auth/ipguard" emodel "git.sendico.io/tech/sxchnge/product/auth/model" - "git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/sessions/metrics" "git.sendico.io/tech/sxchnge/shared/errors" api "git.sendico.io/tech/sxchnge/shared/http" diff --git a/backend/product/auth/http/authorized/router.go b/backend/product/auth/http/v1/authorized/router.go similarity index 89% rename from backend/product/auth/http/authorized/router.go rename to backend/product/auth/http/v1/authorized/router.go index 17b1495..df9af97 100644 --- a/backend/product/auth/http/authorized/router.go +++ b/backend/product/auth/http/v1/authorized/router.go @@ -4,7 +4,7 @@ import ( "net/http" "git.sendico.io/tech/sxchnge/product/auth/config" - re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint" + re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint" emodel "git.sendico.io/tech/sxchnge/product/auth/model" "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/sessions/metrics" @@ -27,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, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter { +func Install(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)) diff --git a/backend/product/auth/http/authorized/session_test.go b/backend/product/auth/http/v1/authorized/session_test.go similarity index 100% rename from backend/product/auth/http/authorized/session_test.go rename to backend/product/auth/http/v1/authorized/session_test.go diff --git a/backend/product/auth/http/dispatcher.go b/backend/product/auth/http/v1/dispatcher.go similarity index 73% rename from backend/product/auth/http/dispatcher.go rename to backend/product/auth/http/v1/dispatcher.go index 01e383b..dd330dd 100644 --- a/backend/product/auth/http/dispatcher.go +++ b/backend/product/auth/http/v1/dispatcher.go @@ -1,12 +1,10 @@ package router import ( - "os" - "git.sendico.io/tech/sxchnge/product/auth/config" - rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized" - rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public" - "git.sendico.io/tech/sxchnge/product/auth/response" + rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized" + rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/sessions/store" api "git.sendico.io/tech/sxchnge/shared/http" @@ -34,13 +32,15 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin d.protected.PendingAccountHandler(service, endpoint, method, handler) } -func NewDispatcher( +func Install( logger mlogger.Logger, router chi.Router, + endpoint string, db account.DB, rtdb refreshtokens.DB, attempts ratelimits.Store, - config *middleware.Config, + token *middleware.TokenConfig, + signature middleware.Signature, loginRateLimits middleware.LoginRateLimits, flows ...rpublic.Flows, ) *Dispatcher { @@ -49,13 +49,11 @@ func NewDispatcher( } d.logger.Debug("Installing endpoints middleware...") - endpoint := os.Getenv(config.EndPointEnv) - signature := middleware.SignatureConf(config) router.Group(func(r chi.Router) { - d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...) + d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...) }) router.Group(func(r chi.Router) { - d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature) + d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature) }) return d diff --git a/backend/product/auth/http/endpoint/endpoint.go b/backend/product/auth/http/v1/endpoint/endpoint.go similarity index 94% rename from backend/product/auth/http/endpoint/endpoint.go rename to backend/product/auth/http/v1/endpoint/endpoint.go index 8f7642d..d353f51 100644 --- a/backend/product/auth/http/endpoint/endpoint.go +++ b/backend/product/auth/http/v1/endpoint/endpoint.go @@ -7,7 +7,7 @@ import ( "net/http" "git.sendico.io/tech/sxchnge/product/auth/config" - "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/shared/logging" "github.com/go-chi/chi/v5" ) diff --git a/backend/product/auth/http/endpoint/install.go b/backend/product/auth/http/v1/endpoint/install.go similarity index 93% rename from backend/product/auth/http/endpoint/install.go rename to backend/product/auth/http/v1/endpoint/install.go index 2c94d88..f3bdee1 100644 --- a/backend/product/auth/http/endpoint/install.go +++ b/backend/product/auth/http/v1/endpoint/install.go @@ -4,7 +4,7 @@ import ( "net/http" "path" - "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" api "git.sendico.io/tech/sxchnge/shared/http" "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" @@ -33,7 +33,7 @@ func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT { } func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string { - return path.Join(er.apiEndpoint, service, handler) + return path.Join("/", er.apiEndpoint, service, handler) } func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) { diff --git a/backend/product/auth/http/endpoint/token.go b/backend/product/auth/http/v1/endpoint/token.go similarity index 95% rename from backend/product/auth/http/endpoint/token.go rename to backend/product/auth/http/v1/endpoint/token.go index b6df9e8..f0c0c9e 100644 --- a/backend/product/auth/http/endpoint/token.go +++ b/backend/product/auth/http/v1/endpoint/token.go @@ -5,8 +5,8 @@ import ( sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "time" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" emodel "git.sendico.io/tech/sxchnge/product/auth/model" - "git.sendico.io/tech/sxchnge/product/auth/response" "github.com/go-chi/jwtauth/v5" ) diff --git a/backend/product/auth/http/public/login.go b/backend/product/auth/http/v1/public/login.go similarity index 98% rename from backend/product/auth/http/public/login.go rename to backend/product/auth/http/v1/public/login.go index 7f25dce..bf863a9 100644 --- a/backend/product/auth/http/public/login.go +++ b/backend/product/auth/http/v1/public/login.go @@ -9,9 +9,9 @@ import ( "strings" clientauth "git.sendico.io/tech/sxchnge/product/auth/client" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/auth/ipguard" - "git.sendico.io/tech/sxchnge/product/auth/request" - "git.sendico.io/tech/sxchnge/product/auth/response" customermodel "git.sendico.io/tech/sxchnge/product/customers/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "git.sendico.io/tech/sxchnge/shared/errors" diff --git a/backend/product/auth/http/public/login_rate_limit.go b/backend/product/auth/http/v1/public/login_rate_limit.go similarity index 98% rename from backend/product/auth/http/public/login_rate_limit.go rename to backend/product/auth/http/v1/public/login_rate_limit.go index e7520e6..a0ecb2f 100644 --- a/backend/product/auth/http/public/login_rate_limit.go +++ b/backend/product/auth/http/v1/public/login_rate_limit.go @@ -9,8 +9,8 @@ import ( "time" "git.sendico.io/tech/sxchnge/product/auth/config" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" "git.sendico.io/tech/sxchnge/product/auth/ipguard" - "git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/ratelimit/store" diff --git a/backend/product/auth/http/public/login_rate_limit_test.go b/backend/product/auth/http/v1/public/login_rate_limit_test.go similarity index 99% rename from backend/product/auth/http/public/login_rate_limit_test.go rename to backend/product/auth/http/v1/public/login_rate_limit_test.go index 25785c9..54cd0ca 100644 --- a/backend/product/auth/http/public/login_rate_limit_test.go +++ b/backend/product/auth/http/v1/public/login_rate_limit_test.go @@ -13,7 +13,7 @@ import ( "time" "git.sendico.io/tech/sxchnge/product/auth/config" - "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" diff --git a/backend/product/auth/http/public/login_test.go b/backend/product/auth/http/v1/public/login_test.go similarity index 99% rename from backend/product/auth/http/public/login_test.go rename to backend/product/auth/http/v1/public/login_test.go index 1a589d8..cf381ca 100644 --- a/backend/product/auth/http/public/login_test.go +++ b/backend/product/auth/http/v1/public/login_test.go @@ -11,7 +11,7 @@ import ( "testing" "git.sendico.io/tech/sxchnge/product/auth/config" - "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5" @@ -183,7 +183,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) { PrivateKey: []byte("test-secret"), } - router := NewRouter( + router := Install( zap.NewNop(), "/", &testAccountDB{result: account}, diff --git a/backend/product/auth/http/public/login_test_support_test.go b/backend/product/auth/http/v1/public/login_test_support_test.go similarity index 100% rename from backend/product/auth/http/public/login_test_support_test.go rename to backend/product/auth/http/v1/public/login_test_support_test.go diff --git a/backend/product/auth/http/public/logout.go b/backend/product/auth/http/v1/public/logout.go similarity index 97% rename from backend/product/auth/http/public/logout.go rename to backend/product/auth/http/v1/public/logout.go index c4fa2e1..a3c8368 100644 --- a/backend/product/auth/http/public/logout.go +++ b/backend/product/auth/http/v1/public/logout.go @@ -4,7 +4,7 @@ import ( "encoding/json" "net/http" - "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" "git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/metrics" "git.sendico.io/tech/sxchnge/shared/http/response" diff --git a/backend/product/auth/http/public/logout_test.go b/backend/product/auth/http/v1/public/logout_test.go similarity index 100% rename from backend/product/auth/http/public/logout_test.go rename to backend/product/auth/http/v1/public/logout_test.go diff --git a/backend/product/auth/http/public/metrics.go b/backend/product/auth/http/v1/public/metrics.go similarity index 94% rename from backend/product/auth/http/public/metrics.go rename to backend/product/auth/http/v1/public/metrics.go index c2baa27..4be3e7a 100644 --- a/backend/product/auth/http/public/metrics.go +++ b/backend/product/auth/http/v1/public/metrics.go @@ -4,7 +4,7 @@ import ( "net/http" "time" - sresponse "git.sendico.io/tech/sxchnge/product/auth/response" + sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" diff --git a/backend/product/auth/http/public/refresh.go b/backend/product/auth/http/v1/public/refresh.go similarity index 93% rename from backend/product/auth/http/public/refresh.go rename to backend/product/auth/http/v1/public/refresh.go index 53f31a0..3278af2 100644 --- a/backend/product/auth/http/public/refresh.go +++ b/backend/product/auth/http/v1/public/refresh.go @@ -6,8 +6,8 @@ import ( "net/http" "time" - "git.sendico.io/tech/sxchnge/product/auth/request" - "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/service" diff --git a/backend/product/auth/http/public/refresh_test.go b/backend/product/auth/http/v1/public/refresh_test.go similarity index 99% rename from backend/product/auth/http/public/refresh_test.go rename to backend/product/auth/http/v1/public/refresh_test.go index d74b3c4..d78941a 100644 --- a/backend/product/auth/http/public/refresh_test.go +++ b/backend/product/auth/http/v1/public/refresh_test.go @@ -47,7 +47,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter, Algorithm: "HS256", PrivateKey: []byte("browser-refresh-test-secret"), } - return NewRouter( + return Install( zap.NewNop(), "/api/v1", &testAccountDB{result: account}, diff --git a/backend/product/auth/http/public/respond.go b/backend/product/auth/http/v1/public/respond.go similarity index 96% rename from backend/product/auth/http/public/respond.go rename to backend/product/auth/http/v1/public/respond.go index e9d96e8..466f2b8 100644 --- a/backend/product/auth/http/public/respond.go +++ b/backend/product/auth/http/v1/public/respond.go @@ -6,7 +6,7 @@ import ( sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "net/http" - "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens" "git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/logging/fields" diff --git a/backend/product/auth/http/public/rotate.go b/backend/product/auth/http/v1/public/rotate.go similarity index 95% rename from backend/product/auth/http/public/rotate.go rename to backend/product/auth/http/v1/public/rotate.go index a72cbb9..b857680 100644 --- a/backend/product/auth/http/public/rotate.go +++ b/backend/product/auth/http/v1/public/rotate.go @@ -5,7 +5,7 @@ import ( "errors" "net/http" - "git.sendico.io/tech/sxchnge/product/auth/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" "git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" diff --git a/backend/product/auth/http/public/router.go b/backend/product/auth/http/v1/public/router.go similarity index 95% rename from backend/product/auth/http/public/router.go rename to backend/product/auth/http/v1/public/router.go index 47e60b2..0e00462 100644 --- a/backend/product/auth/http/public/router.go +++ b/backend/product/auth/http/v1/public/router.go @@ -5,8 +5,8 @@ import ( "git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/flow" - re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint" - "git.sendico.io/tech/sxchnge/product/auth/response" + re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/twofactor" @@ -41,7 +41,7 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m pr.imp.InstallHandler(service, endpoint, method, handler) } -func NewRouter( +func Install( logger mlogger.Logger, apiEndpoint string, db account.DB, diff --git a/backend/product/auth/http/public/validate.go b/backend/product/auth/http/v1/public/validate.go similarity index 96% rename from backend/product/auth/http/public/validate.go rename to backend/product/auth/http/v1/public/validate.go index b8fab90..dbe5cb6 100644 --- a/backend/product/auth/http/public/validate.go +++ b/backend/product/auth/http/v1/public/validate.go @@ -8,8 +8,8 @@ import ( "net/http" "time" - "git.sendico.io/tech/sxchnge/product/auth/request" - "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/request" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/logging/fields" diff --git a/backend/product/auth/http/public/verification.go b/backend/product/auth/http/v1/public/verification.go similarity index 99% rename from backend/product/auth/http/public/verification.go rename to backend/product/auth/http/v1/public/verification.go index 209f9a9..2128473 100644 --- a/backend/product/auth/http/public/verification.go +++ b/backend/product/auth/http/v1/public/verification.go @@ -11,9 +11,9 @@ import ( "net/http" "time" + sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/auth/ipguard" authmodel "git.sendico.io/tech/sxchnge/product/auth/model" - sresponse "git.sendico.io/tech/sxchnge/product/auth/response" customer "git.sendico.io/tech/sxchnge/product/customers/model" session "git.sendico.io/tech/sxchnge/product/sessions/model" factor "git.sendico.io/tech/sxchnge/product/twofactor/model" diff --git a/backend/product/auth/http/public/verification_integration_test.go b/backend/product/auth/http/v1/public/verification_integration_test.go similarity index 95% rename from backend/product/auth/http/public/verification_integration_test.go rename to backend/product/auth/http/v1/public/verification_integration_test.go index b89ce5a..7604d81 100644 --- a/backend/product/auth/http/public/verification_integration_test.go +++ b/backend/product/auth/http/v1/public/verification_integration_test.go @@ -71,7 +71,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail}) require.NoError(t, err) routes := chi.NewRouter() - NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors}) + Install(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors}) post := func(path string, body any) *httptest.ResponseRecorder { raw, err := json.Marshal(body) require.NoError(t, err) diff --git a/backend/product/auth/request/login.go b/backend/product/auth/http/v1/request/login.go similarity index 100% rename from backend/product/auth/request/login.go rename to backend/product/auth/http/v1/request/login.go diff --git a/backend/product/auth/request/refresh.go b/backend/product/auth/http/v1/request/refresh.go similarity index 100% rename from backend/product/auth/request/refresh.go rename to backend/product/auth/http/v1/request/refresh.go diff --git a/backend/product/auth/request/rotate.go b/backend/product/auth/http/v1/request/rotate.go similarity index 100% rename from backend/product/auth/request/rotate.go rename to backend/product/auth/http/v1/request/rotate.go diff --git a/backend/product/auth/request/validateable.go b/backend/product/auth/http/v1/request/validateable.go similarity index 100% rename from backend/product/auth/request/validateable.go rename to backend/product/auth/http/v1/request/validateable.go diff --git a/backend/product/auth/request/verification.go b/backend/product/auth/http/v1/request/verification.go similarity index 100% rename from backend/product/auth/request/verification.go rename to backend/product/auth/http/v1/request/verification.go diff --git a/backend/product/auth/response/account.go b/backend/product/auth/http/v1/response/account.go similarity index 100% rename from backend/product/auth/response/account.go rename to backend/product/auth/http/v1/response/account.go diff --git a/backend/product/auth/response/authresp.go b/backend/product/auth/http/v1/response/authresp.go similarity index 100% rename from backend/product/auth/response/authresp.go rename to backend/product/auth/http/v1/response/authresp.go diff --git a/backend/product/auth/response/file.go b/backend/product/auth/http/v1/response/file.go similarity index 100% rename from backend/product/auth/response/file.go rename to backend/product/auth/http/v1/response/file.go diff --git a/backend/product/auth/response/login.go b/backend/product/auth/http/v1/response/login.go similarity index 100% rename from backend/product/auth/response/login.go rename to backend/product/auth/http/v1/response/login.go diff --git a/backend/product/auth/response/login_pending.go b/backend/product/auth/http/v1/response/login_pending.go similarity index 100% rename from backend/product/auth/response/login_pending.go rename to backend/product/auth/http/v1/response/login_pending.go diff --git a/backend/product/auth/response/login_test.go b/backend/product/auth/http/v1/response/login_test.go similarity index 100% rename from backend/product/auth/response/login_test.go rename to backend/product/auth/http/v1/response/login_test.go diff --git a/backend/product/auth/response/response.go b/backend/product/auth/http/v1/response/response.go similarity index 100% rename from backend/product/auth/response/response.go rename to backend/product/auth/http/v1/response/response.go diff --git a/backend/product/auth/response/result.go b/backend/product/auth/http/v1/response/result.go similarity index 100% rename from backend/product/auth/response/result.go rename to backend/product/auth/http/v1/response/result.go diff --git a/backend/product/auth/response/token.go b/backend/product/auth/http/v1/response/token.go similarity index 100% rename from backend/product/auth/response/token.go rename to backend/product/auth/http/v1/response/token.go diff --git a/backend/product/auth/response/verification.go b/backend/product/auth/http/v1/response/verification.go similarity index 100% rename from backend/product/auth/response/verification.go rename to backend/product/auth/http/v1/response/verification.go diff --git a/backend/product/auth/http/router.go b/backend/product/auth/http/v1/router.go similarity index 89% rename from backend/product/auth/http/router.go rename to backend/product/auth/http/v1/router.go index 2580c5b..ccfecdb 100644 --- a/backend/product/auth/http/router.go +++ b/backend/product/auth/http/v1/router.go @@ -1,7 +1,7 @@ package router import ( - "git.sendico.io/tech/sxchnge/product/auth/response" + "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" api "git.sendico.io/tech/sxchnge/shared/http" "git.sendico.io/tech/sxchnge/shared/service" ) diff --git a/backend/product/config.example.yml b/backend/product/config.example.yml index b455b11..899eb3b 100644 --- a/backend/product/config.example.yml +++ b/backend/product/config.example.yml @@ -9,6 +9,10 @@ database: uri_env: SXCHNGE_MONGO_URI database: sxchnge api: + # Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs. + # versions: + # - version: v1 + # path: /api/v1 middleware: domain_env: SXCHNGE_DOMAIN api_endpoint_env: SXCHNGE_API_ENDPOINT diff --git a/backend/product/customers/internal/service.go b/backend/product/customers/internal/service.go index cf73553..9d031de 100644 --- a/backend/product/customers/internal/service.go +++ b/backend/product/customers/internal/service.go @@ -243,6 +243,13 @@ func NewAccountService( if err != nil { return nil, err } - return &service{logger: logger, config: config, accountDB: stores.Accounts, - vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil + return FromStores(l, config, stores, dbf.TransactionFactory()) +} + +func FromStores(l mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (*service, error) { + if config == nil || stores == nil || transactions == nil { + return nil, merrors.InvalidArgument("customer dependencies are required") + } + return &service{logger: l.Named("account_service"), config: config, accountDB: stores.Accounts, + vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: transactions}, nil } diff --git a/backend/product/customers/types.go b/backend/product/customers/types.go index fa3b0e7..d885790 100644 --- a/backend/product/customers/types.go +++ b/backend/product/customers/types.go @@ -2,11 +2,13 @@ package accountservice import ( "context" - customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "git.sendico.io/tech/sxchnge/product/auth/config" + authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal" + customermodel "git.sendico.io/tech/sxchnge/product/customers/model" "git.sendico.io/tech/sxchnge/shared/database" + "git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -61,3 +63,8 @@ func NewAccountService( ) (AccountService, error) { return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig) } + +// FromStores composes a customer service using already initialized storage. +func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) { + return accountserviceimp.FromStores(logger, config, stores, transactions) +} diff --git a/backend/product/sessions/browser/browser.go b/backend/product/sessions/browser/browser.go index 29ec15a..9cb7aa1 100644 --- a/backend/product/sessions/browser/browser.go +++ b/backend/product/sessions/browser/browser.go @@ -1,9 +1,9 @@ package browser import ( + "context" "net/http" "net/url" - "os" "path" "strings" "time" @@ -86,8 +86,10 @@ func ValidateCSRF(r *http.Request, allowedOrigins []string) error { func cookiePath(r *http.Request) string { const accountsSegment = "/accounts" - if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" { - return path.Join("/", apiEndpoint, accountsSegment) + if r != nil { + if mount, ok := r.Context().Value(scopeKey{}).(string); ok { + return path.Join(mount, accountsSegment) + } } if r != nil && r.URL != nil { if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 { @@ -119,3 +121,14 @@ func originAllowed(origin string, allowedOrigins []string) bool { } return false } + +type scopeKey struct{} + +// Scope binds cookie creation and deletion to a validated installed API mount. +func Scope(mount string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, mount))) + }) + } +} diff --git a/backend/product/sessions/browser/browser_test.go b/backend/product/sessions/browser/browser_test.go index d376b28..c0f7d74 100644 --- a/backend/product/sessions/browser/browser_test.go +++ b/backend/product/sessions/browser/browser_test.go @@ -31,13 +31,15 @@ func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) { require.Positive(t, cookie.MaxAge) } -func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) { +func TestSetRefreshCookieUsesInstalledScopeOutsideAccountsRouter(t *testing.T) { t.Setenv("API_ENDPOINT", "/gateway/v2/") req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil) req.Header.Set(TransportHeader, TransportCookie) recorder := httptest.NewRecorder() - SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour)) + Scope("/gateway/v2")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + SetRefreshCookie(w, r, "refresh", time.Now().Add(time.Hour)) + })).ServeHTTP(recorder, req) require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path) } diff --git a/backend/product/twofactor/http/router.go b/backend/product/twofactor/http/v1/router.go similarity index 99% rename from backend/product/twofactor/http/router.go rename to backend/product/twofactor/http/v1/router.go index 375ea3e..75f8ab3 100644 --- a/backend/product/twofactor/http/router.go +++ b/backend/product/twofactor/http/v1/router.go @@ -11,8 +11,8 @@ import ( "strconv" "time" + sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/auth/ipguard" - sresponse "git.sendico.io/tech/sxchnge/product/auth/response" customer "git.sendico.io/tech/sxchnge/product/customers/model" "git.sendico.io/tech/sxchnge/product/twofactor" model "git.sendico.io/tech/sxchnge/product/twofactor/model" diff --git a/backend/product/twofactor/http/router_test.go b/backend/product/twofactor/http/v1/router_test.go similarity index 96% rename from backend/product/twofactor/http/router_test.go rename to backend/product/twofactor/http/v1/router_test.go index 1045cbf..c5a61e3 100644 --- a/backend/product/twofactor/http/router_test.go +++ b/backend/product/twofactor/http/v1/router_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - sresponse "git.sendico.io/tech/sxchnge/product/auth/response" + sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" customer "git.sendico.io/tech/sxchnge/product/customers/model" api "git.sendico.io/tech/sxchnge/shared/http" mservice "git.sendico.io/tech/sxchnge/shared/service" diff --git a/docs/api-versions.md b/docs/api-versions.md new file mode 100644 index 0000000..3d473ea --- /dev/null +++ b/docs/api-versions.md @@ -0,0 +1,91 @@ +# Installing product HTTP API versions + +The product executable selects supported implementations in +`backend/product/app/profile/runtime.go`. Each entry maps a version name to an +`api.Installer`; v1 composes the existing authentication and second-factor +contracts. Adding an implementation requires a package and a registry entry, +without changing runtime installation code. + +Select installations in the product YAML: + +```yaml +api: + versions: + - version: v1 + path: /api/v1 + # Existing middleware, storage and notification configuration follows. +``` + +Only v1 ships today. Once a v2 implementation is registered, a second entry with +`version: v2` and `path: /api/v2` serves both versions in the same process. Each +version can be selected once. Unknown/unavailable versions, duplicate versions, +overlapping mounts, noncanonical paths and mounts occupying operational endpoints +fail startup. Paths must be absolute, non-root, without trailing slashes, URL +escapes, queries, fragments or router pattern syntax. Mounts use ASCII letters, +digits, slashes, hyphens, underscores, periods and tildes. + +Omitting `versions` retains compatibility: v1 mounts at the path in the environment +variable named by `api.middleware.api_endpoint_env` (normally +`SXCHNGE_API_ENDPOINT=/api/v1`). An explicit `versions: []` installs no application +routes; process infrastructure and operational endpoints remain available. + +The endpoint environment variable continues to locate the process health and +metrics compatibility endpoints, including when explicit application mounts are +used. With `/api/v1`, these are `/api/v1/health`, `/api/v1/metrics` and `/metrics`. +Keep this value unchanged when adding a version. Health is outside application +middleware; the existing HTTP collector excludes operational traffic. These +operational endpoints are reserved and are never version-owned contracts. + +## Ownership and lifecycle + +Runtime bootstrap opens shared stores once and injects those stores into the +customer service, auth flow and second-factor service. Notification connections +are opened once. Every installer receives the same typed dependencies and its +own router, validated mount, URL provider and logger. Installers register routes +relative to their mount and do not open or close process resources. + +Auth v1 handlers, request DTOs and response mappers live under +`backend/product/auth/http/v1`; factor transport lives under +`backend/product/twofactor/http/v1`. Models, services and storage remain in their +owning features. A future version owns its wire contract and reuses domain +services, so changing its DTOs does not silently change v1. The generic runtime +and HTTP middleware do not import concrete API versions. + +All version routers are built before any application router is attached. An +installer error aborts startup, preserves the cause and closes shared resources. +Bootstrap must either return a cleanup callback for resources it opened or close +them itself before returning an error; it must not do both. The built-in bootstrap +cleans its partial initialization internally. Runtime invokes an acquired cleanup +callback once, including on installation failure or repeated/concurrent shutdown. +The server retains ownership of the shared database factory. + +The removed generic service registry and untyped runtime component lookup have no +product consumers. The typed dependencies and installer registry replace them. +This changes internal Go composition APIs; public v1 JSON and HTTP paths remain +compatible. No database migration is required. + +## Cookies and links + +Runtime binds browser cookie scope to the installed mount using request context. +Set and clear operations use the same `/accounts` path. The existing v1 +path remains `/api/v1/accounts`; `/api/v2` would use `/api/v2/accounts`. Cookie +name, host-only policy, Secure, HttpOnly and SameSite settings are unchanged. +Changing an existing mount requires clients to authenticate at the new path; +cookies at the old path do not migrate automatically. Versions share domain +session state, so account-wide revocation continues to apply across versions. + +`API_ENDPOINT` no longer overrides cookies globally. Direct standalone handler +calls retain their request-path fallback; installed APIs always use explicit +scope. Installers generate API links through `scope.URLs`, which combines the +configured origin and that installation's mount. The origin must use HTTP or +HTTPS with a hostname and optional port. Configured notification activation URLs +remain frontend links and are not rewritten as API links. + +## Validation + +Runtime tests exercise independently behaving versions, shared dependency identity, +disabled routes, invalid mounts, scoped links, cookie set/delete behavior, failed +installation cleanup and repeated shutdown. The existing Mongo-backed startup +test covers the default v1 installation on an empty database. Existing auth and +factor tests moved with their transports and retain their contract assertions. +Run `make test` and `make lint-backend` from the repository root.