15 Commits
Author SHA1 Message Date
protuberanets 88217fc06b Merge branch 'main' into prod-vault-74 2026-11-03 13:15:11 +00:00
protuberanets 4cdf71b7f8 Name the production Vault after the exch product
The internal name of the product is exch, like its production server.
Paths, the Compose project, the Docker network, raft node ids and the
transit seal key and policy use it before the first rollout, so nothing
has to be renamed once data exists.
2026-11-03 16:15:06 +03:00
tech 125a9e673e Merge pull request 'docs: generate code from externally published schemas' (#78) from codex/docs-external-schema into main 2026-11-02 15:09:54 +00:00
Stephan D 5a0b211d5f docs: generate code from externally published schemas 2026-11-02 16:09:42 +01:00
tech cd0981cea1 Merge pull request 'docs: require schema code generation at build time' (#77) from codex/docs-build-time-generation into main 2026-11-02 15:07:54 +00:00
Stephan D efeac88a49 docs: require schema code generation at build time 2026-11-02 16:07:39 +01:00
protuberanets 1b9a89d9c4 Keep the production Vault private and reach its UI through SSH 2026-10-29 21:07:59 +03:00
protuberanets 2f74d3627e Match the platform Vault on UI, mlock and the TLS flag
The UI stays reachable on the private addresses only. Integrated raft storage runs with mlock disabled, as on the platform Vault and as Vault recommends for raft.
2026-10-29 20:07:22 +03:00
protuberanets f436b2528c Run the production Vault as three raft nodes on exch and infra-gamma
Two nodes on exch and one on infra-gamma, plain Compose per server, ports
on the private addresses only. Nodes unseal through the transit seal on the
platform Vault; the transit token reaches only the server process.

make prod-images resolves the latest stable Vault release once and pins its
digest in ignored .local/prod/images.env, so every node runs the same build.

infra/prod/README.md records the layout, the first rollout, node
replacement and recovery when the platform Vault is lost (#74).
2026-10-29 20:00:33 +03:00
tech 3b551f1565 Merge pull request 'Set up RUB, USDT and USDC accounts automatically' (#73) from wallet-72 into main 2026-10-23 11:10:45 +00:00
Stephan D 645181c65c Record automatic currency account validation and delivery (#72) 2026-10-23 12:58:28 +02:00
tech de449d53bf Merge pull request 'Restore identity photo previews in Flutter Web' (#71) from kyc-69 into main 2026-10-23 10:51:08 +00:00
Stephan D 9370eacd9c Merge remote-tracking branch 'origin/main' into wallet-72 2026-10-23 12:43:55 +02:00
Stephan D 5c255296d3 Set up default currency accounts automatically (#72) 2026-10-23 12:43:55 +02:00
Stephan D 62df9eac5f Merge remote-tracking branch 'origin/main' into wallet-66 2026-10-23 12:17:10 +02:00
33 changed files with 984 additions and 54 deletions

No files matched your search

+9 -6
View File
@@ -13,14 +13,17 @@
- Never commit autogenerated code to the repository. This applies to backend,
frontend, shared packages, and every other module, including generated
protobuf bindings, API clients, serializers, and framework-generated code.
- Commit the source definitions, generator configuration, and reproducible
generation commands instead. Generate code locally or during build and CI
workflows before any dependent validation or compilation steps.
- Use externally published schemas as generation inputs. Do not embed or commit
schema snapshots in the repository. Commit external schema references,
generator configuration, and reproducible generation commands instead.
- Fetch the externally published schemas and generate code at build time, before
any dependent validation or compilation steps, including in CI.
- Keep generated outputs ignored by Git. Do not force-add ignored generated
files or copy generated code into tracked source files.
- Change the source definitions or generator inputs rather than editing
generated outputs. Document the generation workflow so a clean checkout can
build without committed generated code.
- Change schema sources in their owning project or update external schema
references and generator inputs rather than editing generated outputs.
Document the generation workflow so a clean checkout can build without
committed generated code or embedded schema snapshots.
## Local development tooling
+6
View File
@@ -80,6 +80,12 @@ test-dev:
sh infra/local/tools.sh test
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
test-dev-infra:
sh infra/local/tools.sh test
+1
View File
@@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
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")
}
records, err := factorstore.Open(logger, db)
+15
View File
@@ -31,8 +31,13 @@ type SignupFacts interface {
Ready(context.Context, bson.ObjectID) error
}
type CurrencyAccounts interface {
Ensure(context.Context, *customer.Account) error
}
type Service struct {
Facts SignupFacts
Wallet CurrencyAccounts
Accounts account.DB
Customers accountservice.AccountService
Tokens verification.DB
@@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
return nil, err
}
}
if s.Wallet != nil {
if err := s.Wallet.Ensure(ctx, a); err != nil {
return nil, err
}
}
if raw != "" {
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
@@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
return err
}
}
if s.Wallet != nil {
if err := s.Wallet.Ensure(ctx, a); err != nil {
return err
}
}
_, err = s.sendActivation(ctx, a, key)
return err
}
@@ -75,6 +75,17 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
}
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) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
@@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
require.NoError(t, err)
provider := &signupProvider{fail: true}
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.
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
records, err := factorstore.Open(zap.NewNop(), factory)
@@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
signup["name"] = "Changed retry name"
provider.fail = false
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, 1, provider.registrations)
require.Equal(t, "Customer", provider.last.FullName)
@@ -149,6 +170,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
require.NoError(t, err)
require.False(t, a.IsActive())
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))
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)
+50
View File
@@ -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
}
+28
View File
@@ -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))
}
+1
View File
@@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" }
type Provider interface {
Namespace() string
EnsureAccounts(context.Context, string) error
Accounts(context.Context, string) ([]Account, error)
Quote(context.Context, string, Request, []Account) (Quote, error)
Execute(context.Context, string, string) (RemoteOperation, error)
+1 -1
View File
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
if err := s.ready(); err != nil {
return Snapshot{}, err
}
subject, err := s.Identity.Subject(ctx, a)
subject, err := s.ensureAccounts(ctx, a)
if err != nil {
return Snapshot{}, err
}
+10
View File
@@ -82,9 +82,19 @@ type provider struct {
status string
key string
namespace string
setupCalls int
setupSubject string
setupErr error
}
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) {
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
}
}
var data struct {
Accounts []accountDTO `json:"accounts"`
}
var data accountListDTO
root := "/ledger_accounts/" + c.config.OrganizationID
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
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 {
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
}
scale, ok := scales[row.Currency]
@@ -61,6 +61,7 @@ type accountDTO struct {
Type string `json:"accountType"`
LedgerRole string `json:"role"`
Status string `json:"status"`
AllowNegative bool `json:"allowNegative"`
Metadata map[string]string `json:"metadata"`
}
type currencyDTO struct {
@@ -72,7 +73,7 @@ type balanceDTO struct {
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()
if err != nil {
return err
@@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
}
defer func() { _ = res.Body.Close() }()
// 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
}
var envelope struct {
+4 -2
View File
@@ -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
authenticated Sendico currency and ledger reads through the same loopback
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
saved methods are provisioned as described in [payment provisioning](payments.md).
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
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
+36 -7
View File
@@ -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
credentials or organization/source settings in Flutter. Provision the integration
account's organization membership, payment-create/read, ledger-account/balance
read and the permissions needed to resolve its stored payment methods. Verify
account's organization membership, payment-create/read, ledger-account create/read,
balance read and the permissions needed to resolve its stored payment methods. Verify
those permissions in the intended environment before enabling real operations.
Through Sendico's existing operator APIs, provision one dedicated unowned
organization ledger per customer currency, with active `asset` / `operating`
semantics, no negative balance and metadata:
### Automatic currency accounts (#72)
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 |
| --- | --- |
| `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_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 |
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
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,
@@ -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,
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
integration exposes the durable operations initiated through this BFF, with live
provider reconciliation; independent inbound deposits/history ingestion and
+45 -23
View File
@@ -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
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
that no currency accounts have been set up and directs the customer to support
to request setup. English and Russian no longer suggest that refreshing creates
accounts or imply that automatic provisioning is underway. This follows the
existing support guidance for missing funding and payout setup. The application
has no configured support contact channel; the message therefore provides text
guidance without a nonfunctional contact button or invented contact details.
New customers receive RUB, USDT and USDC accounts through backend-owned setup
during signup. Existing customers receive missing defaults on their next wallet
load. The UI requires no account-creation form or support contact. During initial
loading it announces that the accounts are being prepared. Failure shows one
clear load error and a **Try again** action that reruns the same idempotent setup.
An unexpected empty snapshot has its own setup-failure fallback, rather than
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
existing wrapping text and accessible reading order, and does not alter loading,
error or populated-state behavior. Existing manual refresh remains available for
accounts provisioned externally. This copy-only change adds no controls or
journey outcomes and requires no new analytics events. Existing transaction
journey signals and their production measurement limitations below still apply;
support resolution and external account provisioning are not measured in-app.
The existing DTO → mapper → service → model → provider → UI boundaries remain
intact. Account creation belongs to the backend/provider, and widgets invoke the
existing provider refresh action. English and Russian use wrapping text and the
existing accessible button pattern; status text uses a live region. Dedicated
widget regressions cover loading, failure, retry and successful default accounts
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
screen-reader QA remain a release check; tests do not claim physical-device QA.
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
changed, so Dart formatting was not applicable. Existing responsive workspace
tests cover Russian at 320px with enlarged text; this copy-only change does not
add a dedicated empty-state rendering or native screen-reader test. No security
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
Merge and deployment remain separate.
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
`ready` is emitted only after all default bindings are confirmed, including
idempotent completion of provider wallet/route setup for newly bound ledgers.
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
customer conversion. The failure fraction uses all attempts as its denominator;
the histogram measures one attempt, not time across multiple browser sessions.
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
+4 -1
View File
@@ -161,7 +161,10 @@
"wRecent": "Recent activity",
"wSeeAll": "See all",
"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",
"wSource": "From account",
"wDestination": "To account",
+4 -1
View File
@@ -161,7 +161,10 @@
"wRecent": "Последние операции",
"wSeeAll": "Смотреть все",
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
"wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
"wAccountsRetry": "Повторить попытку",
"wRefresh": "Обновить",
"wSource": "Со счёта",
"wDestination": "На счёт",
+22 -3
View File
@@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget {
),
const SizedBox(height: 28),
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: const EdgeInsets.symmetric(vertical: 16),
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 &&
p.account(accountId) == null &&
!p.loading)
!p.loading &&
!p.failed &&
p.data != null)
Text(l.wNotFound),
if (selected != null) ...[
const SizedBox(height: 24),
+15 -3
View File
@@ -256,10 +256,22 @@ class TransferPageState extends State<TransferPage> {
),
] else ...[
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(
onPressed: wallet.loading ? null : wallet.refresh,
child: Text(l.wRefresh),
onPressed: wallet.refresh,
child: Text(l.wAccountsRetry),
),
] else ...[
DropdownButtonFormField<String>(
+104
View File
@@ -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());
},
);
}
}
}
+1
View File
@@ -5,6 +5,7 @@ set -o pipefail
. infra/local/scripts/secrets.sh
case "${1:-}" in
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;;
email) . infra/local/scripts/bootstrap.sh; email;;
sendico) . infra/local/scripts/bootstrap.sh; . infra/local/scripts/sendico.sh; configure_sendico;;
+7
View File
@@ -22,3 +22,10 @@ images() (
cat "$WORK/images.env" | write "$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"
)
+1 -1
View File
@@ -3,7 +3,7 @@ set -eu
set -o pipefail
# 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 -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
. infra/local/scripts/common.sh
. infra/local/scripts/images.sh
+97
View File
@@ -0,0 +1,97 @@
# Production
Production runs on two servers of the shared private network: `exch`
(192.168.10.191), the production 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/exch/<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 |
| --- | --- | --- | --- | --- |
| `exch-vault-1` | exch | 192.168.10.191:8200 | :8201 | `vault/exch.yml` |
| `exch-vault-2` | exch | 192.168.10.191:8210 | :8211 | `vault/exch.yml` |
| `exch-vault-3` | infra-gamma | 192.168.10.98:8200 | :8201 | `vault/gamma.yml` |
Containers on `exch` join the Docker network `exch` 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/exch-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/exch-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/exch-vault-unseal exportable=true allow_plaintext_backup=true
vault read -field=backup transit/backup/exch-vault-unseal # store with the holders, then clear the terminal
vault policy write exch-vault-unseal - <<'EOF'
path "transit/encrypt/exch-vault-unseal" { capabilities = ["update"] }
path "transit/decrypt/exch-vault-unseal" { capabilities = ["update"] }
EOF
```
Create one token per server and write it straight into that server's
`/srv/exch/vault/secrets/transit-token`, never to a terminal or a log:
```sh
vault token create -orphan -period=768h -policy=exch-vault-unseal \
-display-name=exch-vault-<server> -field=token
```
2. Copy `vault/` and `.local/prod/images.env` to `/srv/exch/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 exch-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/exch-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.
+30
View File
@@ -0,0 +1,30 @@
# 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 = "exch-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 = "exch-vault-unseal"
}
+30
View File
@@ -0,0 +1,30 @@
# 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 = "exch-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 = "exch-vault-unseal"
}
+30
View File
@@ -0,0 +1,30 @@
# 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 = "exch-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 = "exch-vault-unseal"
}
+59
View File
@@ -0,0 +1,59 @@
# 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: exch-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:
exch:
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:
exch:
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:
exch:
name: exch
+34
View File
@@ -0,0 +1,34 @@
# 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: exch-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:
+8
View File
@@ -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