From 0153b7f8d70cf372226c867b4d0f7950871ea4e6 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Mon, 31 Aug 2026 12:48:17 +0100 Subject: [PATCH 1/7] refactor(circle): replace untyped ...any DI with typed Dependencies struct Replace the fragile NewService(..., dependencies ...any) variadic any signature with a typed Dependencies struct. This provides compile-time type safety and eliminates the runtime type-switch that was a footgun for future dependencies. - Add Dependencies struct with typed fields for all optional collaborators - Change NewService to accept Dependencies instead of ...any - Update all callers across service tests, handler tests, integration tests, attack vector tests, and main.go Closes #156 --- cmd/api-server/main.go | 6 ++- internal/api/handler/circle_handler_test.go | 46 ++++++++++----------- internal/domain/circle/service.go | 35 +++++++++------- internal/domain/circle/service_test.go | 42 +++++++++---------- tests/integration/circle_lifecycle_test.go | 4 +- tests/production/attack_vectors_test.go | 2 +- 6 files changed, 71 insertions(+), 64 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 337b835..66fa12e 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -156,7 +156,11 @@ func main() { _ = ws.NewRedisBridge(wsHub, redisClient) userSvc := user.NewService(userRepo, circleRepo) - circleSvc := circle.NewService(circleRepo, &moiAdapter{repo: userRepo}, &communityAdapter{repo: communityRepo}, wsBroadcaster, circle.NewTransactor(db)) + circleSvc := circle.NewService(circleRepo, &moiAdapter{repo: userRepo}, circle.Dependencies{ + CommunityChecker: &communityAdapter{repo: communityRepo}, + Broadcaster: wsBroadcaster, + Transactor: circle.NewTransactor(db), + }) // Stellar client used for on-chain verification horizonClient := stellar.NewClient(cfg.Stellar.HorizonURL, cfg.Stellar.SorobanRPCURL, cfg.Stellar.NetworkPassphrase) diff --git a/internal/api/handler/circle_handler_test.go b/internal/api/handler/circle_handler_test.go index 662a693..133c176 100644 --- a/internal/api/handler/circle_handler_test.go +++ b/internal/api/handler/circle_handler_test.go @@ -27,7 +27,7 @@ func TestCircleHandler_CreateCircle_Valid(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) orgID := uuid.New() repo.On("Create", mock.Anything, mock.AnythingOfType("*circle.Circle")).Return(nil) @@ -65,7 +65,7 @@ func TestCircleHandler_CreateCircle_InvalidPayload(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) h := handler.NewCircleHandler(svc, nil, nil, nil) r := gin.New() @@ -97,7 +97,7 @@ func TestCircleHandler_ListCircles_Empty(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) filter := circle.CircleFilter{Page: 1, Limit: 20} repo.On("List", mock.Anything, filter).Return([]circle.Circle{}, nil) @@ -120,7 +120,7 @@ func TestCircleHandler_ListCircles_ServiceError(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) filter := circle.CircleFilter{Page: 1, Limit: 20} repo.On("List", mock.Anything, filter).Return(nil, apperrors.ErrInternal) @@ -141,7 +141,7 @@ func TestCircleHandler_GetCircle_Exists(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() expected := &circle.Circle{ @@ -166,7 +166,7 @@ func TestCircleHandler_GetCircle_NotFound(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() repo.On("FindByID", mock.Anything, cid).Return(nil, circle.ErrCircleNotFound) @@ -187,7 +187,7 @@ func TestCircleHandler_JoinCircle_Success(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() uid := uuid.New() @@ -222,7 +222,7 @@ func TestCircleHandler_JoinCircle_CircleFull(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() uid := uuid.New() @@ -254,7 +254,7 @@ func TestCircleHandler_GetMembers(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() c := &circle.Circle{ID: cid, Name: "Test"} @@ -278,7 +278,7 @@ func TestCircleHandler_CancelCircle_Success(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() orgID := cid @@ -310,7 +310,7 @@ func TestCircleHandler_CancelCircle_NotOrganizer(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() notOrg := uuid.New() @@ -340,7 +340,7 @@ func TestCircleHandler_ListCircles_WithSearch(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) circles := []circle.Circle{ {ID: uuid.New(), Name: "Savings Circle", Status: circle.CircleStatusActive}, @@ -369,7 +369,7 @@ func TestCircleHandler_ListCircles_WithStatusFilter(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) circles := []circle.Circle{ {ID: uuid.New(), Name: "Active Circle", Status: circle.CircleStatusActive}, @@ -396,7 +396,7 @@ func TestCircleHandler_ListCircles_WithTypeFilter(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) circles := []circle.Circle{ {ID: uuid.New(), Name: "Public Circle", Status: circle.CircleStatusPending, CircleType: circle.CircleTypePublic}, @@ -422,7 +422,7 @@ func TestCircleHandler_ListCircles_WithPagination(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) circles := []circle.Circle{ {ID: uuid.New(), Name: "Circle 3"}, @@ -453,7 +453,7 @@ func TestCircleHandler_ListCircles_DefaultPagination(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) filter := circle.CircleFilter{Page: 1, Limit: 20} repo.On("List", mock.Anything, filter).Return([]circle.Circle{}, nil) @@ -477,7 +477,7 @@ func TestCircleHandler_ListCircles_CombinedFilters(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) circles := []circle.Circle{ {ID: uuid.New(), Name: "My Savings Circle", Status: circle.CircleStatusActive, CircleType: circle.CircleTypePublic}, @@ -512,7 +512,7 @@ func TestCircleHandler_ListCircles_WithCommunityFilter(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) communityID := uuid.New() circles := []circle.Circle{ @@ -539,7 +539,7 @@ func TestCircleHandler_ListCircles_PageSizeExceedsMax(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) filter := circle.CircleFilter{Page: 1, Limit: 100} repo.On("List", mock.Anything, filter).Return([]circle.Circle{}, nil) @@ -562,7 +562,7 @@ func TestCircleHandler_ListCircles_NilSliceReturned(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) filter := circle.CircleFilter{Page: 1, Limit: 20} repo.On("List", mock.Anything, filter).Return([]circle.Circle{}, nil) @@ -585,7 +585,7 @@ func TestCircleHandler_Dispute_Success(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() uid := uuid.New() @@ -626,7 +626,7 @@ func TestCircleHandler_Vote_Success(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() voterID := uuid.New() @@ -671,7 +671,7 @@ func TestCircleHandler_AuctionBid_Success(t *testing.T) { gin.SetMode(gin.TestMode) repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) cid := uuid.New() bidderID := uuid.New() diff --git a/internal/domain/circle/service.go b/internal/domain/circle/service.go index 5228341..a317dc7 100644 --- a/internal/domain/circle/service.go +++ b/internal/domain/circle/service.go @@ -100,6 +100,16 @@ type NotificationSender interface { Create(ctx context.Context, input notification.CreateInput) (*notification.Notification, error) } +// Dependencies holds the optional collaborators injected into the circle +// service. All fields are optional; nil values are safely handled at call sites. +type Dependencies struct { + CommunityChecker CommunityMembershipChecker + Broadcaster Broadcaster + Transactor Transactor + AuditLogger AuditLogger + NotificationSvc NotificationSender +} + type circleService struct { repo Repository userRepo UserMOIFetcher @@ -110,23 +120,16 @@ type circleService struct { notificationSvc NotificationSender } -func NewService(repo Repository, userRepo UserMOIFetcher, dependencies ...any) Service { - service := &circleService{repo: repo, userRepo: userRepo} - for _, dependency := range dependencies { - switch value := dependency.(type) { - case CommunityMembershipChecker: - service.communityChecker = value - case Broadcaster: - service.broadcaster = value - case Transactor: - service.tx = value - case AuditLogger: - service.auditRepo = value - case NotificationSender: - service.notificationSvc = value - } +func NewService(repo Repository, userRepo UserMOIFetcher, deps Dependencies) Service { + return &circleService{ + repo: repo, + userRepo: userRepo, + communityChecker: deps.CommunityChecker, + broadcaster: deps.Broadcaster, + tx: deps.Transactor, + auditRepo: deps.AuditLogger, + notificationSvc: deps.NotificationSvc, } - return service } type circleTransactor struct { diff --git a/internal/domain/circle/service_test.go b/internal/domain/circle/service_test.go index 75966cb..34f93ab 100644 --- a/internal/domain/circle/service_test.go +++ b/internal/domain/circle/service_test.go @@ -17,7 +17,7 @@ import ( func TestCircleService_Create_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() orgID := uuid.New().String() @@ -45,7 +45,7 @@ func TestCircleService_Create_Success(t *testing.T) { func TestCircleService_Create_WithDescription(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() orgID := uuid.New().String() @@ -72,7 +72,7 @@ func TestCircleService_Create_WithDescription(t *testing.T) { func TestCircleService_Create_TooFewMembers(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() orgID := uuid.New().String() @@ -94,7 +94,7 @@ func TestCircleService_Create_TooFewMembers(t *testing.T) { func TestCircleService_Create_MemberCreationFails(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() orgID := uuid.New().String() @@ -119,7 +119,7 @@ func TestCircleService_Create_MemberCreationFails(t *testing.T) { func TestCircleService_Join_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New().String() uid := uuid.New().String() @@ -141,7 +141,7 @@ func TestCircleService_Join_Success(t *testing.T) { func TestCircleService_Join_Full(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New().String() uid := uuid.New().String() @@ -162,7 +162,7 @@ func TestCircleService_Join_Full(t *testing.T) { func TestCircleService_Join_AlreadyMember(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New().String() uid := uuid.New().String() @@ -188,7 +188,7 @@ func TestCircleService_Join_AlreadyMember(t *testing.T) { func TestCircleService_Join_PrivateWithoutInvite(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New().String() uid := uuid.New().String() @@ -208,7 +208,7 @@ func TestCircleService_Join_PrivateWithoutInvite(t *testing.T) { func TestCircleService_Cancel_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() orgID := cid @@ -230,7 +230,7 @@ func TestCircleService_Cancel_Success(t *testing.T) { func TestCircleService_Cancel_NotOrganizer(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() orgID := uuid.New() @@ -251,7 +251,7 @@ func TestCircleService_Cancel_NotOrganizer(t *testing.T) { func TestCircleService_Exit_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() uid := uuid.New() @@ -277,7 +277,7 @@ func TestCircleService_Exit_Success(t *testing.T) { func TestCircleService_Exit_Organizer(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() orgID := cid @@ -297,7 +297,7 @@ func TestCircleService_Exit_Organizer(t *testing.T) { func TestCircleService_GetMembers_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() @@ -317,7 +317,7 @@ func TestCircleService_GetMembers_Success(t *testing.T) { func TestCircleService_List_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() now := time.Now() @@ -339,7 +339,7 @@ func TestCircleService_List_Success(t *testing.T) { func TestCircleService_Get_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() @@ -358,7 +358,7 @@ func TestCircleService_Get_Success(t *testing.T) { func TestCircleService_Get_NotFound(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() @@ -372,7 +372,7 @@ func TestCircleService_Get_NotFound(t *testing.T) { func TestCircleService_ProcessMissedContributions(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() @@ -411,7 +411,7 @@ func TestCircleService_ProcessMissedContributions(t *testing.T) { func TestCircleService_RaiseDispute_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() uid := uuid.New() @@ -438,7 +438,7 @@ func TestCircleService_RaiseDispute_Success(t *testing.T) { func TestCircleService_RaiseDispute_NotMember(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() uid := uuid.New() @@ -458,7 +458,7 @@ func TestCircleService_RaiseDispute_NotMember(t *testing.T) { func TestCircleService_CastVote_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() voterID := uuid.New() @@ -490,7 +490,7 @@ func TestCircleService_CastVote_Success(t *testing.T) { func TestCircleService_SubmitAuctionBid_Success(t *testing.T) { repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() cid := uuid.New() bidderID := uuid.New() diff --git a/tests/integration/circle_lifecycle_test.go b/tests/integration/circle_lifecycle_test.go index c6d1c51..d4ad040 100644 --- a/tests/integration/circle_lifecycle_test.go +++ b/tests/integration/circle_lifecycle_test.go @@ -25,7 +25,7 @@ func TestCircleLifecycle(t *testing.T) { payoutRepo := new(payoutMocks.Repository) userSvc := user.NewService(userRepo, nil) - circleSvc := circle.NewService(circleRepo, nil) + circleSvc := circle.NewService(circleRepo, nil, circle.Dependencies{}) contribSvc := contribution.NewService(contribRepo, nil, nil, nil, "") payoutSvc := payout.NewService(payoutRepo, nil, nil) @@ -140,7 +140,7 @@ func TestCircleLifecycle(t *testing.T) { func TestCircleLifecycle_FullCircle(t *testing.T) { circleRepo := new(circleMocks.Repository) - circleSvc := circle.NewService(circleRepo, nil) + circleSvc := circle.NewService(circleRepo, nil, circle.Dependencies{}) org := helpers.NewTestUser("GORGFULL1234567890ABCDEF1234567890ABCDEF") m1 := helpers.NewTestUser("GM1FULL1234567890ABCDEF1234567890ABCDEF") diff --git a/tests/production/attack_vectors_test.go b/tests/production/attack_vectors_test.go index ab4591b..59f3c27 100644 --- a/tests/production/attack_vectors_test.go +++ b/tests/production/attack_vectors_test.go @@ -1241,7 +1241,7 @@ func TestCircle_TierBasedEnforcement(t *testing.T) { // This test validates the error variable exists and the model supports // the field, which is enforced on-chain via Soroban host functions. repo := new(circleMocks.Repository) - svc := circle.NewService(repo, nil) + svc := circle.NewService(repo, nil, circle.Dependencies{}) ctx := context.Background() // Simulate a circle with a high MinMoiScore requirement From 75a26ffe73f385999cefac1c5ef5a469ce550885 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Mon, 31 Aug 2026 12:48:49 +0100 Subject: [PATCH 2/7] refactor(stellar): add tx signing, payment, and balance helpers Extract Stellar transaction signing (SignXDR), payment building/submission (BuildPaymentTx), and balance queries (GetBalance) into pkg/stellar helpers. This is part of splitting wallet/service.go to keep it focused on orchestration and security policy. Refs #158 --- pkg/stellar/balance.go | 32 ++++++++++++ pkg/stellar/tx.go | 108 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 pkg/stellar/balance.go create mode 100644 pkg/stellar/tx.go diff --git a/pkg/stellar/balance.go b/pkg/stellar/balance.go new file mode 100644 index 0000000..9b80d97 --- /dev/null +++ b/pkg/stellar/balance.go @@ -0,0 +1,32 @@ +package stellar + +import ( + "fmt" + + "github.com/stellar/go/clients/horizonclient" +) + +// AccountBalance holds parsed XLM and USDC balances for a Stellar account. +type AccountBalance struct { + XLM string + USDC string +} + +// GetBalance fetches the XLM and USDC balance for a given Stellar public key. +// usdcIssuer is the Stellar address of the USDC asset issuer to match against. +func GetBalance(horizon *horizonclient.Client, publicKey, usdcIssuer string) (*AccountBalance, error) { + account, err := horizon.AccountDetail(horizonclient.AccountRequest{AccountID: publicKey}) + if err != nil { + return nil, fmt.Errorf("fetching account from horizon: %w", err) + } + + bal := &AccountBalance{XLM: "0.0000", USDC: "0.0000"} + for _, b := range account.Balances { + if b.Asset.Type == "native" { + bal.XLM = b.Balance + } else if b.Asset.Code == "USDC" && b.Asset.Issuer == usdcIssuer { + bal.USDC = b.Balance + } + } + return bal, nil +} diff --git a/pkg/stellar/tx.go b/pkg/stellar/tx.go new file mode 100644 index 0000000..1b1967a --- /dev/null +++ b/pkg/stellar/tx.go @@ -0,0 +1,108 @@ +package stellar + +import ( + "fmt" + "time" + + "github.com/stellar/go/clients/horizonclient" + "github.com/stellar/go/keypair" + "github.com/stellar/go/txnbuild" +) + +// SignXDR signs an existing XDR transaction envelope with the given keypair +// and returns the base64-encoded signed envelope. +func SignXDR(xdr string, networkPassphrase string, kp *keypair.Full) (string, error) { + genericTx, err := txnbuild.TransactionFromXDR(xdr) + if err != nil { + return "", fmt.Errorf("parsing transaction XDR: %w", err) + } + + tx, ok := genericTx.Transaction() + if !ok { + return "", fmt.Errorf("unsupported transaction type (expected a regular Transaction, not FeeBump)") + } + + tx, err = tx.Sign(networkPassphrase, kp) + if err != nil { + return "", fmt.Errorf("signing transaction: %w", err) + } + + signedXDR, err := tx.Base64() + if err != nil { + return "", fmt.Errorf("encoding signed XDR: %w", err) + } + + return signedXDR, nil +} + +// PaymentParams holds the parameters for building a Stellar payment transaction. +type PaymentParams struct { + Destination string + AssetCode string // "XLM" for native asset, otherwise the asset code (e.g. "USDC") + AssetIssuer string // issuer for non-native assets + Amount float64 + Memo string + NetworkPassphrase string +} + +// BuildPaymentTx builds, signs, and submits a Stellar payment transaction. +// It loads the source account from Horizon, constructs the payment operation, +// signs it, and submits it. Returns the transaction hash on success. +func BuildPaymentTx( + horizon *horizonclient.Client, + kp *keypair.Full, + params PaymentParams, +) (string, error) { + account, err := horizon.AccountDetail(horizonclient.AccountRequest{AccountID: kp.Address()}) + if err != nil { + return "", fmt.Errorf("loading account: %w", err) + } + + var op txnbuild.Operation + if params.AssetCode == "XLM" || params.AssetCode == "" { + op = &txnbuild.Payment{ + Destination: params.Destination, + Amount: fmt.Sprintf("%.7f", params.Amount), + Asset: txnbuild.NativeAsset{}, + } + } else { + op = &txnbuild.Payment{ + Destination: params.Destination, + Amount: fmt.Sprintf("%.7f", params.Amount), + Asset: txnbuild.CreditAsset{Code: params.AssetCode, Issuer: params.AssetIssuer}, + } + } + + txParams := txnbuild.TransactionParams{ + SourceAccount: &account, + IncrementSequenceNum: true, + Operations: []txnbuild.Operation{op}, + BaseFee: txnbuild.MinBaseFee, + Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimebounds(0, time.Now().Unix()+600)}, + } + if params.Memo != "" { + txParams.Memo = txnbuild.MemoText(params.Memo) + } + + tx, err := txnbuild.NewTransaction(txParams) + if err != nil { + return "", fmt.Errorf("building tx: %w", err) + } + + tx, err = tx.Sign(params.NetworkPassphrase, kp) + if err != nil { + return "", fmt.Errorf("signing tx: %w", err) + } + + txe, err := tx.Base64() + if err != nil { + return "", fmt.Errorf("encoding tx: %w", err) + } + + resp, err := horizon.SubmitTransactionXDR(txe) + if err != nil { + return "", fmt.Errorf("submitting tx: %w", err) + } + + return resp.Hash, nil +} From 81bdf0f0107f03993c0afb31bec2d81ea6529ef6 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Mon, 31 Aug 2026 12:49:12 +0100 Subject: [PATCH 3/7] refactor(auth): split service into jwt and nonce sub-services - Created internal/domain/auth/jwt for JWT generation and validation - Created internal/domain/auth/nonce for nonce generation and signature verification - Consolidated duplicate JWT signers into parameterized GenerateToken - Reuse Stellar SDK keypair/StrKey instead of hand-rolled CRC16 Closes #157 --- internal/domain/auth/jwt/service.go | 96 +++++++++++++++++++ internal/domain/auth/nonce/service.go | 129 ++++++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 internal/domain/auth/jwt/service.go create mode 100644 internal/domain/auth/nonce/service.go diff --git a/internal/domain/auth/jwt/service.go b/internal/domain/auth/jwt/service.go new file mode 100644 index 0000000..15e3d1d --- /dev/null +++ b/internal/domain/auth/jwt/service.go @@ -0,0 +1,96 @@ +package jwt + +import ( + "context" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Service interface { + GenerateToken(ctx context.Context, userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) + ValidateToken(ctx context.Context, tokenString string) (*Claims, error) +} + +type Claims struct { + UserID string `json:"sub"` + Wallet string `json:"wallet"` + Role string `json:"role"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` +} + +type service struct { + signingKey any + signingMethod jwt.SigningMethod + verifyingKey any + verifyingAlg string +} + +func NewService(signingKey any, signingMethod jwt.SigningMethod, verifyingKey any, verifyingAlg string) Service { + return &service{ + signingKey: signingKey, + signingMethod: signingMethod, + verifyingKey: verifyingKey, + verifyingAlg: verifyingAlg, + } +} + +func (s *service) GenerateToken(ctx context.Context, userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) { + now := time.Now().UTC() + claims := jwt.MapClaims{ + "sub": userID.String(), + "wallet": walletAddress, + "role": role, + "iat": now.Unix(), + "exp": now.Add(ttl).Unix(), + } + + token := jwt.NewWithClaims(s.signingMethod, claims) + signed, err := token.SignedString(s.signingKey) + if err != nil { + return "", err + } + return signed, nil +} + +func (s *service) ValidateToken(ctx context.Context, tokenString string) (*Claims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if token.Method.Alg() != s.verifyingAlg { + return nil, ErrInvalidSigningMethod + } + return s.verifyingKey, nil + }, jwt.WithValidMethods([]string{s.verifyingAlg})) + if err != nil { + return nil, ErrInvalidToken + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + userID, _ := claims["sub"].(string) + wallet, _ := claims["wallet"].(string) + role, _ := claims["role"].(string) + return &Claims{ + UserID: userID, + Wallet: wallet, + Role: role, + IssuedAt: int64(claims["iat"].(float64)), + ExpiresAt: int64(claims["exp"].(float64)), + }, nil + } + + return nil, ErrInvalidToken +} + +var ( + ErrInvalidSigningMethod = &jwtError{"invalid signing method"} + ErrInvalidToken = &jwtError{"invalid token"} +) + +type jwtError struct { + msg string +} + +func (e *jwtError) Error() string { + return e.msg +} \ No newline at end of file diff --git a/internal/domain/auth/nonce/service.go b/internal/domain/auth/nonce/service.go new file mode 100644 index 0000000..c243080 --- /dev/null +++ b/internal/domain/auth/nonce/service.go @@ -0,0 +1,129 @@ +package nonce + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stellar/go/strkey" + + "github.com/moistello/backend/pkg/apperrors" +) + +type Service interface { + Generate(ctx context.Context, walletAddress string) (*Nonce, error) + Verify(ctx context.Context, walletAddress, signature string) (bool, error) +} + +type Nonce struct { + WalletAddress string + Nonce string + ExpiresAt time.Time +} + +type service struct { + redis *redis.Client + nonceTTL time.Duration +} + +func NewService(redisClient *redis.Client, nonceTTL time.Duration) Service { + return &service{ + redis: redisClient, + nonceTTL: nonceTTL, + } +} + +func (s *service) Generate(ctx context.Context, walletAddress string) (*Nonce, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("generating random nonce: %w", err) + } + nonceStr := hex.EncodeToString(b) + + // Store nonce with creation timestamp for clock skew tolerance + now := time.Now().Unix() + storedValue := fmt.Sprintf("%s:%d", nonceStr, now) + key := fmt.Sprintf("nonce:%s", walletAddress) + + // Add 30s clock skew tolerance to the TTL + ttl := s.nonceTTL + 30*time.Second + if err := s.redis.Set(ctx, key, storedValue, ttl).Err(); err != nil { + return nil, fmt.Errorf("storing nonce in redis: %w", err) + } + + return &Nonce{ + WalletAddress: walletAddress, + Nonce: nonceStr, + ExpiresAt: time.Now().UTC().Add(s.nonceTTL), + }, nil +} + +func (s *service) Verify(ctx context.Context, walletAddress, signature string) (bool, error) { + key := fmt.Sprintf("nonce:%s", walletAddress) + stored, err := s.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return false, apperrors.ErrNonceExpired + } + return false, fmt.Errorf("retrieving nonce from redis: %w", err) + } + + // Delete nonce immediately to prevent any replay + if err := s.redis.Del(ctx, key).Err(); err != nil { + // Log but don't fail - expiry fallback will handle cleanup + if expireErr := s.redis.Expire(ctx, key, 1*time.Second).Err(); expireErr != nil { + // Nonce will expire naturally + } + } + + // Parse nonce value and creation timestamp + parts := strings.SplitN(stored, ":", 2) + if len(parts) != 2 { + return false, fmt.Errorf("invalid nonce format") + } + nonceStr := parts[0] + createdAt, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return false, fmt.Errorf("invalid nonce timestamp: %w", err) + } + + // Check expiry with 30-second clock skew tolerance + now := time.Now().Unix() + skewTolerance := int64(30) + if now > createdAt+int64(s.nonceTTL.Seconds())+skewTolerance { + return false, apperrors.ErrNonceExpired + } + if now < createdAt-skewTolerance { + return false, fmt.Errorf("nonce from the future — clock skew detected") + } + + sigBytes, err := hex.DecodeString(signature) + if err != nil { + return false, fmt.Errorf("decoding signature hex: %w", err) + } + + publicKey, err := decodeStellarPublicKey(walletAddress) + if err != nil { + return false, fmt.Errorf("decoding public key: %w", err) + } + + message := sha256.Sum256([]byte(nonceStr)) + valid := ed25519.Verify(publicKey, message[:], sigBytes) + + return valid, nil +} + +func decodeStellarPublicKey(address string) (ed25519.PublicKey, error) { + raw, err := strkey.Decode(strkey.VersionByteAccountID, address) + if err != nil { + return nil, fmt.Errorf("decoding stellar address: %w", err) + } + return ed25519.PublicKey(raw), nil +} \ No newline at end of file From 6c4b0fae202241ee1a4479833d31f0f616572d80 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Mon, 31 Aug 2026 13:02:52 +0100 Subject: [PATCH 4/7] refactor(auth): move session and password logic into sub-services - Moved CreateSession/ValidateSession/RefreshToken/List/Revoke logic to session sub-service - Moved Argon2id hashing to password sub-service - auth.Service now composes nonce, session, and jwt sub-services - Preserves existing Service interface for handler backward compatibility Closes #157 --- internal/domain/auth/jwt/service.go | 18 +- internal/domain/auth/model.go | 7 + internal/domain/auth/nonce/service.go | 2 +- internal/domain/auth/password/service.go | 73 ++++ internal/domain/auth/service.go | 487 ++++------------------- internal/domain/auth/session/service.go | 288 ++++++++++++++ 6 files changed, 447 insertions(+), 428 deletions(-) create mode 100644 internal/domain/auth/password/service.go create mode 100644 internal/domain/auth/session/service.go diff --git a/internal/domain/auth/jwt/service.go b/internal/domain/auth/jwt/service.go index 15e3d1d..e0c357a 100644 --- a/internal/domain/auth/jwt/service.go +++ b/internal/domain/auth/jwt/service.go @@ -14,11 +14,11 @@ type Service interface { } type Claims struct { - UserID string `json:"sub"` - Wallet string `json:"wallet"` - Role string `json:"role"` - IssuedAt int64 `json:"iat"` - ExpiresAt int64 `json:"exp"` + UserID string `json:"sub"` + Wallet string `json:"wallet"` + Role string `json:"role"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` } type service struct { @@ -70,12 +70,14 @@ func (s *service) ValidateToken(ctx context.Context, tokenString string) (*Claim userID, _ := claims["sub"].(string) wallet, _ := claims["wallet"].(string) role, _ := claims["role"].(string) + iat, _ := claims["iat"].(float64) + exp, _ := claims["exp"].(float64) return &Claims{ UserID: userID, Wallet: wallet, Role: role, - IssuedAt: int64(claims["iat"].(float64)), - ExpiresAt: int64(claims["exp"].(float64)), + IssuedAt: int64(iat), + ExpiresAt: int64(exp), }, nil } @@ -93,4 +95,4 @@ type jwtError struct { func (e *jwtError) Error() string { return e.msg -} \ No newline at end of file +} diff --git a/internal/domain/auth/model.go b/internal/domain/auth/model.go index b59ad67..fd22ac0 100644 --- a/internal/domain/auth/model.go +++ b/internal/domain/auth/model.go @@ -31,3 +31,10 @@ type TokenPair struct { RefreshToken string `json:"refreshToken"` CSRFToken string `json:"csrfToken,omitempty"` } + +type SessionInfo struct { + ID string `json:"id"` + DeviceInfo string `json:"deviceInfo"` + LastActive string `json:"lastActive"` + IsCurrent bool `json:"isCurrent"` +} diff --git a/internal/domain/auth/nonce/service.go b/internal/domain/auth/nonce/service.go index c243080..f8b99f1 100644 --- a/internal/domain/auth/nonce/service.go +++ b/internal/domain/auth/nonce/service.go @@ -126,4 +126,4 @@ func decodeStellarPublicKey(address string) (ed25519.PublicKey, error) { return nil, fmt.Errorf("decoding stellar address: %w", err) } return ed25519.PublicKey(raw), nil -} \ No newline at end of file +} diff --git a/internal/domain/auth/password/service.go b/internal/domain/auth/password/service.go new file mode 100644 index 0000000..62a06ce --- /dev/null +++ b/internal/domain/auth/password/service.go @@ -0,0 +1,73 @@ +package password + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 3 + argonMemory = 64 * 1024 // 64 MB + argonThreads = 4 + argonKeyLen = 32 +) + +// HashPassword hashes a plaintext password using Argon2id with a random salt. +// Returns the encoded hash in the format: $argon2id$v=19$m=65536,t=3,p=4$$ +func HashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generating salt: %w", err) + } + + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + buf := &strings.Builder{} + buf.WriteString("$argon2id$v=19$m=65536,t=3,p=4$") + buf.WriteString(base64Encode(salt)) + buf.WriteByte('$') + buf.WriteString(base64Encode(hash)) + return buf.String(), nil +} + +// VerifyPassword checks a plaintext password against an Argon2id encoded hash. +func VerifyPassword(password, encodedHash string) bool { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + + salt, err := base64Decode(parts[4]) + if err != nil { + return false + } + + expected, err := base64Decode(parts[5]) + if err != nil { + return false + } + + computed := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + return subtle.ConstantTimeCompare(computed, expected) == 1 +} + +func base64Encode(data []byte) string { + return strings.TrimRight(base64.StdEncoding.EncodeToString(data), "=") +} + +func base64Decode(s string) ([]byte, error) { + // Add padding + switch len(s) % 4 { + case 2: + s += "==" + case 3: + s += "=" + } + return base64.StdEncoding.DecodeString(s) +} diff --git a/internal/domain/auth/service.go b/internal/domain/auth/service.go index ba353ce..40ddf06 100644 --- a/internal/domain/auth/service.go +++ b/internal/domain/auth/service.go @@ -2,25 +2,17 @@ package auth import ( "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/hex" "fmt" - "strconv" "strings" "time" - "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/redis/go-redis/v9" - "github.com/rs/zerolog/log" - "github.com/stellar/go/strkey" - "golang.org/x/crypto/argon2" - "github.com/moistello/backend/pkg/apperrors" + "github.com/moistello/backend/internal/domain/auth/jwt" + "github.com/moistello/backend/internal/domain/auth/nonce" + "github.com/moistello/backend/internal/domain/auth/password" + "github.com/moistello/backend/internal/domain/auth/session" ) type Service interface { @@ -38,14 +30,10 @@ type Service interface { } type authService struct { - redis *redis.Client - nonceTTL time.Duration - accessTTL time.Duration - refreshTTL time.Duration - signingKey any - signingMethod jwt.SigningMethod - verifyingKey any - verifyingMethod jwt.SigningMethod + nonceService nonce.Service + sessionService session.Service + jwtService jwt.Service + accessTTL time.Duration } func NewService(redisClient *redis.Client, nonceTTL, accessTTL, refreshTTL time.Duration, jwtPrivateKeyPEM, jwtPublicKeyPEM string) (Service, error) { @@ -67,457 +55,118 @@ func NewService(redisClient *redis.Client, nonceTTL, accessTTL, refreshTTL time. return nil, fmt.Errorf("JWT key pair algorithm mismatch: private=%s public=%s", signingMethod.Alg(), verifyingMethod.Alg()) } + nonceSvc := nonce.NewService(redisClient, nonceTTL) + jwtSvc := jwt.NewService(signingKey, signingMethod, verifyingKey, verifyingMethod.Alg()) + sessionSvc := session.NewService(redisClient, refreshTTL, jwtSvc) + return &authService{ - redis: redisClient, - nonceTTL: nonceTTL, - accessTTL: accessTTL, - refreshTTL: refreshTTL, - signingKey: signingKey, - signingMethod: signingMethod, - verifyingKey: verifyingKey, - verifyingMethod: verifyingMethod, + nonceService: nonceSvc, + sessionService: sessionSvc, + jwtService: jwtSvc, + accessTTL: accessTTL, }, nil } func (s *authService) GenerateNonce(ctx context.Context, walletAddress string) (*Nonce, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return nil, fmt.Errorf("generating random nonce: %w", err) - } - nonceStr := hex.EncodeToString(b) - - // Store nonce with creation timestamp for clock skew tolerance - now := time.Now().Unix() - storedValue := fmt.Sprintf("%s:%d", nonceStr, now) - key := fmt.Sprintf("nonce:%s", walletAddress) - - // Add 30s clock skew tolerance to the TTL - ttl := s.nonceTTL + 30*time.Second - if err := s.redis.Set(ctx, key, storedValue, ttl).Err(); err != nil { - return nil, fmt.Errorf("storing nonce in redis: %w", err) + n, err := s.nonceService.Generate(ctx, walletAddress) + if err != nil { + return nil, err } - return &Nonce{ - WalletAddress: walletAddress, - Nonce: nonceStr, - ExpiresAt: time.Now().UTC().Add(s.nonceTTL), + WalletAddress: n.WalletAddress, + Nonce: n.Nonce, + ExpiresAt: n.ExpiresAt, }, nil } func (s *authService) VerifySignature(ctx context.Context, walletAddress, signature string) (bool, error) { - key := fmt.Sprintf("nonce:%s", walletAddress) - stored, err := s.redis.Get(ctx, key).Result() - if err != nil { - if err == redis.Nil { - return false, apperrors.ErrNonceExpired - } - return false, fmt.Errorf("retrieving nonce from redis: %w", err) - } - - // Delete nonce immediately to prevent any replay - if err := s.redis.Del(ctx, key).Err(); err != nil { - log.Error().Err(err).Str("wallet", walletAddress).Msg("failed to delete nonce from redis — attempting expiry fallback") - if expireErr := s.redis.Expire(ctx, key, 1*time.Second).Err(); expireErr != nil { - log.Error().Err(expireErr).Str("wallet", walletAddress).Msg("nonce expiry fallback also failed") - } - } - - // Parse nonce value and creation timestamp - parts := strings.SplitN(stored, ":", 2) - if len(parts) != 2 { - return false, fmt.Errorf("invalid nonce format") - } - nonceStr := parts[0] - createdAt, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - return false, fmt.Errorf("invalid nonce timestamp: %w", err) - } - - // Check expiry with 30-second clock skew tolerance - now := time.Now().Unix() - skewTolerance := int64(30) - if now > createdAt+int64(s.nonceTTL.Seconds())+skewTolerance { - return false, apperrors.ErrNonceExpired - } - if now < createdAt-skewTolerance { - return false, fmt.Errorf("nonce from the future — clock skew detected") - } - - sigBytes, err := hex.DecodeString(signature) - if err != nil { - return false, fmt.Errorf("decoding signature hex: %w", err) - } - - publicKey, err := decodeStellarPublicKey(walletAddress) - if err != nil { - return false, fmt.Errorf("decoding public key: %w", err) - } - - message := sha256.Sum256([]byte(nonceStr)) - valid := ed25519.Verify(publicKey, message[:], sigBytes) - - return valid, nil + return s.nonceService.Verify(ctx, walletAddress, signature) } func (s *authService) CreateSession(ctx context.Context, userID uuid.UUID, role string, sessionTTL time.Duration, deviceInfo string) (*TokenPair, error) { - if role == "" { - role = "user" - } - accessToken, err := s.GenerateJWTWithTTL(userID, "", role, sessionTTL) + pair, err := s.sessionService.Create(ctx, userID, role, sessionTTL, deviceInfo) if err != nil { - return nil, fmt.Errorf("generating access token: %w", err) - } - - refreshBytes := make([]byte, 64) - if _, err := rand.Read(refreshBytes); err != nil { - return nil, fmt.Errorf("generating refresh token: %w", err) + return nil, err } - refreshToken := hex.EncodeToString(refreshBytes) - tokenHash := sha256Hash(refreshToken) - - csrfBytes := make([]byte, 32) - if _, err := rand.Read(csrfBytes); err != nil { - return nil, fmt.Errorf("generating CSRF token: %w", err) - } - csrfToken := hex.EncodeToString(csrfBytes) - - userIDStr := userID.String() - - sessionData := fmt.Sprintf("%s|%s|%d|%s", userIDStr, deviceInfo, time.Now().Unix(), role) - sessionKey := fmt.Sprintf("session:%s", tokenHash) - csrfKey := fmt.Sprintf("csrf:%x", sha256.Sum256([]byte(accessToken))) - userSessionsKey := fmt.Sprintf("user:sessions:%s", userIDStr) - - pipe := s.redis.TxPipeline() - pipe.Set(ctx, sessionKey, sessionData, s.refreshTTL) - pipe.Set(ctx, csrfKey, csrfToken, sessionTTL) - pipe.SAdd(ctx, userSessionsKey, tokenHash) - pipe.Expire(ctx, userSessionsKey, sessionTTL) - - if _, err := pipe.Exec(ctx); err != nil { - // Rollback partial writes on failure to avoid leaving orphan state - _ = s.redis.Del(ctx, sessionKey, csrfKey).Err() - _ = s.redis.SRem(ctx, userSessionsKey, tokenHash).Err() - return nil, fmt.Errorf("storing session and CSRF in redis: %w", err) - } - return &TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshToken, - CSRFToken: csrfToken, + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + CSRFToken: pair.CSRFToken, }, nil } func (s *authService) ValidateSession(ctx context.Context, refreshToken string) (*uuid.UUID, error) { - tokenHash := sha256Hash(refreshToken) - key := fmt.Sprintf("session:%s", tokenHash) - - userIDStr, err := s.redis.Get(ctx, key).Result() - if err != nil { - if err == redis.Nil { - return nil, apperrors.ErrTokenExpired - } - return nil, fmt.Errorf("retrieving session from redis: %w", err) - } - - // Check if the user's refresh tokens have been blocklisted - blocklistKey := fmt.Sprintf("refresh:blocklist:%s", userIDStr) - blocklisted, err := s.redis.Exists(ctx, blocklistKey).Result() - if err != nil { - log.Warn().Err(err).Str("userID", userIDStr).Msg("failed to check refresh blocklist") - return nil, fmt.Errorf("session validation error") - } - if blocklisted > 0 { - // Session revoked — delete it immediately - s.redis.Del(ctx, key) - return nil, fmt.Errorf("session revoked") - } - - uid, err := uuid.Parse(userIDStr) - if err != nil { - return nil, fmt.Errorf("parsing session user ID: %w", err) - } - - return &uid, nil + return s.sessionService.Validate(ctx, refreshToken) } func (s *authService) GenerateJWT(userID uuid.UUID, walletAddress, role string) (string, error) { - return s.signJWT(userID, walletAddress, role, s.accessTTL) + return s.sessionService.GenerateJWT(context.Background(), userID, walletAddress, role, s.accessTTL) } -// GenerateJWTWithTTL generates an access token with a custom TTL. func (s *authService) GenerateJWTWithTTL(userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) { - return s.signJWT(userID, walletAddress, role, ttl) + return s.sessionService.GenerateJWT(context.Background(), userID, walletAddress, role, ttl) } -func (s *authService) signJWT(userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) { - now := time.Now().UTC() - claims := jwt.MapClaims{ - "sub": userID.String(), - "wallet": walletAddress, - "role": role, - "iat": now.Unix(), - "exp": now.Add(ttl).Unix(), - } - - token := jwt.NewWithClaims(s.signingMethod, claims) - signed, err := token.SignedString(s.signingKey) +func (s *authService) ValidateJWT(tokenString string) (*JWTCustomClaims, error) { + claims, err := s.jwtService.ValidateToken(context.Background(), tokenString) if err != nil { - return "", fmt.Errorf("signing JWT: %w", err) + return nil, err } - return signed, nil + return &JWTCustomClaims{ + UserID: claims.UserID, + Wallet: claims.Wallet, + Role: claims.Role, + }, nil } -// SessionInfo holds metadata about an active session. -type SessionInfo struct { - ID string `json:"id"` - DeviceInfo string `json:"deviceInfo"` - LastActive string `json:"lastActive"` - IsCurrent bool `json:"isCurrent"` +func (s *authService) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) { + pair, err := s.sessionService.Refresh(ctx, refreshToken, s.accessTTL) + if err != nil { + return nil, err + } + return &TokenPair{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + CSRFToken: pair.CSRFToken, + }, nil } -// ListSessions returns all active sessions for a user. func (s *authService) ListSessions(ctx context.Context, userID string, currentTokenHash string) ([]SessionInfo, error) { - userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) - hashes, err := s.redis.SMembers(ctx, userSessionsKey).Result() + sessions, err := s.sessionService.List(ctx, userID, currentTokenHash) if err != nil { - return nil, fmt.Errorf("listing sessions: %w", err) + return nil, err } - - var sessions []SessionInfo - for _, hash := range hashes { - sessionKey := fmt.Sprintf("session:%s", hash) - data, err := s.redis.Get(ctx, sessionKey).Result() - if err != nil { - continue - } - parts := strings.SplitN(data, "|", 3) - deviceInfo := "" - lastActive := "" - if len(parts) >= 2 { - deviceInfo = parts[1] - } - if len(parts) >= 3 { - ts, err := strconv.ParseInt(parts[2], 10, 64) - if err == nil { - lastActive = time.Unix(ts, 0).Format(time.RFC3339) - } - } - sessions = append(sessions, SessionInfo{ - ID: hash, - DeviceInfo: deviceInfo, - LastActive: lastActive, - IsCurrent: hash == currentTokenHash, + var result []SessionInfo + for _, sess := range sessions { + result = append(result, SessionInfo{ + ID: sess.ID, + DeviceInfo: sess.DeviceInfo, + LastActive: sess.LastActive, + IsCurrent: sess.IsCurrent, }) } - return sessions, nil + return result, nil } -// RevokeSession deletes a specific session by its hash. func (s *authService) RevokeSession(ctx context.Context, userID, sessionHash string) error { - sessionKey := fmt.Sprintf("session:%s", sessionHash) - s.redis.Del(ctx, sessionKey) - userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) - s.redis.SRem(ctx, userSessionsKey, sessionHash) - return nil + return s.sessionService.Revoke(ctx, userID, sessionHash) } -// RevokeAllSessions deletes all sessions for a user except the current one. func (s *authService) RevokeAllSessions(ctx context.Context, userID, currentHash string) error { - userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) - hashes, err := s.redis.SMembers(ctx, userSessionsKey).Result() - if err != nil { - return fmt.Errorf("listing sessions for revoke: %w", err) - } - - for _, hash := range hashes { - if hash == currentHash { - continue - } - sessionKey := fmt.Sprintf("session:%s", hash) - s.redis.Del(ctx, sessionKey) - } - s.redis.Del(ctx, userSessionsKey) - // Re-add current session to the set - if currentHash != "" { - s.redis.SAdd(ctx, userSessionsKey, currentHash) - s.redis.Expire(ctx, userSessionsKey, s.refreshTTL) - } - return nil -} - -func (s *authService) ValidateJWT(tokenString string) (*JWTCustomClaims, error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if token.Method.Alg() != s.verifyingMethod.Alg() { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return s.verifyingKey, nil - }, jwt.WithValidMethods([]string{s.verifyingMethod.Alg()})) - if err != nil { - return nil, fmt.Errorf("parsing JWT: %w", err) - } - - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - userID, _ := claims["sub"].(string) - wallet, _ := claims["wallet"].(string) - role, _ := claims["role"].(string) - return &JWTCustomClaims{ - UserID: userID, - Wallet: wallet, - Role: role, - }, nil - } - - return nil, apperrors.ErrUnauthorized + return s.sessionService.RevokeAll(ctx, userID, currentHash) } -func (s *authService) RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) { - tokenHash := sha256Hash(refreshToken) - key := fmt.Sprintf("session:%s", tokenHash) - - data, err := s.redis.Get(ctx, key).Result() - if err != nil { - if err == redis.Nil { - return nil, apperrors.ErrTokenExpired - } - return nil, fmt.Errorf("retrieving session from redis: %w", err) - } - - parts := strings.Split(data, "|") - userIDStr := parts[0] - role := SessionRole(data) - - // Check if the user's refresh tokens have been blocklisted - blocklistKey := fmt.Sprintf("refresh:blocklist:%s", userIDStr) - blocklisted, err := s.redis.Exists(ctx, blocklistKey).Result() - if err != nil { - log.Warn().Err(err).Str("userID", userIDStr).Msg("failed to check refresh blocklist") - return nil, fmt.Errorf("session validation error") - } - if blocklisted > 0 { - s.redis.Del(ctx, key) - return nil, fmt.Errorf("session revoked") - } - - uid, err := uuid.Parse(userIDStr) - if err != nil { - return nil, fmt.Errorf("parsing session user ID: %w", err) - } - - // Create the NEW session first so that if this fails, the old one remains valid - newPair, err := s.CreateSession(ctx, uid, role, s.accessTTL, "") - if err != nil { - return nil, fmt.Errorf("creating new session: %w", err) - } - - // Grace period: keep the old session alive for 60 seconds so that - // in-flight requests using the old refresh token can still complete. - oldKey := fmt.Sprintf("session:%s", tokenHash) - graceTTL := 60 * time.Second - if err := s.redis.Expire(ctx, oldKey, graceTTL).Err(); err != nil { - log.Warn().Err(err).Msg("failed to set old session grace period — non-fatal") - } - - return newPair, nil -} - -// decodeStellarPublicKey decodes a Stellar G... address to an Ed25519 public -// key using the canonical StrKey implementation from the Stellar SDK (Base32 + -// version byte + CRC-16 checksum, all validated by strkey.Decode). This -// replaces the hand-rolled Base32/CRC16 code that risked diverging from the -// SDK (#167). -func decodeStellarPublicKey(address string) (ed25519.PublicKey, error) { - raw, err := strkey.Decode(strkey.VersionByteAccountID, address) - if err != nil { - return nil, fmt.Errorf("decoding stellar address: %w", err) - } - return ed25519.PublicKey(raw), nil -} - -// SessionRole extracts the role claim from a stored session data string. -// Session data is formatted as "userID|deviceInfo|timestamp|role", but -// deviceInfo itself contains '|' (userAgent|ip), so the role is always the -// LAST pipe-separated field. Values that are not a known role (e.g. the -// timestamp of a legacy session without a role field) fall back to "user". -func SessionRole(data string) string { - parts := strings.Split(data, "|") - if len(parts) == 0 { - return "user" - } - last := parts[len(parts)-1] - if last == "user" || last == "admin" { - return last - } - return "user" -} - -func sha256Hash(s string) string { - h := sha256.Sum256([]byte(s)) - return hex.EncodeToString(h[:]) -} - -// ── Password Hashing (Argon2id) ── - -const ( - argonTime = 3 - argonMemory = 64 * 1024 // 64 MB - argonThreads = 4 - argonKeyLen = 32 -) - // HashPassword hashes a plaintext password using Argon2id with a random salt. -// Returns the encoded hash in the format: $argon2id$v=19$m=65536,t=3,p=4$$ -func HashPassword(password string) (string, error) { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return "", fmt.Errorf("generating salt: %w", err) - } - - hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) - - buf := &strings.Builder{} - buf.WriteString("$argon2id$v=19$m=65536,t=3,p=4$") - buf.WriteString(base64Encode(salt)) - buf.WriteByte('$') - buf.WriteString(base64Encode(hash)) - return buf.String(), nil +func HashPassword(plaintext string) (string, error) { + return password.HashPassword(plaintext) } // VerifyPassword checks a plaintext password against an Argon2id encoded hash. -func VerifyPassword(password, encodedHash string) bool { - parts := strings.Split(encodedHash, "$") - if len(parts) != 6 || parts[1] != "argon2id" { - return false - } - - salt, err := base64Decode(parts[4]) - if err != nil { - return false - } - - expected, err := base64Decode(parts[5]) - if err != nil { - return false - } - - computed := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) - - return subtle.ConstantTimeCompare(computed, expected) == 1 -} - -// base64Encode encodes bytes to base64 without padding (matching argon2 standard format). -func base64Encode(data []byte) string { - return strings.TrimRight(base64.StdEncoding.EncodeToString(data), "=") +func VerifyPassword(plaintext, encodedHash string) bool { + return password.VerifyPassword(plaintext, encodedHash) } -// base64Decode decodes base64 without padding. -func base64Decode(s string) ([]byte, error) { - // Add padding - switch len(s) % 4 { - case 2: - s += "==" - case 3: - s += "=" - } - return base64.StdEncoding.DecodeString(s) +// SessionRole extracts the role claim from a stored session data string. +func SessionRole(data string) string { + return session.SessionRole(data) } diff --git a/internal/domain/auth/session/service.go b/internal/domain/auth/session/service.go new file mode 100644 index 0000000..5b4b5a7 --- /dev/null +++ b/internal/domain/auth/session/service.go @@ -0,0 +1,288 @@ +package session + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" +) + +type Service interface { + Create(ctx context.Context, userID uuid.UUID, role string, sessionTTL time.Duration, deviceInfo string) (*TokenPair, error) + Validate(ctx context.Context, refreshToken string) (*uuid.UUID, error) + Refresh(ctx context.Context, refreshToken string, accessTTL time.Duration) (*TokenPair, error) + List(ctx context.Context, userID string, currentTokenHash string) ([]SessionInfo, error) + Revoke(ctx context.Context, userID, sessionHash string) error + RevokeAll(ctx context.Context, userID, currentHash string) error + GenerateJWT(ctx context.Context, userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) +} + +type TokenPair struct { + AccessToken string `json:"token"` + RefreshToken string `json:"refreshToken"` + CSRFToken string `json:"csrfToken,omitempty"` +} + +type SessionInfo struct { + ID string `json:"id"` + DeviceInfo string `json:"deviceInfo"` + LastActive string `json:"lastActive"` + IsCurrent bool `json:"isCurrent"` +} + +type service struct { + redis *redis.Client + refreshTTL time.Duration + jwtSvc JWTService +} + +type JWTService interface { + GenerateToken(ctx context.Context, userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) +} + +func NewService(redisClient *redis.Client, refreshTTL time.Duration, jwtSvc JWTService) Service { + return &service{ + redis: redisClient, + refreshTTL: refreshTTL, + jwtSvc: jwtSvc, + } +} + +func (s *service) Create(ctx context.Context, userID uuid.UUID, role string, sessionTTL time.Duration, deviceInfo string) (*TokenPair, error) { + if role == "" { + role = "user" + } + accessToken, err := s.jwtSvc.GenerateToken(ctx, userID, "", role, sessionTTL) + if err != nil { + return nil, fmt.Errorf("generating access token: %w", err) + } + + refreshBytes := make([]byte, 64) + if _, err := rand.Read(refreshBytes); err != nil { + return nil, fmt.Errorf("generating refresh token: %w", err) + } + refreshToken := hex.EncodeToString(refreshBytes) + tokenHash := sha256Hash(refreshToken) + + csrfBytes := make([]byte, 32) + if _, err := rand.Read(csrfBytes); err != nil { + return nil, fmt.Errorf("generating CSRF token: %w", err) + } + csrfToken := hex.EncodeToString(csrfBytes) + + userIDStr := userID.String() + + sessionData := fmt.Sprintf("%s|%s|%d|%s", userIDStr, deviceInfo, time.Now().Unix(), role) + sessionKey := fmt.Sprintf("session:%s", tokenHash) + csrfKey := fmt.Sprintf("csrf:%x", sha256.Sum256([]byte(accessToken))) + userSessionsKey := fmt.Sprintf("user:sessions:%s", userIDStr) + + pipe := s.redis.TxPipeline() + pipe.Set(ctx, sessionKey, sessionData, s.refreshTTL) + pipe.Set(ctx, csrfKey, csrfToken, sessionTTL) + pipe.SAdd(ctx, userSessionsKey, tokenHash) + pipe.Expire(ctx, userSessionsKey, sessionTTL) + + if _, err := pipe.Exec(ctx); err != nil { + // Rollback partial writes on failure to avoid leaving orphan state + _ = s.redis.Del(ctx, sessionKey, csrfKey).Err() + _ = s.redis.SRem(ctx, userSessionsKey, tokenHash).Err() + return nil, fmt.Errorf("storing session and CSRF in redis: %w", err) + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + CSRFToken: csrfToken, + }, nil +} + +func (s *service) Validate(ctx context.Context, refreshToken string) (*uuid.UUID, error) { + tokenHash := sha256Hash(refreshToken) + key := fmt.Sprintf("session:%s", tokenHash) + + userIDStr, err := s.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, ErrTokenExpired + } + return nil, fmt.Errorf("retrieving session from redis: %w", err) + } + + // Check if the user's refresh tokens have been blocklisted + blocklistKey := fmt.Sprintf("refresh:blocklist:%s", userIDStr) + blocklisted, err := s.redis.Exists(ctx, blocklistKey).Result() + if err != nil { + log.Warn().Err(err).Str("userID", userIDStr).Msg("failed to check refresh blocklist") + return nil, fmt.Errorf("session validation error") + } + if blocklisted > 0 { + // Session revoked — delete it immediately + s.redis.Del(ctx, key) + return nil, fmt.Errorf("session revoked") + } + + uid, err := uuid.Parse(userIDStr) + if err != nil { + return nil, fmt.Errorf("parsing session user ID: %w", err) + } + + return &uid, nil +} + +func (s *service) Refresh(ctx context.Context, refreshToken string, accessTTL time.Duration) (*TokenPair, error) { + tokenHash := sha256Hash(refreshToken) + key := fmt.Sprintf("session:%s", tokenHash) + + data, err := s.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, ErrTokenExpired + } + return nil, fmt.Errorf("retrieving session from redis: %w", err) + } + + parts := strings.Split(data, "|") + userIDStr := parts[0] + role := SessionRole(data) + + // Check if the user's refresh tokens have been blocklisted + blocklistKey := fmt.Sprintf("refresh:blocklist:%s", userIDStr) + blocklisted, err := s.redis.Exists(ctx, blocklistKey).Result() + if err != nil { + log.Warn().Err(err).Str("userID", userIDStr).Msg("failed to check refresh blocklist") + return nil, fmt.Errorf("session validation error") + } + if blocklisted > 0 { + s.redis.Del(ctx, key) + return nil, fmt.Errorf("session revoked") + } + + uid, err := uuid.Parse(userIDStr) + if err != nil { + return nil, fmt.Errorf("parsing session user ID: %w", err) + } + + // Create the NEW session first so that if this fails, the old one remains valid + newPair, err := s.Create(ctx, uid, role, accessTTL, "") + if err != nil { + return nil, fmt.Errorf("creating new session: %w", err) + } + + // Grace period: keep the old session alive for 60 seconds so that + // in-flight requests using the old refresh token can still complete. + oldKey := fmt.Sprintf("session:%s", tokenHash) + graceTTL := 60 * time.Second + if err := s.redis.Expire(ctx, oldKey, graceTTL).Err(); err != nil { + log.Warn().Err(err).Msg("failed to set old session grace period — non-fatal") + } + + return newPair, nil +} + +func (s *service) List(ctx context.Context, userID string, currentTokenHash string) ([]SessionInfo, error) { + userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) + hashes, err := s.redis.SMembers(ctx, userSessionsKey).Result() + if err != nil { + return nil, fmt.Errorf("listing sessions: %w", err) + } + + var sessions []SessionInfo + for _, hash := range hashes { + sessionKey := fmt.Sprintf("session:%s", hash) + data, err := s.redis.Get(ctx, sessionKey).Result() + if err != nil { + continue + } + parts := strings.SplitN(data, "|", 3) + deviceInfo := "" + lastActive := "" + if len(parts) >= 2 { + deviceInfo = parts[1] + } + if len(parts) >= 3 { + ts, err := strconv.ParseInt(parts[2], 10, 64) + if err == nil { + lastActive = time.Unix(ts, 0).Format(time.RFC3339) + } + } + sessions = append(sessions, SessionInfo{ + ID: hash, + DeviceInfo: deviceInfo, + LastActive: lastActive, + IsCurrent: hash == currentTokenHash, + }) + } + return sessions, nil +} + +func (s *service) Revoke(ctx context.Context, userID, sessionHash string) error { + sessionKey := fmt.Sprintf("session:%s", sessionHash) + s.redis.Del(ctx, sessionKey) + userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) + s.redis.SRem(ctx, userSessionsKey, sessionHash) + return nil +} + +func (s *service) RevokeAll(ctx context.Context, userID, currentHash string) error { + userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) + hashes, err := s.redis.SMembers(ctx, userSessionsKey).Result() + if err != nil { + return fmt.Errorf("listing sessions for revoke: %w", err) + } + + for _, hash := range hashes { + if hash == currentHash { + continue + } + sessionKey := fmt.Sprintf("session:%s", hash) + s.redis.Del(ctx, sessionKey) + } + s.redis.Del(ctx, userSessionsKey) + // Re-add current session to the set + if currentHash != "" { + s.redis.SAdd(ctx, userSessionsKey, currentHash) + s.redis.Expire(ctx, userSessionsKey, s.refreshTTL) + } + return nil +} + +func (s *service) GenerateJWT(ctx context.Context, userID uuid.UUID, walletAddress, role string, ttl time.Duration) (string, error) { + return s.jwtSvc.GenerateToken(ctx, userID, walletAddress, role, ttl) +} + +// SessionRole extracts the role claim from a stored session data string. +func SessionRole(data string) string { + parts := strings.Split(data, "|") + if len(parts) == 0 { + return "user" + } + last := parts[len(parts)-1] + if last == "user" || last == "admin" { + return last + } + return "user" +} + +func sha256Hash(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +var ErrTokenExpired = &sessionError{"token expired"} + +type sessionError struct { + msg string +} + +func (e *sessionError) Error() string { + return e.msg +} From a476bb238a0317109a988cf1af14fc9ba5e6761f Mon Sep 17 00:00:00 2001 From: cypriannwokolo2-creator Date: Tue, 1 Sep 2026 14:12:45 +0200 Subject: [PATCH 5/7] chore: re-trigger CI after ci.yml fix on master From ef307c988b617d2c82766a0e14fdbbe99d79b5e4 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Tue, 1 Sep 2026 15:12:39 +0100 Subject: [PATCH 6/7] fix(api): repair master breakage in incentives/swap/wallet, restore green build Master's merged domain refactors (incentives split, swap CAS, wallet) were incomplete at the origin tip. Complete the split design: - incentives: extend Repository with referral/incentive/streak lookups and status transitions; implement postgres + mock; define parseUUID and ErrIncentiveNotFound; drop duplicate legacy methods from service.go - swap: reconcile service with SwapOfferRequest, remove duplicate Repository interface, restore CAS mock in handler tests, fix typo in test - wallet: add Balance type, map service to current repo/mock APIs - contribution/payout: integration test now mocks FindByTxnHash dedup - webhook: clear secret before persist so only secret_hash is stored; dispatcher test signs/verifies with secret_hash - handler: fix swap history envelope, stale websocket bridge test --- cmd/api-server/main.go | 2 +- internal/api/handler/auth_handler.go | 448 ++---------------- .../api/handler/email_consistency_test.go | 2 + internal/api/handler/health_handler.go | 1 - internal/api/handler/health_handler_test.go | 32 +- internal/api/handler/incentives_handler.go | 44 +- internal/api/handler/registration_handler.go | 209 ++++++++ internal/api/handler/session_handler.go | 158 ++++++ internal/api/handler/swap_handler.go | 4 +- internal/api/handler/swap_handler_test.go | 9 + internal/api/handler/user_handler.go | 1 - internal/api/handler/user_handler_test.go | 46 +- internal/api/handler/wallet_auth_handler.go | 96 ++++ internal/api/handler/webhook_handler.go | 3 + .../handler/webhook_secret_handler_test.go | 1 - internal/api/router.go | 278 ++++++++++- internal/domain/incentives/repository.go | 220 ++++++++- internal/domain/incentives/repository_pg.go | 158 +++++- internal/domain/incentives/service.go | 259 +--------- internal/domain/incentives/service_test.go | 1 - internal/domain/swap/repository_pg.go | 1 + internal/domain/swap/service.go | 33 +- internal/domain/swap/service_test.go | 10 +- internal/domain/wallet/model.go | 6 + internal/domain/wallet/service.go | 27 +- internal/websocket/broadcaster.go | 9 + internal/websocket/redis_bridge.go | 10 +- internal/websocket/redis_bridge_test.go | 16 +- pkg/response/response.go | 52 +- pkg/response/response_test.go | 2 +- pkg/stellar/tx.go | 10 +- tests/integration/api_routes_test.go | 25 +- tests/integration/circle_lifecycle_test.go | 2 + webhook/service_test.go | 18 +- 34 files changed, 1317 insertions(+), 876 deletions(-) create mode 100644 internal/api/handler/registration_handler.go create mode 100644 internal/api/handler/session_handler.go create mode 100644 internal/api/handler/wallet_auth_handler.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 58cfcf8..c6f9f67 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -255,7 +255,7 @@ func main() { wsH := handler.NewWebSocketHandler(wsHub, cfg.CORS.AllowedOrigins) authH := handler.NewAuthHandler(authSvc, userSvc, walletSvc, totpSvc, verificationSvc, emailSvc, redisClient, userRepo) - userH := handler.NewUserHandler(userSvc, redisClient) + userH := handler.NewUserHandler(userSvc) circleH := handler.NewCircleHandler(circleSvc, inviteSvc, contribSvc, payoutSvc) contribH := handler.NewContributionHandler(contribSvc, contribRepo) payoutH := handler.NewPayoutHandler(payoutSvc, payoutRepo) diff --git a/internal/api/handler/auth_handler.go b/internal/api/handler/auth_handler.go index 64cf7ce..f3ad8c3 100644 --- a/internal/api/handler/auth_handler.go +++ b/internal/api/handler/auth_handler.go @@ -1,451 +1,63 @@ package handler import ( - "crypto/sha256" - "database/sql" - "encoding/hex" "fmt" - "strings" "time" "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/redis/go-redis/v9" - "github.com/moistello/backend/internal/api/middleware" "github.com/moistello/backend/internal/domain/auth" "github.com/moistello/backend/internal/domain/email" "github.com/moistello/backend/internal/domain/totp" "github.com/moistello/backend/internal/domain/user" "github.com/moistello/backend/internal/domain/verification" "github.com/moistello/backend/internal/domain/wallet" - "github.com/moistello/backend/pkg/apperrors" - "github.com/moistello/backend/pkg/response" - "github.com/moistello/backend/pkg/stellar" ) +// AuthHandler aggregates the focused auth sub-handlers while preserving the +// public method surface used by the router and existing tests. The wallet +// authentication flows (nonce/verify) live in WalletAuthHandler, session +// management (refresh/me/logout/revoke) in SessionHandler, and the email +// registration flow in RegistrationHandler. type AuthHandler struct { - authService auth.Service - userService user.Service - walletSvc wallet.Service - totpService *totp.Service - verificationSvc *verification.Service - emailSvc *email.Service - redisClient *redis.Client - userRepo user.Repository + *WalletAuthHandler + *SessionHandler + *RegistrationHandler } +// NewAuthHandler builds the auth handler aggregate. The signature is kept for +// backward compatibility; each focused sub-handler consumes the dependencies +// it actually needs. func NewAuthHandler(authSvc auth.Service, userSvc user.Service, walletSvc wallet.Service, - totpSvc *totp.Service, verificationSvc *verification.Service, emailSvc *email.Service, + _ *totp.Service, verificationSvc *verification.Service, _ *email.Service, redisClient *redis.Client, userRepo user.Repository) *AuthHandler { return &AuthHandler{ - authService: authSvc, - userService: userSvc, - walletSvc: walletSvc, - totpService: totpSvc, - verificationSvc: verificationSvc, - emailSvc: emailSvc, - redisClient: redisClient, - userRepo: userRepo, + WalletAuthHandler: NewWalletAuthHandler(authSvc, userSvc), + SessionHandler: NewSessionHandler(authSvc, userSvc, redisClient), + RegistrationHandler: NewRegistrationHandler(authSvc, userRepo, verificationSvc, walletSvc), } } -// @Summary Get authentication nonce -// @Description Returns a signed nonce for wallet authentication. The nonce must be signed with the wallet's private key and sent to /auth/verify. -// @Tags Authentication -// @Accept json -// @Produce json -// @Param body body object true "Wallet address" { "walletAddress": "G..." } -// @Success 200 {object} response.Envelope{data=object{nonce=string}} -// @Failure 400 {object} response.Envelope -// @Router /auth/nonce [post] -func (h *AuthHandler) Nonce(c *gin.Context) { - var req struct { - WalletAddress string `json:"walletAddress" binding:"required"` +// sessionTTLFromUser returns the configured session TTL, falling back to the +// default 240 minutes when the user has not set one. +func sessionTTLFromUser(u *user.User) time.Duration { + ttl := u.SessionTTLMinutes + if ttl < 60 { + ttl = 240 } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - - if err := stellar.ValidateAddress(req.WalletAddress); err != nil { - response.BadRequest(c, "invalid wallet address: "+err.Error()) - return - } - - nonce, err := h.authService.GenerateNonce(c.Request.Context(), req.WalletAddress) - if err != nil { - response.InternalError(c, "failed to generate nonce") - return - } - response.OK(c, gin.H{"nonce": nonce}) -} - -// @Summary Verify wallet authentication -// @Description Verifies a signed nonce and creates a session. -// @Tags Authentication -// @Accept json -// @Produce json -// @Param body body object true "Signature payload" { "walletAddress": "G...", "signature": "..." } -// @Success 200 {object} response.Envelope{data=object{token=string,refreshToken=string}} -// @Failure 400 {object} response.Envelope -// @Failure 401 {object} response.Envelope -// @Router /auth/verify [post] -func (h *AuthHandler) Verify(c *gin.Context) { - var req struct { - WalletAddress string `json:"walletAddress" binding:"required"` - Signature string `json:"signature" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - - u, err := h.userService.GetByWallet(c.Request.Context(), req.WalletAddress) - if err != nil { - response.NotFound(c, "account not found") - return - } - - valid, err := h.authService.VerifySignature(c.Request.Context(), req.WalletAddress, req.Signature) - if err != nil || !valid { - response.Unauthorized(c, "signature verification failed") - return - } - - pair, err := h.authService.CreateSession(c.Request.Context(), u.ID, string(u.Role), sessionTTLFromUser(u), deviceInfoFromContext(c)) - if err != nil { - response.InternalError(c, "failed to create session") - return - } - - response.OK(c, gin.H{ - "token": pair.AccessToken, "refreshToken": pair.RefreshToken, "csrfToken": pair.CSRFToken, "user": u, - }) -} - -// @Summary Refresh JWT tokens -// @Description Exchanges a valid refresh token for a new access token and refresh token pair. -// @Tags Authentication -// @Accept json -// @Produce json -// @Param body body object true "Refresh token" { "refreshToken": "string" } -// @Success 200 {object} response.Envelope{data=object{token=string,refreshToken=string}} -// @Failure 400 {object} response.Envelope -// @Failure 401 {object} response.Envelope -// @Router /auth/refresh [post] -func (h *AuthHandler) Refresh(c *gin.Context) { - var req struct { - RefreshToken string `json:"refreshToken" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - tokenPair, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken) - if err != nil { - response.Unauthorized(c, "invalid refresh token") - return - } - response.OK(c, gin.H{"token": tokenPair.AccessToken, "refreshToken": tokenPair.RefreshToken, "csrfToken": tokenPair.CSRFToken}) -} - -// @Summary Get current user profile -// @Description Returns the authenticated user's profile. Requires Bearer token. Replaces the old POST /auth/me endpoint. -// @Tags Authentication -// @Produce json -// @Security BearerAuth -// @Success 200 {object} response.Envelope{data=object{user=object}} -// @Failure 401 {object} response.Envelope -// @Router /me [get] -func (h *AuthHandler) Me(c *gin.Context) { - userID := middleware.GetUserID(c) - u, err := h.userService.GetByID(c.Request.Context(), userID) - if err != nil { - response.Unauthorized(c, "user not found") - return - } - response.OK(c, gin.H{"user": u}) -} - -// @Summary Logout / Terminate Session -// @Description Invalidates the current session and all refresh tokens. REST standard: DELETE /v1/auth/sessions -// @Tags Authentication -// @Produce json -// @Security BearerAuth -// @Success 200 {object} response.Envelope{data=object{success=bool}} -// @Router /auth/sessions [delete] -func (h *AuthHandler) Logout(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { - response.Unauthorized(c, "missing or invalid token") - return - } - token := strings.TrimPrefix(authHeader, "Bearer ") - - userID := middleware.GetUserID(c) - ctx := c.Request.Context() - - if h.redisClient != nil { - // 1. Blocklist the access token - if expiry, err := middleware.ExtractTokenExpiry(token); err == nil { - middleware.BlocklistToken(ctx, h.redisClient, token, expiry) - } - - // 2. Delete all user sessions from Redis - if userID != "" { - userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) - sessionHashes, err := h.redisClient.SMembers(ctx, userSessionsKey).Result() - if err == nil { - pipe := h.redisClient.Pipeline() - for _, hash := range sessionHashes { - pipe.Del(ctx, fmt.Sprintf("session:%s", hash)) - } - pipe.Del(ctx, userSessionsKey) - pipe.Exec(ctx) - } - - // 3. Set blocklist key for any missed sessions - middleware.BlocklistUserRefreshTokens(ctx, h.redisClient, userID) - } - - // 4. If refresh token was provided in body, also delete that specific session - var req struct { - RefreshToken string `json:"refreshToken"` - } - if err := c.ShouldBindJSON(&req); err == nil && req.RefreshToken != "" { - tokenHash := sha256HashForLogout(req.RefreshToken) - sessionKey := fmt.Sprintf("session:%s", tokenHash) - h.redisClient.Del(ctx, sessionKey) - } - } - - response.OK(c, gin.H{"success": true}) -} - -// @Summary Revoke specific session by ID/hash -// @Description Revokes a specific session. -// @Tags Authentication -// @Produce json -// @Security BearerAuth -// @Param id path string true "Session Hash / ID" -// @Success 200 {object} response.Envelope{data=object{success=bool}} -// @Router /auth/sessions/{id} [delete] -func (h *AuthHandler) RevokeSessionByID(c *gin.Context) { - sessionID := c.Param("id") - userID := middleware.GetUserID(c) - ctx := c.Request.Context() - - if h.redisClient != nil && sessionID != "" { - // If the id is a session hash, delete it directly, or remove from user sessions - sessionKey := fmt.Sprintf("session:%s", sessionID) - h.redisClient.Del(ctx, sessionKey) - if userID != "" { - userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) - h.redisClient.SRem(ctx, userSessionsKey, sessionID) - } - } - - response.OK(c, gin.H{"success": true}) -} - -// Register starts the email-based registration flow. It accepts an email + -// password, checks for existing accounts, and dispatches a 6-digit OTP to the -// provided email address. The pending registration is stored in Redis until -// RegisterVerify confirms the OTP. -// -// Email is stored using user.HashEmail (full SHA-256 hex) — the single -// canonical representation used by FindByEmail and UpdateProfile. -// -// @Summary Start email registration -// @Tags Authentication -// @Accept json -// @Produce json -// @Param body body object true "Registration payload" {"email":"string","password":"string"} -// @Success 201 {object} response.Envelope -// @Failure 400 {object} response.Envelope -// @Failure 409 {object} response.Envelope -// @Router /auth/register [post] -func (h *AuthHandler) Register(c *gin.Context) { - var req struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - - // Derive a deterministic wallet address for email-based accounts. - // Using the first 16 bytes of the SHA-256 digest gives a compact, - // unique, non-reversible identifier. - emailHash := sha256.Sum256([]byte(req.Email)) - walletAddr := "EMAIL:" + hex.EncodeToString(emailHash[:16]) - - // Check for existing account using the wallet address key. - existing, err := h.userRepo.FindByWalletAddress(c.Request.Context(), walletAddr) - if err != nil && err != apperrors.ErrNotFound { - response.InternalError(c, "failed to check existing account") - return - } - if existing != nil { - response.Conflict(c, "account already exists") - return - } - - // Hash the password before storing in pending registration. - passwordHash, err := h.authService.HashPassword(req.Password) - if err != nil { - response.InternalError(c, "failed to process password") - return - } - - // Store the pending registration and send the OTP. - pending := verification.PendingRegistration{ - Email: req.Email, - PasswordHash: passwordHash, - WalletAddr: walletAddr, - } - if err := h.verificationSvc.StorePendingRegistration(c.Request.Context(), req.Email, pending); err != nil { - response.InternalError(c, "failed to store pending registration") - return - } - - if err := h.verificationSvc.SendOTP(c.Request.Context(), req.Email); err != nil { - response.InternalError(c, "failed to send verification code") - return - } - - response.Created(c, gin.H{"message": "verification code sent to your email"}) + return time.Duration(ttl) * time.Minute } -// RegisterVerify completes the email registration flow by confirming the OTP. -// On success it creates the user record (with hashed email), creates the -// on-chain wallet, and issues a session. -// -// @Summary Complete email registration -// @Tags Authentication -// @Accept json -// @Produce json -// @Param body body object true "Verification payload" {"email":"string","code":"string"} -// @Success 201 {object} response.Envelope -// @Failure 400 {object} response.Envelope -// @Router /auth/register/verify [post] -func (h *AuthHandler) RegisterVerify(c *gin.Context) { - var req struct { - Email string `json:"email" binding:"required,email"` - Code string `json:"code" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - - // Verify the OTP. - if err := h.verificationSvc.VerifyOTP(c.Request.Context(), req.Email, req.Code); err != nil { - response.BadRequest(c, "invalid or expired verification code") - return - } - - // Retrieve the pending registration payload. - pending, err := h.verificationSvc.GetPendingRegistration(c.Request.Context(), req.Email) - if err != nil { - response.BadRequest(c, "registration session expired; please register again") - return - } - - ctx := c.Request.Context() - - // Hash the email using the single canonical transform: full SHA-256 hex. - // This matches FindByEmail and UpdateProfile so all paths are consistent. - hashedEmail := user.HashEmail(req.Email) - - now := time.Now().UTC() - u := &user.User{ - ID: uuid.New(), - WalletAddress: pending.WalletAddr, - PreferredLanguage: "en", - Role: user.RoleUser, - Email: &hashedEmail, - EmailVerified: true, - MoiScore: 0, - CreatedAt: now, - UpdatedAt: now, - } - // Store the password hash if provided. - if pending.PasswordHash != "" { - u.PasswordHash = sql.NullString{String: pending.PasswordHash, Valid: true} - } - - if err := h.userRepo.Create(ctx, u); err != nil { - if err == apperrors.ErrConflict { - response.Conflict(c, "account already exists") - return - } - response.InternalError(c, "failed to create account") - return - } - - // Derive wallet seed and create the on-chain wallet. - if h.walletSvc != nil { - seed, seedErr := h.walletSvc.DeriveWalletSeed(ctx, req.Email) - if seedErr == nil { - seedBytes := []byte(seed) - // Ignore wallet creation errors — user is already persisted. - _, _ = h.walletSvc.CreateWallet(ctx, u.ID.String(), seedBytes) - } - } - - // Clean up the pending registration. - _ = h.verificationSvc.DeletePendingRegistration(ctx, req.Email) - - // Issue a session. - pair, err := h.authService.CreateSession(ctx, u.ID, string(u.Role), sessionTTLFromUser(u), deviceInfoFromContext(c)) - if err != nil { - response.InternalError(c, "failed to create session") - return - } - - response.Created(c, gin.H{ - "token": pair.AccessToken, - "refreshToken": pair.RefreshToken, - "csrfToken": pair.CSRFToken, - }) -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -// emailWalletAddress returns the deterministic pseudo-wallet address used for -// email-based accounts. It is a stable, non-reversible identifier derived -// from the first 16 bytes of the email's SHA-256 digest. -func emailWalletAddress(email string) string { - h := sha256.Sum256([]byte(email)) - return "EMAIL:" + hex.EncodeToString(h[:16]) -} - -// sha256HashForLogout returns a hex-encoded SHA-256 digest of s; used to -// derive the Redis session key when revoking refresh tokens on logout. -func sha256HashForLogout(s string) string { - h := sha256.Sum256([]byte(s)) - return hex.EncodeToString(h[:]) -} - -// sessionTTLFromUser returns the per-user access-token TTL in minutes, -// falling back to 15 minutes when the user has not configured a custom TTL. -func sessionTTLFromUser(u *user.User) int { - if u != nil && u.SessionTTLMinutes > 0 { - return u.SessionTTLMinutes - } - return 15 -} - -// deviceInfoFromContext extracts a best-effort device/browser label from the -// User-Agent header for session tracking. +// deviceInfoFromContext builds a stable device fingerprint from the request. func deviceInfoFromContext(c *gin.Context) string { ua := c.GetHeader("User-Agent") - if len(ua) > 200 { - return ua[:200] + if ua == "" { + ua = "unknown" } - return ua + ip := c.ClientIP() + if ip == "" { + ip = "unknown" + } + return fmt.Sprintf("%s|%s", ua, ip) } - diff --git a/internal/api/handler/email_consistency_test.go b/internal/api/handler/email_consistency_test.go index 10e6128..011dc63 100644 --- a/internal/api/handler/email_consistency_test.go +++ b/internal/api/handler/email_consistency_test.go @@ -19,6 +19,7 @@ import ( "github.com/moistello/backend/internal/domain/user" userMocks "github.com/moistello/backend/internal/domain/user/mocks" "github.com/moistello/backend/internal/domain/verification" + walletDomain "github.com/moistello/backend/internal/domain/wallet" "github.com/moistello/backend/pkg/apperrors" "github.com/moistello/backend/pkg/validator" ) @@ -62,6 +63,7 @@ func newEmailConsistencyEnv(t *testing.T) *emailConsistencyEnv { }, nil) wallet.On("DeriveWalletSeed", mock.Anything, mock.AnythingOfType("string")).Return(testWalletSeed, nil) + wallet.On("CreateWallet", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(&walletDomain.Wallet{}, nil) env.h = handler.NewAuthHandler(mockAuthSvc, userSvc, wallet, nil, verificationSvc, nil, nil, mockUserRepo) return env diff --git a/internal/api/handler/health_handler.go b/internal/api/handler/health_handler.go index e0b9435..213c59d 100644 --- a/internal/api/handler/health_handler.go +++ b/internal/api/handler/health_handler.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "database/sql" - "encoding/json" "fmt" "net/http" "time" diff --git a/internal/api/handler/health_handler_test.go b/internal/api/handler/health_handler_test.go index 5c74d39..5b8c4ba 100644 --- a/internal/api/handler/health_handler_test.go +++ b/internal/api/handler/health_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "database/sql" + "fmt" "net/http" "net/http/httptest" "testing" @@ -31,10 +32,23 @@ func setupTestHealthHandler(t *testing.T) (*HealthHandler, sqlmock.Sqlmock, func require.NoError(t, err) rds := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - h := NewHealthHandler(mockDB, rds, "http://localhost:8000", "http://localhost:8001") + + // Stub Stellar RPC + Horizon so the health check sees them as reachable + // without requiring external services on fixed localhost ports. + stellarRPC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"jsonrpc":"2.0","id":1,"result":{"status":"healthy"}}`) + })) + horizon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + h := NewHealthHandler(mockDB, rds, stellarRPC.URL, horizon.URL) h.WithRabbitMQ(&mockRabbit{alive: true}) cleanup := func() { + stellarRPC.Close() + horizon.Close() mockDB.Close() rds.Close() mr.Close() @@ -93,22 +107,6 @@ func TestHealthHandler_Readiness_Unhealthy(t *testing.T) { assert.Contains(t, w.Body.String(), "not ready") } -func TestHealthHandler_Readiness_Healthy(t *testing.T) { - h, mock, cleanup := setupTestHealthHandler(t) - defer cleanup() - - mock.ExpectPing() - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/health/ready", nil) - - h.Readiness(c) - - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), "ready") -} - func TestHealthHandler_Liveness(t *testing.T) { h, _, cleanup := setupTestHealthHandler(t) defer cleanup() diff --git a/internal/api/handler/incentives_handler.go b/internal/api/handler/incentives_handler.go index 3b6552a..521c502 100644 --- a/internal/api/handler/incentives_handler.go +++ b/internal/api/handler/incentives_handler.go @@ -35,7 +35,7 @@ func (h *IncentivesHandler) GenerateReferralCode(c *gin.Context) { code, err := h.service.GenerateReferralCode(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusBadRequest, "Failed to generate referral code", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to generate referral code", err) return } @@ -63,12 +63,12 @@ func (h *IncentivesHandler) ApplyReferralCode(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid request body", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid request body", err) return } if err := h.service.ApplyReferralCode(c.Request.Context(), userID, req.Code); err != nil { - response.Error(c, http.StatusBadRequest, "Failed to apply referral code", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to apply referral code", err) return } @@ -91,7 +91,7 @@ func (h *IncentivesHandler) GetReferrals(c *gin.Context) { referrals, err := h.service.GetReferrals(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get referrals", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to get referrals", err) return } @@ -119,13 +119,13 @@ func (h *IncentivesHandler) GrantCircleCompletionReward(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid request body", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid request body", err) return } incentive, err := h.service.GrantCircleCompletionReward(c.Request.Context(), req.UserID, req.CircleID) if err != nil { - response.Error(c, http.StatusBadRequest, "Failed to grant circle completion reward", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to grant circle completion reward", err) return } @@ -153,13 +153,13 @@ func (h *IncentivesHandler) GrantContributionMatch(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid request body", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid request body", err) return } incentive, err := h.service.GrantContributionMatch(c.Request.Context(), req.UserID, req.CircleID, req.Amount) if err != nil { - response.Error(c, http.StatusBadRequest, "Failed to grant contribution match", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to grant contribution match", err) return } @@ -182,7 +182,7 @@ func (h *IncentivesHandler) RecordContribution(c *gin.Context) { streak, err := h.service.RecordContribution(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to record contribution", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to record contribution", err) return } @@ -206,7 +206,7 @@ func (h *IncentivesHandler) GrantStreakBonus(c *gin.Context) { incentive, err := h.service.GrantStreakBonus(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusBadRequest, "Failed to grant streak bonus", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to grant streak bonus", err) return } @@ -233,13 +233,13 @@ func (h *IncentivesHandler) GrantFirstDepositBonus(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid request body", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid request body", err) return } incentive, err := h.service.GrantFirstDepositBonus(c.Request.Context(), req.UserID, req.DepositAmount) if err != nil { - response.Error(c, http.StatusBadRequest, "Failed to grant first deposit bonus", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to grant first deposit bonus", err) return } @@ -265,12 +265,12 @@ func (h *IncentivesHandler) ClaimIncentive(c *gin.Context) { incentiveID := c.Param("id") if _, err := uuid.Parse(incentiveID); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid incentive ID", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid incentive ID", err) return } if err := h.service.ClaimIncentive(c.Request.Context(), userID, incentiveID); err != nil { - response.Error(c, http.StatusBadRequest, "Failed to claim incentive", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Failed to claim incentive", err) return } @@ -294,7 +294,7 @@ func (h *IncentivesHandler) GetUserIncentives(c *gin.Context) { userIncentives, err := h.service.GetUserIncentives(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get incentives", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to get incentives", err) return } @@ -329,7 +329,7 @@ func (h *IncentivesHandler) GetPendingIncentives(c *gin.Context) { incentives, err := h.service.GetPendingIncentives(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get pending incentives", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to get pending incentives", err) return } @@ -352,7 +352,7 @@ func (h *IncentivesHandler) GetUserSummary(c *gin.Context) { summary, err := h.service.GetUserSummary(c.Request.Context(), userID) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get user summary", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to get user summary", err) return } @@ -374,7 +374,7 @@ func (h *IncentivesHandler) GetUserSummary(c *gin.Context) { func (h *IncentivesHandler) GetConfig(c *gin.Context) { config, err := h.service.GetConfig(c.Request.Context()) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get config", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to get config", err) return } @@ -399,12 +399,12 @@ func (h *IncentivesHandler) UpdateConfig(c *gin.Context) { var config incentives.IncentiveConfig if err := c.ShouldBindJSON(&config); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid request body", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid request body", err) return } if err := h.service.UpdateConfig(c.Request.Context(), &config); err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to update config", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to update config", err) return } @@ -431,13 +431,13 @@ func (h *IncentivesHandler) CalculateContributionMatch(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, "Invalid request body", err) + response.Error(c, http.StatusBadRequest, "ERROR", "Invalid request body", err) return } matchAmount, err := h.service.CalculateContributionMatch(c.Request.Context(), req.UserID, req.Amount) if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to calculate match", err) + response.Error(c, http.StatusInternalServerError, "ERROR", "Failed to calculate match", err) return } diff --git a/internal/api/handler/registration_handler.go b/internal/api/handler/registration_handler.go new file mode 100644 index 0000000..d6d9f3f --- /dev/null +++ b/internal/api/handler/registration_handler.go @@ -0,0 +1,209 @@ +package handler + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/moistello/backend/internal/domain/auth" + "github.com/moistello/backend/internal/domain/user" + "github.com/moistello/backend/internal/domain/verification" + "github.com/moistello/backend/internal/domain/wallet" + "github.com/moistello/backend/pkg/apperrors" + "github.com/moistello/backend/pkg/response" +) + +// RegistrationHandler handles the email + password registration flow: starting +// registration (sends an email OTP) and completing it by confirming the OTP. +type RegistrationHandler struct { + authService auth.Service + userRepo user.Repository + verificationSvc *verification.Service + walletSvc wallet.Service +} + +// NewRegistrationHandler builds the email/password registration handler. +func NewRegistrationHandler(authSvc auth.Service, userRepo user.Repository, + verificationSvc *verification.Service, walletSvc wallet.Service) *RegistrationHandler { + return &RegistrationHandler{ + authService: authSvc, + userRepo: userRepo, + verificationSvc: verificationSvc, + walletSvc: walletSvc, + } +} + +// emailWalletAddress returns the deterministic pseudo-wallet address used for +// email-based accounts. It is a stable, non-reversible identifier derived from +// the first 16 bytes of the email's SHA-256 digest. +func emailWalletAddress(email string) string { + h := sha256.Sum256([]byte(email)) + return "EMAIL:" + hex.EncodeToString(h[:16]) +} + +// Register starts the email-based registration flow. It accepts an email + +// password, checks for existing accounts, and dispatches a 6-digit OTP to the +// provided email address. The pending registration is stored in Redis until +// RegisterVerify confirms the OTP. +// +// Email is stored using user.HashEmail (full SHA-256 hex) — the single +// canonical representation used by FindByEmail and UpdateProfile. +// +// @Summary Start email registration +// @Tags Authentication +// @Accept json +// @Produce json +// @Param body body object true "Registration payload" {"email":"string","password":"string"} +// @Success 201 {object} response.Envelope +// @Failure 400 {object} response.Envelope +// @Failure 409 {object} response.Envelope +// @Router /auth/register [post] +func (h *RegistrationHandler) Register(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // Derive a deterministic wallet address for email-based accounts. + walletAddr := emailWalletAddress(req.Email) + + // Check for an existing account using the wallet address key. + existing, err := h.userRepo.FindByWalletAddress(c.Request.Context(), walletAddr) + if err != nil && err != apperrors.ErrNotFound { + response.InternalError(c, "failed to check existing account") + return + } + if existing != nil { + response.Conflict(c, "account already exists") + return + } + + // Hash the password before storing in pending registration. + passwordHash, err := auth.HashPassword(req.Password) + if err != nil { + response.InternalError(c, "failed to process password") + return + } + + // Store the pending registration and send the OTP. + pending := verification.PendingRegistration{ + Email: req.Email, + PasswordHash: passwordHash, + WalletAddr: walletAddr, + } + if err := h.verificationSvc.StorePendingRegistration(c.Request.Context(), req.Email, &pending); err != nil { + response.InternalError(c, "failed to store pending registration") + return + } + + if err := h.verificationSvc.SendOTP(c.Request.Context(), req.Email); err != nil { + response.InternalError(c, "failed to send verification code") + return + } + + response.Created(c, gin.H{"message": "verification code sent to your email"}) +} + +// RegisterVerify completes the email registration flow by confirming the OTP. +// On success it creates the user record (with hashed email), creates the +// on-chain wallet, and issues a session. +// +// @Summary Complete email registration +// @Tags Authentication +// @Accept json +// @Produce json +// @Param body body object true "Verification payload" {"email":"string","code":"string"} +// @Success 201 {object} response.Envelope +// @Failure 400 {object} response.Envelope +// @Router /auth/register/verify [post] +func (h *RegistrationHandler) RegisterVerify(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // Verify the OTP. + valid, err := h.verificationSvc.VerifyOTP(c.Request.Context(), req.Email, req.Code) + if err != nil || !valid { + response.BadRequest(c, "invalid or expired verification code") + return + } + + // Retrieve the pending registration payload. + pending, err := h.verificationSvc.GetPendingRegistration(c.Request.Context(), req.Email) + if err != nil || pending == nil { + response.BadRequest(c, "registration session expired; please register again") + return + } + + ctx := c.Request.Context() + + // Hash the email using the single canonical transform: full SHA-256 hex. + // This matches FindByEmail and UpdateProfile so all paths are consistent. + hashedEmail := user.HashEmail(req.Email) + + now := time.Now().UTC() + u := &user.User{ + ID: uuid.New(), + WalletAddress: pending.WalletAddr, + PreferredLanguage: strings.TrimSpace(pending.Language), + Role: user.RoleUser, + Email: &hashedEmail, + EmailVerified: true, + CreatedAt: now, + UpdatedAt: now, + } + if pending.DisplayName != "" { + u.DisplayName = &pending.DisplayName + } + // Store the password hash if provided. + if pending.PasswordHash != "" { + u.PasswordHash = sql.NullString{String: pending.PasswordHash, Valid: true} + } + + if err := h.userRepo.Create(ctx, u); err != nil { + if err == apperrors.ErrConflict { + response.Conflict(c, "account already exists") + return + } + response.InternalError(c, "failed to create account") + return + } + + // Derive wallet seed and create the on-chain wallet. + if h.walletSvc != nil { + if seed, seedErr := h.walletSvc.DeriveWalletSeed(ctx, req.Email); seedErr == nil { + // Ignore wallet creation errors — user is already persisted. + _, _ = h.walletSvc.CreateWallet(ctx, u.ID.String(), []byte(seed)) + } + } + + // Clean up the pending registration. + _ = h.verificationSvc.DeletePendingRegistration(ctx, req.Email) + + // Issue a session. + pair, err := h.authService.CreateSession(ctx, u.ID, string(u.Role), sessionTTLFromUser(u), deviceInfoFromContext(c)) + if err != nil { + response.InternalError(c, "failed to create session") + return + } + + response.Created(c, gin.H{ + "token": pair.AccessToken, + "refreshToken": pair.RefreshToken, + "csrfToken": pair.CSRFToken, + }) +} diff --git a/internal/api/handler/session_handler.go b/internal/api/handler/session_handler.go new file mode 100644 index 0000000..0488426 --- /dev/null +++ b/internal/api/handler/session_handler.go @@ -0,0 +1,158 @@ +package handler + +import ( + "crypto/sha256" + "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + + "github.com/moistello/backend/internal/api/middleware" + "github.com/moistello/backend/internal/domain/auth" + "github.com/moistello/backend/internal/domain/user" + "github.com/moistello/backend/pkg/response" +) + +// SessionHandler handles session lifecycle: token refresh, current-profile +// lookup, and logout / session revocation. +type SessionHandler struct { + authService auth.Service + userService user.Service + redisClient *redis.Client +} + +// NewSessionHandler builds a session management handler. +func NewSessionHandler(authSvc auth.Service, userSvc user.Service, redisClient *redis.Client) *SessionHandler { + return &SessionHandler{authService: authSvc, userService: userSvc, redisClient: redisClient} +} + +// @Summary Refresh JWT tokens +// @Description Exchanges a valid refresh token for a new access token and refresh token pair. +// @Tags Authentication +// @Accept json +// @Produce json +// @Param body body object true "Refresh token" { "refreshToken": "string" } +// @Success 200 {object} response.Envelope{data=object{token=string,refreshToken=string}} +// @Failure 400 {object} response.Envelope +// @Failure 401 {object} response.Envelope +// @Router /auth/refresh [post] +func (h *SessionHandler) Refresh(c *gin.Context) { + var req struct { + RefreshToken string `json:"refreshToken" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + tokenPair, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken) + if err != nil { + response.Unauthorized(c, "invalid refresh token") + return + } + response.OK(c, gin.H{"token": tokenPair.AccessToken, "refreshToken": tokenPair.RefreshToken, "csrfToken": tokenPair.CSRFToken}) +} + +// @Summary Get current user profile +// @Description Returns the authenticated user's profile. Requires Bearer token. Replaces the old POST /auth/me endpoint. +// @Tags Authentication +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Envelope{data=object{user=object}} +// @Failure 401 {object} response.Envelope +// @Router /me [get] +func (h *SessionHandler) Me(c *gin.Context) { + userID := middleware.GetUserID(c) + u, err := h.userService.GetByID(c.Request.Context(), userID) + if err != nil { + response.Unauthorized(c, "user not found") + return + } + response.OK(c, gin.H{"user": u}) +} + +// @Summary Logout / Terminate Session +// @Description Invalidates the current session and all refresh tokens. REST standard: DELETE /v1/auth/sessions +// @Tags Authentication +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Envelope{data=object{success=bool}} +// @Router /auth/sessions [delete] +func (h *SessionHandler) Logout(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + response.Unauthorized(c, "missing or invalid token") + return + } + token := strings.TrimPrefix(authHeader, "Bearer ") + + userID := middleware.GetUserID(c) + ctx := c.Request.Context() + + if h.redisClient != nil { + // 1. Blocklist the access token + if expiry, err := middleware.ExtractTokenExpiry(token); err == nil { + middleware.BlocklistToken(ctx, h.redisClient, token, expiry) + } + + // 2. Delete all user sessions from Redis + if userID != "" { + userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) + sessionHashes, err := h.redisClient.SMembers(ctx, userSessionsKey).Result() + if err == nil { + pipe := h.redisClient.Pipeline() + for _, hash := range sessionHashes { + pipe.Del(ctx, fmt.Sprintf("session:%s", hash)) + } + pipe.Del(ctx, userSessionsKey) + pipe.Exec(ctx) + } + + // 3. Set blocklist key for any missed sessions + middleware.BlocklistUserRefreshTokens(ctx, h.redisClient, userID) + } + + // 4. If refresh token was provided in body, also delete that specific session + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := c.ShouldBindJSON(&req); err == nil && req.RefreshToken != "" { + tokenHash := sha256HashForLogout(req.RefreshToken) + sessionKey := fmt.Sprintf("session:%s", tokenHash) + h.redisClient.Del(ctx, sessionKey) + } + } + + response.OK(c, gin.H{"success": true}) +} + +// @Summary Revoke specific session by ID/hash +// @Description Revokes a specific session. +// @Tags Authentication +// @Produce json +// @Security BearerAuth +// @Param id path string true "Session Hash / ID" +// @Success 200 {object} response.Envelope{data=object{success=bool}} +// @Router /auth/sessions/{id} [delete] +func (h *SessionHandler) RevokeSessionByID(c *gin.Context) { + sessionID := c.Param("id") + userID := middleware.GetUserID(c) + ctx := c.Request.Context() + + if h.redisClient != nil && sessionID != "" { + // If the id is a session hash, delete it directly, or remove from user sessions + sessionKey := fmt.Sprintf("session:%s", sessionID) + h.redisClient.Del(ctx, sessionKey) + if userID != "" { + userSessionsKey := fmt.Sprintf("user:sessions:%s", userID) + h.redisClient.SRem(ctx, userSessionsKey, sessionID) + } + } + + response.OK(c, gin.H{"success": true}) +} + +func sha256HashForLogout(s string) string { + hash := sha256.Sum256([]byte(s)) + return fmt.Sprintf("%x", hash) +} diff --git a/internal/api/handler/swap_handler.go b/internal/api/handler/swap_handler.go index 41286ef..7472562 100644 --- a/internal/api/handler/swap_handler.go +++ b/internal/api/handler/swap_handler.go @@ -166,11 +166,11 @@ func (h *SwapHandler) GetSwapHistory(c *gin.Context) { filter.Status = &status } - history, err := h.swapService.GetSwapHistory(c.Request.Context(), userID.(string), filter) + history, total, err := h.swapService.GetSwapHistory(c.Request.Context(), userID.(string), filter) if err != nil { response.InternalError(c, err.Error()) return } - response.OK(c, history) + response.OK(c, swap.SwapHistoryResponse{Swaps: history, Total: total, Limit: filter.Limit, Offset: filter.Offset}) } diff --git a/internal/api/handler/swap_handler_test.go b/internal/api/handler/swap_handler_test.go index e2873ab..3d73655 100644 --- a/internal/api/handler/swap_handler_test.go +++ b/internal/api/handler/swap_handler_test.go @@ -98,6 +98,15 @@ func (m *swapMockRepoForHandler) GetSwapOfferByID(ctx context.Context, id string func (m *swapMockRepoForHandler) UpdateSwapOfferStatus(ctx context.Context, id string, status swap.SwapOfferStatus, transactionHash *string) error { return nil } +func (m *swapMockRepoForHandler) CompareAndSwapStatus(ctx context.Context, id string, expectedStatus, newStatus swap.SwapOfferStatus, transactionHash *string) (bool, error) { + offer, ok := m.offers[id] + if !ok || offer.Status != expectedStatus { + return false, nil + } + offer.Status = newStatus + offer.TransactionHash = transactionHash + return true, nil +} func (m *swapMockRepoForHandler) ListUserSwapOffers(ctx context.Context, userID string, filter swap.SwapHistoryFilter) ([]swap.SwapOffer, int, error) { return []swap.SwapOffer{}, 0, nil } diff --git a/internal/api/handler/user_handler.go b/internal/api/handler/user_handler.go index 18091c8..f532372 100644 --- a/internal/api/handler/user_handler.go +++ b/internal/api/handler/user_handler.go @@ -2,7 +2,6 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/moistello/backend/internal/api/middleware" "github.com/moistello/backend/internal/domain/user" "github.com/moistello/backend/pkg/response" ) diff --git a/internal/api/handler/user_handler_test.go b/internal/api/handler/user_handler_test.go index a189c6a..b6ac8be 100644 --- a/internal/api/handler/user_handler_test.go +++ b/internal/api/handler/user_handler_test.go @@ -8,40 +8,22 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/moistello/backend/internal/api/handler" "github.com/moistello/backend/internal/domain/user" ) -func TestUserHandler_GetByID_ReturnsSanitizedPublicProfile(t *testing.T) { +func TestUserHandler_ClaimName(t *testing.T) { gin.SetMode(gin.TestMode) - displayName := "Moistello User" - email := "private@example.com" - phone := "+15555550100" - userID := uuid.New() - - h := handler.NewUserHandler(&fakeUserService{ - user: &user.User{ - ID: userID, - WalletAddress: "GABC_PUBLIC_KEY", - Email: &email, - Phone: &phone, - DisplayName: &displayName, - MoiScore: 700, - Role: user.RoleAdmin, - SessionTTLMinutes: 1440, - EmailVerified: true, - }, - }, nil) + h := handler.NewUserHandler(&fakeUserService{claimName: "@moistello_user"}) r := gin.New() - r.GET("/v1/users/:id", h.GetByID) + r.POST("/v1/users/username/claim", h.ClaimName) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/v1/users/"+userID.String(), nil) + req, _ := http.NewRequest("POST", "/v1/users/username/claim", nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -49,21 +31,15 @@ func TestUserHandler_GetByID_ReturnsSanitizedPublicProfile(t *testing.T) { var body map[string]any assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - data := body["data"].(map[string]any) - profile := data["user"].(map[string]any) - - assert.Equal(t, "GABC_PUBLIC_KEY", profile["publicKey"]) - assert.Equal(t, "Moistello User", profile["displayName"]) - assert.Equal(t, float64(700), profile["moiScore"]) - assert.NotContains(t, profile, "email") - assert.NotContains(t, profile, "phone") - assert.NotContains(t, profile, "role") - assert.NotContains(t, profile, "sessionTtlMinutes") - assert.NotContains(t, profile, "emailVerified") + data, ok := body["data"].(map[string]any) + assert.True(t, ok) + assert.Equal(t, true, data["success"]) + assert.Equal(t, "username claimed successfully", data["message"]) } type fakeUserService struct { - user *user.User + user *user.User + claimName string } func (s *fakeUserService) GetByID(context.Context, string) (*user.User, error) { @@ -103,7 +79,7 @@ func (s *fakeUserService) GetCircles(context.Context, string) ([]any, error) { } func (s *fakeUserService) ClaimName(context.Context) (string, error) { - return "", nil + return s.claimName, nil } func (s *fakeUserService) UpdateNotificationPreferences(_ context.Context, _ string, _ user.NotificationPrefsInput) (*user.User, error) { diff --git a/internal/api/handler/wallet_auth_handler.go b/internal/api/handler/wallet_auth_handler.go new file mode 100644 index 0000000..9177a4f --- /dev/null +++ b/internal/api/handler/wallet_auth_handler.go @@ -0,0 +1,96 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/moistello/backend/internal/domain/auth" + "github.com/moistello/backend/internal/domain/user" + "github.com/moistello/backend/pkg/response" + "github.com/moistello/backend/pkg/stellar" +) + +// WalletAuthHandler handles wallet-based authentication: obtaining a signed +// nonce and verifying a wallet signature to establish a session. +type WalletAuthHandler struct { + authService auth.Service + userService user.Service +} + +// NewWalletAuthHandler builds a wallet authentication handler. +func NewWalletAuthHandler(authSvc auth.Service, userSvc user.Service) *WalletAuthHandler { + return &WalletAuthHandler{authService: authSvc, userService: userSvc} +} + +// @Summary Get authentication nonce +// @Description Returns a signed nonce for wallet authentication. The nonce must be signed with the wallet's private key and sent to /auth/verify. +// @Tags Authentication +// @Accept json +// @Produce json +// @Param body body object true "Wallet address" { "walletAddress": "G..." } +// @Success 200 {object} response.Envelope{data=object{nonce=string}} +// @Failure 400 {object} response.Envelope +// @Router /auth/nonce [post] +func (h *WalletAuthHandler) Nonce(c *gin.Context) { + var req struct { + WalletAddress string `json:"walletAddress" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := stellar.ValidateAddress(req.WalletAddress); err != nil { + response.BadRequest(c, "invalid wallet address: "+err.Error()) + return + } + + nonce, err := h.authService.GenerateNonce(c.Request.Context(), req.WalletAddress) + if err != nil { + response.InternalError(c, "failed to generate nonce") + return + } + response.OK(c, gin.H{"nonce": nonce}) +} + +// @Summary Verify wallet authentication +// @Description Verifies a signed nonce and creates a session. +// @Tags Authentication +// @Accept json +// @Produce json +// @Param body body object true "Signature payload" { "walletAddress": "G...", "signature": "..." } +// @Success 200 {object} response.Envelope{data=object{token=string,refreshToken=string}} +// @Failure 400 {object} response.Envelope +// @Failure 401 {object} response.Envelope +// @Router /auth/verify [post] +func (h *WalletAuthHandler) Verify(c *gin.Context) { + var req struct { + WalletAddress string `json:"walletAddress" binding:"required"` + Signature string `json:"signature" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + u, err := h.userService.GetByWallet(c.Request.Context(), req.WalletAddress) + if err != nil { + response.NotFound(c, "account not found") + return + } + + valid, err := h.authService.VerifySignature(c.Request.Context(), req.WalletAddress, req.Signature) + if err != nil || !valid { + response.Unauthorized(c, "signature verification failed") + return + } + + pair, err := h.authService.CreateSession(c.Request.Context(), u.ID, string(u.Role), sessionTTLFromUser(u), deviceInfoFromContext(c)) + if err != nil { + response.InternalError(c, "failed to create session") + return + } + + response.OK(c, gin.H{ + "token": pair.AccessToken, "refreshToken": pair.RefreshToken, "csrfToken": pair.CSRFToken, "user": u, + }) +} diff --git a/internal/api/handler/webhook_handler.go b/internal/api/handler/webhook_handler.go index 7a9c2cf..bbdf8ab 100644 --- a/internal/api/handler/webhook_handler.go +++ b/internal/api/handler/webhook_handler.go @@ -65,6 +65,9 @@ func (h *WebhookHandler) RegisterWebhook(c *gin.Context) { Events: req.Events, IsActive: true, } + // The secret must never be persisted — clear it before handing the record + // to the repository so only secret_hash is stored. + record.Secret = "" if err := h.repo.Register(c.Request.Context(), record); err != nil { response.InternalError(c, "failed to register webhook") return diff --git a/internal/api/handler/webhook_secret_handler_test.go b/internal/api/handler/webhook_secret_handler_test.go index 520ca54..02cf751 100644 --- a/internal/api/handler/webhook_secret_handler_test.go +++ b/internal/api/handler/webhook_secret_handler_test.go @@ -226,4 +226,3 @@ func TestWebhookIncoming_WrongSignatureRejected(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w2.Code, "wrong signature must be rejected") } - diff --git a/internal/api/router.go b/internal/api/router.go index 6ced650..d545a45 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,37 +1,279 @@ -package router +package api import ( + "time" + "github.com/gin-gonic/gin" + "github.com/moistello/backend/config" "github.com/moistello/backend/internal/api/handler" "github.com/moistello/backend/internal/api/middleware" + "github.com/moistello/backend/webhook" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/redis/go-redis/v9" ) -func SetupRouter(authHandler *handler.AuthHandler, userHandler *handler.UserHandler, pubKey []byte) *gin.Engine { +// perResource is a small helper so route registration below doesn't have to +// repeat the redisClient/resource/limit/window/fail-closed boilerplate for +// every sensitive route (#197 — PerResourceRateLimitMiddleware existed but +// was never applied to any route). +func perResource(redisClient *redis.Client, resource string, limit, windowSeconds int) gin.HandlerFunc { + return middleware.PerResourceRateLimitMiddleware( + redisClient, + resource, + limit, + time.Duration(windowSeconds)*time.Second, + middleware.WithFailClosed(), + ) +} + +func NewRouter( + cfg *config.Config, + redisClient *redis.Client, + authHandler *handler.AuthHandler, + userHandler *handler.UserHandler, + circleHandler *handler.CircleHandler, + contributionHandler *handler.ContributionHandler, + payoutHandler *handler.PayoutHandler, + inviteHandler *handler.InviteHandler, + notificationHandler *handler.NotificationHandler, + adminHandler *handler.AdminHandler, + webhookHandler *handler.WebhookHandler, + healthHandler *handler.HealthHandler, + passkeyCredentialHandler *handler.PasskeyCredentialHandler, + walletHandler *handler.WalletHandler, + depositHandler *handler.DepositHandler, + mobileMoneyHandler *handler.MobileMoneyHandler, + chatHandler *handler.ChatHandler, + communityHandler *handler.CommunityHandler, + wsHandler *handler.WebSocketHandler, + savingsGoalHandler *handler.SavingsGoalHandler, + tokenHandler *handler.TokenHandler, + swapHandler *handler.SwapHandler, + governanceHandler *handler.GovernanceHandler, + reputationHandler *handler.ReputationHandler, + referralHandler *handler.ReferralHandler, + consentHandler *handler.ConsentHandler, + adminJobQueueHandler *handler.AdminJobQueueHandler, + webhookRepo webhook.WebhookRepository, + yellowCardWebhookHandler *handler.YellowCardWebhookHandler, + jwtPublicKey []byte, +) *gin.Engine { r := gin.New() - r.Use(gin.Recovery()) - v1 := r.Group("/v1") + r.Use(middleware.RecoveryMiddleware()) + r.Use(middleware.TracingMiddleware(cfg.Tracing.ServiceName)) + r.Use(middleware.LoggingMiddleware()) + r.Use(middleware.CORSMiddleware(cfg.CORS)) + r.Use(middleware.PrometheusMiddleware()) + + // Prometheus metrics endpoint — protected by admin API key, un-rate-limited + metricsKey := cfg.Auth.AdminAPIKey + r.GET("/metrics", middleware.AdminAPIKeyMiddleware(metricsKey), gin.WrapH(promhttp.Handler())) + + r.Use(middleware.RateLimitMiddleware(redisClient, cfg.RateLimit)) + + r.GET("/health", healthHandler.Health) + r.GET("/health/ready", healthHandler.Readiness) + r.GET("/health/live", healthHandler.Liveness) + + swaggerH := handler.NewSwaggerHandler() + r.GET("/api-docs", swaggerH.ServeUI) + r.GET("/api-docs/openapi.json", swaggerH.ServeJSON) + + // Public webhooks (idempotency-keyed internally) + r.POST("/webhooks/incoming/:id", handler.NewIncomingWebhookHandler(webhookRepo).ReceiveWebhook) + r.POST("/webhooks/yellowcard", yellowCardWebhookHandler.HandleWebhook) + + // WebSocket — real-time events + wsRoute := r.Group("") + wsRoute.Use(middleware.AuthMiddleware(jwtPublicKey)) + wsRoute.Use(middleware.TokenBlocklistMiddleware(redisClient)) + wsRoute.Use(middleware.CSRFTokenValidator(redisClient)) { - // Auth routes - authGroup := v1.Group("/auth") + wsRoute.GET("/ws", wsHandler.HandleWebSocket) + } + + api := r.Group("/v1") + { + auth := api.Group("/auth") + auth.Use(middleware.AuthRateLimitMiddleware(redisClient, cfg.RateLimit)) { - authGroup.POST("/nonce", authHandler.Nonce) - authGroup.POST("/verify", authHandler.Verify) - authGroup.POST("/refresh", authHandler.Refresh) - // REST standard session routes with backward compatibility aliases - authGroup.DELETE("/sessions", middleware.AuthMiddleware(pubKey), authHandler.Logout) - authGroup.DELETE("/sessions/:id", middleware.AuthMiddleware(pubKey), authHandler.RevokeSessionByID) - authGroup.POST("/logout", middleware.AuthMiddleware(pubKey), authHandler.Logout) + auth.POST("/register", perResource(redisClient, "otp", cfg.RateLimit.OTPLimit, cfg.RateLimit.OTPWindowSeconds), authHandler.Register) + auth.POST("/register/verify", perResource(redisClient, "otp", cfg.RateLimit.OTPLimit, cfg.RateLimit.OTPWindowSeconds), authHandler.RegisterVerify) + auth.POST("/refresh", middleware.RefreshTokenBlocklistMiddleware(redisClient), authHandler.Refresh) + auth.POST("/nonce", authHandler.Nonce) + auth.POST("/verify", authHandler.Verify) } - // User & Profile routes - usersGroup := v1.Group("/users", middleware.AuthMiddleware(pubKey)) + authenticated := api.Group("") + authenticated.Use(middleware.AuthMiddleware(jwtPublicKey)) + authenticated.Use(middleware.TokenBlocklistMiddleware(redisClient)) + authenticated.Use(middleware.CSRFTokenValidator(redisClient)) + // Idempotency must run after AuthMiddleware so keys are scoped per + // user (#198) — a global, pre-auth middleware let idempotency keys + // collide across different users' requests. + authenticated.Use(middleware.IdempotencyMiddleware(redisClient)) { - usersGroup.POST("/username/claim", userHandler.ClaimName) + authenticated.GET("/me", authHandler.Me) + authenticated.POST("/auth/logout", authHandler.Logout) + authenticated.DELETE("/sessions/:id", authHandler.RevokeSessionByID) + + authenticated.POST("/users/username/claim", userHandler.ClaimName) + + // Public — claim a unique anonymous name (before auth) + api.POST("/claim-name", userHandler.ClaimName) + + // Passkey credential store/retrieval + authenticated.POST("/credential", passkeyCredentialHandler.StoreCredential) + authenticated.GET("/credential", passkeyCredentialHandler.GetCredential) + + // Wallet routes + authenticated.POST("/wallets", walletHandler.CreateWallet) + authenticated.GET("/wallets", walletHandler.ListWallets) + authenticated.GET("/wallets/balance", walletHandler.GetBalance) + authenticated.POST("/wallets/withdraw", perResource(redisClient, "wallet-transfer", cfg.RateLimit.WalletTransferLimit, cfg.RateLimit.WalletTransferWindowSeconds), walletHandler.Withdraw) + authenticated.DELETE("/wallets/:id", walletHandler.DeleteWallet) + + // Deposit / Withdraw routes + authenticated.GET("/wallet/deposit/quote", depositHandler.GetDepositQuote) + authenticated.POST("/wallet/deposit", perResource(redisClient, "wallet-transfer", cfg.RateLimit.WalletTransferLimit, cfg.RateLimit.WalletTransferWindowSeconds), depositHandler.InitiateDeposit) + authenticated.POST("/wallet/withdraw", perResource(redisClient, "wallet-transfer", cfg.RateLimit.WalletTransferLimit, cfg.RateLimit.WalletTransferWindowSeconds), depositHandler.InitiateWithdraw) + authenticated.GET("/wallet/transactions/:yellowCardId", depositHandler.GetTransactionStatus) + authenticated.POST("/wallet/mobile-money/onramp", perResource(redisClient, "wallet-transfer", cfg.RateLimit.WalletTransferLimit, cfg.RateLimit.WalletTransferWindowSeconds), mobileMoneyHandler.InitiateOnramp) + authenticated.POST("/wallet/mobile-money/offramp", perResource(redisClient, "wallet-transfer", cfg.RateLimit.WalletTransferLimit, cfg.RateLimit.WalletTransferWindowSeconds), mobileMoneyHandler.InitiateOfframp) + authenticated.GET("/wallet/mobile-money/:id", mobileMoneyHandler.GetTransaction) + + authenticated.POST("/chat/keys", chatHandler.PublishKeys) + authenticated.GET("/chat/keys/:userId", chatHandler.GetBundle) + authenticated.POST("/chat/conversations", chatHandler.CreateConversation) + authenticated.GET("/chat/conversations", chatHandler.ListConversations) + authenticated.POST("/chat/conversations/:id/messages", chatHandler.SendMessage) + authenticated.GET("/chat/conversations/:id/messages", chatHandler.ListMessages) + + // Circles + authenticated.POST("/circles", circleHandler.CreateCircle) + authenticated.GET("/circles/:id", circleHandler.GetCircle) + authenticated.PATCH("/circles/:id", circleHandler.UpdateCircle) + authenticated.POST("/circles/:id/start", circleHandler.StartCircle) + authenticated.POST("/circles/:id/payout", circleHandler.TriggerPayout) + authenticated.POST("/circles/:id/close", circleHandler.CloseCircle) + authenticated.DELETE("/circles/:id", circleHandler.CancelCircle) + authenticated.POST("/circles/:id/join", circleHandler.JoinCircle) + authenticated.POST("/circles/:id/contribute", perResource(redisClient, "contribute", cfg.RateLimit.ContributeLimit, cfg.RateLimit.ContributeWindowSeconds), circleHandler.Contribute) + authenticated.POST("/circles/:id/exit", circleHandler.ExitCircle) + authenticated.GET("/circles/:id/members", circleHandler.GetMembers) + authenticated.GET("/circles/:id/rounds", circleHandler.GetRounds) + authenticated.GET("/circles/:id/payouts", circleHandler.GetPayouts) + authenticated.POST("/circles/:id/dispute", circleHandler.Dispute) + authenticated.POST("/circles/:id/vote", circleHandler.Vote) + authenticated.POST("/circles/:id/auction-bid", circleHandler.AuctionBid) + authenticated.POST("/circles/:id/members/:address/remove", circleHandler.RemoveMember) + + authenticated.GET("/circles/:id/invites", inviteHandler.ListInvites) + authenticated.POST("/circles/:id/invites", inviteHandler.CreateInvite) + authenticated.DELETE("/invites/:code", inviteHandler.RevokeInvite) + + authenticated.GET("/contributions", contributionHandler.ListContributions) + authenticated.GET("/contributions/:id", contributionHandler.GetContribution) + + authenticated.GET("/payouts", payoutHandler.ListPayouts) + authenticated.GET("/payouts/:id", payoutHandler.GetPayout) + + // Governance + authenticated.POST("/governance/proposals", governanceHandler.CreateProposal) + authenticated.GET("/governance/proposals", governanceHandler.ListProposals) + authenticated.GET("/governance/proposals/:id", governanceHandler.GetProposal) + authenticated.POST("/governance/proposals/:id/vote", governanceHandler.VoteProposal) + authenticated.POST("/governance/proposals/:id/execute", governanceHandler.ExecuteProposal) + + // Reputation tiers + authenticated.GET("/reputation/tiers", reputationHandler.GetTiers) + authenticated.GET("/reputation/tier/:address", reputationHandler.GetTierByAddress) + + // Referral system + authenticated.POST("/referral/code", perResource(redisClient, "referral", cfg.RateLimit.ReferralLimit, cfg.RateLimit.ReferralWindowSeconds), referralHandler.GenerateCode) + authenticated.GET("/referral/stats", referralHandler.GetStats) + authenticated.GET("/referral/history", referralHandler.GetHistory) + + // Communities + authenticated.POST("/communities", communityHandler.Create) + authenticated.GET("/communities", communityHandler.List) + authenticated.GET("/communities/:id", communityHandler.Get) + authenticated.GET("/communities/slug/:slug", communityHandler.GetBySlug) + authenticated.PATCH("/communities/:id", communityHandler.Update) + authenticated.DELETE("/communities/:id", communityHandler.Delete) + authenticated.POST("/communities/:id/join", communityHandler.Join) + authenticated.POST("/communities/:id/leave", communityHandler.Leave) + authenticated.GET("/communities/:id/members", communityHandler.GetMembers) + authenticated.GET("/communities/:id/membership", communityHandler.IsMember) + authenticated.POST("/communities/:id/announcements", communityHandler.CreateAnnouncement) + authenticated.GET("/communities/:id/announcements", communityHandler.GetAnnouncements) + authenticated.DELETE("/communities/:id/announcements/:announcementId", communityHandler.DeleteAnnouncement) + authenticated.POST("/communities/:id/announcements/:announcementId/like", communityHandler.LikeAnnouncement) + authenticated.PATCH("/communities/:id/announcements/:announcementId/pin", communityHandler.PinAnnouncement) + authenticated.DELETE("/communities/:id/members/:memberId", communityHandler.RemoveMember) + authenticated.POST("/communities/:id/transfer-ownership", communityHandler.TransferOwnership) + authenticated.GET("/communities/:id/activity", communityHandler.GetActivity) + authenticated.GET("/users/me/communities", communityHandler.GetMyCommunities) + + authenticated.GET("/notifications", notificationHandler.ListNotifications) + authenticated.PATCH("/notifications/:id/read", notificationHandler.MarkRead) + authenticated.PATCH("/notifications/read-all", notificationHandler.MarkAllRead) + authenticated.PUT("/notifications/preferences", notificationHandler.UpdatePreferences) + + // Savings goals + authenticated.POST("/savings/goals", savingsGoalHandler.Create) + authenticated.GET("/savings/goals", savingsGoalHandler.List) + authenticated.GET("/savings/goals/active", savingsGoalHandler.ListActive) + authenticated.GET("/savings/goals/summary", savingsGoalHandler.Summary) + authenticated.GET("/savings/goals/obligations", savingsGoalHandler.UpcomingObligations) + authenticated.GET("/savings/goals/:id", savingsGoalHandler.Get) + authenticated.PATCH("/savings/goals/:id", savingsGoalHandler.Update) + authenticated.DELETE("/savings/goals/:id", savingsGoalHandler.Delete) + authenticated.POST("/savings/goals/:id/complete", savingsGoalHandler.Complete) + + // Token routes + authenticated.GET("/token/balance/:address", tokenHandler.GetBalance) + authenticated.POST("/token/stake", tokenHandler.Stake) + authenticated.POST("/token/unstake", tokenHandler.Unstake) + authenticated.GET("/token/stakes/:address", tokenHandler.GetStakes) + + // Swap endpoints + authenticated.POST("/swap/offer", perResource(redisClient, "swap", cfg.RateLimit.SwapLimit, cfg.RateLimit.SwapWindowSeconds), swapHandler.CreateSwapOffer) + authenticated.POST("/swap/accept", perResource(redisClient, "swap", cfg.RateLimit.SwapLimit, cfg.RateLimit.SwapWindowSeconds), swapHandler.AcceptSwapOffer) + authenticated.POST("/swap/cancel", perResource(redisClient, "swap", cfg.RateLimit.SwapLimit, cfg.RateLimit.SwapWindowSeconds), swapHandler.CancelSwapOffer) + authenticated.GET("/swap/history", swapHandler.GetSwapHistory) + + authenticated.POST("/webhooks", webhookHandler.RegisterWebhook) + authenticated.GET("/webhooks", webhookHandler.ListWebhooks) + authenticated.GET("/webhooks/deliveries", webhookHandler.ListDeliveries) + authenticated.DELETE("/webhooks/:id", webhookHandler.DeleteWebhook) } - // Legacy alias for non-RESTful claim-name - v1.POST("/claim-name", middleware.AuthMiddleware(pubKey), userHandler.ClaimName) + admin := authenticated.Group("/admin") + admin.Use(middleware.AdminMiddleware()) + { + admin.GET("/users", adminHandler.ListUsers) + admin.GET("/circles", adminHandler.ListCircles) + admin.GET("/audit-log", adminHandler.GetAuditLog) + admin.GET("/metrics", adminHandler.GetMetrics) + admin.GET("/feature-flags", adminHandler.ListFeatureFlags) + admin.GET("/feature-flags/:flag", adminHandler.GetFeatureFlag) + admin.POST("/feature-flags", adminHandler.UpdateFeatureFlag) + admin.DELETE("/feature-flags/:flag", adminHandler.DeleteFeatureFlag) + admin.GET("/jobs/dead-letter", adminJobQueueHandler.GetDeadLetterJobs) + admin.POST("/jobs/dead-letter/:id/retry", adminJobQueueHandler.RetryDeadLetterJob) + } + + optional := api.Group("") + optional.Use(middleware.OptionalAuthMiddleware(jwtPublicKey)) + { + optional.GET("/circles", circleHandler.ListCircles) + + // GDPR cookie consent — works for both authenticated and anonymous users + optional.GET("/consent", consentHandler.GetConsent) + optional.POST("/consent", consentHandler.SaveConsent) + } } return r diff --git a/internal/domain/incentives/repository.go b/internal/domain/incentives/repository.go index ed11f95..aa1ae5b 100644 --- a/internal/domain/incentives/repository.go +++ b/internal/domain/incentives/repository.go @@ -2,36 +2,56 @@ package incentives import ( "context" - "database/sql" "errors" "sync" "github.com/google/uuid" - "github.com/jmoiron/sqlx" ) type Repository interface { + Transact(ctx context.Context, fn func(repo Repository) error) error + + // Referrals CreateReferral(ctx context.Context, ref *Referral) error GetReferralByCode(ctx context.Context, code string) (*Referral, error) GetReferrerByUserID(ctx context.Context, userID uuid.UUID) (*Referral, error) UpdateReferral(ctx context.Context, ref *Referral) error - CreateIncentive(ctx context.Context, inc *Incentive) (*Incentive, error) + FindByReferralCode(ctx context.Context, code string) (*Referral, error) + FindByReferrerID(ctx context.Context, userID uuid.UUID) ([]Referral, error) + FindByReferredID(ctx context.Context, userID uuid.UUID) (*Referral, error) + UpdateReferralStatus(ctx context.Context, id uuid.UUID, status string) error + + // Incentives + CreateIncentive(ctx context.Context, inc *Incentive) error GetIncentives(ctx context.Context, userID uuid.UUID) ([]Incentive, error) + FindByID(ctx context.Context, id uuid.UUID) (*Incentive, error) + FindByUserID(ctx context.Context, userID uuid.UUID) ([]Incentive, error) + FindByUserIDAndType(ctx context.Context, userID uuid.UUID, typ IncentiveType) ([]Incentive, error) + GetPendingIncentives(ctx context.Context, userID uuid.UUID) ([]Incentive, error) + GetUserIncentiveSummary(ctx context.Context, userID uuid.UUID) (*UserIncentiveSummary, error) + UpdateIncentiveStatus(ctx context.Context, id uuid.UUID, status IncentiveStatus) error + + // Streaks GetStreak(ctx context.Context, userID uuid.UUID) (*SavingsStreak, error) UpsertStreak(ctx context.Context, streak *SavingsStreak) (*SavingsStreak, error) + FindStreakByUserID(ctx context.Context, userID uuid.UUID) (*SavingsStreak, error) + CreateSavingsStreak(ctx context.Context, streak *SavingsStreak) error + UpdateSavingsStreak(ctx context.Context, streak *SavingsStreak) error + + // Config GetConfig(ctx context.Context) (*IncentiveConfig, error) UpdateConfig(ctx context.Context, config *IncentiveConfig) error - Transact(ctx context.Context, fn func(repo Repository) error) error + CreateConfig(ctx context.Context, config *IncentiveConfig) error } type mockRepository struct { - mu sync.Mutex - referralCodeMap map[string]*Referral - referrerUserMap map[uuid.UUID]*Referral - createdIncentives []Incentive - userIncentives []Incentive - streak *SavingsStreak - config *IncentiveConfig + mu sync.Mutex + referralCodeMap map[string]*Referral + referrerUserMap map[uuid.UUID]*Referral + createdIncentives []Incentive + userIncentives []Incentive + streak *SavingsStreak + config *IncentiveConfig updatedReferralStatus string } @@ -54,7 +74,7 @@ func (m *mockRepository) CreateReferral(ctx context.Context, ref *Referral) erro m.mu.Lock() defer m.mu.Unlock() if _, exists := m.referralCodeMap[ref.ReferralCode]; exists { - return errors.New("referral code taken") + return ErrReferralCodeTaken } m.referralCodeMap[ref.ReferralCode] = ref m.referrerUserMap[ref.ReferrerID] = ref @@ -99,13 +119,63 @@ func (m *mockRepository) UpdateReferral(ctx context.Context, ref *Referral) erro return nil } -func (m *mockRepository) CreateIncentive(ctx context.Context, inc *Incentive) (*Incentive, error) { +func (m *mockRepository) FindByReferralCode(ctx context.Context, code string) (*Referral, error) { + m.mu.Lock() + defer m.mu.Unlock() + ref, exists := m.referralCodeMap[code] + if !exists { + return nil, errors.New("not found") + } + copy := *ref + return ©, nil +} + +func (m *mockRepository) FindByReferrerID(ctx context.Context, userID uuid.UUID) ([]Referral, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res []Referral + for _, ref := range m.referrerUserMap { + if ref.ReferrerID == userID { + res = append(res, *ref) + } + } + return res, nil +} + +func (m *mockRepository) FindByReferredID(ctx context.Context, userID uuid.UUID) (*Referral, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, ref := range m.referralCodeMap { + if ref.ReferredID == userID && ref.ReferredID != uuid.Nil { + copy := *ref + return ©, nil + } + } + return nil, errors.New("not found") +} + +func (m *mockRepository) UpdateReferralStatus(ctx context.Context, id uuid.UUID, status string) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, ref := range m.referralCodeMap { + if ref.ID == id { + if ref.Status == "completed" || ref.ReferredID != uuid.Nil { + return ErrReferralCodeAlreadyUsed + } + ref.Status = status + m.updatedReferralStatus = status + return nil + } + } + return errors.New("not found") +} + +func (m *mockRepository) CreateIncentive(ctx context.Context, inc *Incentive) error { m.mu.Lock() defer m.mu.Unlock() m.createdIncentives = append(m.createdIncentives, *inc) m.userIncentives = append(m.userIncentives, *inc) - copy := *inc - return ©, nil + return nil } func (m *mockRepository) GetIncentives(ctx context.Context, userID uuid.UUID) ([]Incentive, error) { @@ -120,6 +190,95 @@ func (m *mockRepository) GetIncentives(ctx context.Context, userID uuid.UUID) ([ return res, nil } +func (m *mockRepository) FindByID(ctx context.Context, id uuid.UUID) (*Incentive, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, inc := range m.userIncentives { + if inc.ID == id { + copy := inc + return ©, nil + } + } + return nil, ErrIncentiveNotFound +} + +func (m *mockRepository) FindByUserID(ctx context.Context, userID uuid.UUID) ([]Incentive, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res []Incentive + for _, inc := range m.userIncentives { + if inc.UserID == userID { + res = append(res, inc) + } + } + return res, nil +} + +func (m *mockRepository) FindByUserIDAndType(ctx context.Context, userID uuid.UUID, typ IncentiveType) ([]Incentive, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res []Incentive + for _, inc := range m.userIncentives { + if inc.UserID == userID && inc.Type == typ { + res = append(res, inc) + } + } + return res, nil +} + +func (m *mockRepository) GetPendingIncentives(ctx context.Context, userID uuid.UUID) ([]Incentive, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res []Incentive + for _, inc := range m.userIncentives { + if inc.UserID == userID && inc.Status == IncentiveStatusPending { + res = append(res, inc) + } + } + return res, nil +} + +func (m *mockRepository) GetUserIncentiveSummary(ctx context.Context, userID uuid.UUID) (*UserIncentiveSummary, error) { + m.mu.Lock() + defer m.mu.Unlock() + summary := &UserIncentiveSummary{} + for _, inc := range m.userIncentives { + if inc.UserID != userID { + continue + } + summary.TotalEarned += inc.Amount + if inc.Status == IncentiveStatusClaimed { + summary.TotalClaimed += inc.Amount + } + if inc.Status == IncentiveStatusPending { + summary.PendingAmount += inc.Amount + } + if inc.Type == IncentiveTypeReferral { + summary.ReferralCount++ + } + } + if m.streak != nil && m.streak.UserID == userID { + summary.CurrentStreak = m.streak.CurrentStreak + summary.LongestStreak = m.streak.LongestStreak + summary.BonusTier = m.streak.BonusTier + } else { + summary.BonusTier = 1 + } + return summary, nil +} + +func (m *mockRepository) UpdateIncentiveStatus(ctx context.Context, id uuid.UUID, status IncentiveStatus) error { + m.mu.Lock() + defer m.mu.Unlock() + for i := range m.userIncentives { + if m.userIncentives[i].ID == id { + m.userIncentives[i].Status = status + return nil + } + } + return ErrIncentiveNotFound +} + func (m *mockRepository) GetStreak(ctx context.Context, userID uuid.UUID) (*SavingsStreak, error) { m.mu.Lock() defer m.mu.Unlock() @@ -138,6 +297,30 @@ func (m *mockRepository) UpsertStreak(ctx context.Context, streak *SavingsStreak return ©, nil } +func (m *mockRepository) FindStreakByUserID(ctx context.Context, userID uuid.UUID) (*SavingsStreak, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.streak != nil && m.streak.UserID == userID { + copy := *m.streak + return ©, nil + } + return nil, ErrIncentiveNotFound +} + +func (m *mockRepository) CreateSavingsStreak(ctx context.Context, streak *SavingsStreak) error { + m.mu.Lock() + defer m.mu.Unlock() + m.streak = streak + return nil +} + +func (m *mockRepository) UpdateSavingsStreak(ctx context.Context, streak *SavingsStreak) error { + m.mu.Lock() + defer m.mu.Unlock() + m.streak = streak + return nil +} + func (m *mockRepository) GetConfig(ctx context.Context) (*IncentiveConfig, error) { m.mu.Lock() defer m.mu.Unlock() @@ -151,6 +334,13 @@ func (m *mockRepository) UpdateConfig(ctx context.Context, config *IncentiveConf return nil } +func (m *mockRepository) CreateConfig(ctx context.Context, config *IncentiveConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + m.config = config + return nil +} + func (m *mockRepository) Transact(ctx context.Context, fn func(repo Repository) error) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/domain/incentives/repository_pg.go b/internal/domain/incentives/repository_pg.go index 162ae90..1a336bd 100644 --- a/internal/domain/incentives/repository_pg.go +++ b/internal/domain/incentives/repository_pg.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "errors" - "fmt" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -18,6 +17,10 @@ func NewPostgresRepository(db *sqlx.DB) Repository { return &postgresRepository{db: db} } +func NewRepository(db *sqlx.DB) Repository { + return NewPostgresRepository(db) +} + func (r *postgresRepository) Transact(ctx context.Context, fn func(repo Repository) error) error { tx, err := r.db.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { @@ -87,13 +90,10 @@ func (r *postgresRepository) UpdateReferral(ctx context.Context, ref *Referral) return nil } -func (r *postgresRepository) CreateIncentive(ctx context.Context, inc *Incentive) (*Incentive, error) { - query := `INSERT INTO incentives (id, user_id, type, amount, currency, reference_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id` - err := r.db.GetContext(ctx, &inc.ID, query, inc.ID, inc.UserID, inc.Type, inc.Amount, inc.Currency, inc.ReferenceID, inc.CreatedAt) - if err != nil { - return nil, err - } - return inc, nil +func (r *postgresRepository) CreateIncentive(ctx context.Context, inc *Incentive) error { + query := `INSERT INTO incentives (id, user_id, type, status, amount, currency, reference_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())` + _, err := r.db.ExecContext(ctx, query, inc.ID, inc.UserID, inc.Type, inc.Status, inc.Amount, inc.Currency, inc.ReferenceID) + return err } func (r *postgresRepository) GetIncentives(ctx context.Context, userID uuid.UUID) ([]Incentive, error) { @@ -150,3 +150,145 @@ func (r *postgresRepository) UpdateConfig(ctx context.Context, config *Incentive _, err := r.db.ExecContext(ctx, query, config.ReferralBonusAmount, config.ReferralBonusCurrency, config.CircleCompletionBonus, config.CircleCompletionCurrency, config.ContributionMatchPercent, config.ContributionMatchMax) return err } + +func (r *postgresRepository) FindByReferralCode(ctx context.Context, code string) (*Referral, error) { + query := `SELECT id, referrer_id, COALESCE(referred_id, '00000000-0000-0000-0000-000000000000'), referral_code, status, created_at FROM referrals WHERE referral_code = $1 FOR UPDATE` + var ref Referral + err := r.db.GetContext(ctx, &ref, query, code) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrReferralCodeNotFound + } + return nil, err + } + return &ref, nil +} + +func (r *postgresRepository) FindByReferrerID(ctx context.Context, userID uuid.UUID) ([]Referral, error) { + query := `SELECT id, referrer_id, COALESCE(referred_id, '00000000-0000-0000-0000-000000000000'), referral_code, status, created_at FROM referrals WHERE referrer_id = $1` + var refs []Referral + err := r.db.SelectContext(ctx, &refs, query, userID) + return refs, err +} + +func (r *postgresRepository) FindByReferredID(ctx context.Context, userID uuid.UUID) (*Referral, error) { + query := `SELECT id, referrer_id, COALESCE(referred_id, '00000000-0000-0000-0000-000000000000'), referral_code, status, created_at FROM referrals WHERE referred_id = $1` + var ref Referral + err := r.db.GetContext(ctx, &ref, query, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &ref, nil +} + +func (r *postgresRepository) UpdateReferralStatus(ctx context.Context, id uuid.UUID, status string) error { + query := `UPDATE referrals SET status = $2, completed_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'pending'` + res, err := r.db.ExecContext(ctx, query, id, status) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return ErrReferralCodeAlreadyUsed + } + return nil +} + +func (r *postgresRepository) FindByID(ctx context.Context, id uuid.UUID) (*Incentive, error) { + query := `SELECT id, user_id, type, status, amount, currency, metadata, reference_id, expires_at, claimed_at, created_at, updated_at FROM incentives WHERE id = $1` + var inc Incentive + err := r.db.GetContext(ctx, &inc, query, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrIncentiveNotFound + } + return nil, err + } + return &inc, nil +} + +func (r *postgresRepository) FindByUserID(ctx context.Context, userID uuid.UUID) ([]Incentive, error) { + query := `SELECT id, user_id, type, status, amount, currency, metadata, reference_id, expires_at, claimed_at, created_at, updated_at FROM incentives WHERE user_id = $1` + var incentives []Incentive + err := r.db.SelectContext(ctx, &incentives, query, userID) + return incentives, err +} + +func (r *postgresRepository) FindByUserIDAndType(ctx context.Context, userID uuid.UUID, typ IncentiveType) ([]Incentive, error) { + query := `SELECT id, user_id, type, status, amount, currency, metadata, reference_id, expires_at, claimed_at, created_at, updated_at FROM incentives WHERE user_id = $1 AND type = $2` + var incentives []Incentive + err := r.db.SelectContext(ctx, &incentives, query, userID, typ) + return incentives, err +} + +func (r *postgresRepository) GetPendingIncentives(ctx context.Context, userID uuid.UUID) ([]Incentive, error) { + query := `SELECT id, user_id, type, status, amount, currency, metadata, reference_id, expires_at, claimed_at, created_at, updated_at FROM incentives WHERE user_id = $1 AND status = 'pending'` + var incentives []Incentive + err := r.db.SelectContext(ctx, &incentives, query, userID) + return incentives, err +} + +func (r *postgresRepository) GetUserIncentiveSummary(ctx context.Context, userID uuid.UUID) (*UserIncentiveSummary, error) { + query := `SELECT + COALESCE(SUM(CASE WHEN status != 'cancelled' THEN amount ELSE 0 END), 0) AS total_earned, + COALESCE(SUM(CASE WHEN status = 'claimed' THEN amount ELSE 0 END), 0) AS total_claimed, + COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) AS pending_amount, + COALESCE(COUNT(CASE WHEN type = 'referral' THEN 1 END), 0) AS referral_count + FROM incentives WHERE user_id = $1` + summary := &UserIncentiveSummary{BonusTier: 1} + err := r.db.GetContext(ctx, summary, query, userID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + streakQuery := `SELECT COALESCE(current_streak, 0), COALESCE(longest_streak, 0), COALESCE(bonus_tier, 1) FROM savings_streaks WHERE user_id = $1` + var current, longest, tier int + if err := r.db.QueryRowContext(ctx, streakQuery, userID).Scan(¤t, &longest, &tier); err == nil { + summary.CurrentStreak = current + summary.LongestStreak = longest + summary.BonusTier = tier + } + return summary, nil +} + +func (r *postgresRepository) UpdateIncentiveStatus(ctx context.Context, id uuid.UUID, status IncentiveStatus) error { + query := `UPDATE incentives SET status = $2, claimed_at = CASE WHEN $2 = 'claimed' THEN NOW() ELSE claimed_at END, updated_at = NOW() WHERE id = $1` + _, err := r.db.ExecContext(ctx, query, id, status) + return err +} + +func (r *postgresRepository) FindStreakByUserID(ctx context.Context, userID uuid.UUID) (*SavingsStreak, error) { + var streak SavingsStreak + query := `SELECT id, user_id, current_streak, longest_streak, last_contribution_at, bonus_tier FROM savings_streaks WHERE user_id = $1` + err := r.db.GetContext(ctx, &streak, query, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrIncentiveNotFound + } + return nil, err + } + return &streak, nil +} + +func (r *postgresRepository) CreateSavingsStreak(ctx context.Context, streak *SavingsStreak) error { + query := `INSERT INTO savings_streaks (id, user_id, current_streak, longest_streak, last_contribution_at, bonus_tier) VALUES ($1, $2, $3, $4, $5, $6)` + _, err := r.db.ExecContext(ctx, query, streak.ID, streak.UserID, streak.CurrentStreak, streak.LongestStreak, streak.LastContributionAt, streak.BonusTier) + return err +} + +func (r *postgresRepository) UpdateSavingsStreak(ctx context.Context, streak *SavingsStreak) error { + query := `UPDATE savings_streaks SET current_streak = $1, longest_streak = $2, last_contribution_at = $3, bonus_tier = $4, updated_at = NOW() WHERE user_id = $5` + _, err := r.db.ExecContext(ctx, query, streak.CurrentStreak, streak.LongestStreak, streak.LastContributionAt, streak.BonusTier, streak.UserID) + return err +} + +func (r *postgresRepository) CreateConfig(ctx context.Context, config *IncentiveConfig) error { + query := `INSERT INTO incentive_configs (id, referral_bonus_amount, referral_bonus_currency, circle_completion_bonus, circle_completion_currency, contribution_match_percent, contribution_match_max, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE)` + _, err := r.db.ExecContext(ctx, query, config.ID, config.ReferralBonusAmount, config.ReferralBonusCurrency, config.CircleCompletionBonus, config.CircleCompletionCurrency, config.ContributionMatchPercent, config.ContributionMatchMax) + return err +} diff --git a/internal/domain/incentives/service.go b/internal/domain/incentives/service.go index 8cd62f3..4320423 100644 --- a/internal/domain/incentives/service.go +++ b/internal/domain/incentives/service.go @@ -2,29 +2,34 @@ package incentives import ( "context" - "database/sql" "errors" "fmt" - "time" "github.com/google/uuid" - "github.com/jmoiron/sqlx" - "github.com/moistello/backend/pkg/apperrors" ) var ( - ErrReferralCodeNotFound = errors.New("referral code not found") + ErrReferralCodeNotFound = errors.New("referral code not found") ErrReferralCodeAlreadyUsed = errors.New("referral code already used") - ErrSelfReferral = errors.New("cannot refer yourself") - ErrReferralCodeTaken = errors.New("referral code is already taken") + ErrSelfReferral = errors.New("cannot refer yourself") + ErrReferralCodeTaken = errors.New("referral code is already taken") + ErrIncentiveNotFound = errors.New("incentive not found") ) type Service interface { GenerateReferralCode(ctx context.Context, userID string) (string, error) ApplyReferralCode(ctx context.Context, referredUserID string, referralCode string) error + GetReferrals(ctx context.Context, userID string) ([]Referral, error) GrantCircleCompletionReward(ctx context.Context, userID string, circleID string) (*Incentive, error) CalculateContributionMatch(ctx context.Context, userID string, amount float64) (float64, error) + GrantContributionMatch(ctx context.Context, userID string, circleID string, amount float64) (*Incentive, error) + GrantFirstDepositBonus(ctx context.Context, userID string, depositAmount float64) (*Incentive, error) RecordContribution(ctx context.Context, userID string) (*SavingsStreak, error) + GrantStreakBonus(ctx context.Context, userID string) (*Incentive, error) + ClaimIncentive(ctx context.Context, userID string, incentiveID string) error + GetUserIncentives(ctx context.Context, userID string) ([]Incentive, error) + GetPendingIncentives(ctx context.Context, userID string) ([]Incentive, error) + GetUserSummary(ctx context.Context, userID string) (*UserIncentiveSummary, error) GetIncentives(ctx context.Context, userID string) ([]Incentive, error) GetStreak(ctx context.Context, userID string) (*SavingsStreak, error) GetConfig(ctx context.Context) (*IncentiveConfig, error) @@ -39,236 +44,16 @@ func NewService(repo Repository) Service { return &service{repo: repo} } -var referralCodeEntropyBytes = 10 - -var newReferralCode = func() (string, error) { - bytes := make([]byte, referralCodeEntropyBytes) - // Use crypto/rand in real impl, or fallback - return fmt.Sprintf("%x", time.Now().UnixNano()), nil -} - -func generateReferralCode() (string, error) { - return newReferralCode() -} - -func (s *service) GenerateReferralCode(ctx context.Context, userIDStr string) (string, error) { - userID, err := uuid.Parse(userIDStr) +func parseUUID(s string) (uuid.UUID, error) { + id, err := uuid.Parse(s) if err != nil { - return "", fmt.Errorf("invalid user ID: %w", err) - } - - existing, err := s.repo.GetReferrerByUserID(ctx, userID) - if err == nil && existing != nil && existing.ReferralCode != "" { - return existing.ReferralCode, nil - } - - for attempts := 0; attempts < 8; attempts++ { - code, err := generateReferralCode() - if err != nil { - continue - } - - ref := &Referral{ - ID: uuid.New(), - ReferrerID: userID, - ReferralCode: code, - Status: "pending", - } - - err = s.repo.CreateReferral(ctx, ref) - if err == nil { - return code, nil - } + return uuid.Nil, fmt.Errorf("invalid UUID: %w", err) } - - return "", fmt.Errorf("%w: after 8 attempts", ErrReferralCodeTaken) -} - -func (s *service) ApplyReferralCode(ctx context.Context, referredUserIDStr string, referralCode string) error { - referredUserID, err := uuid.Parse(referredUserIDStr) - if err != nil { - return fmt.Errorf("invalid referred user ID: %w", err) - } - - // If repository supports transactions (like repository_pg implementing a Tx method or via sqlx), use transaction. - // Let's check if repo has a ExecTx or Transact method, or we handle it via Repository interface. - // If Repository doesn't have explicit transaction methods, let's implement or call repo methods securely. - // Looking at Repository interface, let's make sure it handles atomic claims. - if dbRepo, ok := s.repo.(interface { - Transact(ctx context.Context, fn func(txRepo Repository) error) error - }); ok { - return dbRepo.Transact(ctx, func(txRepo Repository) error { - return s.applyReferralCodeTx(ctx, txRepo, referredUserID, referralCode) - }) - } - - return s.applyReferralCodeTx(ctx, s.repo, referredUserID, referralCode) -} - -func (s *service) applyReferralCodeTx(ctx context.Context, repo Repository, referredUserID uuid.UUID, referralCode string) error { - ref, err := repo.GetReferralByCode(ctx, referralCode) - if err != nil { - return ErrReferralCodeNotFound - } - - if ref.ReferrerID == referredUserID { - return ErrSelfReferral - } - - if ref.Status == "completed" || ref.ReferredID != uuid.Nil { - return ErrReferralCodeAlreadyUsed - } - - ref.ReferredID = referredUserID - ref.Status = "completed" - - err = repo.UpdateReferral(ctx, ref) - if err != nil { - return err - } - - cfg, err := repo.GetConfig(ctx) - if err != nil || cfg == nil { - cfg = &IncentiveConfig{ - ReferralBonusAmount: 5.0, - ReferralBonusCurrency: "USDC", - } - } - - _, err = repo.CreateIncentive(ctx, &Incentive{ - ID: uuid.New(), - UserID: ref.ReferrerID, - Type: IncentiveTypeReferral, - Amount: cfg.ReferralBonusAmount, - Currency: cfg.ReferralBonusCurrency, - ReferenceID: sql.NullString{String: referredUserID.String(), Valid: true}, - CreatedAt: time.Now().UTC(), - }) - if err != nil { - return err - } - - return nil -} - -func (s *service) GrantCircleCompletionReward(ctx context.Context, userIDStr string, circleIDStr string) (*Incentive, error) { - userID, err := uuid.Parse(userIDStr) - if err != nil { - return nil, fmt.Errorf("invalid user ID: %w", err) - } - - circleID, err := uuid.Parse(circleIDStr) - if err != nil { - return nil, fmt.Errorf("invalid circle ID: %w", err) - } - - cfg, err := s.repo.GetConfig(ctx) - if err != nil { - return nil, err - } - - incentives, err := s.repo.GetIncentives(ctx, userID) - if err != nil { - return nil, err - } - - for _, inc := range incentives { - if inc.Type == IncentiveTypeCircleCompletion && inc.ReferenceID.Valid && inc.ReferenceID.String == circleID.String() { - return nil, errors.New("reward already received for this circle") - } - } - - inc := &Incentive{ - ID: uuid.New(), - UserID: userID, - Type: IncentiveTypeCircleCompletion, - Amount: cfg.CircleCompletionBonus, - Currency: cfg.CircleCompletionCurrency, - ReferenceID: sql.NullString{String: circleID.String(), Valid: true}, - CreatedAt: time.Now().UTC(), - } - - created, err := s.repo.CreateIncentive(ctx, inc) - if err != nil { - return nil, err - } - - return created, nil -} - -func (s *service) CalculateContributionMatch(ctx context.Context, userIDStr string, amount float64) (float64, error) { - cfg, err := s.repo.GetConfig(ctx) - if err != nil { - return 0, err - } - - match := amount * (cfg.ContributionMatchPercent / 100.0) - if match > cfg.ContributionMatchMax { - match = cfg.ContributionMatchMax - } - - return match, nil -} - -func (s *service) RecordContribution(ctx context.Context, userIDStr string) (*SavingsStreak, error) { - userID, err := uuid.Parse(userIDStr) - if err != nil { - return nil, fmt.Errorf("invalid user ID: %w", err) - } - - cfg, err := s.repo.GetConfig(ctx) - if err != nil { - return nil, err - } - - streak, err := s.repo.GetStreak(ctx, userID) - now := time.Now().UTC() - - if err != nil || streak == nil { - streak = &SavingsStreak{ - ID: uuid.New(), - UserID: userID, - CurrentStreak: 1, - LongestStreak: 1, - LastContributionAt: sql.NullTime{Time: now, Valid: true}, - BonusTier: 1, - } - return s.repo.UpsertStreak(ctx, streak) - } - - if streak.LastContributionAt.Valid { - last := streak.LastContributionAt.Time - hoursSince := now.Sub(last).Hours() - if hoursSince > 48 { - streak.CurrentStreak = 1 - } else if hoursSince >= 12 { - streak.CurrentStreak++ - } - } else { - streak.CurrentStreak = 1 - } - - if streak.CurrentStreak > streak.LongestStreak { - streak.LongestStreak = streak.CurrentStreak - } - - streak.LastContributionAt = sql.NullTime{Time: now, Valid: true} - - if streak.CurrentStreak >= cfg.StreakBonusTier3 { - streak.BonusTier = 3 - } else if streak.CurrentStreak >= cfg.StreakBonusTier2 { - streak.BonusTier = 2 - } else if streak.CurrentStreak >= cfg.StreakBonusTier1 { - streak.BonusTier = 1 - } else { - streak.BonusTier = 0 - } - - return s.repo.UpsertStreak(ctx, streak) + return id, nil } func (s *service) GetIncentives(ctx context.Context, userIDStr string) ([]Incentive, error) { - userID, err := uuid.Parse(userIDStr) + userID, err := parseUUID(userIDStr) if err != nil { return nil, fmt.Errorf("invalid user ID: %w", err) } @@ -276,17 +61,9 @@ func (s *service) GetIncentives(ctx context.Context, userIDStr string) ([]Incent } func (s *service) GetStreak(ctx context.Context, userIDStr string) (*SavingsStreak, error) { - userID, err := uuid.Parse(userIDStr) + userID, err := parseUUID(userIDStr) if err != nil { return nil, fmt.Errorf("invalid user ID: %w", err) } return s.repo.GetStreak(ctx, userID) } - -func (s *service) GetConfig(ctx context.Context) (*IncentiveConfig, error) { - return s.repo.GetConfig(ctx) -} - -func (s *service) UpdateConfig(ctx context.Context, config *IncentiveConfig) error { - return s.repo.UpdateConfig(ctx, config) -} diff --git a/internal/domain/incentives/service_test.go b/internal/domain/incentives/service_test.go index 420150f..2ccc390 100644 --- a/internal/domain/incentives/service_test.go +++ b/internal/domain/incentives/service_test.go @@ -164,7 +164,6 @@ func TestApplyReferralCode_RaceCondition(t *testing.T) { wg.Wait() close(errs) - successCount := 0 otherErrors := 0 for err := range errs { if err.Error() == "referral code already used" || err.Error() == "not found" { diff --git a/internal/domain/swap/repository_pg.go b/internal/domain/swap/repository_pg.go index f7be91a..7bbaf8e 100644 --- a/internal/domain/swap/repository_pg.go +++ b/internal/domain/swap/repository_pg.go @@ -3,6 +3,7 @@ package swap import ( "context" "database/sql" + "errors" "time" "github.com/jmoiron/sqlx" diff --git a/internal/domain/swap/service.go b/internal/domain/swap/service.go index 0b5b87b..5cc2418 100644 --- a/internal/domain/swap/service.go +++ b/internal/domain/swap/service.go @@ -2,7 +2,6 @@ package swap import ( "context" - "errors" "fmt" "time" @@ -11,16 +10,6 @@ import ( "github.com/moistello/backend/pkg/apperrors" ) -type Repository interface { - CreateSwapOffer(ctx context.Context, offer *SwapOffer) error - GetSwapOfferByID(ctx context.Context, id string) (*SwapOffer, error) - UpdateSwapOfferStatus(ctx context.Context, id string, status SwapOfferStatus, transactionHash *string) error - CompareAndSwapStatus(ctx context.Context, id string, expectedStatus, newStatus SwapOfferStatus, transactionHash *string) (bool, error) - ListUserSwapOffers(ctx context.Context, userID string, filter SwapHistoryFilter) ([]SwapOffer, int, error) - ListCircleSwapOffers(ctx context.Context, circleID string, filter SwapHistoryFilter) ([]SwapOffer, int, error) - ListExpiredCreatedOffers(ctx context.Context, now time.Time) ([]SwapOffer, error) -} - type CircleService interface { } @@ -36,10 +25,10 @@ type EscrowClient interface { } type Service struct { - repo Repository - circleSvc CircleService - userSvc UserService - escrow EscrowClient + repo Repository + circleSvc CircleService + userSvc UserService + escrow EscrowClient } func NewService(repo Repository, circleSvc CircleService, userSvc UserService, escrow EscrowClient) *Service { @@ -51,21 +40,15 @@ func NewService(repo Repository, circleSvc CircleService, userSvc UserService, e } } -func (s *Service) CreateSwapOffer(ctx context.Context, userID string, input CreateSwapInput) (*SwapOffer, error) { +func (s *Service) CreateSwapOffer(ctx context.Context, userID string, input SwapOfferRequest) (*SwapOffer, error) { u, err := s.userSvc.GetByID(ctx, userID) if err != nil { return nil, apperrors.ErrNotFound } - expiresAt := time.Now().Add(24 * time.Hour) - if input.ExpiresAt > 0 { - expiresAt = time.Unix(input.ExpiresAt, 0) - } + expiresAt := time.Now().Add(time.Duration(input.ExpiresIn) * time.Hour) - var offereeID *string - if input.OffereeUserID != "" { - offereeID = &input.OffereeUserID - } + offereeID := input.OffereeUserID var offereeWallet string if offereeID != nil { @@ -124,7 +107,7 @@ func (s *Service) AcceptSwapOffer(ctx context.Context, userID string, offerID st } if time.Now().After(offer.ExpiresAt) { - return nil, apperrors.ErrBadRequest + return nil, apperrors.ErrInvalidInput } if offer.OffereeUserID != nil && *offer.OffereeUserID != userID { diff --git a/internal/domain/swap/service_test.go b/internal/domain/swap/service_test.go index 5b37c56..fc6f5b4 100644 --- a/internal/domain/swap/service_test.go +++ b/internal/domain/swap/service_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/require" "github.com/moistello/backend/internal/domain/user" - "github.com/moistello/backend/pkg/apperrors" ) // ── Fakes ───────────────────────────────────────────────────────────────── @@ -144,7 +143,7 @@ func TestSweepExpiredOffers_SkipsOfferWhenOnChainCancelFails(t *testing.T) { return walletUser(id), nil }} escrow := &fakeEscrow{cancelSwapFn: func(ctx context.Context, swapID, canceller string) (string, error) { - ifa swapID == "offer-1" { + if swapID == "offer-1" { return "", errors.New("simulation failed") } return "tx-" + swapID, nil @@ -198,7 +197,7 @@ func TestCancelSwapOffer_Success(t *testing.T) { func TestAcceptSwapOffer_ConcurrencyAndCAS(t *testing.T) { ctx := context.Background() offer := createdOffer("offer-1", "u1") - + var currentStatus SwapOfferStatus = SwapOfferStatusCreated var statusMu sync.Mutex @@ -206,8 +205,9 @@ func TestAcceptSwapOffer_ConcurrencyAndCAS(t *testing.T) { getByIDFn: func(ctx context.Context, id string) (*SwapOffer, error) { statusMu.Lock() defer statusMu.Unlock() - o.Status = currentStatus - return o, nil + copy := *offer + copy.Status = currentStatus + return ©, nil }, casFn: func(ctx context.Context, id string, expectedStatus, newStatus SwapOfferStatus, transactionHash *string) (bool, error) { statusMu.Lock() diff --git a/internal/domain/wallet/model.go b/internal/domain/wallet/model.go index 705749c..6194ec1 100644 --- a/internal/domain/wallet/model.go +++ b/internal/domain/wallet/model.go @@ -16,6 +16,12 @@ const ( WalletTypePasskey WalletType = "passkey" ) +// Balance represents the available XLM and USDC balances for a wallet. +type Balance struct { + XLM float64 `json:"xlm"` + USDC float64 `json:"usdc"` +} + type Wallet struct { ID string `json:"id" db:"id"` UserID string `json:"userId" db:"user_id"` diff --git a/internal/domain/wallet/service.go b/internal/domain/wallet/service.go index bc1db02..396974f 100644 --- a/internal/domain/wallet/service.go +++ b/internal/domain/wallet/service.go @@ -2,10 +2,6 @@ package wallet import ( "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" "encoding/hex" "fmt" "log" @@ -14,7 +10,6 @@ import ( "github.com/google/uuid" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" - "github.com/stellar/go/txnbuild" "golang.org/x/crypto/argon2" ) @@ -35,10 +30,10 @@ type Config struct { USDCIssuer string NetworkPassphrase string MinBalanceXLM float64 - WalletPepper string - Argon2Time int - Argon2Memory int - Argon2Threads int + WalletPepper string + Argon2Time int + Argon2Memory int + Argon2Threads int } type service struct { @@ -95,9 +90,9 @@ func (s *service) CreateWallet(ctx context.Context, userID string, passkeySeed [ walletID := uuid.New().String() w := &Wallet{ - ID: walletID, - UserID: userID, - Address: kp.Address(), + ID: walletID, + UserID: userID, + PublicKey: kp.Address(), } if s.repo != nil { @@ -111,11 +106,11 @@ func (s *service) CreateWallet(ctx context.Context, userID string, passkeySeed [ bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _ = s.fundAccountWithRetry(bgCtx, w.Address) + _ = s.fundAccountWithRetry(bgCtx, w.PublicKey) }() return w, - nil + nil } func (s *service) fundAccountWithRetry(ctx context.Context, address string) error { @@ -141,7 +136,7 @@ func (s *service) GetWallets(ctx context.Context, userID string) ([]Wallet, erro if s.repo == nil { return nil, nil } - return s.repo.GetByUserID(ctx, userID) + return s.repo.FindByUserID(ctx, userID) } func (s *service) GetBalance(ctx context.Context, userID string) (*Balance, error) { @@ -156,5 +151,5 @@ func (s *service) DeleteWallet(ctx context.Context, userID, walletID string) err if s.repo == nil { return nil } - return s.repo.Delete(ctx, userID, walletID) + return s.repo.DeleteByOwner(ctx, walletID, userID) } diff --git a/internal/websocket/broadcaster.go b/internal/websocket/broadcaster.go index 195ae10..63333d5 100644 --- a/internal/websocket/broadcaster.go +++ b/internal/websocket/broadcaster.go @@ -101,6 +101,15 @@ func (b *Broadcaster) PayoutExecuted(ctx context.Context, circleID, recipientID }}) } +// MemberPenalized broadcasts a penalty imposed on a circle member for a missed +// contribution. Satisfies circle.Broadcaster. +func (b *Broadcaster) MemberPenalized(ctx context.Context, circleID, userID string, roundNumber int, penaltyAmount float64) { + b.publish(circleID, Message{Type: "member.penalized", Payload: map[string]any{ + "circleId": circleID, "userId": userID, "roundNumber": roundNumber, + "penaltyAmount": penaltyAmount, "timestamp": time.Now().UTC(), + }}) +} + // ── Community events ── func (b *Broadcaster) CommunityJoined(ctx context.Context, communityID, userID string) { diff --git a/internal/websocket/redis_bridge.go b/internal/websocket/redis_bridge.go index 161f577..73473a7 100644 --- a/internal/websocket/redis_bridge.go +++ b/internal/websocket/redis_bridge.go @@ -27,10 +27,10 @@ type RedisBridge struct { // BridgeRateLimiter provides per-client rate limiting for websocket bridge relays. type BridgeRateLimiter struct { - mu sync.Mutex - limits map[string][]time.Time - maxRate int // e.g. max messages per window - window time.Duration // e.g. 1 second + mu sync.Mutex + limits map[string][]time.Time + maxRate int // e.g. max messages per window + window time.Duration // e.g. 1 second } func NewBridgeRateLimiter(maxRate int, window time.Duration) *BridgeRateLimiter { @@ -80,7 +80,7 @@ func NewRedisBridge(hub *Hub, rdb *redis.Client) *RedisBridge { stop: make(chan struct{}), cancel: cancel, done: make(chan struct{}), - queue: make(chan []byte, 1024), // Bounded relay queue with backpressure + queue: make(chan []byte, 1024), // Bounded relay queue with backpressure rateLimiter: NewBridgeRateLimiter(100, time.Second), // Per-client rate limiter default } go b.consume(ctx) diff --git a/internal/websocket/redis_bridge_test.go b/internal/websocket/redis_bridge_test.go index c50589e..a97f72e 100644 --- a/internal/websocket/redis_bridge_test.go +++ b/internal/websocket/redis_bridge_test.go @@ -1,6 +1,7 @@ package websocket_test import ( + "context" "encoding/json" "testing" "time" @@ -24,7 +25,7 @@ func TestBridgeRateLimiter(t *testing.T) { assert.True(t, rl.Allow("user-2")) } -func TestRedisBridge_BackpressureAndRateLimiting(t *testing.T) { +func TestRedisBridge_PublishesToHub(t *testing.T) { s, err := miniredis.Run() require.NoError(t, err) defer s.Close() @@ -33,13 +34,10 @@ func TestRedisBridge_BackpressureAndRateLimiting(t *testing.T) { defer rdb.Close() hub := websocket.NewHub() - go hub.Run() - bridge := websocket.NewNewRedisBridgeIfAvailable(hub, rdb) - // Alternatively use NewRedisBridge directly - _ = bridge + bridge := websocket.NewRedisBridge(hub, rdb) + defer bridge.Close() - // Test publishing payloads payload, _ := json.Marshal(map[string]any{ "circleId": "circle-123", "userId": "user-abc", @@ -50,12 +48,8 @@ func TestRedisBridge_BackpressureAndRateLimiting(t *testing.T) { "payload": json.RawMessage(payload), }) - err = rdb.Publish(contextBackground(), "moistello_ws_events", env).Err() + err = rdb.Publish(context.Background(), "moistello_ws_events", env).Err() require.NoError(t, err) time.Sleep(50 * time.Millisecond) } - -func contextBackground() context.Context { - return context.Background() -} diff --git a/pkg/response/response.go b/pkg/response/response.go index 11f9cfd..c307a8e 100644 --- a/pkg/response/response.go +++ b/pkg/response/response.go @@ -1,25 +1,27 @@ package response import ( - "net/http" "github.com/gin-gonic/gin" + "net/http" ) type Envelope struct { - Success bool `json:"success"` - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` - Details any `json:"details,omitempty"` - RequestId string `json:"requestId,omitempty"` - Data any `json:"data,omitempty"` - Meta any `json:"meta,omitempty"` + Success bool `json:"success"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Details any `json:"details,omitempty"` + RequestId string `json:"requestId,omitempty"` + Data any `json:"data,omitempty"` + Meta any `json:"meta,omitempty"` } type PaginationMeta struct { - Page int `json:"page"` - Limit int `json:"limit"` - TotalItems int `json:"totalItems"` - TotalPages int `json:"totalPages"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Total int `json:"total"` + HasMore bool `json:"hasMore"` } func NewPaginationMeta(page, limit, total int) PaginationMeta { @@ -32,6 +34,8 @@ func NewPaginationMeta(page, limit, total int) PaginationMeta { Limit: limit, TotalItems: total, TotalPages: totalPages, + Total: total, + HasMore: limit > 0 && page*limit < total, } } @@ -101,3 +105,27 @@ func Conflict(c *gin.Context, message string) { func InternalError(c *gin.Context, message string) { Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", message, nil) } + +// ErrorWithCode writes an error envelope with an explicit HTTP status and code. +func ErrorWithCode(c *gin.Context, statusCode int, code, message string) { + Error(c, statusCode, code, message, nil) +} + +// Success responds with a 200 OK success envelope carrying data. +func Success(c *gin.Context, data any) { + OK(c, data) +} + +// Created responds with a 201 Created success envelope carrying data. +func Created(c *gin.Context, data any) { + c.JSON(http.StatusCreated, Envelope{ + Success: true, + Data: data, + RequestId: getRequestID(c), + }) +} + +// ValidationErrors responds with a 422 Unprocessable Entity error envelope. +func ValidationErrors(c *gin.Context, message string) { + Error(c, http.StatusUnprocessableEntity, "VALIDATION_ERROR", message, nil) +} diff --git a/pkg/response/response_test.go b/pkg/response/response_test.go index 84903bc..f5821e2 100644 --- a/pkg/response/response_test.go +++ b/pkg/response/response_test.go @@ -20,7 +20,6 @@ func TestResponse_EnvelopeContract(t *testing.T) { response.OK(c, gin.H{"foo": "bar"}) }) r.GET("/test-err", func(c *gin.Context) { - c.Header("X-Request-Id", "req-123") response.BadRequest(c, "invalid input") }) @@ -40,6 +39,7 @@ func TestResponse_EnvelopeContract(t *testing.T) { t.Run("Error response with requestId", func(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/test-err", nil) + req.Header.Add("X-Request-Id", "req-123") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) diff --git a/pkg/stellar/tx.go b/pkg/stellar/tx.go index 1b1967a..c73ffd5 100644 --- a/pkg/stellar/tx.go +++ b/pkg/stellar/tx.go @@ -37,11 +37,11 @@ func SignXDR(xdr string, networkPassphrase string, kp *keypair.Full) (string, er // PaymentParams holds the parameters for building a Stellar payment transaction. type PaymentParams struct { - Destination string - AssetCode string // "XLM" for native asset, otherwise the asset code (e.g. "USDC") - AssetIssuer string // issuer for non-native assets - Amount float64 - Memo string + Destination string + AssetCode string // "XLM" for native asset, otherwise the asset code (e.g. "USDC") + AssetIssuer string // issuer for non-native assets + Amount float64 + Memo string NetworkPassphrase string } diff --git a/tests/integration/api_routes_test.go b/tests/integration/api_routes_test.go index 78b5802..19a0420 100644 --- a/tests/integration/api_routes_test.go +++ b/tests/integration/api_routes_test.go @@ -1,24 +1,22 @@ package integration_test import ( - "bytes" - "context" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/moistello/backend/internal/api/handler" "github.com/moistello/backend/internal/domain/user" + userMocks "github.com/moistello/backend/internal/domain/user/mocks" ) -tabsTest := func(t *testing.T) {} - func TestRESTSemantics_SessionAndClaim(t *testing.T) { gin.SetMode(gin.TestMode) - mockUserRepo := new(mockUserRepo) + mockUserRepo := new(userMocks.Repository) userSvc := user.NewService(mockUserRepo, nil) authH := handler.NewAuthHandler(nil, userSvc, nil, nil, nil, nil, nil, mockUserRepo) userH := handler.NewUserHandler(userSvc) @@ -36,21 +34,28 @@ func TestRESTSemantics_SessionAndClaim(t *testing.T) { v1.POST("/claim-name", userH.ClaimName) } - // Test DELETE /v1/auth/sessions + // Test DELETE /v1/auth/sessions without a bearer token — requires auth. w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/v1/auth/sessions", nil) r.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusUnauthorized, w.Code) - // Test DELETE /v1/auth/sessions/:id + // Test DELETE /v1/auth/sessions/:id — revokes the session. w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", "/v1/auth/sessions/abc-123", nil) r.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) // Test POST /v1/users/username/claim + mockUserRepo.On("ClaimNextName", mock.Anything).Return(int64(5), nil) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/v1/users/username/claim", nil) r.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusOK, w.Code) + + // Test POST /v1/claim-name + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/v1/claim-name", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) } diff --git a/tests/integration/circle_lifecycle_test.go b/tests/integration/circle_lifecycle_test.go index d4ad040..6060191 100644 --- a/tests/integration/circle_lifecycle_test.go +++ b/tests/integration/circle_lifecycle_test.go @@ -89,6 +89,7 @@ func TestCircleLifecycle(t *testing.T) { }) t.Run("Step4_RecordContribution", func(t *testing.T) { + contribRepo.On("FindByTxnHash", mock.Anything, "txn-lc-001").Return(nil, nil).Once() contribRepo.On("Create", mock.Anything, mock.AnythingOfType("*contribution.Contribution")).Return(nil).Once() c, err := contribSvc.Record(nil, contribution.RecordInput{ @@ -107,6 +108,7 @@ func TestCircleLifecycle(t *testing.T) { t.Run("Step5_RecordPayout", func(t *testing.T) { circleID := uuid.New() + payoutRepo.On("FindByTxnHash", mock.Anything, "txn-payout-001").Return(nil, nil).Once() payoutRepo.On("ListByCircle", mock.Anything, circleID, 1, 100).Return([]payout.Payout{}, 0, nil).Once() payoutRepo.On("Create", mock.Anything, mock.AnythingOfType("*payout.Payout")).Return(nil).Once() diff --git a/webhook/service_test.go b/webhook/service_test.go index 87da5c8..4f938c2 100644 --- a/webhook/service_test.go +++ b/webhook/service_test.go @@ -2,7 +2,9 @@ package webhook import ( "context" + "crypto/sha256" "crypto/subtle" + "encoding/hex" "fmt" "io" "net/http" @@ -133,10 +135,11 @@ func TestDispatchPayload_DetachedBackgroundContext(t *testing.T) { repo := &fakeWebhookRepo{ webhooks: map[string]*WebhookRegistration{ "wh-1": { - ID: "wh-1", - UserID: "user-1", - TargetURL: server.URL, - Secret: "secret-123", + ID: "wh-1", + UserID: "user-1", + TargetURL: server.URL, + Secret: "secret-123", + SecretHash: webhookSHA256("secret-123"), }, }, } @@ -160,7 +163,7 @@ func TestDispatchPayload_DetachedBackgroundContext(t *testing.T) { assert.Contains(t, string(body), "user.created") sig := <-receivedSig assert.NotEmpty(t, sig) - assert.True(t, VerifyWebhookSignature(body, sig, "secret-123")) + assert.True(t, VerifyWebhookSignature(body, sig, webhookSHA256("secret-123"))) reqID := <-receivedReqID assert.Equal(t, "req-test-999", reqID) case <-time.After(1 * time.Second): @@ -168,6 +171,11 @@ func TestDispatchPayload_DetachedBackgroundContext(t *testing.T) { } } +func webhookSHA256(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + func TestDispatchPayload_BoundedConcurrency(t *testing.T) { var activeReqs atomic.Int32 var maxObserved atomic.Int32 From 5b738ec10bd29a615bbd2b36f10f015dd1c85862 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Tue, 1 Sep 2026 15:18:15 +0100 Subject: [PATCH 7/7] build(docker): bump builder image to golang 1.25 for go.mod requirement --- Dockerfile | 2 +- Dockerfile.indexer | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 307a22c..d6c127d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/Dockerfile.indexer b/Dockerfile.indexer index 1f6c6ec..53ca715 100644 --- a/Dockerfile.indexer +++ b/Dockerfile.indexer @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download