Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9a89d9c4 | ||
|
|
2f74d3627e | ||
|
|
f436b2528c | ||
|
|
3b551f1565 | ||
|
|
645181c65c | ||
|
|
de449d53bf | ||
|
|
9370eacd9c | ||
|
|
5c255296d3 | ||
|
|
62df9eac5f |
No files matched your search
@@ -80,6 +80,12 @@ test-dev:
|
|||||||
sh infra/local/tools.sh test
|
sh infra/local/tools.sh test
|
||||||
sh infra/local/tools.sh verify
|
sh infra/local/tools.sh verify
|
||||||
|
|
||||||
|
# Production pins (infra/prod/README.md): resolved once, copied to every node.
|
||||||
|
.PHONY: prod-images
|
||||||
|
prod-images:
|
||||||
|
sh infra/local/tools.sh build
|
||||||
|
sh infra/local/tools.sh prod-images
|
||||||
|
|
||||||
.PHONY: test-dev-infra
|
.PHONY: test-dev-infra
|
||||||
test-dev-infra:
|
test-dev-infra:
|
||||||
sh infra/local/tools.sh test
|
sh infra/local/tools.sh test
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
|
deps.Auth.Wallet = deps.Payments
|
||||||
logger.Info("Sendico sign-up facts integration enabled")
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
}
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ type SignupFacts interface {
|
|||||||
Ready(context.Context, bson.ObjectID) error
|
Ready(context.Context, bson.ObjectID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrencyAccounts interface {
|
||||||
|
Ensure(context.Context, *customer.Account) error
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Facts SignupFacts
|
Facts SignupFacts
|
||||||
|
Wallet CurrencyAccounts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.Wallet != nil {
|
||||||
|
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signupWallet struct {
|
||||||
|
customers []bson.ObjectID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
|
||||||
|
s.customers = append(s.customers, a.ID)
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
provider := &signupProvider{fail: true}
|
provider := &signupProvider{fail: true}
|
||||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
|
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
|
||||||
|
service.Wallet = wallet
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
signup["name"] = "Changed retry name"
|
signup["name"] = "Changed retry name"
|
||||||
provider.fail = false
|
provider.fail = false
|
||||||
w = post("signup", signup)
|
w = post("signup", signup)
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
require.Len(t, wallet.customers, 1)
|
||||||
|
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
|
||||||
|
require.Equal(t, 503, w.Code, w.Body.String())
|
||||||
|
require.Empty(t, mail.messages)
|
||||||
|
wallet.err = nil
|
||||||
|
w = post("signup", signup)
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 1, provider.registrations)
|
require.Equal(t, 1, provider.registrations)
|
||||||
require.Equal(t, "Customer", provider.last.FullName)
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
@@ -149,6 +170,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
|
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
|
||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
|
||||||
|
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
|
||||||
|
|
||||||
|
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
|
||||||
|
_, err := s.ensureAccounts(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
|
||||||
|
started := time.Now()
|
||||||
|
defer func() {
|
||||||
|
outcome := "ready"
|
||||||
|
if result != nil {
|
||||||
|
outcome = "failed"
|
||||||
|
}
|
||||||
|
accountSetup.WithLabelValues(outcome).Inc()
|
||||||
|
accountSetupDuration.Observe(time.Since(started).Seconds())
|
||||||
|
}()
|
||||||
|
if s == nil || s.Identity == nil || s.Provider == nil {
|
||||||
|
return "", Unavailable
|
||||||
|
}
|
||||||
|
if a == nil || a.ID.IsZero() {
|
||||||
|
return "", NotFound
|
||||||
|
}
|
||||||
|
subject, result = s.Identity.Subject(ctx, a)
|
||||||
|
if result != nil {
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
|
||||||
|
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
|
||||||
|
return "", result
|
||||||
|
}
|
||||||
|
return subject, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
|
||||||
|
a := &customer.Account{}
|
||||||
|
a.ID = bson.NewObjectID()
|
||||||
|
p := &provider{namespace: "provider"}
|
||||||
|
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
|
||||||
|
require.NoError(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Equal(t, "subject", p.setupSubject)
|
||||||
|
snapshot, err := s.Fetch(t.Context(), a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Accounts, 1)
|
||||||
|
require.Equal(t, 2, p.setupCalls)
|
||||||
|
p.setupErr = Unavailable
|
||||||
|
_, err = s.Fetch(t.Context(), a)
|
||||||
|
require.ErrorIs(t, err, Unavailable)
|
||||||
|
require.Error(t, s.Ensure(t.Context(), a))
|
||||||
|
require.Error(t, s.Ensure(t.Context(), nil))
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" }
|
|||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Namespace() string
|
Namespace() string
|
||||||
|
EnsureAccounts(context.Context, string) error
|
||||||
Accounts(context.Context, string) ([]Account, error)
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
Quote(context.Context, string, Request, []Account) (Quote, error)
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
Execute(context.Context, string, string) (RemoteOperation, error)
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
|
|||||||
if err := s.ready(); err != nil {
|
if err := s.ready(); err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
subject, err := s.Identity.Subject(ctx, a)
|
subject, err := s.ensureAccounts(ctx, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,19 @@ type provider struct {
|
|||||||
status string
|
status string
|
||||||
key string
|
key string
|
||||||
namespace string
|
namespace string
|
||||||
|
setupCalls int
|
||||||
|
setupSubject string
|
||||||
|
setupErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *provider) Namespace() string { return p.namespace }
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
|
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.setupCalls++
|
||||||
|
p.setupSubject = subject
|
||||||
|
return p.setupErr
|
||||||
|
}
|
||||||
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/payments"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
|
||||||
|
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
|
||||||
|
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
|
||||||
|
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
|
||||||
|
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAccounts also repairs older customers, using provider-enforced
|
||||||
|
// organization/subject/currency uniqueness for safe retries after lost responses.
|
||||||
|
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
|
||||||
|
if !validRef(subject) {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
|
var data accountListDTO
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := map[string]bool{}
|
||||||
|
reconcile := map[string]bool{}
|
||||||
|
for _, a := range data.Accounts {
|
||||||
|
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
existing[a.Currency] = true
|
||||||
|
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
for _, code := range []string{"RUB", "USDT", "USDC"} {
|
||||||
|
if !existing[code] || reconcile[code] {
|
||||||
|
missing = append(missing, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Older providers can accept the metadata but cannot prevent duplicates.
|
||||||
|
// Require the explicit capability before issuing the first create.
|
||||||
|
if !data.ExternalSubjectAccounts {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
var catalog struct {
|
||||||
|
Rows []currencyDTO `json:"currencies"`
|
||||||
|
}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, code := range missing {
|
||||||
|
found := false
|
||||||
|
for _, currency := range catalog.Rows {
|
||||||
|
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replaying a bound create also resumes Sendico's durable wallet/route setup
|
||||||
|
// if a previous response failed after the ledger itself had been committed.
|
||||||
|
for _, code := range missing {
|
||||||
|
var response struct {
|
||||||
|
Account accountDTO `json:"account"`
|
||||||
|
}
|
||||||
|
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
|
||||||
|
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
|
||||||
|
return payments.Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows []accountDTO
|
||||||
|
posts []string
|
||||||
|
capable bool
|
||||||
|
missingCurrency bool
|
||||||
|
failCurrency string
|
||||||
|
loseResponse bool
|
||||||
|
wrongResponse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func customerLedger(code string) accountDTO {
|
||||||
|
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||||
|
var data any
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/currencies/"):
|
||||||
|
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
|
||||||
|
if s.missingCurrency {
|
||||||
|
rows = rows[:2]
|
||||||
|
}
|
||||||
|
data = map[string]any{"currencies": rows}
|
||||||
|
case r.Method == http.MethodPost:
|
||||||
|
var request createAccountDTO
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||||
|
require.Equal(t, "asset", request.Type)
|
||||||
|
require.Equal(t, "operating", request.LedgerRole)
|
||||||
|
require.False(t, request.AllowNegative)
|
||||||
|
require.Equal(t, subject, request.Metadata["external_subject_ref"])
|
||||||
|
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
|
||||||
|
s.posts = append(s.posts, request.Currency)
|
||||||
|
if s.failCurrency == request.Currency {
|
||||||
|
s.failCurrency = ""
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row accountDTO
|
||||||
|
for _, a := range s.rows {
|
||||||
|
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
|
||||||
|
row = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.ID == "" {
|
||||||
|
row = customerLedger(request.Currency)
|
||||||
|
s.rows = append(s.rows, row)
|
||||||
|
}
|
||||||
|
if s.loseResponse {
|
||||||
|
s.loseResponse = false
|
||||||
|
w.WriteHeader(503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.wrongResponse {
|
||||||
|
row.Currency = "EUR"
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
data = map[string]any{"account": row}
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
|
||||||
|
default:
|
||||||
|
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
|
||||||
|
for _, lost := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
|
||||||
|
if lost {
|
||||||
|
s.failCurrency = ""
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 1)
|
||||||
|
firstID := s.rows[0].ID
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
require.Equal(t, firstID, s.rows[0].ID)
|
||||||
|
// Bound creates are replayed to finish interrupted wallet/route setup;
|
||||||
|
// the provider must return the original ledger each time.
|
||||||
|
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCurrency(rows []string, code string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if r == code {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
errors := make(chan error, 12)
|
||||||
|
for range 12 {
|
||||||
|
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
|
||||||
|
}
|
||||||
|
for range 12 {
|
||||||
|
require.NoError(t, <-errors)
|
||||||
|
}
|
||||||
|
require.Len(t, s.rows, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
|
||||||
|
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true}
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
switch kind {
|
||||||
|
case "old provider":
|
||||||
|
s.capable = false
|
||||||
|
case "missing currency":
|
||||||
|
s.missingCurrency = true
|
||||||
|
case "frozen":
|
||||||
|
a.Status = "ACCOUNT_STATUS_FROZEN"
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "owned":
|
||||||
|
a.Owner = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "negative":
|
||||||
|
a.AllowNegative = true
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
case "duplicate":
|
||||||
|
s.rows = []accountDTO{a, customerLedger("RUB")}
|
||||||
|
case "wrong subject":
|
||||||
|
a.Metadata["sxchnge_subject_ref"] = source
|
||||||
|
s.rows = []accountDTO{a}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Empty(t, s.posts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
|
||||||
|
s := &accountServer{capable: true, wrongResponse: true}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Len(t, s.posts, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
|
||||||
|
a := customerLedger("RUB")
|
||||||
|
delete(a.Metadata, "external_subject_ref")
|
||||||
|
s := &accountServer{capable: true, rows: []accountDTO{a}}
|
||||||
|
server := httptest.NewServer(s.serve(t))
|
||||||
|
defer server.Close()
|
||||||
|
c, err := Open(config(t, server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
||||||
|
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
|
||||||
|
// A subsequent read uses the original RUB ledger and its authoritative funds.
|
||||||
|
s.rows = s.rows[:1]
|
||||||
|
accounts, err := c.Accounts(t.Context(), subject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, accounts, 1)
|
||||||
|
require.Equal(t, a.ID, accounts[0].ID)
|
||||||
|
require.EqualValues(t, 4500, accounts[0].Available)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sendico
|
||||||
|
|
||||||
|
type accountListDTO struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createAccountDTO struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Type string `json:"accountType"`
|
||||||
|
LedgerRole string `json:"role"`
|
||||||
|
AllowNegative bool `json:"allowNegative"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
@@ -27,9 +27,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
scales[row.Code] = *row.Scale
|
scales[row.Code] = *row.Scale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var data struct {
|
var data accountListDTO
|
||||||
Accounts []accountDTO `json:"accounts"`
|
|
||||||
}
|
|
||||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,7 +37,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
if row.Metadata["sxchnge_subject_ref"] != subject {
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
if !c.validCustomerAccount(row, subject) {
|
||||||
return nil, payments.Unavailable
|
return nil, payments.Unavailable
|
||||||
}
|
}
|
||||||
scale, ok := scales[row.Currency]
|
scale, ok := scales[row.Currency]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type accountDTO struct {
|
|||||||
Type string `json:"accountType"`
|
Type string `json:"accountType"`
|
||||||
LedgerRole string `json:"role"`
|
LedgerRole string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
AllowNegative bool `json:"allowNegative"`
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
}
|
}
|
||||||
type currencyDTO struct {
|
type currencyDTO struct {
|
||||||
@@ -72,7 +73,7 @@ type balanceDTO struct {
|
|||||||
Money moneyDTO `json:"balance"`
|
Money moneyDTO `json:"balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any, accepted ...int) error {
|
||||||
token, err := c.token()
|
token, err := c.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
|
|||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() { _ = res.Body.Close() }()
|
||||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
if res.StatusCode != http.StatusOK {
|
expected := http.StatusOK
|
||||||
|
if len(accepted) != 0 {
|
||||||
|
expected = accepted[0]
|
||||||
|
}
|
||||||
|
if res.StatusCode != expected {
|
||||||
return payments.Unavailable
|
return payments.Unavailable
|
||||||
}
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ This recovery retains the configured provider URL, account and replay namespace.
|
|||||||
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
authenticated Sendico currency and ledger reads through the same loopback
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
connection and token used by BFF. It sends no email and executes no payment.
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
|
||||||
saved methods are provisioned as described in [payment provisioning](payments.md).
|
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
|
||||||
|
Deploy the provider prerequisite and grant ledger-create permission first.
|
||||||
|
Saved funding and payout methods remain separate from currency-account setup.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
|
|||||||
+36
-7
@@ -52,22 +52,50 @@ coordinated deployment of the platform's financial services.
|
|||||||
|
|
||||||
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||||
credentials or organization/source settings in Flutter. Provision the integration
|
credentials or organization/source settings in Flutter. Provision the integration
|
||||||
account's organization membership, payment-create/read, ledger-account/balance
|
account's organization membership, payment-create/read, ledger-account create/read,
|
||||||
read and the permissions needed to resolve its stored payment methods. Verify
|
balance read and the permissions needed to resolve its stored payment methods. Verify
|
||||||
those permissions in the intended environment before enabling real operations.
|
those permissions in the intended environment before enabling real operations.
|
||||||
|
|
||||||
Through Sendico's existing operator APIs, provision one dedicated unowned
|
### Automatic currency accounts (#72)
|
||||||
organization ledger per customer currency, with active `asset` / `operating`
|
|
||||||
semantics, no negative balance and metadata:
|
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
|
||||||
|
during signup, before sending the activation email. Resending activation retries
|
||||||
|
the same setup. Every authenticated wallet load also ensures the defaults, so
|
||||||
|
existing customers receive missing accounts automatically when they next open
|
||||||
|
their wallet. This is lazy backfill, not an eager scan of dormant customers.
|
||||||
|
|
||||||
|
Setup creates dedicated unowned organization ledgers through the public
|
||||||
|
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
|
||||||
|
semantics, zero initial funds, no negative balance and metadata:
|
||||||
|
|
||||||
| Ledger metadata | Meaning |
|
| Ledger metadata | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||||
|
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
|
||||||
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||||
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||||
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||||
|
|
||||||
These are financial-resource bindings maintained by trusted provisioning, not
|
Existing customer ledgers and their balances are preserved, including manually
|
||||||
|
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
|
||||||
|
defaults are created; existing additional currencies remain visible. Repeated
|
||||||
|
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
|
||||||
|
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
|
||||||
|
fail instead of creating a replacement. All required currency precision entries
|
||||||
|
are checked against the provider catalog before issuing creates. Saved funding
|
||||||
|
and payout bindings remain separate from creation of the currency accounts.
|
||||||
|
|
||||||
|
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
|
||||||
|
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
|
||||||
|
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
|
||||||
|
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
|
||||||
|
any create requests. Older providers fail safely without creating accounts.
|
||||||
|
The acknowledgement assumes Ledger and Edge are deployed in that order; it
|
||||||
|
does not discover the version of a separately running Ledger. No new runtime
|
||||||
|
configuration or credentials are introduced. Existing deployments must give the
|
||||||
|
integration principal the ledger-create permission in addition to its reads.
|
||||||
|
|
||||||
|
These are financial-resource bindings maintained by server-side setup, not
|
||||||
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||||
ledger as a customer ledger. Do not place full bank/card details or secret values
|
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||||
in labels or metadata. The BFF checks subject, organization, account type, role,
|
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||||
@@ -79,7 +107,8 @@ review action. Funding uses a quote for the provisioned stored source; it does
|
|||||||
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||||
recipient onboarding and other funding instructions remain separate features.
|
recipient onboarding and other funding instructions remain separate features.
|
||||||
|
|
||||||
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
If setup reports a failure, the API returns an unavailable wallet rather than an
|
||||||
|
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
|
||||||
payments and pooled balances are never imported into customer history. This
|
payments and pooled balances are never imported into customer history. This
|
||||||
integration exposes the durable operations initiated through this BFF, with live
|
integration exposes the durable operations initiated through this BFF, with live
|
||||||
provider reconciliation; independent inbound deposits/history ingestion and
|
provider reconciliation; independent inbound deposits/history ingestion and
|
||||||
|
|||||||
@@ -302,32 +302,54 @@ that contradicts displayed balances or usage without explaining the fixtures.
|
|||||||
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
||||||
block the whole product behind an exchange spinner.
|
block the whole product behind an exchange spinner.
|
||||||
|
|
||||||
## Empty currency accounts (task #66)
|
## Automatic currency accounts (task #72)
|
||||||
|
|
||||||
The shared empty-account message used on Accounts and transfer screens explains
|
New customers receive RUB, USDT and USDC accounts through backend-owned setup
|
||||||
that no currency accounts have been set up and directs the customer to support
|
during signup. Existing customers receive missing defaults on their next wallet
|
||||||
to request setup. English and Russian no longer suggest that refreshing creates
|
load. The UI requires no account-creation form or support contact. During initial
|
||||||
accounts or imply that automatic provisioning is underway. This follows the
|
loading it announces that the accounts are being prepared. Failure shows one
|
||||||
existing support guidance for missing funding and payout setup. The application
|
clear load error and a **Try again** action that reruns the same idempotent setup.
|
||||||
has no configured support contact channel; the message therefore provides text
|
An unexpected empty snapshot has its own setup-failure fallback, rather than
|
||||||
guidance without a nonfunctional contact button or invented contact details.
|
being shown simultaneously with an error. A failed refresh retains existing
|
||||||
|
cards and the stale-data warning. Account-not-found copy requires loaded data.
|
||||||
|
|
||||||
UX review: the copy names the missing resource and the next step, retains the
|
The existing DTO → mapper → service → model → provider → UI boundaries remain
|
||||||
existing wrapping text and accessible reading order, and does not alter loading,
|
intact. Account creation belongs to the backend/provider, and widgets invoke the
|
||||||
error or populated-state behavior. Existing manual refresh remains available for
|
existing provider refresh action. English and Russian use wrapping text and the
|
||||||
accounts provisioned externally. This copy-only change adds no controls or
|
existing accessible button pattern; status text uses a live region. Dedicated
|
||||||
journey outcomes and requires no new analytics events. Existing transaction
|
widget regressions cover loading, failure, retry and successful default accounts
|
||||||
journey signals and their production measurement limitations below still apply;
|
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
|
||||||
support resolution and external account provisioning are not measured in-app.
|
screen-reader QA remain a release check; tests do not claim physical-device QA.
|
||||||
|
|
||||||
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
|
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
|
||||||
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
|
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
|
||||||
changed, so Dart formatting was not applicable. Existing responsive workspace
|
`ready` is emitted only after all default bindings are confirmed, including
|
||||||
tests cover Russian at 320px with enlarged text; this copy-only change does not
|
idempotent completion of provider wallet/route setup for newly bound ledgers.
|
||||||
add a dedicated empty-state rendering or native screen-reader test. No security
|
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
|
||||||
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
|
customer conversion. The failure fraction uses all attempts as its denominator;
|
||||||
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
|
the histogram measures one attempt, not time across multiple browser sessions.
|
||||||
Merge and deployment remain separate.
|
No customer, ledger, currency, balance, credentials or form input is a metric
|
||||||
|
label. These server metrics do not measure client abandonment or prove that a
|
||||||
|
balance card was viewed. Existing transaction journey outcomes below are reused;
|
||||||
|
no speculative button-click events are added.
|
||||||
|
|
||||||
|
Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch
|
||||||
|
`wallet-72`, [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The provider
|
||||||
|
prerequisite and deployment order are documented in [payments](../payments.md).
|
||||||
|
Merge and deployment remain separate. Final validation is recorded in the task
|
||||||
|
and PR, including any transient failures and environmental blockers.
|
||||||
|
|
||||||
|
Final validation (2026-10-23): `flutter gen-l10n`, `flutter analyze` in
|
||||||
|
`frontend/app`, the changed-Dart formatting check, `make lint-backend`, and
|
||||||
|
`git diff --check` passed. `make test-frontend` passed all 242 tests on the current
|
||||||
|
app base, including the eight setup recovery scenarios. The full `make test`
|
||||||
|
passed with `TESTCONTAINERS_RYUK_DISABLED=true GOFLAGS='-p=2'`: earlier attempts
|
||||||
|
hit dependency DNS and Mongo/cleanup-helper startup timeouts. Disabling only the
|
||||||
|
failing helper retained every integration assertion, test timeout and race check;
|
||||||
|
each test's existing container cleanup still ran. No security scan was requested.
|
||||||
|
The live local provider catalog confirms RUB scale 2 and USDT/USDC scale 6.
|
||||||
|
Provider-side concurrency and frozen-binding checks passed against a disposable
|
||||||
|
Mongo replica set; see [Sendico PR #2970](https://git.sendico.io/tech/sendico/pulls/2970).
|
||||||
|
|
||||||
## Journey measurement plan
|
## Journey measurement plan
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,10 @@
|
|||||||
"wRecent": "Recent activity",
|
"wRecent": "Recent activity",
|
||||||
"wSeeAll": "See all",
|
"wSeeAll": "See all",
|
||||||
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
||||||
"wEmptyAccounts": "No currency accounts have been set up for you yet. Contact support to request account setup.",
|
"wEmptyAccounts": "We couldn’t finish setting up your currency accounts. Try again to continue.",
|
||||||
|
"wAccountsLoading": "Getting your RUB, USDT and USDC accounts ready…",
|
||||||
|
"wAccountsFailed": "We couldn’t load your currency accounts. Try again to continue.",
|
||||||
|
"wAccountsRetry": "Try again",
|
||||||
"wRefresh": "Refresh",
|
"wRefresh": "Refresh",
|
||||||
"wSource": "From account",
|
"wSource": "From account",
|
||||||
"wDestination": "To account",
|
"wDestination": "To account",
|
||||||
|
|||||||
@@ -161,7 +161,10 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
|
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
|
||||||
|
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
|
||||||
|
"wAccountsRetry": "Повторить попытку",
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
|
|||||||
@@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if (p.failed)
|
if ((p.loading || (!p.failed && p.data == null)) &&
|
||||||
|
accounts.isEmpty)
|
||||||
|
Semantics(liveRegion: true, child: Text(l.wAccountsLoading)),
|
||||||
|
if (p.failed && accounts.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
),
|
),
|
||||||
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
if (!p.loading &&
|
||||||
|
accounts.isEmpty &&
|
||||||
|
(p.failed || p.data != null)) ...[
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(p.failed ? l.wAccountsFailed : l.wEmptyAccounts),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: p.refresh,
|
||||||
|
child: Text(l.wAccountsRetry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (accountId != null &&
|
if (accountId != null &&
|
||||||
p.account(accountId) == null &&
|
p.account(accountId) == null &&
|
||||||
!p.loading)
|
!p.loading &&
|
||||||
|
!p.failed &&
|
||||||
|
p.data != null)
|
||||||
Text(l.wNotFound),
|
Text(l.wNotFound),
|
||||||
if (selected != null) ...[
|
if (selected != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -256,10 +256,22 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (accounts.isEmpty) ...[
|
if (accounts.isEmpty) ...[
|
||||||
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
wallet.loading ||
|
||||||
|
(!wallet.failed && wallet.data == null)
|
||||||
|
? l.wAccountsLoading
|
||||||
|
: wallet.failed
|
||||||
|
? l.wAccountsFailed
|
||||||
|
: l.wEmptyAccounts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!wallet.loading &&
|
||||||
|
(wallet.failed || wallet.data != null))
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: wallet.loading ? null : wallet.refresh,
|
onPressed: wallet.refresh,
|
||||||
child: Text(l.wRefresh),
|
child: Text(l.wAccountsRetry),
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class SetupWallet extends DummyWalletService {
|
||||||
|
SetupWallet(String locale)
|
||||||
|
: super(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
initialLocale: locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
Completer<WalletSnapshot> pending = Completer<WalletSnapshot>();
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletSnapshot> fetch() {
|
||||||
|
calls++;
|
||||||
|
return pending.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletSnapshot readyAccounts() => WalletSnapshot(
|
||||||
|
accounts: [
|
||||||
|
for (final currency in ['RUB', 'USDT', 'USDC'])
|
||||||
|
CurrencyAccount(
|
||||||
|
id: currency.toLowerCase(),
|
||||||
|
currency: currency,
|
||||||
|
precision: currency == 'RUB' ? 2 : 6,
|
||||||
|
available: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
operations: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
for (final path in ['/accounts', '/top-up', '/exchange', '/payout']) {
|
||||||
|
testWidgets(
|
||||||
|
'$path recovers account setup in $locale at narrow width and large text',
|
||||||
|
(t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 1100);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final wallet = SetupWallet(locale);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
walletService: wallet,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pump();
|
||||||
|
await t.pump();
|
||||||
|
final loading = locale == 'en'
|
||||||
|
? 'Getting your RUB, USDT and USDC accounts ready…'
|
||||||
|
: 'Подготавливаем ваши счета в RUB, USDT и USDC…';
|
||||||
|
final failed = locale == 'en'
|
||||||
|
? 'We couldn’t load your currency accounts. Try again to continue.'
|
||||||
|
: 'Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.';
|
||||||
|
final retry = locale == 'en' ? 'Try again' : 'Повторить попытку';
|
||||||
|
expect(find.text(loading), findsOneWidget);
|
||||||
|
expect(find.textContaining('Contact support'), findsNothing);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
wallet.pending.completeError(
|
||||||
|
const WalletException(WalletError.unavailable),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text(failed), findsOneWidget);
|
||||||
|
expect(find.text(loading), findsNothing);
|
||||||
|
expect(find.text(retry), findsOneWidget);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
wallet.pending = Completer<WalletSnapshot>();
|
||||||
|
await t.ensureVisible(find.text(retry));
|
||||||
|
await t.tap(find.text(retry));
|
||||||
|
await t.pump();
|
||||||
|
expect(wallet.calls, 2);
|
||||||
|
expect(find.text(loading), findsOneWidget);
|
||||||
|
expect(find.text(failed), findsNothing);
|
||||||
|
wallet.pending.complete(readyAccounts());
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text(loading), findsNothing);
|
||||||
|
expect(find.text(failed), findsNothing);
|
||||||
|
expect(find.text('RUB'), findsWidgets);
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
await t.pumpWidget(const SizedBox.shrink());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ set -o pipefail
|
|||||||
. infra/local/scripts/secrets.sh
|
. infra/local/scripts/secrets.sh
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
images) . infra/local/scripts/images.sh; images;;
|
images) . infra/local/scripts/images.sh; images;;
|
||||||
|
prod-images) TOOL_STATE="$PWD/.local"; . infra/local/scripts/images.sh; prod_images;;
|
||||||
up) . infra/local/scripts/bootstrap.sh; up;;
|
up) . infra/local/scripts/bootstrap.sh; up;;
|
||||||
email) . infra/local/scripts/bootstrap.sh; email;;
|
email) . infra/local/scripts/bootstrap.sh; email;;
|
||||||
sendico) . infra/local/scripts/bootstrap.sh; . infra/local/scripts/sendico.sh; configure_sendico;;
|
sendico) . infra/local/scripts/bootstrap.sh; . infra/local/scripts/sendico.sh; configure_sendico;;
|
||||||
|
|||||||
@@ -22,3 +22,10 @@ images() (
|
|||||||
cat "$WORK/images.env" | write "$STATE/images.env"
|
cat "$WORK/images.env" | write "$STATE/images.env"
|
||||||
cat "$STATE/images.env"
|
cat "$STATE/images.env"
|
||||||
)
|
)
|
||||||
|
# Production pins: one resolution for every node of a cluster, so all of them
|
||||||
|
# run the same build. Written outside .local/dev; copied to the servers.
|
||||||
|
prod_images() (
|
||||||
|
ref=$(image hashicorp/vault latest) || exit 1
|
||||||
|
printf 'VAULT_IMAGE=%s\n' "$ref" | write "$PWD/.local/prod/images.env"
|
||||||
|
cat "$PWD/.local/prod/images.env"
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@ set -eu
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
# Shell syntax and static analysis cover the actual container shell scripts.
|
# Shell syntax and static analysis cover the actual container shell scripts.
|
||||||
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
|
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
|
||||||
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
|
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh infra/prod/vault/start.sh
|
||||||
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh infra/local/sendico/network.sh
|
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh infra/local/sendico/network.sh
|
||||||
. infra/local/scripts/common.sh
|
. infra/local/scripts/common.sh
|
||||||
. infra/local/scripts/images.sh
|
. infra/local/scripts/images.sh
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Production
|
||||||
|
|
||||||
|
Production runs on two servers of the shared private network: `exch`
|
||||||
|
(192.168.10.191), the SXCHNGE server, and `infra-gamma` (192.168.10.98), which
|
||||||
|
keeps the third copy of the data. Each component runs two nodes on `exch` and
|
||||||
|
one on `infra-gamma`: losing `infra-gamma` keeps the majority, losing one node on
|
||||||
|
`exch` keeps two of three. Every server runs plain Docker Compose; files are
|
||||||
|
deployed to `/srv/sxchnge/<component>` on the server.
|
||||||
|
|
||||||
|
Ports are published on the private addresses only. Traffic between the servers
|
||||||
|
is not wrapped in TLS: the network is private and limited by security groups,
|
||||||
|
and every component authenticates its peers.
|
||||||
|
|
||||||
|
## Vault
|
||||||
|
|
||||||
|
[#74](https://git.sendico.io/tech/sxchnge/issues/74). Three raft nodes:
|
||||||
|
|
||||||
|
| Node | Server | API | Cluster | Compose |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `sxchnge-vault-1` | exch | 192.168.10.191:8200 | :8201 | `vault/exch.yml` |
|
||||||
|
| `sxchnge-vault-2` | exch | 192.168.10.191:8210 | :8211 | `vault/exch.yml` |
|
||||||
|
| `sxchnge-vault-3` | infra-gamma | 192.168.10.98:8200 | :8201 | `vault/gamma.yml` |
|
||||||
|
|
||||||
|
Containers on `exch` join the Docker network `sxchnge` and use
|
||||||
|
`http://vault:8200`; the alias belongs to both local nodes. A standby node
|
||||||
|
forwards requests to the active one, so the leader may be on any server.
|
||||||
|
|
||||||
|
Vault is not published to the internet. Its UI is opened through an SSH tunnel
|
||||||
|
from an administrator's machine, for example
|
||||||
|
`ssh -N -L 8200:192.168.10.191:8200 exch` and then `http://localhost:8200/ui`.
|
||||||
|
|
||||||
|
Nodes unseal themselves with the `transit` seal on the platform Vault
|
||||||
|
(`https://vault.sendico.io`, key `transit/keys/sxchnge-vault-unseal`). Each server
|
||||||
|
has its own periodic token in `secrets/transit-token` (root, mode 600, not in Git)
|
||||||
|
that may only encrypt and decrypt with that key. If the platform Vault is
|
||||||
|
unreachable while a node starts, the node stays sealed until it is reachable
|
||||||
|
again; running nodes are not affected. `vault operator init` returns recovery
|
||||||
|
keys, not unseal keys: they authorize operations such as generating a new root
|
||||||
|
token but cannot unseal a node. Only the transit key can. Its backup
|
||||||
|
(`transit/backup/sxchnge-vault-unseal`), the recovery keys and the root token are
|
||||||
|
kept outside the servers by their holders; see "Platform Vault lost".
|
||||||
|
|
||||||
|
`make prod-images` resolves the latest stable Vault release and pins its digest in
|
||||||
|
ignored `.local/prod/images.env`. All three nodes get the same file: a cluster
|
||||||
|
runs one build, and an upgrade is a separate rollout of a new file, one node at a
|
||||||
|
time.
|
||||||
|
|
||||||
|
### First rollout
|
||||||
|
|
||||||
|
1. On the platform Vault, with an administrator token:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault secrets list | grep -q '^transit/' || vault secrets enable transit
|
||||||
|
vault write transit/keys/sxchnge-vault-unseal exportable=true allow_plaintext_backup=true
|
||||||
|
vault read -field=backup transit/backup/sxchnge-vault-unseal # store with the holders, then clear the terminal
|
||||||
|
vault policy write sxchnge-vault-unseal - <<'EOF'
|
||||||
|
path "transit/encrypt/sxchnge-vault-unseal" { capabilities = ["update"] }
|
||||||
|
path "transit/decrypt/sxchnge-vault-unseal" { capabilities = ["update"] }
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Create one token per server and write it straight into that server's
|
||||||
|
`/srv/sxchnge/vault/secrets/transit-token`, never to a terminal or a log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vault token create -orphan -period=768h -policy=sxchnge-vault-unseal \
|
||||||
|
-display-name=sxchnge-vault-<server> -field=token
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Copy `vault/` and `.local/prod/images.env` to `/srv/sxchnge/vault` on both
|
||||||
|
servers.
|
||||||
|
3. Start the nodes: on `exch`
|
||||||
|
`docker compose --env-file images.env -f exch.yml up -d`, on `infra-gamma`
|
||||||
|
the same with `gamma.yml`.
|
||||||
|
4. Initialize once, on `exch`:
|
||||||
|
`docker exec -it sxchnge-vault-vault-1-1 vault operator init -recovery-shares=5 -recovery-threshold=3`.
|
||||||
|
The output is shown once; store the recovery keys and the root token with
|
||||||
|
their holders. Revoke the root token after the bootstrap; a new one is
|
||||||
|
generated with the recovery keys when needed.
|
||||||
|
5. Check: `vault operator raft list-peers` lists three voters; `docker restart`
|
||||||
|
of each node in turn unseals it without a person and the raft committed index
|
||||||
|
matches on all three.
|
||||||
|
|
||||||
|
### Replacing a node
|
||||||
|
|
||||||
|
Stop the node, remove it with `vault operator raft remove-peer <node id>`,
|
||||||
|
delete its volume and start it again: it joins through `retry_join` and copies the
|
||||||
|
data from the leader. A node keeps its node id and addresses; moving it to
|
||||||
|
another server is a change of its config file and of every `retry_join` list.
|
||||||
|
|
||||||
|
### Platform Vault lost
|
||||||
|
|
||||||
|
Running nodes keep working. To start a node without the platform Vault, start
|
||||||
|
any temporary Vault, enable `transit`, load the stored key backup with
|
||||||
|
`vault write transit/restore/sxchnge-vault-unseal backup=<backup>`, issue a token
|
||||||
|
with the policy above, and point `address` in the node configs at that Vault.
|
||||||
|
Without the key backup the data cannot be unsealed again.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SXCHNGE production Vault, node 1. Only api_addr, cluster_addr and node_id
|
||||||
|
# differ between the three node files.
|
||||||
|
ui = true
|
||||||
|
disable_mlock = true
|
||||||
|
api_addr = "http://192.168.10.191:8200"
|
||||||
|
cluster_addr = "https://192.168.10.191:8201"
|
||||||
|
|
||||||
|
listener "tcp" {
|
||||||
|
address = "0.0.0.0:8200"
|
||||||
|
cluster_address = "0.0.0.0:8201"
|
||||||
|
# Plain HTTP on the private network; raft and request forwarding on the
|
||||||
|
# cluster port are encrypted by Vault itself.
|
||||||
|
tls_disable = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
storage "raft" {
|
||||||
|
path = "/vault/file"
|
||||||
|
node_id = "sxchnge-vault-1"
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.191:8200" }
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.191:8210" }
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.98:8200" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-unseal with the platform Vault. The token comes from VAULT_TOKEN, set by
|
||||||
|
# start.sh from the secret file; recovery keys stay with people.
|
||||||
|
seal "transit" {
|
||||||
|
address = "https://vault.sendico.io"
|
||||||
|
mount_path = "transit/"
|
||||||
|
key_name = "sxchnge-vault-unseal"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SXCHNGE production Vault, node 2. Only api_addr, cluster_addr and node_id
|
||||||
|
# differ between the three node files.
|
||||||
|
ui = true
|
||||||
|
disable_mlock = true
|
||||||
|
api_addr = "http://192.168.10.191:8210"
|
||||||
|
cluster_addr = "https://192.168.10.191:8211"
|
||||||
|
|
||||||
|
listener "tcp" {
|
||||||
|
address = "0.0.0.0:8200"
|
||||||
|
cluster_address = "0.0.0.0:8201"
|
||||||
|
# Plain HTTP on the private network; raft and request forwarding on the
|
||||||
|
# cluster port are encrypted by Vault itself.
|
||||||
|
tls_disable = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
storage "raft" {
|
||||||
|
path = "/vault/file"
|
||||||
|
node_id = "sxchnge-vault-2"
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.191:8200" }
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.191:8210" }
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.98:8200" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-unseal with the platform Vault. The token comes from VAULT_TOKEN, set by
|
||||||
|
# start.sh from the secret file; recovery keys stay with people.
|
||||||
|
seal "transit" {
|
||||||
|
address = "https://vault.sendico.io"
|
||||||
|
mount_path = "transit/"
|
||||||
|
key_name = "sxchnge-vault-unseal"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SXCHNGE production Vault, node 3. Only api_addr, cluster_addr and node_id
|
||||||
|
# differ between the three node files.
|
||||||
|
ui = true
|
||||||
|
disable_mlock = true
|
||||||
|
api_addr = "http://192.168.10.98:8200"
|
||||||
|
cluster_addr = "https://192.168.10.98:8201"
|
||||||
|
|
||||||
|
listener "tcp" {
|
||||||
|
address = "0.0.0.0:8200"
|
||||||
|
cluster_address = "0.0.0.0:8201"
|
||||||
|
# Plain HTTP on the private network; raft and request forwarding on the
|
||||||
|
# cluster port are encrypted by Vault itself.
|
||||||
|
tls_disable = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
storage "raft" {
|
||||||
|
path = "/vault/file"
|
||||||
|
node_id = "sxchnge-vault-3"
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.191:8200" }
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.191:8210" }
|
||||||
|
retry_join { leader_api_addr = "http://192.168.10.98:8200" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-unseal with the platform Vault. The token comes from VAULT_TOKEN, set by
|
||||||
|
# start.sh from the secret file; recovery keys stay with people.
|
||||||
|
seal "transit" {
|
||||||
|
address = "https://vault.sendico.io"
|
||||||
|
mount_path = "transit/"
|
||||||
|
key_name = "sxchnge-vault-unseal"
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# SXCHNGE production Vault on exch: nodes 1 and 2 of three (#74). Node 3 runs
|
||||||
|
# on infra-gamma (gamma.yml). Ports are published on the private address only.
|
||||||
|
name: sxchnge-vault
|
||||||
|
|
||||||
|
x-vault: &vault
|
||||||
|
image: ${VAULT_IMAGE:?Run make prod-images and copy images.env}
|
||||||
|
restart: unless-stopped
|
||||||
|
cap_add: [IPC_LOCK]
|
||||||
|
ulimits: {memlock: {soft: -1, hard: -1}}
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: http://127.0.0.1:8200
|
||||||
|
SKIP_SETCAP: 'true'
|
||||||
|
entrypoint: [/bin/sh, /etc/vault/start.sh]
|
||||||
|
secrets: [transit-token]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, 'vault status >/dev/null']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
|
services:
|
||||||
|
vault-1:
|
||||||
|
<<: *vault
|
||||||
|
ports:
|
||||||
|
- 192.168.10.191:8200:8200
|
||||||
|
- 192.168.10.191:8201:8201
|
||||||
|
volumes:
|
||||||
|
- vault-1-data:/vault/file
|
||||||
|
- ./config/vault-1.hcl:/vault/config/vault.hcl:ro
|
||||||
|
- ./start.sh:/etc/vault/start.sh:ro
|
||||||
|
networks:
|
||||||
|
sxchnge:
|
||||||
|
aliases: [vault]
|
||||||
|
|
||||||
|
vault-2:
|
||||||
|
<<: *vault
|
||||||
|
ports:
|
||||||
|
- 192.168.10.191:8210:8200
|
||||||
|
- 192.168.10.191:8211:8201
|
||||||
|
volumes:
|
||||||
|
- vault-2-data:/vault/file
|
||||||
|
- ./config/vault-2.hcl:/vault/config/vault.hcl:ro
|
||||||
|
- ./start.sh:/etc/vault/start.sh:ro
|
||||||
|
networks:
|
||||||
|
sxchnge:
|
||||||
|
aliases: [vault]
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
transit-token:
|
||||||
|
file: ./secrets/transit-token
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vault-1-data:
|
||||||
|
vault-2-data:
|
||||||
|
|
||||||
|
# Applications on exch join this network and use http://vault:8200.
|
||||||
|
networks:
|
||||||
|
sxchnge:
|
||||||
|
name: sxchnge
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# SXCHNGE production Vault on infra-gamma: node 3 of three (#74). It keeps a
|
||||||
|
# copy of the data off exch; nodes 1 and 2 run on exch (exch.yml).
|
||||||
|
name: sxchnge-vault
|
||||||
|
|
||||||
|
services:
|
||||||
|
vault-3:
|
||||||
|
image: ${VAULT_IMAGE:?Run make prod-images and copy images.env}
|
||||||
|
restart: unless-stopped
|
||||||
|
cap_add: [IPC_LOCK]
|
||||||
|
ulimits: {memlock: {soft: -1, hard: -1}}
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: http://127.0.0.1:8200
|
||||||
|
SKIP_SETCAP: 'true'
|
||||||
|
entrypoint: [/bin/sh, /etc/vault/start.sh]
|
||||||
|
secrets: [transit-token]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, 'vault status >/dev/null']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
ports:
|
||||||
|
- 192.168.10.98:8200:8200
|
||||||
|
- 192.168.10.98:8201:8201
|
||||||
|
volumes:
|
||||||
|
- vault-3-data:/vault/file
|
||||||
|
- ./config/vault-3.hcl:/vault/config/vault.hcl:ro
|
||||||
|
- ./start.sh:/etc/vault/start.sh:ro
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
transit-token:
|
||||||
|
file: ./secrets/transit-token
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vault-3-data:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Hands the transit seal token to the Vault server process only: it is read
|
||||||
|
# from the secret file here, so it is not part of the container environment.
|
||||||
|
set -eu
|
||||||
|
VAULT_TOKEN=$(cat /run/secrets/transit-token)
|
||||||
|
[ -n "$VAULT_TOKEN" ] || { echo 'Empty transit seal token' >&2; exit 1; }
|
||||||
|
export VAULT_TOKEN
|
||||||
|
exec docker-entrypoint.sh vault server -config=/vault/config/vault.hcl
|
||||||
Reference in new issue
Block a user