bff for callbacks

This commit is contained in:
Stephan D
2026-03-01 02:04:15 +01:00
parent 709df51512
commit 86eab3bb70
44 changed files with 1563 additions and 25 deletions

View File

@@ -109,6 +109,19 @@ api:
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
callbacks:
default_event_types:
- payment.status.updated
default_status: active
secret_path_prefix: sendico/callbacks
secret_field: value
secret_length_bytes: 32
vault:
address: "http://dev-vault:8200"
token_env: VAULT_TOKEN
token_file_env: VAULT_TOKEN_FILE
namespace: ""
mount_path: kv
app:

View File

@@ -111,6 +111,19 @@ api:
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
callbacks:
default_event_types:
- payment.status.updated
default_status: active
secret_path_prefix: sendico/callbacks
secret_field: value
secret_length_bytes: 32
vault:
address: "https://vault.sendico.io"
token_env: VAULT_TOKEN
token_file_env: VAULT_TOKEN_FILE
namespace: ""
mount_path: kv
app:

View File

@@ -25,6 +25,7 @@ require (
github.com/go-chi/metrics v0.1.1
github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/tron v0.0.0-00010101000000-000000000000
@@ -83,11 +84,22 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/chi v1.5.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/vault/api v1.22.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
@@ -100,6 +112,7 @@ require (
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
@@ -116,10 +129,10 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
@@ -145,5 +158,6 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
)

View File

@@ -85,6 +85,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
@@ -98,6 +100,8 @@ github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTw
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -106,6 +110,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -123,6 +129,29 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
@@ -159,6 +188,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -208,6 +239,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=

View File

@@ -1,6 +1,7 @@
package api
import (
"github.com/tech/sendico/pkg/vault/kv"
mwa "github.com/tech/sendico/server/interface/middleware"
fsc "github.com/tech/sendico/server/interface/services/fileservice/config"
)
@@ -13,6 +14,7 @@ type Config struct {
PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"`
PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"`
PaymentMethods *PaymentOrchestratorConfig `yaml:"payment_methods"`
Callbacks *CallbacksConfig `yaml:"callbacks"`
}
type ChainGatewayConfig struct {
@@ -45,3 +47,12 @@ type PaymentOrchestratorConfig struct {
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
Insecure bool `yaml:"insecure"`
}
type CallbacksConfig struct {
DefaultEventTypes []string `yaml:"default_event_types"`
DefaultStatus string `yaml:"default_status"`
SecretPathPrefix string `yaml:"secret_path_prefix"`
SecretField string `yaml:"secret_field"`
SecretLengthBytes int `yaml:"secret_length_bytes"`
Vault kv.Config `yaml:"vault"`
}

View File

@@ -0,0 +1,11 @@
package callbacks
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/callbacksimp"
)
func Create(a api.API) (mservice.MicroService, error) {
return callbacksimp.CreateAPI(a)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/account"
"github.com/tech/sendico/server/interface/services/callbacks"
"github.com/tech/sendico/server/interface/services/invitation"
"github.com/tech/sendico/server/interface/services/ledger"
"github.com/tech/sendico/server/interface/services/logo"
@@ -91,6 +92,7 @@ func (a *APIImp) installServices() error {
srvf = append(srvf, wallet.Create)
srvf = append(srvf, ledger.Create)
srvf = append(srvf, recipient.Create)
srvf = append(srvf, callbacks.Create)
srvf = append(srvf, paymethod.Create)
srvf = append(srvf, payment.Create)

View File

@@ -0,0 +1,337 @@
package callbacksimp
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type callbackWriteResponse struct {
AccessToken sresponse.TokenData `json:"accessToken"`
Callbacks []model.Callback `json:"callbacks"`
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
}
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
organizationRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
var callback model.Callback
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
return response.BadPayload(a.Logger, a.Name(), err)
}
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, true)
if err != nil {
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &callback); err != nil {
a.Logger.Warn("Failed to create callback", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&callback, accessToken, generatedSecret, true)
}
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var input model.Callback
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
return response.BadPayload(a.Logger, a.Name(), err)
}
callbackRef := *input.GetID()
if callbackRef.IsZero() {
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback id is required", "id"))
}
var existing model.Callback
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
mergeCallbackMutable(&existing, &input)
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, true)
if err != nil {
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.DB.Update(r.Context(), *account.GetID(), &existing); err != nil {
a.Logger.Warn("Failed to update callback", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&existing, accessToken, generatedSecret, false)
}
func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
callbackRef, err := a.Cph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to parse callback reference", zap.Error(err), mutil.PLog(a.Cph, r))
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
}
var callback model.Callback
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &callback); err != nil {
a.Logger.Warn("Failed to fetch callback for secret rotation", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
if callback.RetryPolicy.SigningMode != model.CallbackSigningModeHMACSHA256 {
return response.BadRequest(a.Logger, a.Name(), "invalid_signing_mode", "rotate-secret is available only for hmac_sha256 callbacks")
}
secretRef, generatedSecret, err := a.secrets.Provision(r.Context(), callback.OrganizationRef, callbackRef)
if err != nil {
a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
callback.RetryPolicy.SecretRef = secretRef
if err := a.DB.Update(r.Context(), *account.GetID(), &callback); err != nil {
a.Logger.Warn("Failed to persist rotated callback secret reference", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&callback, accessToken, generatedSecret, false)
}
func (a *CallbacksAPI) normalizeAndPrepare(
ctx context.Context,
callback *model.Callback,
organizationRef bson.ObjectID,
allowSecretGeneration bool,
) (string, error) {
if callback == nil {
return "", merrors.InvalidArgument("callback payload is required")
}
if organizationRef.IsZero() {
return "", merrors.InvalidArgument("organization reference is required", "organizationRef")
}
callback.SetOrganizationRef(organizationRef)
callback.Name = strings.TrimSpace(callback.Name)
callback.Description = trimDescription(callback.Description)
callback.ClientID = strings.TrimSpace(callback.ClientID)
if callback.ClientID == "" {
callback.ClientID = organizationRef.Hex()
}
callback.URL = strings.TrimSpace(callback.URL)
if callback.URL == "" {
return "", merrors.InvalidArgument("url is required", "url")
}
if err := validateCallbackURL(callback.URL); err != nil {
return "", err
}
if callback.Name == "" {
callback.Name = callback.URL
}
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
if err != nil {
return "", err
}
callback.Status = status
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
callback.RetryPolicy.MinDelayMS = defaultInt(callback.RetryPolicy.MinDelayMS, defaultRetryMinDelayMS)
callback.RetryPolicy.MaxDelayMS = defaultInt(callback.RetryPolicy.MaxDelayMS, defaultRetryMaxDelayMS)
if callback.RetryPolicy.MaxDelayMS < callback.RetryPolicy.MinDelayMS {
callback.RetryPolicy.MaxDelayMS = callback.RetryPolicy.MinDelayMS
}
callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts)
callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS)
callback.RetryPolicy.Headers = normalizeHeaders(callback.RetryPolicy.Headers)
mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode)
if err != nil {
return "", err
}
callback.RetryPolicy.SigningMode = mode
callback.RetryPolicy.SecretRef = strings.TrimSpace(callback.RetryPolicy.SecretRef)
switch callback.RetryPolicy.SigningMode {
case model.CallbackSigningModeNone:
callback.RetryPolicy.SecretRef = ""
return "", nil
case model.CallbackSigningModeHMACSHA256:
if callback.RetryPolicy.SecretRef != "" {
return "", nil
}
if !allowSecretGeneration {
return "", merrors.InvalidArgument("secretRef is required for hmac_sha256 callbacks", "retryPolicy.secretRef")
}
if callback.GetID().IsZero() {
callback.SetID(bson.NewObjectID())
}
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
if err != nil {
return "", err
}
callback.RetryPolicy.SecretRef = secretRef
return generatedSecret, nil
default:
return "", merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
}
}
func (a *CallbacksAPI) callbackResponse(
callback *model.Callback,
accessToken *sresponse.TokenData,
generatedSecret string,
created bool,
) http.HandlerFunc {
if callback == nil || accessToken == nil {
return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response"))
}
resp := callbackWriteResponse{
AccessToken: *accessToken,
Callbacks: []model.Callback{*callback},
GeneratedSigningSecret: generatedSecret,
}
if created {
return response.Created(a.Logger, resp)
}
return response.Ok(a.Logger, resp)
}
func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) {
candidate := strings.ToLower(strings.TrimSpace(string(raw)))
if candidate == "" {
candidate = strings.ToLower(strings.TrimSpace(string(fallback)))
}
switch candidate {
case "", "active", "enabled":
return model.CallbackStatusActive, nil
case "disabled", "inactive":
return model.CallbackStatusDisabled, nil
default:
return "", merrors.InvalidArgument("unsupported callback status", "status")
}
}
func normalizeSigningMode(raw model.CallbackSigningMode) (model.CallbackSigningMode, error) {
mode := strings.ToLower(strings.TrimSpace(string(raw)))
switch mode {
case "", "none":
return model.CallbackSigningModeNone, nil
case "hmac_sha256", "hmac-sha256", "hmac":
return model.CallbackSigningModeHMACSHA256, nil
default:
return "", merrors.InvalidArgument("unsupported callback signing mode", "retryPolicy.signingMode")
}
}
func normalizeEventTypes(eventTypes []string, defaults []string) []string {
if len(eventTypes) == 0 {
return normalizeEventTypes(defaults, nil)
}
seen := make(map[string]struct{}, len(eventTypes))
out := make([]string, 0, len(eventTypes))
for _, eventType := range eventTypes {
value := strings.TrimSpace(eventType)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
if len(out) == 0 {
if len(defaults) > 0 {
return normalizeEventTypes(defaults, nil)
}
return []string{model.PaymentStatusUpdatedType}
}
return out
}
func normalizeHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return nil
}
out := make(map[string]string, len(headers))
for key, value := range headers {
k := strings.TrimSpace(key)
if k == "" {
continue
}
out[k] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil
}
return out
}
func mergeCallbackMutable(dst, src *model.Callback) {
dst.Describable = src.Describable
dst.ClientID = src.ClientID
dst.Status = src.Status
dst.URL = src.URL
dst.EventTypes = append([]string(nil), src.EventTypes...)
dst.RetryPolicy = model.CallbackRetryPolicy{
MinDelayMS: src.RetryPolicy.MinDelayMS,
MaxDelayMS: src.RetryPolicy.MaxDelayMS,
SigningMode: src.RetryPolicy.SigningMode,
SecretRef: src.RetryPolicy.SecretRef,
Headers: normalizeHeaders(src.RetryPolicy.Headers),
MaxAttempts: src.RetryPolicy.MaxAttempts,
RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS,
}
}
func defaultInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
func trimDescription(in *string) *string {
if in == nil {
return nil
}
value := strings.TrimSpace(*in)
if value == "" {
return nil
}
return &value
}
func validateCallbackURL(raw string) error {
parsed, err := url.ParseRequestURI(raw)
if err != nil {
return merrors.InvalidArgument("url is invalid", "url")
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "https", "http":
default:
return merrors.InvalidArgument("url scheme must be http or https", "url")
}
if strings.TrimSpace(parsed.Host) == "" {
return merrors.InvalidArgument("url host is required", "url")
}
return nil
}

