Make product API versions independently installable

This commit is contained in:
Stephan D committed 2026-09-13 10:26:06 +02:00
1 parent ce6583f454
commit d04c8b12ab
69 files changed
+755 -404

No files matched your search

+4 -1
View File
@@ -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;
+28 -14
View File
@@ -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"`
}
+2
View File
@@ -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"`
-13
View File
@@ -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)
}
+13 -17
View File
@@ -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()
+26
View File
@@ -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
}
+10 -108
View File
@@ -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
}
+4 -13
View File
@@ -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}}
}
+9 -57
View File
@@ -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" }
+54 -77
View File
@@ -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
}
@@ -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)
+12 -20
View File
@@ -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
}
+58
View File
@@ -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
}
+190
View File
@@ -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)
}
+56 -26
View File
@@ -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
}
@@ -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)
}
})
}
}
@@ -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
}
@@ -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)
}
@@ -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
@@ -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)
}
@@ -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)
}
}
}
@@ -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
@@ -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"
@@ -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))
@@ -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
@@ -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"
)
@@ -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) {
@@ -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"
)
@@ -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"
@@ -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"
@@ -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"
@@ -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},
@@ -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"
@@ -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"
@@ -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"
@@ -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},
@@ -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"
@@ -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"
@@ -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,
@@ -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"
@@ -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"
@@ -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)
@@ -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"
)
+4
View File
@@ -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
@@ -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
}
+8 -1
View File
@@ -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)
}
+16 -3
View File
@@ -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)))
})
}
}
@@ -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)
}
@@ -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"
@@ -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"
+91
View File
@@ -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 `<mount>/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.