View File

@@ -0,0 +1,178 @@
package callbacksimp
import (
"context"
"crypto/rand"
"encoding/base64"
"path"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/vault/kv"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type signingSecretManager interface {
Provision(ctx context.Context, organizationRef, callbackRef bson.ObjectID) (secretRef string, generatedSecret string, err error)
}
type vaultSigningSecretManager struct {
logger mlogger.Logger
store kv.Client
pathPrefix string
field string
secretLength int
}
const (
metricsResultSuccess = "success"
metricsResultError = "error"
)
var (
signingSecretMetricsOnce sync.Once
signingSecretStatus *prometheus.CounterVec
signingSecretLatency *prometheus.HistogramVec
)
func ensureSigningSecretMetrics() {
signingSecretMetricsOnce.Do(func() {
signingSecretStatus = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "bff_callbacks",
Name: "signing_secret_provision_total",
Help: "Total callback signing secret provisioning attempts.",
}, []string{"result"})
signingSecretLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "sendico",
Subsystem: "bff_callbacks",
Name: "signing_secret_provision_duration_seconds",
Help: "Duration of callback signing secret provisioning attempts.",
Buckets: prometheus.DefBuckets,
}, []string{"result"})
})
}
func newSigningSecretManager(logger mlogger.Logger, cfg callbacksConfig) (signingSecretManager, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
if logger == nil {
logger = zap.NewNop()
}
manager := &vaultSigningSecretManager{
logger: logger.Named("callbacks_secrets"),
pathPrefix: strings.Trim(strings.TrimSpace(cfg.SecretPathPrefix), "/"),
field: strings.TrimSpace(cfg.SecretField),
secretLength: cfg.SecretLengthBytes,
}
if manager.pathPrefix == "" {
manager.pathPrefix = defaultSigningSecretPathPrefix
}
if manager.field == "" {
manager.field = defaultSigningSecretField
}
if isVaultConfigEmpty(cfg.Vault) {
manager.logger.Warn("Callbacks Vault config is not set; secret generation requires explicit secretRef in payloads")
ensureSigningSecretMetrics()
return manager, nil
}
store, err := kv.New(kv.Options{
Logger: manager.logger,
Config: kv.Config{
Address: strings.TrimSpace(cfg.Vault.Address),
TokenEnv: strings.TrimSpace(cfg.Vault.TokenEnv),
TokenFileEnv: strings.TrimSpace(cfg.Vault.TokenFileEnv),
TokenFile: strings.TrimSpace(cfg.Vault.TokenFile),
Namespace: strings.TrimSpace(cfg.Vault.Namespace),
MountPath: strings.TrimSpace(cfg.Vault.MountPath),
},
Component: "bff callbacks signing secret manager",
})
if err != nil {
return nil, err
}
manager.store = store
ensureSigningSecretMetrics()
return manager, nil
}
func (m *vaultSigningSecretManager) Provision(
ctx context.Context,
organizationRef,
callbackRef bson.ObjectID,
) (string, string, error) {
start := time.Now()
result := metricsResultSuccess
defer func() {
signingSecretStatus.WithLabelValues(result).Inc()
signingSecretLatency.WithLabelValues(result).Observe(time.Since(start).Seconds())
}()
if organizationRef.IsZero() {
result = metricsResultError
return "", "", merrors.InvalidArgument("organization reference is required", "organizationRef")
}
if callbackRef.IsZero() {
result = metricsResultError
return "", "", merrors.InvalidArgument("callback reference is required", "callbackRef")
}
if m.store == nil {
result = metricsResultError
return "", "", merrors.InvalidArgument("callbacks vault config is required to generate signing secrets", "api.callbacks.vault")
}
secret, err := generateSigningSecret(m.secretLength)
if err != nil {
result = metricsResultError
return "", "", err
}
secretPath := path.Join(m.pathPrefix, organizationRef.Hex(), callbackRef.Hex())
payload := map[string]interface{}{
m.field: secret,
"organization_ref": organizationRef.Hex(),
"callback_ref": callbackRef.Hex(),
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
}
if err := m.store.Put(ctx, secretPath, payload); err != nil {
result = metricsResultError
m.logger.Warn("Failed to store callback signing secret", zap.String("path", secretPath), zap.Error(err))
return "", "", err
}
secretRef := "vault:" + secretPath + "#" + m.field
m.logger.Info("Callback signing secret stored", zap.String("secret_ref", secretRef), zap.String("callback_ref", callbackRef.Hex()))
return secretRef, secret, nil
}
func isVaultConfigEmpty(cfg VaultConfig) bool {
return strings.TrimSpace(cfg.Address) == "" &&
strings.TrimSpace(cfg.TokenEnv) == "" &&
strings.TrimSpace(cfg.TokenFileEnv) == "" &&
strings.TrimSpace(cfg.TokenFile) == "" &&
strings.TrimSpace(cfg.MountPath) == "" &&
strings.TrimSpace(cfg.Namespace) == ""
}
func generateSigningSecret(length int) (string, error) {
if length <= 0 {
return "", merrors.InvalidArgument("secret length must be greater than zero", "secret_length")
}
raw := make([]byte, length)
if _, err := rand.Read(raw); err != nil {
return "", merrors.Internal("failed to generate signing secret: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}

View File

@@ -0,0 +1,139 @@
package callbacksimp
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/papitemplate"
"go.uber.org/zap"
)
type CallbacksAPI struct {
papitemplate.ProtectedAPI[model.Callback]
db callbacks.DB
secrets signingSecretManager
config callbacksConfig
}
func (a *CallbacksAPI) Name() mservice.Type {
return mservice.Callbacks
}
func (a *CallbacksAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(apiCtx eapi.API) (*CallbacksAPI, error) {
dbFactory := func() (papitemplate.ProtectedDB[model.Callback], error) {
return apiCtx.DBFactory().NewCallbacksDB()
}
res := &CallbacksAPI{
config: newCallbacksConfig(apiCtx.Config().Callbacks),
}
p, err := papitemplate.CreateAPI(apiCtx, dbFactory, mservice.Organizations, mservice.Callbacks)
if err != nil {
return nil, err
}
res.ProtectedAPI = *p.
WithNoCreateNotification().
WithNoUpdateNotification().
WithNoDeleteNotification().
WithCreateHandler(res.create).
WithUpdateHandler(res.update).
Build()
if res.db, err = apiCtx.DBFactory().NewCallbacksDB(); err != nil {
res.Logger.Warn("Failed to create callbacks database", zap.Error(err))
return nil, err
}
if res.secrets, err = newSigningSecretManager(res.Logger, res.config); err != nil {
res.Logger.Warn("Failed to initialize callbacks signing secret manager", zap.Error(err))
return nil, err
}
apiCtx.Register().AccountHandler(res.Name(), res.Cph.AddRef("/rotate-secret"), api.Post, res.rotateSecret)
return res, nil
}
const (
defaultCallbackStatus = model.CallbackStatusActive
defaultRetryMaxAttempts = 8
defaultRetryMinDelayMS = 1000
defaultRetryMaxDelayMS = 300000
defaultRetryRequestTimeoutMS = 10000
defaultSigningSecretLengthBytes = 32
defaultSigningSecretField = "value"
defaultSigningSecretPathPrefix = "sendico/callbacks"
)
type callbacksConfig struct {
DefaultEventTypes []string
DefaultStatus model.CallbackStatus
SecretPathPrefix string
SecretField string
SecretLengthBytes int
Vault VaultConfig
}
type VaultConfig struct {
Address string
TokenEnv string
TokenFileEnv string
TokenFile string
Namespace string
MountPath string
}
func newCallbacksConfig(source *eapi.CallbacksConfig) callbacksConfig {
cfg := callbacksConfig{
DefaultEventTypes: []string{model.PaymentStatusUpdatedType},
DefaultStatus: defaultCallbackStatus,
SecretPathPrefix: defaultSigningSecretPathPrefix,
SecretField: defaultSigningSecretField,
SecretLengthBytes: defaultSigningSecretLengthBytes,
}
if source == nil {
return cfg
}
if source.SecretPathPrefix != "" {
cfg.SecretPathPrefix = source.SecretPathPrefix
}
if source.SecretField != "" {
cfg.SecretField = source.SecretField
}
if source.SecretLengthBytes > 0 {
cfg.SecretLengthBytes = source.SecretLengthBytes
}
if len(source.DefaultEventTypes) > 0 {
cfg.DefaultEventTypes = source.DefaultEventTypes
}
if source.DefaultStatus != "" {
cfg.DefaultStatus = model.CallbackStatus(source.DefaultStatus)
}
cfg.Vault = VaultConfig{
Address: source.Vault.Address,
TokenEnv: source.Vault.TokenEnv,
TokenFileEnv: source.Vault.TokenFileEnv,
TokenFile: source.Vault.TokenFile,
Namespace: source.Vault.Namespace,
MountPath: source.Vault.MountPath,
}
return cfg
}
func (c callbacksConfig) validate() error {
if c.SecretLengthBytes <= 0 {
return merrors.InvalidArgument("callbacks signing secret length must be greater than zero", "api.callbacks.secret_length_bytes")
}
return nil
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
mutil "github.com/tech/sendico/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
@@ -22,7 +23,7 @@ import (
const (
inboxCollection string = "inbox"
tasksCollection string = "tasks"
endpointsCollection string = "endpoints"
endpointsCollection string = mservice.Callbacks
)
type mongoRepository struct {

View File

@@ -0,0 +1,15 @@
package callbacks
import (
"context"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type DB interface {
auth.ProtectedDB[*model.Callback]
SetArchived(ctx context.Context, accountRef, organizationRef, callbackRef bson.ObjectID, archived, cascade bool) error
List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Callback, error)
}

View File

@@ -3,6 +3,7 @@ package db
import (
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes"
mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo"
@@ -29,6 +30,7 @@ type Factory interface {
NewOrganizationDB() (organization.DB, error)
NewInvitationsDB() (invitation.DB, error)
NewRecipientsDB() (recipient.DB, error)
NewCallbacksDB() (callbacks.DB, error)
NewVerificationsDB() (verification.DB, error)
NewRolesDB() (role.DB, error)

View File

@@ -0,0 +1,30 @@
package callbacksdb
import (
"context"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *CallbacksDB) SetArchived(
ctx context.Context,
accountRef,
organizationRef,
callbackRef bson.ObjectID,
isArchived,
cascade bool,
) error {
if err := db.ArchivableDB.SetArchived(ctx, accountRef, callbackRef, isArchived); err != nil {
db.DBImp.Logger.Warn("Failed to change callback archive status", zap.Error(err),
mzap.AccRef(accountRef),
mzap.ObjRef("organization_ref", organizationRef),
mzap.ObjRef("callback_ref", callbackRef),
zap.Bool("archived", isArchived),
zap.Bool("cascade", cascade),
)
return err
}
return nil
}

View File

@@ -0,0 +1,112 @@
package callbacksdb
import (
"context"
"errors"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/policy"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type CallbacksDB struct {
auth.ProtectedDBImp[*model.Callback]
auth.ArchivableDB[*model.Callback]
}
func Create(
ctx context.Context,
logger mlogger.Logger,
enforcer auth.Enforcer,
pdb policy.DB,
db *mongo.Database,
) (*CallbacksDB, error) {
if err := ensureBuiltInPolicy(ctx, logger, pdb); err != nil {
return nil, err
}
p, err := auth.CreateDBImp[*model.Callback](ctx, logger, pdb, enforcer, mservice.Callbacks, db)
if err != nil {
return nil, err
}
for _, definition := range []*ri.Definition{
{
Name: "uq_callbacks_client_url",
Keys: []ri.Key{
{Field: storable.OrganizationRefField, Sort: ri.Asc},
{Field: "client_id", Sort: ri.Asc},
{Field: "url", Sort: ri.Asc},
},
Unique: true,
},
{
Name: "idx_callbacks_lookup",
Keys: []ri.Key{
{Field: storable.OrganizationRefField, Sort: ri.Asc},
{Field: "status", Sort: ri.Asc},
{Field: "event_types", Sort: ri.Asc},
},
},
} {
if err := p.DBImp.Repository.CreateIndex(definition); err != nil {
p.DBImp.Logger.Warn("Failed to create callbacks index", zap.String("index", definition.Name), zap.Error(err))
return nil, err
}
}
createEmpty := func() *model.Callback {
return &model.Callback{}
}
getArchivable := func(callback *model.Callback) model.Archivable {
return &callback.ArchivableBase
}
return &CallbacksDB{
ProtectedDBImp: *p,
ArchivableDB: auth.NewArchivableDB(
p.DBImp,
p.DBImp.Logger,
enforcer,
createEmpty,
getArchivable,
),
}, nil
}
func ensureBuiltInPolicy(ctx context.Context, logger mlogger.Logger, pdb policy.DB) error {
var existing model.PolicyDescription
if err := pdb.GetBuiltInPolicy(ctx, mservice.Callbacks, &existing); err == nil {
return nil
} else if !errors.Is(err, merrors.ErrNoData) {
return err
}
description := "Callbacks subscription management"
resourceTypes := []mservice.Type{mservice.Callbacks}
policyDescription := &model.PolicyDescription{
Describable: model.Describable{
Name: "Callbacks",
Description: &description,
},
ResourceTypes: &resourceTypes,
}
if err := pdb.Create(ctx, policyDescription); err != nil && !errors.Is(err, merrors.ErrDataConflict) {
if logger != nil {
logger.Warn("Failed to create built-in callbacks policy", zap.Error(err))
}
return err
}
return pdb.GetBuiltInPolicy(ctx, mservice.Callbacks, &existing)
}
var _ callbacks.DB = (*CallbacksDB)(nil)

View File

@@ -0,0 +1,36 @@
package callbacksdb
import (
"context"
"errors"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
mauth "github.com/tech/sendico/pkg/mutil/db/auth"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *CallbacksDB) List(
ctx context.Context,
accountRef,
organizationRef,
_ bson.ObjectID,
cursor *model.ViewCursor,
) ([]model.Callback, error) {
res, err := mauth.GetProtectedObjects[model.Callback](
ctx,
db.DBImp.Logger,
accountRef,
organizationRef,
model.ActionRead,
repository.OrgFilter(organizationRef),
cursor,
db.Enforcer,
db.DBImp.Repository,
)
if errors.Is(err, merrors.ErrNoData) {
return []model.Callback{}, nil
}
return res, err
}

View File

@@ -10,9 +10,11 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes"
"github.com/tech/sendico/pkg/db/internal/mongo/accountdb"
"github.com/tech/sendico/pkg/db/internal/mongo/callbacksdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainassetsdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainwalletroutesdb"
"github.com/tech/sendico/pkg/db/internal/mongo/invitationdb"
@@ -218,6 +220,10 @@ func (db *DB) NewRecipientsDB() (recipient.DB, error) {
return newProtectedDB(db, create)
}
func (db *DB) NewCallbacksDB() (callbacks.DB, error) {
return newProtectedDB(db, callbacksdb.Create)
}
func (db *DB) NewRefreshTokensDB() (refreshtokens.DB, error) {
return refreshtokensdb.Create(db.logger, db.db())
}

41
api/pkg/model/callback.go Normal file
View File

@@ -0,0 +1,41 @@
package model
import "github.com/tech/sendico/pkg/mservice"
type CallbackStatus string
const (
CallbackStatusActive CallbackStatus = "active"
CallbackStatusDisabled CallbackStatus = "disabled"
)
type CallbackSigningMode string
const (
CallbackSigningModeNone CallbackSigningMode = "none"
CallbackSigningModeHMACSHA256 CallbackSigningMode = "hmac_sha256"
)
type CallbackRetryPolicy struct {
MinDelayMS int `bson:"min_ms" json:"minDelayMs"`
MaxDelayMS int `bson:"max_ms" json:"maxDelayMs"`
SigningMode CallbackSigningMode `bson:"signing_mode" json:"signingMode"`
SecretRef string `bson:"secret_ref,omitempty" json:"secretRef,omitempty"`
Headers map[string]string `bson:"headers,omitempty" json:"headers,omitempty"`
MaxAttempts int `bson:"max_attempts" json:"maxAttempts"`
RequestTimeoutMS int `bson:"request_timeout_ms" json:"requestTimeoutMs"`
}
type Callback struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
ClientID string `bson:"client_id" json:"clientId"`
Status CallbackStatus `bson:"status" json:"status"`
URL string `bson:"url" json:"url"`
EventTypes []string `bson:"event_types" json:"eventTypes"`
RetryPolicy CallbackRetryPolicy `bson:"retry_policy" json:"retryPolicy"`
}
func (*Callback) Collection() string {
return mservice.Callbacks
}

View File

@@ -35,6 +35,7 @@ const (
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
ChainTransfers Type = "chain_transfers" // Represents chain transfers
ChainDeposits Type = "chain_deposits" // Represents chain deposits
Callbacks Type = "callbacks" // Represents webhook callback subscriptions
Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service
@@ -58,7 +59,7 @@ const (
func StringToSType(s string) (Type, error) {
switch Type(s) {
case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances,
ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
ChainTransfers, ChainDeposits, Callbacks, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:

View File

@@ -8,10 +8,12 @@ import (
// Config describes Vault KV v2 connection settings.
type Config struct {
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
TokenFileEnv string `mapstructure:"token_file_env" yaml:"token_file_env"`
TokenFile string `mapstructure:"token_file" yaml:"token_file"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
}
// Client defines KV operations used by services.

View File

@@ -36,16 +36,14 @@ func newService(opts Options) (Client, error) {
return nil, merrors.InvalidArgument(component + ": address is required")
}
tokenEnv := strings.TrimSpace(opts.Config.TokenEnv)
if tokenEnv == "" {
logger.Error("Vault token env missing")
return nil, merrors.InvalidArgument(component + ": token_env is required")
token, tokenSource, err := resolveToken(opts.Config)
if err != nil {
logger.Error("Vault token configuration is invalid", zap.Error(err))
return nil, err
}
token := strings.TrimSpace(os.Getenv(tokenEnv))
if token == "" {
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
return nil, merrors.InvalidArgument(component + ": token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
logger.Error("Vault token missing", zap.String("source", tokenSource))
return nil, merrors.InvalidArgument(component + ": vault token is empty")
}
mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/")
@@ -148,4 +146,36 @@ func normalizePath(secretPath string) (string, error) {
return normalizedPath, nil
}
func resolveToken(config Config) (string, string, error) {
tokenEnv := strings.TrimSpace(config.TokenEnv)
if tokenEnv != "" {
if token := strings.TrimSpace(os.Getenv(tokenEnv)); token != "" {
return token, "token_env:" + tokenEnv, nil
}
}
tokenFilePath := strings.TrimSpace(config.TokenFile)
if tokenFileEnv := strings.TrimSpace(config.TokenFileEnv); tokenFileEnv != "" {
if resolved := strings.TrimSpace(os.Getenv(tokenFileEnv)); resolved != "" {
tokenFilePath = resolved
}
}
if tokenFilePath != "" {
raw, err := os.ReadFile(tokenFilePath)
if err != nil {
return "", "", merrors.Internal("vault kv: failed to read token file " + tokenFilePath + ": " + err.Error())
}
return strings.TrimSpace(string(raw)), "token_file:" + tokenFilePath, nil
}
if tokenEnv != "" {
return "", "token_env:" + tokenEnv, merrors.InvalidArgument("vault kv: token env " + tokenEnv + " is empty")
}
if strings.TrimSpace(config.TokenFileEnv) != "" {
return "", "token_file_env:" + strings.TrimSpace(config.TokenFileEnv), merrors.InvalidArgument("vault kv: token file env is empty")
}
return "", "", merrors.InvalidArgument("vault kv: either token_env or token_file/token_file_env must be configured")
}
var _ Client = (*service)(nil)

View File

@@ -10,11 +10,13 @@ import (
// Config describes how to connect to Vault for managed wallet keys.
type Config struct {
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"`
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
TokenFileEnv string `mapstructure:"token_file_env" yaml:"token_file_env"`
TokenFile string `mapstructure:"token_file" yaml:"token_file"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"`
}
// ManagedWalletKey captures metadata returned after key provisioning.

View File

@@ -38,10 +38,12 @@ func newService(opts Options) (Service, error) {
store, err := kv.New(kv.Options{
Logger: logger,
Config: kv.Config{
Address: opts.Config.Address,
TokenEnv: opts.Config.TokenEnv,
Namespace: opts.Config.Namespace,
MountPath: opts.Config.MountPath,
Address: opts.Config.Address,
TokenEnv: opts.Config.TokenEnv,
TokenFileEnv: opts.Config.TokenFileEnv,
TokenFile: opts.Config.TokenFile,
Namespace: opts.Config.Namespace,
MountPath: opts.Config.MountPath,
},
Component: component,
})