83 changed files with 3398 additions and 83 deletions

No files matched your search

+4 -3
View File
@@ -33,9 +33,10 @@ API versions are installed explicitly by the product composition. See
mounts, shared lifecycle and adding a future version. Customer profile endpoint
wiring remains follow-up work.
Sendico integration is not wired. Its organization context belongs only to the
future public HTTP adapter. There is no unused public API snapshot in this module;
copy only the wire definitions consumed by an implemented adapter.
Optional `api.sendico` connects sign-up to the public individual registry and
immutable Facts intake. The adapter owns organization context and consumed wire
contracts; local customers retain no platform tenancy. See
[configuration, recovery and measurement](../../docs/signup-facts.md).
## Validate
+2
View File
@@ -6,6 +6,7 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
@@ -17,6 +18,7 @@ type Dependencies struct {
Customers accountservice.AccountService
Auth *flow.Service
Factors *twofactor.Service
Identity *identity.Service
Token middleware.TokenConfig
Signature middleware.Signature
LoginLimits middleware.LoginRateLimits
+2
View File
@@ -3,10 +3,12 @@ package api
import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
)
type Config struct {
Sendico *sendico.Config `yaml:"sendico"`
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
Versions []Mount `yaml:"versions"`
Notifications *notifications.Config `yaml:"notifications"`
+2
View File
@@ -5,6 +5,7 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api"
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors"
)
@@ -22,5 +23,6 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
if deps.Factors != nil {
factors.Install(routes, deps.Factors, scope.Logger)
}
identity.Install(routes, deps.Identity, scope.Logger)
return nil
}
@@ -2,16 +2,20 @@ package runtime_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5"
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
// Enabling the adapter validates credentials, composes once, and does not
// register any provider resource until a customer starts sign-up.
const source = "507f1f77bcf86cd799439011"
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
require.NoError(t, err)
file := filepath.Join(t.TempDir(), "credential")
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.NoError(t, err)
require.NotNil(t, dependencies.Auth.Facts)
require.NoError(t, finish(ctx))
example.API.Sendico.TokenFile = ""
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
example.API.Notifications = nil
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
}
+19
View File
@@ -8,11 +8,14 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
@@ -55,6 +58,19 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
deps.Auth.Sender = client
if config.Sendico != nil {
provider, err := sendico.Open(*config.Sendico)
if err != nil {
return nil, nil, err
}
bindings, err := identity.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db)
if err != nil {
return nil, nil, err
@@ -64,6 +80,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
}
if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
}
logger.Info("Product dependencies initialized")
return deps, cleanup, nil
}
+27 -3
View File
@@ -26,7 +26,13 @@ import (
const Cooldown = time.Minute
type SignupFacts interface {
Ensure(context.Context, *customer.Account) error
Ready(context.Context, bson.ObjectID) error
}
type Service struct {
Facts SignupFacts
Accounts account.DB
Customers accountservice.AccountService
Tokens verification.DB
@@ -85,20 +91,25 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
if err = s.Limit(ctx, ip, email); err != nil {
return nil, err
}
raw, err := s.Customers.CreateAccount(ctx, a)
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookup := s.Accounts.GetByEmail(ctx, email)
if lookup != nil {
return nil, lookup
}
// A retry after a queue failure may resume only with the original password.
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
return nil, merrors.DataConflict("email already registered")
}
a = existing
} else if err != nil {
return nil, err
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return nil, err
}
}
if raw != "" {
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
@@ -132,14 +143,22 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return err
}
}
_, err = s.sendActivation(ctx, a, key)
return err
}
func (s *Service) activationKey(email, key string) string {
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
}
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return time.Time{}, merrors.InvalidArgument("idempotency key required")
}
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
secretKey := s.activationKey(a.Login, key)
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
result, err := s.Tokens.CreateWithResult(ctx, req)
@@ -176,6 +195,11 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil, merrors.Unauthorized("account cannot be activated")
}
if s.Facts != nil {
if err := s.Facts.Ready(tx, a.ID); err != nil {
return nil, err
}
}
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
})
return err
+1 -1
View File
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
require.Equal(t, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32))
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired)
@@ -0,0 +1,29 @@
package public
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
response := httptest.NewRecorder()
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
require.Equal(t, http.StatusServiceUnavailable, response.Code)
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
var envelope struct {
Data struct {
Error string `json:"error"`
}
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
require.Equal(t, "signup_attention_required", envelope.Data.Error)
require.NotContains(t, response.Body.String(), "private upstream detail")
}
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
return pr.loginRateLimited(details.RetryAfter)
}
switch {
case errors.Is(err, merrors.ErrFailedPrecondition):
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
case errors.Is(err, verification.ErrTokenExpired):
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
@@ -15,6 +15,7 @@ import (
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
session "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
@@ -26,6 +27,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -34,6 +36,38 @@ type capturedMail struct {
fail bool
}
type signupProvider struct {
registrations, submissions int
fail bool
last identity.Facts
}
func (*signupProvider) Namespace() string { return "test-provider" }
func (p *signupProvider) Register(context.Context, string) (string, error) {
p.registrations++
return "subject", nil
}
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
p.submissions++
p.last = facts
if p.fail {
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
}
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
}
type activationGuard struct {
flow.SignupFacts
block bool
}
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
if g.block {
return merrors.ServiceUnavailable("intake receipt unavailable")
}
return g.SignupFacts.Ready(ctx, id)
}
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
m.messages = append(m.messages, r)
if m.fail {
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
mail := &capturedMail{}
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
require.NoError(t, service.Validate())
bindings, err := identity.OpenStore(factory)
require.NoError(t, err)
provider := &signupProvider{fail: true}
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
// 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)
@@ -83,7 +121,22 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
}
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
w := post("signup", signup)
require.Equal(t, 503, w.Code, w.Body.String())
require.Empty(t, mail.messages)
require.Equal(t, 1, provider.registrations)
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
require.Empty(t, mail.messages)
// Only the original password can resume a pending local customer.
signup["password"] = "different password"
require.Equal(t, 400, post("signup", signup).Code)
signup["password"] = " correct password "
signup["name"] = "Changed retry name"
provider.fail = false
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)
require.Equal(t, "user@example.com", provider.last.Email)
require.Len(t, mail.messages, 1)
require.Equal(t, "auth.activation", mail.messages[0].Template)
require.Equal(t, "user@example.com", mail.messages[0].Email)
@@ -95,9 +148,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
require.NoError(t, err)
require.False(t, a.IsActive())
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
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)
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err)
require.False(t, a.IsActive())
guard.block = false
w = post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
+7
View File
@@ -9,6 +9,13 @@ database:
uri_env: SXCHNGE_MONGO_URI
database: sxchnge
api:
# Optional public Sendico facts integration. Enable with provisioned values;
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
# sendico:
# api_url: https://sendico.example/api/v1
# organization_ref: <sxchnge-organization-object-id>
# source_account_ref: <integration-account-object-id>
# token_file: /run/sxchnge/sendico-token
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
# versions:
# - version: v1
+12 -1
View File
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
ctx context.Context,
acct *customermodel.Account,
) (string, error) {
return s.createAccount(ctx, acct, "")
}
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
if len(key) < 32 || len(key) > 128 {
return "", merrors.InvalidArgument("server activation key required")
}
return s.createAccount(ctx, acct, key)
}
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
+3
View File
@@ -36,6 +36,9 @@ type AccountService interface {
ctx context.Context,
acct *customermodel.Account,
) (verificationToken string, err error)
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
// service replay an unsent initial token. The key must be server-secret-derived.
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
VerifyAccount(
ctx context.Context,
+48
View File
@@ -0,0 +1,48 @@
package identityhttp
import "git.sendico.io/tech/sxchnge/product/identity"
type profileDTO struct {
FullName string `json:"fullName"`
DateOfBirth string `json:"dateOfBirth"`
ResidenceCountry string `json:"residenceCountry"`
}
func (p profileDTO) model() identity.Profile {
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
}
type verificationDTO struct {
Status string `json:"status"`
UpgradePending bool `json:"upgradePending"`
Reason string `json:"reason,omitempty"`
SubmissionID string `json:"submissionId,omitempty"`
Profile profileDTO `json:"profile"`
}
func mapVerification(v identity.Verification) verificationDTO {
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
type limitsDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profileRef"`
Entries []limitDTO `json:"entries"`
}
func mapLimits(v identity.Limits) limitsDTO {
entries := make([]limitDTO, 0, len(v.Entries))
for _, r := range v.Entries {
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
}
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
}
+107
View File
@@ -0,0 +1,107 @@
package identityhttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *identity.Service
logger mlogger.Logger
}
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
h := &router{service, logger.Named("identity_http")}
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
}
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Limits(r.Context(), a)
return h.result(mapLimits(v), err)
}
func (h *router) result(data any, err error) http.HandlerFunc {
if err == nil {
return response.Ok(h.logger, data)
}
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
}
}
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
case errors.Is(err, merrors.ErrDataConflict):
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
case errors.Is(err, merrors.ErrAccessDenied):
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
default:
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
}
}
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Verification(r.Context(), a)
return h.result(mapVerification(v), err)
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
// Read multipart as a bounded stream. Never spill identity documents to disk.
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
reader, err := r.MultipartReader()
if err != nil {
return h.result(nil, merrors.InvalidArgument("multipart required"))
}
fields := map[string][]byte{}
for {
part, readErr := reader.NextPart()
if readErr == io.EOF {
break
}
if readErr != nil {
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
}
name := part.FormName()
limit := int64(8192)
switch name {
case "document", "selfie":
limit = identity.MaxPhotoBytes
case "profile", "idempotencyKey":
default:
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("unexpected field"))
}
if _, exists := fields[name]; exists {
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("duplicate field"))
}
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
_ = part.Close()
if readErr != nil || int64(len(data)) > limit {
return h.result(nil, merrors.InvalidArgument("field too large"))
}
fields[name] = data
}
var p profileDTO
if err = json.Unmarshal(fields["profile"], &p); err != nil {
return h.result(nil, merrors.InvalidArgument("profile required"))
}
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
return h.result(mapVerification(v), err)
}
@@ -0,0 +1,20 @@
package identityhttp
import (
"net/http/httptest"
"testing"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
h := router{logger: zap.NewNop()}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/accounts/identity", nil)
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
require.Equal(t, 429, w.Code)
require.Equal(t, "120", w.Header().Get("Retry-After"))
}
+9
View File
@@ -0,0 +1,9 @@
package identity
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
+39
View File
@@ -0,0 +1,39 @@
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
package identity
import (
"time"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
type Facts struct {
FullName string `bson:"fullName"`
Email string `bson:"email"`
CustomerRef string `bson:"customerRef"`
}
type Receipt struct {
SubmissionRef string `bson:"submissionRef"`
SubjectRef string `bson:"subjectRef"`
AcceptedAt time.Time `bson:"acceptedAt"`
}
// Binding uses the local customer ID as its primary key. Namespace is an opaque
// adapter configuration fingerprint; no platform tenancy enters the customer model.
type Binding struct {
storable.Base `bson:",inline"`
Namespace string `bson:"namespace"`
RegistrationRequest string `bson:"registrationRequest"`
Facts Facts `bson:"facts"`
Registering bool `bson:"registering"`
RegisteringAt time.Time `bson:"registeringAt"`
SubjectRef string `bson:"subjectRef"`
Receipt *Receipt `bson:"receipt,omitempty"`
}
func (*Binding) Collection() string { return "signup_facts" }
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
+31
View File
@@ -0,0 +1,31 @@
package identity
import (
"bytes"
"image"
_ "image/jpeg"
_ "image/png"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
const MaxPhotoBytes = 10 << 20
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
// memory before decoding pixels. File names and client MIME types are ignored.
func ValidatePhoto(data []byte) error {
if len(data) == 0 || len(data) > MaxPhotoBytes {
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "jpeg" && format != "png") {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
}
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package identity
import (
"context"
"errors"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Provider interface {
Namespace() string
Register(context.Context, string) (string, error)
Submit(context.Context, string, string, Facts) (Receipt, error)
}
type Service struct {
Store Store
Provider Provider
Logger mlogger.Logger
}
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
// Ensure completes only after a provider receipt has been persisted. Registration
// has no idempotency contract, so its durable claim is never automatically expired.
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
if a == nil || a.ID.IsZero() {
return merrors.InvalidArgument("customer required")
}
started := time.Now()
requestRef := ""
outcome := "unavailable"
defer func() {
requests.WithLabelValues(outcome).Inc()
duration.Observe(time.Since(started).Seconds())
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
if result != nil {
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
} else {
s.Logger.Info("Sign-up facts handoff completed", fields...)
}
}()
namespace := s.Provider.Namespace()
b, err := s.Store.Get(ctx, a.ID)
if errors.Is(err, merrors.ErrNoData) {
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
b.SetID(a.ID)
if err = s.Store.Create(ctx, b); err != nil {
if !errors.Is(err, merrors.ErrDataConflict) {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
}
b, err = s.Store.Get(ctx, a.ID)
}
}
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
}
requestRef = b.RegistrationRequest
if b.Namespace != namespace {
outcome = "attention_required"
return merrors.FailedPrecondition("sign-up provider binding changed")
}
if b.Receipt != nil {
outcome = "replayed"
return nil
}
if b.SubjectRef == "" {
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
}
if !claimed {
// Another request may have completed while this request was claiming.
b, err = s.Store.Get(ctx, a.ID)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
}
if b.SubjectRef == "" {
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
return unavailable()
}
outcome = "attention_required"
return merrors.FailedPrecondition("individual registration requires reconciliation")
}
} else {
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
if err != nil {
outcome = "attention_required"
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
}
// Persist even if the browser left while Sendico accepted registration.
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
cancel()
if err != nil {
outcome = "attention_required"
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
}
b.SubjectRef = subject
}
}
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
}
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
return unavailable()
}
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
}
outcome = "accepted"
return nil
}
// Ready checks confirmed intake without external I/O inside activation transactions.
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
b, err := s.Store.Get(ctx, id)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
}
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
return unavailable()
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package identity
import (
"context"
"sync"
"testing"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type memoryStore struct {
mu sync.Mutex
rows map[bson.ObjectID]Binding
bindErr, acceptErr error
}
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.rows[id]
if !ok {
return nil, merrors.NoData("missing")
}
return &b, nil
}
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.rows[b.ID]; ok {
return merrors.DataConflict("duplicate")
}
s.rows[b.ID] = *b
return nil
}
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
b := s.rows[id]
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
return false, nil
}
b.Registering = true
b.RegisteringAt = time.Now()
s.rows[id] = b
return true, nil
}
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.bindErr != nil {
return s.bindErr
}
b := s.rows[id]
b.SubjectRef = subject
b.Registering = false
s.rows[id] = b
return nil
}
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.acceptErr != nil {
return s.acceptErr
}
b := s.rows[id]
b.Receipt = &r
s.rows[id] = b
return nil
}
type providerStub struct {
registrations, submissions int
registerErr, submitErr error
snapshots []Facts
sources []string
entered, release chan struct{}
}
func (*providerStub) Namespace() string { return "provider-1" }
func (p *providerStub) Register(context.Context, string) (string, error) {
p.registrations++
if p.entered != nil {
close(p.entered)
<-p.release
}
return "subject-1", p.registerErr
}
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
p.submissions++
p.snapshots = append(p.snapshots, f)
p.sources = append(p.sources, source)
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
}
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
provider := &providerStub{}
a := &customer.Account{}
a.ID = bson.NewObjectID()
a.Name = "Ada Lovelace"
a.Login = "ada@example.com"
a.Password = "never transmit"
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
}
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
s, store, p, a := fixture()
p.submitErr = merrors.ServiceUnavailable("upstream")
require.Error(t, s.Ensure(context.Background(), a))
require.Error(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.registrations)
b, err := store.Get(context.Background(), a.ID)
require.NoError(t, err)
require.Equal(t, "subject-1", b.SubjectRef)
require.Nil(t, b.Receipt)
a.Name = "changed retry"
p.submitErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.NoError(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.registrations)
require.Equal(t, 2, p.submissions)
require.Equal(t, p.snapshots[0], p.snapshots[1])
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
require.Equal(t, p.sources[0], p.sources[1])
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 2, p.submissions)
other := a.Copy()
other.ID = bson.NewObjectID()
require.Error(t, s.Ready(context.Background(), other.ID))
}
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
s, store, p, a := fixture()
store.acceptErr = merrors.ServiceUnavailable("storage")
require.Error(t, s.Ensure(context.Background(), a))
store.acceptErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 1, p.registrations)
require.Equal(t, 2, p.submissions)
require.Equal(t, p.sources[0], p.sources[1])
}
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
for _, bindFailure := range []bool{false, true} {
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
s, store, p, a := fixture()
if bindFailure {
store.bindErr = merrors.ServiceUnavailable("storage")
} else {
p.registerErr = merrors.ServiceUnavailable("lost response")
}
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
b := store.rows[a.ID]
b.RegisteringAt = time.Now().Add(-time.Minute)
store.rows[a.ID] = b
store.bindErr = nil
p.registerErr = nil
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
require.Equal(t, 1, p.registrations)
require.Zero(t, p.submissions)
})
}
}
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
s, _, p, a := fixture()
p.entered = make(chan struct{})
p.release = make(chan struct{})
done := make(chan error, 1)
go func() { done <- s.Ensure(context.Background(), a) }()
<-p.entered
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
close(p.release)
require.NoError(t, <-done)
require.Equal(t, 1, p.registrations)
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 1, p.submissions)
}
func TestNamespaceChangeFailsClosed(t *testing.T) {
s, store, p, a := fixture()
require.NoError(t, s.Ensure(context.Background(), a))
b := store.rows[a.ID]
b.Namespace = "other-account-or-organization"
store.rows[a.ID] = b
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
require.Error(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.submissions)
}
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
s, _, p, a := fixture()
core, logs := observer.New(zap.InfoLevel)
s.Logger = zap.New(core)
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
require.Error(t, s.Ensure(context.Background(), a))
p.submitErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.Len(t, logs.All(), 2)
for _, entry := range logs.All() {
require.NotContains(t, entry.Message, a.Login)
require.NotContains(t, entry.Message, a.Name)
fields := entry.ContextMap()
require.Equal(t, a.ID.Hex(), fields["account_ref"])
require.NotEmpty(t, fields["request_ref"])
for _, value := range fields {
require.NotEqual(t, a.Login, value)
require.NotEqual(t, a.Name, value)
require.NotEqual(t, a.Password, value)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package identity
import (
"context"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store interface {
Get(context.Context, bson.ObjectID) (*Binding, error)
Create(context.Context, *Binding) error
Claim(context.Context, bson.ObjectID, string) (bool, error)
Bind(context.Context, bson.ObjectID, string, string) error
Accept(context.Context, bson.ObjectID, string, Receipt) error
}
type mongoStore struct{ records repository.Repository }
func OpenStore(factory db.Factory) (Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{records: records}, nil
}
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
b := &Binding{}
err := s.records.Get(ctx, id, b)
return b, err
}
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
return s.records.Insert(ctx, b, nil)
}
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
}
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
}
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
if err != nil {
return err
}
if !matched {
return unavailable()
}
return nil
}
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
if err != nil {
return err
}
if !matched {
return unavailable()
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package identity
import (
"context"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableBindingClaimsAndIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
// The handoff store must override unsafe caller defaults before claiming
// ownership of a non-idempotent external operation.
q.Set("w", "0")
q.Set("readPreference", "secondary")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
b.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, b))
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
var wg sync.WaitGroup
var wins atomic.Int32
for range 12 {
wg.Go(func() {
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
require.NoError(t, err)
if claimed {
wins.Add(1)
}
})
}
wg.Wait()
require.EqualValues(t, 1, wins.Load())
stored, err := store.Get(ctx, b.ID)
require.NoError(t, err)
require.True(t, stored.Registering)
require.False(t, stored.RegisteringAt.IsZero())
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
duplicate := &Binding{Namespace: b.Namespace}
duplicate.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, duplicate))
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
require.NoError(t, err)
require.True(t, claimedDuplicate)
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
receipt.SubjectRef = "subject"
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
// A separately opened store sees the durable receipt and cannot reclaim registration.
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.Get(ctx, b.ID)
require.NoError(t, err)
require.Equal(t, receipt, *stored.Receipt)
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
require.NoError(t, err)
require.False(t, claimed)
_, err = reopened.Get(ctx, bson.NewObjectID())
require.ErrorIs(t, err, merrors.ErrNoData)
}
+149
View File
@@ -0,0 +1,149 @@
package identity
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
)
// Profile holds unverified customer assertions. Email and customer reference are
// always supplied by the authenticated backend, never accepted from the browser.
type Profile struct {
FullName string
DateOfBirth string
ResidenceCountry string
}
func (p Profile) Validate() error {
birth, err := time.Parse("2006-01-02", p.DateOfBirth)
if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' {
return merrors.InvalidArgument("complete identity profile required")
}
return nil
}
type Verification struct {
Status string
UpgradePending bool
Reason string
SubmissionID string
Profile Profile
}
// VerificationProvider consumes reviewed state through the platform public API.
// It cannot accept a browser-selected subject, policy profile or approval.
type VerificationProvider interface {
Verification(context.Context, string) (Verification, error)
SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error)
}
// Decimal amounts are transported losslessly in their currency's major unit.
// No fiat scale or illustrative client caps are applied to provider policy.
type Limit struct {
Operation string
Period string
Currency string
Cap string
Remaining string
Used string
Reserved string
}
type Limits struct {
Tier string
ProfileRef string
Entries []Limit
}
type LimitsProvider interface {
Limits(context.Context, string) (Limits, error)
}
func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) {
b, err := s.binding(ctx, a)
if err != nil {
return Limits{}, err
}
p, ok := s.Provider.(LimitsProvider)
if !ok {
return Limits{}, unavailable()
}
return p.Limits(ctx, b.SubjectRef)
}
func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) {
if s == nil || s.Provider == nil || s.Store == nil {
return nil, merrors.ServiceUnavailable("identity verification unavailable")
}
if a == nil || a.ID.IsZero() {
return nil, merrors.InvalidArgument("customer required")
}
b, err := s.Store.Get(ctx, a.ID)
if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil {
if err = s.Ensure(ctx, a); err != nil {
return nil, err
}
b, err = s.Store.Get(ctx, a.ID)
}
if err != nil {
return nil, err
}
if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef {
return nil, merrors.FailedPrecondition("identity binding requires reconciliation")
}
return b, nil
}
func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) {
b, err := s.binding(ctx, a)
if err != nil {
return Verification{}, err
}
provider, ok := s.Provider.(VerificationProvider)
if !ok {
return Verification{}, unavailable()
}
v, err := provider.Verification(ctx, b.SubjectRef)
if err == nil && v.Profile.FullName == "" {
v.Profile.FullName = a.Name
}
return v, err
}
func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) {
if len(key) < 16 || len(key) > 128 {
return Verification{}, merrors.InvalidArgument("valid idempotency key required")
}
if err := p.Validate(); err != nil {
return Verification{}, err
}
for _, photo := range [][]byte{document, selfie} {
if err := ValidatePhoto(photo); err != nil {
return Verification{}, err
}
}
b, err := s.binding(ctx, a)
if err != nil {
return Verification{}, err
}
provider, ok := s.Provider.(VerificationProvider)
if !ok {
return Verification{}, unavailable()
}
digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key))
facts := b.Facts
facts.Email = a.Login
facts.CustomerRef = a.ID.Hex()
v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie)
if err != nil {
s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex()))
return Verification{}, err
}
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
return v, nil
}
@@ -0,0 +1,84 @@
package identity
import (
"bytes"
"context"
"image"
"image/png"
"testing"
"github.com/stretchr/testify/require"
)
type proofProvider struct {
*providerStub
subject, key string
profile Profile
facts Facts
calls int
}
func (p *proofProvider) Verification(_ context.Context, subject string) (Verification, error) {
p.subject = subject
return Verification{Status: "unverified"}, nil
}
func (p *proofProvider) SubmitVerification(_ context.Context, subject, key string, profile Profile, facts Facts, _, _ []byte) (Verification, error) {
p.subject = subject
p.key = key
p.profile = profile
p.facts = facts
p.calls++
return Verification{Status: "pending", SubmissionID: "receipt"}, nil
}
func proofImage(t *testing.T, width, height int) []byte {
t.Helper()
var body bytes.Buffer
require.NoError(t, png.Encode(&body, image.NewNRGBA(image.Rect(0, 0, width, height))))
return body.Bytes()
}
func TestVerificationUsesBoundSubjectAndStableCustomerKey(t *testing.T) {
s, _, base, a := fixture()
p := &proofProvider{providerStub: base}
s.Provider = p
ctx := t.Context()
result, err := s.Verification(ctx, a)
require.NoError(t, err)
require.Equal(t, a.Name, result.Profile.FullName)
require.Equal(t, "subject-1", p.subject)
photo := proofImage(t, 600, 600)
profile := Profile{FullName: "Legal Customer Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
key := p.key
require.Equal(t, a.ID.Hex(), p.facts.CustomerRef)
require.Equal(t, a.Login, p.facts.Email)
require.Equal(t, profile, p.profile)
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
require.Equal(t, key, p.key)
require.Equal(t, 1, base.registrations)
other := a.Copy()
other.ID[0]++
_, err = s.SubmitVerification(ctx, other, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
require.NotEqual(t, key, p.key)
}
func TestVerificationValidationBeforeExternalHandoff(t *testing.T) {
s, _, base, a := fixture()
p := &proofProvider{providerStub: base}
s.Provider = p
photo := proofImage(t, 600, 600)
for _, profile := range []Profile{{}, {FullName: "Name", DateOfBirth: "1990-02-30", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "2990-04-23", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "1990-04-23", ResidenceCountry: "USA"}} {
_, err := s.SubmitVerification(t.Context(), a, "0123456789012345", profile, photo, photo)
require.Error(t, err)
}
require.Zero(t, p.calls)
require.Zero(t, base.registrations)
require.Error(t, ValidatePhoto([]byte("not an image")))
require.Error(t, ValidatePhoto(proofImage(t, 599, 600)))
require.Error(t, ValidatePhoto(photo[:len(photo)-20]))
require.Error(t, ValidatePhoto(make([]byte, MaxPhotoBytes+1)))
var unavailableService *Service
_, err := unavailableService.Verification(t.Context(), a)
require.Error(t, err)
}
@@ -0,0 +1,69 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) {
var receipt receiptDTO
token, err := c.token()
if err != nil {
return receipt, err
}
var body []byte
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "encode Sendico request")
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/"+resource+"/"+c.config.OrganizationID, bytes.NewReader(body))
if err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create Sendico request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-ID", requestRef)
response, err := c.http.Do(req)
if err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "Sendico request failed")
}
defer func() { _ = response.Body.Close() }()
// Never decode or expose upstream error bodies, which can contain assertions.
if response.StatusCode != http.StatusOK {
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
}
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
var envelope envelopeDTO
if err := decoder.Decode(&envelope); err != nil {
return receipt, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode Sendico receipt")
}
var trailing any
if decoder.Decode(&trailing) != io.EOF || envelope.Status != "success" {
return receipt, merrors.ServiceUnavailable("invalid Sendico response")
}
return envelope.Data, nil
}
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
dto, err := c.post(ctx, "individuals", requestRef, nil)
if err != nil {
return "", err
}
return mapSubject(dto)
}
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts))
if err != nil {
return identity.Receipt{}, err
}
return mapReceipt(dto, subject)
}
@@ -0,0 +1,155 @@
package sendico
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
"github.com/stretchr/testify/require"
)
const org = "507f1f77bcf86cd799439011"
const source = "507f1f77bcf86cd799439012"
const subject = "507f1f77bcf86cd799439013"
const submission = "507f1f77bcf86cd799439014"
func credential(t *testing.T, id string, expiry int64) string {
t.Helper()
raw, err := json.Marshal(map[string]any{"id": id, "exp": expiry})
require.NoError(t, err)
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature"
}
func config(t *testing.T, endpoint string) Config {
t.Helper()
file := filepath.Join(t.TempDir(), "token")
require.NoError(t, os.WriteFile(file, []byte(credential(t, source, time.Now().Add(time.Hour).Unix())), 0600))
return Config{APIURL: endpoint + "/api/v1", OrganizationID: org, SourceID: source, TokenFile: file}
}
func TestSignupWireContractAndCredentialRotation(t *testing.T) {
paths := []string{}
var token string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
paths = append(paths, r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
if strings.Contains(r.URL.Path, "/individuals/") {
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
require.Empty(t, body)
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
} else {
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"submission_ref":%q,"accepted_at":"2026-10-07T10:30:00.123Z"}}`, subject, submission)
}
}))
defer server.Close()
cfg := config(t, server.URL)
token = credential(t, source, time.Now().Add(time.Hour).Unix())
client, err := Open(cfg)
require.NoError(t, err)
ref, err := client.Register(context.Background(), "registration-request")
require.NoError(t, err)
require.Equal(t, subject, ref)
// File rotation takes effect without restarting; account identity stays fixed.
token = credential(t, source, time.Now().Add(2*time.Hour).Unix())
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(token), 0600))
receipt, err := client.Submit(context.Background(), subject, "signup-1", identity.Facts{FullName: "Ada Lovelace", Email: "ada@example.com", CustomerRef: "local-user"})
require.NoError(t, err)
require.Equal(t, submission, receipt.SubmissionRef)
require.Equal(t, subject, receipt.SubjectRef)
require.Equal(t, []string{"/api/v1/individuals/" + org, "/api/v1/identity_facts/" + org}, paths)
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, org, time.Now().Add(time.Hour).Unix())), 0600))
_, err = client.Register(context.Background(), "registration-request")
require.Error(t, err)
require.Len(t, paths, 2)
}
func TestRejectInvalidConfiguration(t *testing.T) {
for _, endpoint := range []string{"http://remote.example", "https://user:pass@api.example", "https://api.example?secret=x", "https://api.example/#fragment", "//api.example", "ftp://localhost"} {
_, err := Open(config(t, endpoint))
require.Error(t, err, endpoint)
}
cfg := config(t, "https://api.example")
cfg.OrganizationID = "../other"
_, err := Open(cfg)
require.Error(t, err)
cfg = config(t, "https://api.example")
cfg.SourceID = ""
_, err = Open(cfg)
require.Error(t, err)
cfg = config(t, "https://api.example")
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, source, 1)), 0600))
_, err = Open(cfg)
require.Error(t, err)
}
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
}{
{"denied", 403, `{"secret":"private-assertion"}`},
{"conflict", 409, `{"secret":"private-assertion"}`},
{"unavailable", 503, `{"secret":"private-assertion"}`},
{"malformed", 200, `{"private-assertion":`},
{"empty", 200, `{"status":"success","data":{}}`},
{"wrong-status", 200, `{"status":"error","data":{}}`},
{"oversized", 200, `{"padding":"` + strings.Repeat("x", 17000) + `"}`},
{"wrong-subject", 200, `{"status":"success","data":{"subject_ref":"` + org + `","submission_ref":"` + submission + `","accepted_at":"2026-10-07T10:30:00Z"}}`},
{"trailing", 200, `{"status":"success","data":{}} {}`},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Submit(context.Background(), subject, "key", identity.Facts{})
require.Error(t, err)
require.NotContains(t, err.Error(), "private-assertion")
_, err = client.Register(context.Background(), "registration-request")
require.Error(t, err)
})
}
}
func TestRedirectAndTimeoutDoNotRepeatRegistration(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Register(context.Background(), "registration-request")
require.Error(t, err)
require.Equal(t, 1, calls)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.Register(ctx, "registration-request")
require.Error(t, err)
require.Equal(t, 1, calls)
timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
defer timeoutServer.Close()
client, err = Open(config(t, timeoutServer.URL))
require.NoError(t, err)
client.http.Timeout = 20 * time.Millisecond
_, err = client.Register(context.Background(), "timeout-request")
require.Error(t, err)
}
@@ -0,0 +1,87 @@
// Package sendico consumes only Sendico's public HTTP contracts.
package sendico
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Config struct {
APIURL string `yaml:"api_url"`
OrganizationID string `yaml:"organization_ref"`
SourceID string `yaml:"source_account_ref"`
TokenFile string `yaml:"token_file"`
}
type Client struct {
config Config
base string
namespace string
http *http.Client
}
func Open(c Config) (*Client, error) {
u, err := url.Parse(c.APIURL)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)")
}
for _, ref := range []string{c.OrganizationID, c.SourceID} {
id, err := bson.ObjectIDFromHex(ref)
if err != nil || id.IsZero() || ref != id.Hex() {
return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs")
}
}
if strings.TrimSpace(c.TokenFile) == "" {
return nil, merrors.InvalidArgument("Sendico token file required")
}
base := strings.TrimRight(c.APIURL, "/")
hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID))
client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
if _, err := client.token(); err != nil {
return nil, err
}
return client, nil
}
func (c *Client) Namespace() string { return c.namespace }
// Rotation may replace this file, but must retain the authenticated account.
// Claim inspection only protects the replay namespace from operator mistakes;
// Sendico remains responsible for signature validation and authorization.
func (c *Client) token() (string, error) {
raw, err := os.ReadFile(c.config.TokenFile)
if err != nil {
return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential")
}
token := strings.TrimSpace(string(raw))
parts := strings.Split(token, ".")
if len(token) > 16384 || len(parts) != 3 {
return "", merrors.FailedPrecondition("invalid Sendico credential")
}
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
}
var payload struct {
ID string `json:"id"`
Pending bool `json:"pending"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(claims, &payload); err != nil {
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
}
if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() {
return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account")
}
return token, nil
}
+51
View File
@@ -0,0 +1,51 @@
package sendico
import (
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Only consumed wire fields are owned here; never import platform modules.
type factsDTO struct {
FullName string `json:"full_name"`
Email string `json:"email"`
CustomerRef string `json:"external_customer_ref"`
}
type submissionDTO struct {
SubjectRef string `json:"subject_ref"`
SourceRef string `json:"source_submission_ref"`
Facts factsDTO `json:"facts"`
}
type receiptDTO struct {
SubjectRef string `json:"subject_ref"`
RegisteredAt time.Time `json:"registered_at"`
SubmissionRef string `json:"submission_ref"`
AcceptedAt time.Time `json:"accepted_at"`
}
type envelopeDTO struct {
Status string `json:"status"`
Data receiptDTO `json:"data"`
}
func validRef(ref string) bool {
id, err := bson.ObjectIDFromHex(ref)
return err == nil && !id.IsZero() && id.Hex() == ref
}
func mapFacts(subject, source string, facts identity.Facts) submissionDTO {
return submissionDTO{SubjectRef: subject, SourceRef: source, Facts: factsDTO{FullName: facts.FullName, Email: facts.Email, CustomerRef: facts.CustomerRef}}
}
func mapSubject(dto receiptDTO) (string, error) {
if !validRef(dto.SubjectRef) || dto.RegisteredAt.IsZero() {
return "", merrors.ServiceUnavailable("invalid Sendico registration receipt")
}
return dto.SubjectRef, nil
}
func mapReceipt(dto receiptDTO, subject string) (identity.Receipt, error) {
if !validRef(dto.SubmissionRef) || dto.SubjectRef != subject || dto.AcceptedAt.IsZero() {
return identity.Receipt{}, merrors.ServiceUnavailable("invalid Sendico intake receipt")
}
return identity.Receipt{SubmissionRef: dto.SubmissionRef, SubjectRef: dto.SubjectRef, AcceptedAt: dto.AcceptedAt}, nil
}
@@ -0,0 +1,184 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type profileDTO struct {
FullName string `json:"full_name"`
DateOfBirth string `json:"date_of_birth"`
ResidenceCountry string `json:"residence_country"`
Email string `json:"email"`
CustomerRef string `json:"external_customer_ref"`
}
type verificationDTO struct {
SubjectRef string `json:"subject_ref"`
SubmissionRef string `json:"submission_ref"`
Status string `json:"status"`
Reason string `json:"reason"`
Profile profileDTO `json:"profile"`
UpgradePending bool `json:"upgrade_pending"`
}
type policyDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profile_ref"`
Entries []limitDTO `json:"entries"`
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
if !validRef(subject) {
return merrors.InvalidArgument("invalid bound subject")
}
token, err := c.token()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
}
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// Uploads need a longer bound than small sign-up receipts. The base client is
// copied so concurrent sign-up operations keep their own four-second timeout.
client := *c.http
client.Timeout = 60 * time.Second
res, err := client.Do(req)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
}
defer func() { _ = res.Body.Close() }()
switch res.StatusCode {
case http.StatusOK:
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
return merrors.InvalidArgument("identity profile or photos were not accepted")
case http.StatusConflict:
return merrors.DataConflict("identity submission changed; refresh")
case http.StatusForbidden:
return merrors.PermissionDenied("identity resource unavailable")
case http.StatusTooManyRequests:
retry := 60
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
retry = seconds
}
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
default:
return merrors.ServiceUnavailable("identity provider unavailable")
}
var envelope struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
if err := decoder.Decode(&envelope); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
}
var extra any
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
return merrors.ServiceUnavailable("invalid identity response")
}
if err := json.Unmarshal(envelope.Data, dst); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
}
return nil
}
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
if dto.SubjectRef != subject {
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
}
switch dto.Status {
case "unverified", "pending", "approved", "changesRequested", "denied":
default:
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
}
if dto.Status == "approved" && dto.UpgradePending {
dto.Status = "pending"
}
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
}
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
if dto.Status != "unverified" {
if err := profile.Validate(); err != nil {
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
}
}
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
}
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
var dto verificationDTO
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
return identity.Verification{}, err
}
return mapVerification(dto, subject)
}
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
if err != nil {
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
}
if err = writer.WriteField("profile", string(profile)); err != nil {
return identity.Verification{}, err
}
if err = writer.WriteField("source_submission_ref", key); err != nil {
return identity.Verification{}, err
}
for i, data := range [][]byte{document, selfie} {
name := "document"
if i == 1 {
name = "selfie"
}
part, writeErr := writer.CreateFormFile(name, name)
if writeErr != nil {
return identity.Verification{}, writeErr
}
if _, writeErr = part.Write(data); writeErr != nil {
return identity.Verification{}, writeErr
}
}
if err = writer.Close(); err != nil {
return identity.Verification{}, err
}
var dto verificationDTO
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
return identity.Verification{}, err
}
return mapVerification(dto, subject)
}
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
var dto policyDTO
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
return identity.Limits{}, err
}
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
}
entries := make([]identity.Limit, 0, len(dto.Entries))
for _, v := range dto.Entries {
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
}
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
}
@@ -0,0 +1,79 @@
package sendico
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
)
func TestProofWireContractAndLimitsProjection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Contains(t, r.Header.Get("Authorization"), "Bearer ")
expected := "/api/v1/identity_facts/" + org + "/proofs/" + subject
if r.URL.Path == expected+"/limits" {
require.Equal(t, http.MethodGet, r.Method)
_, _ = io.WriteString(w, `{"status":"success","data":{"tier":"verified","profile_ref":"profile_personal_verified","entries":[{"operation":"Transfer","period":"day","currency":"BTC","cap":"0.12345678","remaining":"0.10000001","used":"0.02","reserved":"0.00345677"}]}}`)
return
}
require.Equal(t, expected, r.URL.Path)
if r.Method == http.MethodPost {
reader, err := r.MultipartReader()
require.NoError(t, err)
fields := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
require.NoError(t, err)
data, err := io.ReadAll(part)
require.NoError(t, err)
fields[part.FormName()] = string(data)
}
require.Equal(t, "document bytes", fields["document"])
require.Equal(t, "selfie bytes", fields["selfie"])
require.Equal(t, "stable-source", fields["source_submission_ref"])
var profile map[string]string
require.NoError(t, json.Unmarshal([]byte(fields["profile"]), &profile))
require.Equal(t, "Legal Name", profile["full_name"])
require.Equal(t, "local-customer", profile["external_customer_ref"])
require.Equal(t, "verified@example.test", profile["email"])
}
_, _ = io.WriteString(w, `{"status":"success","data":{"subject_ref":"`+subject+`","submission_ref":"`+submission+`","status":"approved","upgrade_pending":true,"profile":{"full_name":"Legal Name","date_of_birth":"1990-04-23","residence_country":"PL"}}}`)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
result, err := client.SubmitVerification(t.Context(), subject, "stable-source", identity.Profile{FullName: "Legal Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}, identity.Facts{Email: "verified@example.test", CustomerRef: "local-customer"}, []byte("document bytes"), []byte("selfie bytes"))
require.NoError(t, err)
require.Equal(t, "pending", result.Status)
require.True(t, result.UpgradePending)
policy, err := client.Limits(t.Context(), subject)
require.NoError(t, err)
require.Equal(t, "0.12345678", policy.Entries[0].Cap)
require.Equal(t, "verified", policy.Tier)
require.Equal(t, 4*time.Second, client.http.Timeout)
}
func TestProofRateLimitPreservesTypedRecoveryWithoutUpstreamBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "120")
w.WriteHeader(429)
_, _ = io.WriteString(w, "private identity assertions")
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Verification(t.Context(), subject)
require.Error(t, err)
require.NotContains(t, err.Error(), "private identity assertions")
throttle, ok := merrors.ThrottleDetailsFrom(err)
require.True(t, ok)
require.Equal(t, 120*time.Second, throttle.RetryAfter)
}
@@ -70,6 +70,9 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if mongo.IsDuplicateKeyError(err) {
return false, merrors.OpaqueWrap(merrors.ErrDataConflict, err, "duplicate_key")
}
if err != nil {
return false, err
}
@@ -0,0 +1,25 @@
package source
import (
"context"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readconcern"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
)
type majority struct{ Source }
// Majority retains the source's lease and transaction lifecycle while requiring
// durable writes and primary majority reads for externally visible handoffs.
func Majority(database Source) Source { return &majority{Source: database} }
func (s *majority) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
database, scoped, release, err := s.Source.Acquire(ctx)
if err != nil {
return nil, scoped, release, err
}
return database.Client().Database(database.Name(), options.Database().SetWriteConcern(writeconcern.Majority()).SetReadConcern(readconcern.Majority()).SetReadPreference(readpref.Primary())), scoped, release, nil
}
@@ -0,0 +1,40 @@
package source
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"go.mongodb.org/mongo-driver/v2/mongo/writeconcern"
)
type trackedSource struct {
database *mongo.Database
released bool
}
func (s *trackedSource) Acquire(ctx context.Context) (*mongo.Database, context.Context, func(), error) {
return s.database, ctx, func() { s.released = true }, nil
}
func TestMajorityPreservesSourceLifetime(t *testing.T) {
client, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017").SetWriteConcern(&writeconcern.WriteConcern{W: 1}).SetReadPreference(readpref.Secondary()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, client.Disconnect(context.Background())) })
underlying := &trackedSource{database: client.Database("handoff")}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
database, scoped, release, err := Majority(underlying).Acquire(ctx)
require.NoError(t, err)
require.Same(t, client, database.Client())
require.Equal(t, "handoff", database.Name())
require.Equal(t, ctx, scoped)
require.False(t, underlying.released)
release()
require.True(t, underlying.released)
_, _, _, err = Majority(Fixed(nil)).Acquire(ctx)
require.Error(t, err)
}
+9 -1
View File
@@ -61,7 +61,7 @@ envelope. Credentials, codes and activation tokens are sent in POST bodies.
| Route | Request | Successful result |
| --- | --- | --- |
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after activation email queue acceptance |
| POST `/signup` | `name`, `email`, `password`, `locale`, `idempotencyKey` | 202 with `userId`, after configured Facts intake and activation email queue acceptance |
| POST `/verification/send` | `email`, `idempotencyKey` | 202; generic acceptance also applies to absent/already-active accounts |
| POST `/verification/confirm` | `token` | 202 with `verified: true` after activation commits |
| POST `/login` | `login: {login, password}`, `clientId`, `deviceId` | 202 with `userId`, `pendingToken: {token, expiration}`, `challengeId`, `channel`, masked `target`, `expiresAt`, `resendAt` |
@@ -101,6 +101,14 @@ retries can create replacement messages; they are not distributed exactly-once
operations. Tokens are stored before the RPC, so a process crash between those
steps requires user recovery; there is no product-side delivery outbox.
When `api.sendico` is enabled, sign-up and activation resend first require a
durable Facts intake receipt. Activation checks that receipt in its transaction.
Initial account/token creation uses the same server-secret-derived activation
key as a same-key retry, so an intake outage does not force an extra cooldown for
an unsent initial token. Existing pending customers created before this change
retain the prior cooldown behavior. See [Facts integration](signup-facts.md) for
uncertain registration recovery and the `signup_attention_required` error.
Code verification, one-use proof consumption and session creation now commit in
one transaction. A session-write failure leaves the code retryable; a lost HTTP
response after commit requires restarting password login. The adapter
+124
View File
@@ -0,0 +1,124 @@
# Customer identity verification
Task [SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48), following
[sign-up Facts intake](signup-facts.md). Platform implementation:
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
## Customer flow
Sign-up still creates a basic account and submits its initial name/email/customer
reference as immutable facts. Email verification and login remain independent of
identity proof collection. After the first authenticated login in an app session,
an unverified customer is offered verification and can choose **Keep basic
limits**. An unavailable optional identity dependency does not prevent login.
**Accounts → Identity verification**, **Limits**, and the existing identity
navigation let the customer return later.
The customer supplies their full legal name, date of birth (YYYY-MM-DD), country
of residence, an ID document photo and a selfie holding that same document. The
backend supplies the authenticated customer's email and reference. The client
cannot choose a platform subject, organization, source principal, review status
or limits profile. JPEG/PNG photos must decode, be at least 600×600, at most
40 megapixels and no larger than 10 MiB each. Sendico also enforces its existing
configured evidence cap, MIME/extension rules and submission rate limits.
Unsent fields and photos stay in memory, survive retryable errors and are cleared
on successful submission, discard or page disposal. Previews leave Flutter's
image cache on exit/replacement. Profile edits or photo replacements create a
new request key; unchanged retries retain their key. Draft loss requires an
explicit discard choice. No sensitive extras enter browser history or analytics.
A successful submission means **pending staff review**. Corrections permit a new
submission; rejection retains basic access and the existing support route. A
recorded approval with an unfinished limits assignment is shown as **Limits
upgrade pending**, rather than verified. Refresh fetches the real reviewed state
and the authoritative limits. Production uses no simulated KYC review controls
or illustrative limit amounts. Demo mode remains separately simulated.
## Boundaries and contracts
The authenticated local routes are `GET/POST /accounts/identity` and
`GET /accounts/limits`, under the configured API mount. Multipart POST fields are
`profile` (JSON), `document`, `selfie`, and `idempotencyKey`. A customer ID is
always resolved from the full authenticated session. Pending-login tokens do not
provide account access. Browser and native requests both carry the access token;
browser refresh cookies remain an auth concern.
The existing `signup_facts` binding resolves each local customer to one independent
Sendico subject. A conditional unique `(namespace, subjectRef)` index prevents
binding the same provider subject to two customers. Empty registration claims
are excluded. Existing duplicates cause bootstrap to fail and require controlled
reconciliation; the application never guesses a replacement subject. Credential
rotation must retain the configured source account.
The Sendico adapter exclusively uses public HTTP:
- `GET/POST /identity_facts/{organization}/proofs/{subject}`;
- `GET /identity_facts/{organization}/proofs/{subject}/limits`.
Adapter DTOs and mappers remain separate from local models and local HTTP DTOs.
There are no Sendico database, gRPC, bus or sibling-module imports in SXCHNGE.
Provider organization/source settings remain in the adapter configuration.
Uploads have a bounded 60-second upstream timeout and redirects are rejected.
Upstream error bodies and photo bytes are never logged. Rate-limit responses
retain typed recovery. Registration's existing non-idempotent reconciliation
rules are unchanged.
Visible policy entries retain exact decimal strings, currency, group labels and
per-operation minimum/maximum, day and month slots. Usage is not invented for
per-operation slots. The backend owns the effective policy and remaining
capacity. The existing wallet remains a simulated service; this contribution
connects identity and visible limits, not real money movement. Future money
adapters must use the platform-owned customer account resource and authoritative
Limits reservation path, not a browser-selected policy or displayed balance.
## Review and rollout
Staff work in the existing **Sendico backoffice → KYC → Identity proof review**.
The platform stores private proofs, immutable profile assertions, review history
and the reviewed limits assignment. See the platform's
`docs/customer-identity-proofs.md` for authorization, retry and retention details.
Deploy Sendico Product/provider, Backoffice, Limits and backoffice frontend
changes together before enabling the new SXCHNGE frontend/backend adapters.
Existing `api.sendico` settings are reused. The integration account needs
organization read/update access. The platform's existing evidence/storage,
cleanup, discovery and limits configuration must be provisioned. Its ACCOUNT
default for SXCHNGE must be the existing unverified profile, and the existing
verified profile and personal-account visible view must be active. No new runtime
configuration parameters or live submissions are introduced by this work.
Merge [SXCHNGE sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47)
before [this flow's PR #49](https://git.sendico.io/tech/sxchnge/pulls/49).
For the combined existing backoffice, apply
[Sendico identity-proofs PR #2886](https://git.sendico.io/tech/sendico/pulls/2886)
before [Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887).
They share one Limits assignment contract and retain both review destinations.
Proof cases go to Identity proof review; generic Facts review cannot link them
to a login account or assign limits through that path. Deploy the reconciled
Sendico services and frontend before using this flow. Deployment is separate.
## UX, measurement and validation
Existing KYC journey events measure preparation, selection, accepted submission,
status reads, retries, abandonment and latency. Profile contents, filenames,
photo bytes and credentials are excluded. Submission success is an intake
outcome, not proof of approval. Pending asynchronous staff review is not counted
as customer abandonment. A resumed route starts a new client attempt; immutable
submission IDs deduplicate backend business outcomes.
Authoritative completion is the staff decision plus the Limits assignment
receipt for that submission. Backend IDs correlate these records without becoming
metric labels. Existing identity request counters/histograms distinguish accepted,
invalid, denied, conflict, rate-limited and unavailable operations and include
actual authorization/I/O duration. Client analytics still defaults to the
existing in-memory sink; retained cross-device analytics depends on deployment.
Review covers optional onboarding, later account navigation, editable profile,
readable ID/selfie guidance, disabled actions, error/session recovery, input
preservation, correction/rejection/pending/approval, exact monetary presentation,
EN/RU at narrow widths and large text, keyboard access and private-image cache
eviction. Tests use synthetic data, local HTTP servers and disposable MongoDB.
No live ID documents, live email, production review or deployment were exercised.
Full validation commands and final PR links are recorded in `implementation.md`
and the task.
+99
View File
@@ -828,3 +828,102 @@ existing mount requires reauthentication at its new cookie path. Notification
activation URLs remain configured frontend URLs. No live deployment, optional
security scans or merge were performed. The unrelated local notifications sender
configuration remains uncommitted. See [API version installation](api-versions.md).
## Sign-up Facts intake — task #46
[Task #46](https://git.sendico.io/tech/sxchnge/issues/46), branch
`identity-facts-46`, connects the product sign-up service to Sendico's public
individual registry and immutable Facts intake through optional `api.sendico`
configuration. Owner: `tech`; project: SXCHNGE; labels: backend and frontend.
Delivered in [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting
`main`. Implementation commit: `a584c81`.
The adapter sends full name, normalized email and the local customer reference.
The BFF persists a customer-owned subject binding, immutable facts snapshot and
confirmed receipt before activation email or sign-up acceptance. Activation and
resend enforce the handoff. The shared database source can require majority
writes and primary majority reads without bypassing its connection/transaction
lifecycle. Initial account/token creation remains atomic and uses a secret-derived
activation key, allowing same-key recovery after an intake outage without an
extra cooldown for an unsent token. Archived customers cannot resume sign-up.
Backend and frontend completion reviews covered the full task contribution,
including the public contracts, customer ownership, stable replay namespace,
concurrent claims, error classification, credential handling, logging, metrics and
DTO/mapper/service/model/provider separation. UX review preserved input, loading
suppression and confirmation-based success. Typed support guidance reaches both
sign-up and verification resend/confirmation through the shared auth package;
EN/RU feedback preserves live-region semantics and existing responsive controls.
The new localized widget fixture initially double-disposed the app-owned locale
provider; the fixture ownership was corrected. Focused integration tests also
exposed the initial-token cooldown issue, which was fixed in the creation flow.
Individual registration is not idempotent upstream. A durable claim prevents
speculative repeat registration after an ambiguous result; reconciliation uses
the saved request reference and authoritative Sendico records. Intake replay is
safe after receipt loss and retains original assertions. Actual provider IDs,
the endpoint and a same-account Vault-rendered access token must be provisioned
before enabling this integration. Tests use simulated HTTP and disposable MongoDB;
no live provider resources, live email, deployment, merge or optional security
scan is part of this delivery. The unrelated notifications runtime configuration
is excluded. Configuration, rollout, telemetry and recovery details are in
[Sign-up Facts intake](signup-facts.md).
Final validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and
`make test-frontend` suites), `make lint-backend`,
`dart format --output=none --set-exit-if-changed` for all 11 changed Dart files,
and `flutter analyze` in `frontend/app` and `frontend/packages/sxchnge_auth`.
Focused Go race suites and Flutter backend/provider/widget tests also passed;
the diagnostic public API run
`go -C backend/product test -race -v ./auth/http/v1/public -count=1 -timeout=90s`
passed. The initial unrestricted `make test` failed when Testcontainers could
not start its reaper before the Mongo fixture deadline. Lower Go package
parallelism resolved that infrastructure contention. The next full run exposed
the new locale fixture disposal error noted above; the corrected final run
passed, including EN/RU support states and verification recovery. No checks were
weakened or unrelated work changed.
## Customer identity verification — task #48
[Implementation and rollout](identity-verification.md) adds optional verification
on authenticated onboarding and later from the account: legal profile, ID photo
and selfie holding the same ID. Real adapters replace demo KYC/limits in backend
mode. Sendico's existing backoffice owns decisions and the reviewed Limits profile;
SXCHNGE has no local tenancy, roles or policy engine. Provider-subject uniqueness,
private bounded uploads, stable retries, exact decimal policy projections and
explicit upgrade-pending feedback are included. The unrelated notifications
runtime configuration is excluded.
Delivered in [PR #49](https://git.sendico.io/tech/sxchnge/pulls/49), branch
`identity-verification-48`, implementation commit `1354551`. It is stacked on
[sign-up Facts PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), which must
merge first, and requires
[Sendico PR #2886](https://git.sendico.io/tech/sendico/pulls/2886) to be deployed
before enabling the new flow. Tracking tasks:
[SXCHNGE #48](https://git.sendico.io/tech/sxchnge/issues/48) and
[Sendico #2885](https://git.sendico.io/tech/sendico/issues/2885).
Completion review covered the full backend and frontend contribution, including
subject ownership, private uploads, idempotent retries, session changes, exact
limits, DTO/mapper/service/model/provider boundaries, and the UX/measurement
checklists. Review fixes included duplicate-binding error classification,
upgrade-pending feedback, bounded response timeouts and stale-session protection.
Final validation passed: `GOFLAGS=-p=1 make test` (all backend race tests and all
frontend suites; 79 app tests), `make lint-backend`, changed-Dart formatting,
and `flutter analyze` in the app and shared KYC package. Focused KYC adapter and
onboarding tests passed (20 tests), as did focused Go identity/provider/HTTP
checks. An initial parallel run hit the existing Testcontainers reaper deadline;
serial Go package execution resolved local Docker contention without weakening
checks. No optional security scan, live customer proof, email, production
approval, deployment or merge was performed. Generated outputs remain ignored.
Reconciliation follow-up: the shared Limits assignment implementation and its
persisted-state validation are owned by Sendico PR #2886. The subsequent
[Sendico Facts review PR #2887](https://git.sendico.io/tech/sendico/pulls/2887)
retains both Product providers, Backoffice services and KYC destinations, and
routes proof cases to Identity proof review while blocking generic login linking
and assignment. Apply #2886 before #2887 and deploy the combined Sendico result
before this SXCHNGE flow. The public proof/limits contract is unchanged; this
SXCHNGE follow-up changes documentation only and requires consistency/diff checks.
+182
View File
@@ -0,0 +1,182 @@
# Sign-up Facts intake
Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
`identity-facts-46`. Primary module: identity-facts; secondary modules: auth,
customer creation, runtime, shared database source and Flutter error feedback.
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
## Behavior and boundaries
With `api.sendico` configured, the product BFF creates the local pending customer
and activation token atomically, then registers an independent individual through
Sendico's public HTTP API and submits the sign-up assertions. It persists the
intake receipt before sending activation email or returning sign-up success.
Activation resend runs the same handoff; activation checks the receipt in its
local transaction, without external I/O. A failed receipt check rolls back token
consumption and leaves the account pending. Existing active customers are not
backfilled or blocked at login.
The adapter consumes these existing Sendico contracts:
- `POST /individuals/{organization_ref}`, empty body, HTTP 200 registration
receipt containing `subject_ref` and `registered_at`.
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
containing `submission_ref`, `subject_ref` and `accepted_at`.
The configured API prefix precedes both paths. See Sendico
[individual subjects](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/individual-subjects.md)
and [Facts intake](https://git.sendico.io/tech/sendico/src/branch/main/docs/api/identity-facts-intake.md).
The only assertions sent are `full_name`, normalized `email` and
`external_customer_ref` (the local customer ID). Passwords, password hashes,
activation tokens, session credentials and device data never enter the adapter.
The input name is retained as a full name, without guessing name parts. No facts
are marked verified; acceptance does not establish identity, KYC completeness,
verification, limits or payout eligibility. The existing KYC preview is unchanged.
Only the BFF owns Sendico credentials and organization scope. Local customer
models have no organization, membership or platform permissions. HTTP DTOs and
mappers stay in `providers/sendico`; the handoff operates on domain facts and
receipts in `identity`. Flutter continues through its DTO, mapper, service,
model and provider layers. It never calls Sendico or supplies subject references.
## Configuration and rollout
Add this block under `api` in the product service configuration, retaining
`api.notifications`:
```yaml
sendico:
api_url: https://your-sendico-host/api/v1
organization_ref: <provisioned-SXCHNGE-organization-object-id>
source_account_ref: <provisioned-integration-account-object-id>
token_file: /run/sxchnge/sendico-token
```
Resolve the real IDs from the target Sendico deployment. The integration account
must be a member of the SXCHNGE organization with the existing organizations
`update` permission; reconciliation GET requests also require `read`. The token
must be a full, unexpired Sendico access JWT whose `id` claim is that account.
The adapter inspects claims only to protect the replay namespace; Sendico checks
the signature and authorizes every call. No login, refresh or privileged account
provisioning is performed by this adapter.
Keep the token in Vault at `services/bff/sendico`, field `access_token`. Render it
with the deployment's Vault Agent into the configured private runtime file; give
only the BFF policy read access to that KV path. Endpoint, organization and source
IDs remain in service YAML. For the local stack, configure
`infra/local/bff/runtime.yml`, render to the BFF's existing `/run/sxchnge` tmpfs,
and extend its Vault Agent policy/template for this provisioned secret. These
deployment values and secret provisioning are deliberately not guessed or
enabled by the example. Do not place tokens in Git, browser builds or shell
history. Refresh the file atomically before expiry using the same Sendico account;
the adapter rereads it on every request. Startup rejects missing, expired or
mismatched credentials and rejects Sendico configuration without notifications.
HTTPS is required except for loopback HTTP test/development endpoints. Redirects
are rejected and each HTTP call has a four-second timeout. Non-200 responses,
malformed/truncated receipts, invalid identifiers, wrong subjects and unsuccessful
envelopes never count as acceptance. Upstream error bodies are not surfaced.
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
activation for previously pending customers; use activation resend to submit
their saved name/email before confirming their old link. There is no automatic
bulk migration. Changing endpoint, organization or source account changes the
adapter fingerprint and blocks existing bindings for explicit reconciliation.
Token rotation for the same account retains that fingerprint. Plan such changes
as migrations; never reset a binding just to bypass the check.
## Persistence, retries and recovery
The local `signup_facts` collection uses the customer ID as `_id`, so its unique
primary key coordinates concurrent requests and separate BFF instances. It stores
an immutable sign-up facts snapshot, adapter fingerprint, random registration
request correlation, registration claim, subject and confirmed receipt. It uses
the shared database repository and connection lifecycle with majority writes and
primary majority reads, overriding weaker connection defaults. No external call
runs in a database transaction. Snapshots and bindings contain personal data and
must follow the customer data retention policy; no TTL silently removes replay
state, and customer deletion does not delete the immutable Sendico submission.
Once a subject is stored, intake retries use
`sxchnge:signup:<local-customer-id>` as `source_submission_ref`. Sendico's
organization/authenticated-account/source-reference uniqueness returns the
original receipt for that subject. Retries send the saved snapshot even if a
later request changes name or email. Losing the HTTP receipt or a local receipt
write is recoverable by replay. A saved receipt skips subsequent external calls.
Retrying sign-up still requires the original password; active, archived or
otherwise conflicting accounts cannot be resumed as a new customer.
**Individual registration is not idempotent.** The BFF commits a claim before
dispatch and never expires or automatically retries that registration. It cannot
prove that a timed-out request, malformed response or failed local binding write
did not create a subject. Another request sees an in-progress claim as temporarily
unavailable for 30 seconds; an older unresolved claim requires support. The
registration caller immediately reports assistance on an uncertain failure. An
accepted registration is saved with a short independent context even if the
browser disconnects. A crash before dispatch can also require reconciliation.
This trades automatic recovery for avoiding duplicate subjects under the current
upstream contract; there is no distributed exactly-once guarantee.
For an unresolved registration, operators must stop requests for the affected
customer and inspect the saved `registrationRequest` plus Sendico identity logs
(`X-Request-ID`, operation `subject_register`) under the configured account and
organization. A successful registration's server-issued `record_ref` identifies
the candidate subject. Confirm it using the organization-scoped individual GET
API before atomically setting the matching local binding's `subjectRef` and
clearing `registering`; retain its snapshot, fingerprint and request reference.
If a local binding write failed after receipt, BFF diagnostics also contain the
subject reference. If authoritative records prove no subject was created, an
operator may clear the claim after fixing the dependency. If neither result can
be established, leave it blocked; never register a speculative replacement.
Resume sign-up with the original credentials or activation resend after this
repair. These are controlled operational repairs, not customer API endpoints.
HTTP 503 `signup_attention_required` maps to typed shared auth and verification
state and localized EN/RU support guidance, including activation resend recovery.
Other intake/receipt failures remain temporary service
failures. Form input is retained, duplicate submissions stay disabled during
loading, and neither failure navigates to an email-sent screen or creates a
session. The existing support channel must be available to handle reconciliation.
## Measurement and UX review
The existing [auth journey](ux/auth-journeys.md) keeps its attempt, retry,
abandonment and 30-minute settled inactivity rules. `auth_signup_succeeded` now
requires server confirmation of configured intake plus activation queue acceptance;
it remains an intermediate step and email confirmation remains the terminal
onboarding outcome. Sign-up dependency failures and assistance outcomes use the
existing allowlisted `unavailable` analytics category; verification assistance
uses it too. Retries retain the journey
and operation semantics. No names, emails or provider response bodies are added.
`sxchnge_signup_facts_total{outcome}` records `accepted`, `replayed`,
`unavailable` and `attention_required` handoff attempts;
`sxchnge_signup_facts_duration_seconds` measures their latency. Counts are
attempts, not distinct users. Use unavailable/attention counts divided by total
handoff attempts for operational failure rates; use the existing correlated auth
journey for conversion, duration and abandonment. Structured handoff logs include
local customer and random registration request references for support correlation,
without asserted values, credentials or raw errors. IDs are never metric labels.
The product team uses the distinction to separate retryable intake outages from
registration reconciliation. The default client analytics sink remains in memory;
production retention and cross-device conversion correlation remain deployment
gaps, as documented by the existing journey contract.
Review covers loading suppression, input preservation, success only on confirmed
handoff, temporary retry and assistance states, existing navigation, mobile layout,
text scaling and live-region error feedback. Focused tests cover these states,
the mapped backend error, non-inflated client success, immutable retry payloads,
credential rotation, timeout/cancellation/redirect behavior, receipt validation,
privacy-safe errors, concurrent durable claims and activation rollback. The tests
use disposable local MongoDB and a simulated Sendico HTTP server; they do not
create live subjects or send email. Live integration and support operations must
be verified against provisioned deployment credentials before enabling the block.
The EN/RU error state is exercised at a 390px viewport with 1.5x text scaling;
existing keyboard regression tests and a code review of the live-region semantics
cover the unchanged controls. No manual browser or screen-reader walkthrough
against a live Sendico deployment was performed.
The initial sign-up assertion remains a separate immutable submission. Optional
profile/ID/selfie collection, later account uploads and reviewed limits are now
covered by [Customer identity verification](identity-verification.md), task #48.
Proofs are not required to activate or log into a basic account.
+5
View File
@@ -2,6 +2,11 @@
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
> This original demo plan is retained as design history. Production identity
> uploads, staff review and authoritative limits are implemented by task #48;
> see [Customer identity verification](../identity-verification.md). Demo behavior
> below applies only when the backend adapters are not selected.
## User outcome and scope
Understand current access, decide whether higher limits are useful, and submit two
+1
View File
@@ -22,6 +22,7 @@ class AuthFeedback extends StatelessWidget {
AuthError.accountNotVerified => l.authVerifyEmail,
AuthError.invalidCredentials => l.authInvalidCredentials,
AuthError.emailInUse => l.authEmailInUse,
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
AuthError.unavailable => l.authUnavailable,
AuthError.unexpected => l.authUnexpected,
AuthError.factorLocked => l.factorLocked,
+15 -2
View File
@@ -8,6 +8,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../routing/routes.dart';
import '../security/challenge.dart';
import 'feedback.dart';
@@ -127,7 +129,16 @@ class _AuthFormState extends State<AuthForm> {
}
await login.complete(challenge, grant);
if (mounted && login.state.status == AuthStatus.success) {
context.go('/accounts');
final accepted = login.state.result!;
final offer = await context.read<IdentityOnboardingProvider>().offer(
accepted.userId,
);
if (mounted &&
!_signUp &&
identical(login.state.result, accepted) &&
login.state.status == AuthStatus.success) {
context.go(offer ? '/identity?onboarding=1' : '/accounts');
}
}
}
}
@@ -299,7 +310,9 @@ class _AuthFormState extends State<AuthForm> {
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('submit'),
onPressed: state.isLoading ? null : _submit,
onPressed: state.isLoading || state.status == AuthStatus.success
? null
: _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
+4 -3
View File
@@ -22,10 +22,11 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
ReviewReason.passportExpired => l.kycExpiredReason,
ReviewReason.unableToVerify => l.kycDeniedReason,
};
String errorText(AppLocalizations l, KycError e) => switch (e) {
String errorText(AppLocalizations l, KycError e, {bool photo = false}) =>
switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => l.kycErrorPermission,
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
@@ -34,4 +35,4 @@ String errorText(AppLocalizations l, KycError e) => switch (e) {
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
};
+13 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'photo.dart';
import 'profile.dart';
import '../generated/i18n/app_localizations.dart';
@@ -26,6 +27,10 @@ class IdentityForm extends StatelessWidget {
),
),
const SizedBox(height: 16),
if (p.step == 0) ...[
ProfileForm(provider: p),
const SizedBox(height: 24),
],
LinearProgressIndicator(
value: (p.step + 1) / 3,
semanticsLabel: titles[p.step],
@@ -47,6 +52,10 @@ class IdentityForm extends StatelessWidget {
),
),
if (p.step == 2) ...[
Text(
'${p.profile.fullName}\n${p.profile.dateOfBirth} · ${p.profile.residenceCountry}',
),
const SizedBox(height: 16),
Text(l.kycReviewHelp),
const SizedBox(height: 16),
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
@@ -68,7 +77,10 @@ class IdentityForm extends StatelessWidget {
),
FilledButton(
key: const ValueKey('kyc-next'),
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
onPressed:
p.busy ||
(p.step == 0 && !p.profile.isComplete) ||
(p.step == 0 ? p.passport : p.selfie) == null
? null
: p.next,
child: Text(l.kycNext),
+4 -1
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
import 'policy.dart';
class LimitsPanel extends StatelessWidget {
const LimitsPanel({super.key});
@@ -48,7 +49,9 @@ class LimitsPanel extends StatelessWidget {
style: t.textTheme.titleLarge,
),
const SizedBox(height: 12),
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
if (data.rows.isEmpty && data.entries.isEmpty)
Text(l.kycLimitsEmpty),
for (final row in data.entries) PolicyRow(row: row),
for (final row in data.rows) ...[
const Divider(height: 32),
Text(
+31 -4
View File
@@ -16,7 +16,8 @@ import 'status.dart';
import '../generated/i18n/app_localizations.dart';
class IdentityPage extends StatefulWidget {
const IdentityPage({super.key});
const IdentityPage({super.key, this.onboarding = false});
final bool onboarding;
@override
State<IdentityPage> createState() => IdentityPageState();
}
@@ -98,6 +99,10 @@ class IdentityPageState extends State<IdentityPage> {
if (!await allowExit() || !mounted) return;
_identity.exit();
if (account) {
if (widget.onboarding) {
context.go('/accounts');
return;
}
if (context.canPop()) {
context.pop();
} else {
@@ -166,7 +171,11 @@ class IdentityPageState extends State<IdentityPage> {
key: _feedbackKey,
liveRegion: true,
child: Text(
errorText(l, p.error!),
errorText(
l,
p.error!,
photo: p.photoError != null,
),
key: const ValueKey('kyc-error'),
style: TextStyle(color: t.colorScheme.error),
),
@@ -178,6 +187,16 @@ class IdentityPageState extends State<IdentityPage> {
l.kycStatusStale,
style: TextStyle(color: t.colorScheme.error),
),
if (p.error == KycError.sessionExpired)
TextButton(
onPressed: () async {
if (await allowExit() && mounted) {
_identity.exit();
if (context.mounted) context.go('/');
}
},
child: Text(l.login),
),
Focus(
includeSemantics: false,
key: _contentKey,
@@ -198,7 +217,7 @@ class IdentityPageState extends State<IdentityPage> {
const LimitsPanel(),
],
const SizedBox(height: 16),
if (p.editing)
if (p.editing && !widget.onboarding)
TextButton(
onPressed: p.busy ? null : _leave,
child: Text(l.kycSkip),
@@ -207,7 +226,15 @@ class IdentityPageState extends State<IdentityPage> {
onPressed: p.busy
? null
: () => _leave(account: true),
child: Text(l.wLimits),
child: Text(
widget.onboarding
? (p.record?.status ==
IdentityStatus.unverified ||
p.editing
? l.kycSkip
: l.kycContinue)
: l.wLimits,
),
),
],
),
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
/// Display exact provider decimals; never infer currency scale or active caps.
class PolicyRow extends StatelessWidget {
const PolicyRow({super.key, required this.row});
final PolicyLimit row;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 32),
Text(
'${row.operation} · ${switch (row.period) {
'day' => l.kycDay,
'month' => l.kycMonth,
'operationMin' => l.kycOperationMinimum,
'operationMax' => l.kycOperationMaximum,
_ => row.period,
}}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 28,
runSpacing: 16,
children: [
_amount(context, l.kycCurrent, row.cap),
if (row.remaining.isNotEmpty)
_amount(context, l.kycRemaining, row.remaining),
if (row.used.isNotEmpty) _amount(context, l.wUsage, row.used),
if (row.reserved.isNotEmpty)
_amount(context, l.kycReserved, row.reserved),
],
),
],
);
}
Widget _amount(BuildContext context, String label, String amount) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
Text(
'$amount ${row.currency}',
style: Theme.of(context).textTheme.titleLarge,
),
],
);
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
class ProfileForm extends StatelessWidget {
const ProfileForm({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final p = provider;
final l = AppLocalizations.of(context)!;
void update({String? name, String? birth, String? country}) =>
p.updateProfile(
IdentityProfile(
fullName: name ?? p.profile.fullName,
dateOfBirth: birth ?? p.profile.dateOfBirth,
residenceCountry: country ?? p.profile.residenceCountry,
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycProfileHelp),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('kyc-name'),
initialValue: p.profile.fullName,
enabled: !p.busy,
maxLength: 200,
autofillHints: const [AutofillHints.name],
decoration: InputDecoration(labelText: l.kycFullName),
onChanged: (value) => update(name: value),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('kyc-birth'),
initialValue: p.profile.dateOfBirth,
enabled: !p.busy,
keyboardType: TextInputType.datetime,
autofillHints: const [AutofillHints.birthday],
decoration: InputDecoration(
labelText: l.kycBirth,
helperText: l.kycBirthHelp,
),
onChanged: (value) => update(birth: value.trim()),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('kyc-country'),
initialValue: p.profile.residenceCountry,
enabled: !p.busy,
maxLength: 2,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: l.kycCountry,
helperText: l.kycCountryHelp,
),
onChanged: (value) => update(country: value.trim().toUpperCase()),
),
if (!p.profile.isComplete) Text(l.kycProfileRequired),
],
);
}
}
+6 -2
View File
@@ -44,7 +44,9 @@ class StatusPanel extends StatelessWidget {
header: true,
liveRegion: true,
child: Text(
statusTitle(l, status),
record.upgradePending
? l.kycUpgradePendingTitle
: statusTitle(l, status),
key: ValueKey('kyc-${status.name}'),
style: t.textTheme.headlineMedium?.copyWith(
color: t.colorScheme.onPrimaryContainer,
@@ -53,7 +55,9 @@ class StatusPanel extends StatelessWidget {
),
const SizedBox(height: 12),
Text(
statusBody(l, status),
record.upgradePending
? l.kycUpgradePendingBody
: statusBody(l, status),
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
),
if (record.reason != null) ...[
+31 -17
View File
@@ -1,5 +1,6 @@
{
"@@locale": "en",
"authSignupNeedsAssistance": "We could not finish setting up your account. Please contact support.",
"appTitle": "SXCHNGE · Your next move",
"nextChapter": "YOUR NEXT CHAPTER",
"loginHeading": "Hey, welcome\nback.",
@@ -72,19 +73,19 @@
"kycUnverified": "Not verified",
"kycPending": "In review",
"kycApproved": "Identity verified",
"kycChanges": "New photos needed",
"kycChanges": "Changes requested",
"kycDenied": "Verification declined",
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.",
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit an ID document photo and a selfie holding it to request an upgrade.",
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
"kycApprovedBody": "Your review is complete. Check your current limits below.",
"kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.",
"kycChangesBody": "Basic limits remain available. Check your details, replace the photos and submit a new review request.",
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
"kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.",
"kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.",
"kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.",
"kycPassportReason": "The ID document details could not be read. Retake the photo in good light, with every edge visible.",
"kycSelfieReason": "Your face or ID document was not clear in the selfie. Keep both visible and in focus.",
"kycExpiredReason": "The ID document has expired. Use a valid ID document and take both photos again.",
"kycDeniedReason": "We could not verify your identity from this submission.",
"kycStart": "Verify identity",
"kycResubmit": "Replace photos",
"kycResubmit": "Update details and photos",
"kycRefresh": "Refresh status & limits",
"kycSupport": "Contact support",
"kycSupportBody": "Support is currently unavailable. Please try again later.",
@@ -99,11 +100,11 @@
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
"kycLoading": "Loading your status…",
"kycPassport": "Passport photo",
"kycSelfie": "Selfie with passport",
"kycReview": "Review your photos",
"kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.",
"kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.",
"kycPassport": "ID document photo",
"kycSelfie": "Selfie with ID document",
"kycReview": "Review details and photos",
"kycPassportHelp": "Photograph the photo page or front of your valid ID document. Show all four edges and keep every detail readable. Avoid glare, blur and covered details.",
"kycSelfieHelp": "Hold the same ID document beside your face. Show your whole face and the document details in the same image, sharp and uncovered.",
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
"kycChoose": "Choose photo",
"kycReplace": "Choose a different photo",
@@ -112,12 +113,12 @@
"kycBack": "Back",
"kycSkip": "Keep basic limits",
"kycClose": "Back to account",
"kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.",
"kycConfirm": "These photos show me and my own valid passport.",
"kycReviewHelp": "Check your details and make sure both photos are readable and show the same ID document. When you submit, they are stored privately for staff review. Submission does not immediately raise your limits.",
"kycConfirm": "These photos show me and my own valid ID document.",
"kycSubmit": "Submit for review",
"kycWorking": "Processing… Please keep this page open.",
"kycDiscardTitle": "Discard these photos?",
"kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.",
"kycDiscardTitle": "Discard this verification draft?",
"kycDiscardBody": "Leaving removes unsent profile changes and selected photos from this session. You can start again later.",
"kycStay": "Keep editing",
"kycDiscard": "Discard & leave",
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
@@ -263,5 +264,18 @@
},
"factorEmailMethod": "Email code",
"factorAppMethod": "Authenticator app",
"factorAppExpired": "This verification has expired. Return and start again."
"factorAppExpired": "This verification has expired. Return and start again.",
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
"kycFullName": "Full legal name",
"kycBirth": "Date of birth",
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
"kycCountry": "Country of residence",
"kycCountryHelp": "Two-letter country code, for example PL or DE",
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.",
"kycContinue": "Continue to my account",
"kycOperationMinimum": "Per operation minimum",
"kycOperationMaximum": "Per operation maximum",
"kycErrorAccess": "Verification is unavailable for this account. Contact support if this continues.",
"kycUpgradePendingTitle": "Limits upgrade pending",
"kycUpgradePendingBody": "Your identity review is approved. We are waiting for confirmation of the limits upgrade. You do not need to submit your photos again."
}
+32 -18
View File
@@ -1,5 +1,6 @@
{
"@@locale": "ru",
"authSignupNeedsAssistance": "Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.",
"appTitle": "SXCHNGE · Ваш следующий шаг",
"nextChapter": "НОВАЯ ГЛАВА",
"loginHeading": "Рады видеть\nвас снова.",
@@ -72,19 +73,19 @@
"kycUnverified": "Личность не подтверждена",
"kycPending": "На проверке",
"kycApproved": "Личность подтверждена",
"kycChanges": "Нужны новые фото",
"kycChanges": "Нужны исправления",
"kycDenied": "Верификация отклонена",
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото документа и селфи с ним.",
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
"kycChangesBody": "Базовые лимиты доступны. Проверьте данные, замените фото и отправьте новый запрос на проверку.",
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
"kycPassportReason": "Данные документа не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
"kycSelfieReason": "На селфи плохо видно лицо или документ. Оба должны быть в кадре и в фокусе.",
"kycExpiredReason": "Срок действия документа истёк. Используйте действующий документ и сделайте оба фото заново.",
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
"kycStart": "Подтвердить личность",
"kycResubmit": "Заменить фото",
"kycResubmit": "Исправить данные и фото",
"kycRefresh": "Обновить статус и лимиты",
"kycSupport": "Обратиться в поддержку",
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
@@ -99,11 +100,11 @@
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
"kycLoading": "Загружаем ваш статус…",
"kycPassport": "Фото паспорта",
"kycSelfie": "Селфи с паспортом",
"kycReview": "Проверьте фото",
"kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.",
"kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.",
"kycPassport": "Фото документа",
"kycSelfie": "Селфи с документом",
"kycReview": "Проверьте данные и фото",
"kycPassportHelp": "Снимите страницу с фото или лицевую сторону действующего документа. Все четыре края и данные должны быть чётко видны. Избегайте бликов и размытия.",
"kycSelfieHelp": "Держите тот же документ рядом с лицом. Лицо целиком и данные документа должны быть в одном кадре, чёткими и ничем не закрытыми.",
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
"kycChoose": "Выбрать фото",
"kycReplace": "Выбрать другое фото",
@@ -112,12 +113,12 @@
"kycBack": "Назад",
"kycSkip": "Оставить базовые лимиты",
"kycClose": "К аккаунту",
"kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.",
"kycConfirm": "На фото я и мой действующий паспорт.",
"kycReviewHelp": "Проверьте данные: оба фото должны быть читаемы и показывать один документ. После отправки они хранятся конфиденциально для проверки сотрудником. Отправка не повышает лимиты сразу.",
"kycConfirm": "На фото я и мой действующий документ.",
"kycSubmit": "Отправить на проверку",
"kycWorking": "Обработка… Не закрывайте страницу.",
"kycDiscardTitle": "Удалить выбранные фото?",
"kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.",
"kycDiscardTitle": "Удалить черновик проверки?",
"kycDiscardBody": "Несохранённые данные и выбранные фото будут удалены из этой сессии. Вы сможете начать заново позже.",
"kycStay": "Продолжить",
"kycDiscard": "Удалить и выйти",
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
@@ -127,7 +128,7 @@
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.",
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
@@ -263,5 +264,18 @@
},
"factorEmailMethod": "Код из письма",
"factorAppMethod": "Приложение-аутентификатор",
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова."
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова.",
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
"kycFullName": "Полное имя по документу",
"kycBirth": "Дата рождения",
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
"kycCountry": "Страна проживания",
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
"kycContinue": "Перейти в личный кабинет",
"kycOperationMinimum": "Минимум на операцию",
"kycOperationMaximum": "Максимум на операцию",
"kycErrorAccess": "Проверка недоступна для этого аккаунта. Если это повторяется, обратитесь в поддержку.",
"kycUpgradePendingTitle": "Ожидается повышение лимитов",
"kycUpgradePendingBody": "Проверка личности одобрена. Ожидается подтверждение повышения лимитов. Отправлять фото повторно не нужно."
}
+15 -2
View File
@@ -66,7 +66,14 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _factors =
widget.backend ?? widget.twoFactorService ?? DummyTwoFactorService();
late final _security = SecurityProvider(_factors, _analytics);
late final _identity = widget.identityService ?? DummyIdentityService();
late final _identity =
widget.identityService ??
(widget.backend == null
? DummyIdentityService()
: BackendIdentityService(
baseUri: widget.backend!.baseUri,
accessToken: () => widget.backend!.session?.accessToken,
));
late final _walletService =
widget.walletService ??
DummyWalletService(
@@ -81,7 +88,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
);
late final _limits =
widget.limitsService ??
(_walletService is DummyWalletService
(_identity is BackendIdentityService
? BackendLimitsService(_identity)
: _walletService is DummyWalletService
? WalletLimitsService(_walletService)
: DummyLimitsService(_identity));
late final _journeys = JourneyRecorder(_analytics);
@@ -122,6 +131,9 @@ class _SxchngeAppState extends State<SxchngeApp> {
_journeys.dispose();
_wallet.dispose();
_security.dispose();
if (_identity is BackendIdentityService) {
_identity.close();
}
_locale.dispose();
widget.backend?.close();
super.dispose();
@@ -136,6 +148,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
Provider<JourneyRecorder>.value(value: _journeys),
Provider<AnalyticsService>.value(value: _analytics),
Provider<IdentityService>.value(value: _identity),
Provider(create: (_) => IdentityOnboardingProvider(_identity)),
Provider<LimitsService>.value(value: _limits),
Provider<PhotoService>.value(value: widget.photoService),
ChangeNotifierProvider.value(value: _locale),
+4 -1
View File
@@ -116,7 +116,10 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute(
path: '/identity',
name: AppRoutes.identity,
builder: (context, state) => IdentityPage(key: identityKey),
builder: (context, state) => IdentityPage(
key: identityKey,
onboarding: state.uri.queryParameters['onboarding'] == '1',
),
onExit: (context, state) async =>
await identityKey.currentState?.allowExit() ?? true,
),
+5 -1
View File
@@ -122,8 +122,10 @@ class _VerificationPageState extends State<VerificationPage> {
(_provider.service as SimulationInfo).isSimulated) ||
state.isSimulated;
final sent = status == VerificationStatus.sent;
final needsAssistance = state.error == VerificationError.needsAssistance;
final retryConfirmation =
status == VerificationStatus.error &&
!needsAssistance &&
!_lastWasSend &&
widget.token != null;
final title = switch (status) {
@@ -139,7 +141,9 @@ class _VerificationPageState extends State<VerificationPage> {
? l.verificationChecking
: l.verificationRequest,
};
final body = switch (status) {
final body = needsAssistance
? l.authSignupNeedsAssistance
: switch (status) {
VerificationStatus.sent => l.verificationSent,
VerificationStatus.verified => l.verificationVerifiedBody,
VerificationStatus.expired => l.verificationExpiredBody,
+16
View File
@@ -47,6 +47,22 @@ class AccountsPage extends StatelessWidget {
),
const SizedBox(height: 12),
Text(l.wSubheading, style: t.textTheme.bodyLarge),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.kycIntro),
TextButton(
onPressed: () => context.push('/identity'),
child: Text(l.wIdentity),
),
],
),
),
),
const SizedBox(height: 28),
if (p.loading) const LinearProgressIndicator(),
if (p.failed)
+80
View File
@@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
@@ -41,6 +43,84 @@ class UnverifiedBackend extends BackendAuthServices {
}
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'signup requiring assistance preserves input and records failure ($language)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final analytics = DummyAnalyticsService();
final locale = LocaleProvider(initialLocale: Locale(language));
await tester.pumpWidget(
SxchngeApp(
localeProvider: locale,
analyticsService: analytics,
signupService: const DummySignupService(
delay: Duration.zero,
failure: AuthError.signupNeedsAssistance,
),
),
);
await tester.pumpAndSettle();
final signupLabel = language == 'ru' ? 'Регистрация' : 'Sign up';
await tester.ensureVisible(find.text(signupLabel));
await tester.tap(find.text(signupLabel));
await tester.pumpAndSettle();
for (final entry in {
'name': 'Ada Lovelace',
'email': 'ada@example.com',
'password': 'password preserved',
'confirmation': 'password preserved',
}.entries) {
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
}
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
await tester.tap(find.byKey(const ValueKey('submit')));
await tester.pumpAndSettle();
final feedback = find.byKey(const ValueKey('auth-error'));
await tester.ensureVisible(feedback);
expect(
find.text(
language == 'ru'
? 'Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.'
: 'We could not finish setting up your account. Please contact support.',
),
findsOneWidget,
);
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
expect(
tester
.widget<TextFormField>(find.byKey(const ValueKey('email')))
.controller!
.text,
'ada@example.com',
);
expect(
tester
.widget<TextFormField>(find.byKey(const ValueKey('password')))
.controller!
.text,
'password preserved',
);
expect(
analytics.events.where((event) => event.name == 'auth_signup_failed'),
hasLength(1),
);
expect(
analytics.events.where(
(event) => event.name == 'auth_signup_succeeded',
),
isEmpty,
);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('backend mode gates workspace and recovers unverified login', (
tester,
) async {
+23
View File
@@ -67,6 +67,10 @@ void main() {
Future<void> submit(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start')));
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL');
await t.pumpAndSettle();
await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byKey(const ValueKey('pick-selfie')));
@@ -100,6 +104,25 @@ void main() {
await tap(t, find.text('Contact support'));
expect(find.byType(AlertDialog), findsOneWidget);
});
testWidgets('optional onboarding can be skipped before collecting proofs', (
t,
) async {
t.platformDispatcher.defaultRouteNameTestValue = '/identity?onboarding=1';
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
await t.pumpWidget(
SxchngeApp(
identityService: DummyIdentityService(delay: Duration.zero),
photoService: Photos(bytes),
),
);
await t.pumpAndSettle();
final router = GoRouter.of(
t.element(find.byKey(const ValueKey('kyc-start'))),
);
await tap(t, find.text('Keep basic limits'));
expect(router.routeInformationProvider.value.uri.path, '/accounts');
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('correctable rejection can be resubmitted', (t) async {
await open(t);
await submit(t);
+31
View File
@@ -42,6 +42,37 @@ Future<void> openVerification(
}
void main() {
testWidgets('resend assistance retains email and does not claim delivery', (
tester,
) async {
await openVerification(
tester,
'/verification-sent',
service: const DummyVerificationService(
delay: Duration.zero,
sendFailure: VerificationError.needsAssistance,
),
);
final email = find.byKey(const ValueKey('verification-email'));
await tester.enterText(email, 'ada@example.com');
await tester.ensureVisible(
find.byKey(const ValueKey('verification-resend')),
);
await tester.tap(find.byKey(const ValueKey('verification-resend')));
await tester.pumpAndSettle();
expect(
find.text(
'We could not finish setting up your account. Please contact support.',
),
findsOneWidget,
);
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
expect(
tester.widget<TextFormField>(email).controller!.text,
'ada@example.com',
);
expect(tester.takeException(), isNull);
});
for (final scenario in {
'demo-valid': 'verified',
'demo-expired': 'expired',
@@ -2,6 +2,7 @@ import '../dto/backend.dart';
import '../models/auth.dart';
import '../models/session.dart';
import '../models/two_factor.dart';
import '../models/verification.dart';
abstract final class BackendMapper {
static AuthResult signup(BackendDTO dto) {
@@ -33,11 +34,19 @@ abstract final class BackendMapper {
static AuthException authFailure(int status, BackendDTO dto) =>
AuthException(switch (dto.error) {
'email_in_use' => AuthError.emailInUse,
'signup_attention_required' => AuthError.signupNeedsAssistance,
'account_not_verified' => AuthError.accountNotVerified,
_ when status == 401 => AuthError.invalidCredentials,
_ when status == 429 => AuthError.factorCooldown,
_ => AuthError.unavailable,
});
static VerificationException verificationFailure(BackendDTO dto) =>
VerificationException(switch (dto.error) {
'expired' => VerificationError.expired,
'invalid' => VerificationError.invalid,
'signup_attention_required' => VerificationError.needsAssistance,
_ => VerificationError.internal,
});
static FactorException factorFailure(int status, BackendDTO dto) =>
FactorException(switch (dto.error) {
'expired' => FactorError.expired,
@@ -41,6 +41,7 @@ enum AuthError {
invalidCredentials,
accountNotVerified,
emailInUse,
signupNeedsAssistance,
unavailable,
unexpected,
factorLocked,
@@ -9,7 +9,7 @@ enum VerificationStatus {
error,
}
enum VerificationError { expired, invalid, internal }
enum VerificationError { expired, invalid, internal, needsAssistance }
class VerificationException implements Exception {
const VerificationException(this.code);
@@ -22,8 +22,9 @@ class VerificationResult {
}
class VerificationState {
const VerificationState(this.status, {this.isSimulated = false});
const VerificationState(this.status, {this.isSimulated = false, this.error});
final VerificationStatus status;
final VerificationError? error;
final bool isSimulated;
bool get isLoading =>
status == VerificationStatus.sending ||
@@ -49,7 +49,9 @@ class VerificationProvider extends ChangeNotifier {
VerificationError.expired => VerificationStatus.expired,
VerificationError.invalid => VerificationStatus.invalid,
VerificationError.internal => VerificationStatus.error,
VerificationError.needsAssistance => VerificationStatus.error,
},
error: code,
);
}
notifyListeners();
@@ -63,6 +63,7 @@ class BackendAuthServices
String path,
Map<String, Object?> body, {
bool factor = false,
bool verification = false,
}) async {
try {
final uri = baseUri.replace(
@@ -81,6 +82,7 @@ class BackendAuthServices
.timeout(const Duration(seconds: 15));
final dto = BackendDTO.decode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) {
if (verification) throw BackendMapper.verificationFailure(dto);
throw factor
? BackendMapper.factorFailure(res.statusCode, dto)
: BackendMapper.authFailure(res.statusCode, dto);
@@ -90,7 +92,11 @@ class BackendAuthServices
rethrow;
} on FactorException {
rethrow;
} on VerificationException {
rethrow;
} catch (_) {
if (verification)
throw const VerificationException(VerificationError.internal);
if (factor) throw const FactorException(FactorError.unavailable);
throw const AuthException(AuthError.unavailable);
}
@@ -148,16 +154,17 @@ class BackendAuthServices
await _post('verification/send', {
'email': normalized,
'idempotencyKey': key,
}, factor: true);
}, verification: true);
_retryKeys.remove('activation:$normalized');
return VerificationMapper.fromDTO(
const VerificationDTO(simulated: false),
);
} on FactorException catch (e) {
if (e.code == FactorError.expired || e.code == FactorError.invalid) {
} on VerificationException catch (e) {
if (e.code == VerificationError.expired ||
e.code == VerificationError.invalid) {
_retryKeys.remove('activation:$normalized');
}
throw const VerificationException(VerificationError.internal);
rethrow;
} catch (_) {
throw const VerificationException(VerificationError.internal);
}
@@ -165,18 +172,8 @@ class BackendAuthServices
@override
Future<VerificationResult> confirm({required String token}) async {
try {
await _post('verification/confirm', {'token': token}, factor: true);
return VerificationMapper.fromDTO(
const VerificationDTO(simulated: false),
);
} on FactorException catch (e) {
throw VerificationException(switch (e.code) {
FactorError.expired => VerificationError.expired,
FactorError.invalid => VerificationError.invalid,
_ => VerificationError.internal,
});
}
await _post('verification/confirm', {'token': token}, verification: true);
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
}
@override
@@ -60,6 +60,7 @@ class InstrumentedAuthServices
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable,
AuthError.signupNeedsAssistance => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected,
AuthError.factorLocked ||
AuthError.factorCooldown => JourneyFailure.unavailable,
@@ -69,6 +70,7 @@ class InstrumentedAuthServices
VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal,
VerificationError.needsAssistance => JourneyFailure.unavailable,
},
FactorException(code: FactorError.expired) => JourneyFailure.expired,
FactorException() => JourneyFailure.unavailable,
@@ -10,6 +10,43 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) =>
http.Response(jsonEncode({'data': data}), status);
test(
'signup distinguishes support recovery and keeps the retry key',
() async {
final keys = <String>[];
var failed = true;
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
return failed
? response({'error': 'signup_attention_required'}, 503)
: response({'userId': 'user'});
}),
);
addTearDown(service.close);
const input = SignupRequest(
name: 'Ada',
email: 'ada@example.com',
password: 'password preserved',
locale: 'en',
);
await expectLater(
service.signup(input),
throwsA(
isA<AuthException>().having(
(error) => error.code,
'code',
AuthError.signupNeedsAssistance,
),
),
);
failed = false;
expect((await service.signup(input)).isSimulated, isFalse);
expect(keys[0], keys[1]);
},
);
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
final paths = <String>[];
final service = BackendAuthServices(
@@ -148,6 +185,34 @@ void main() {
),
);
});
test(
'verification recovery preserves assistance instead of requesting retry',
() async {
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient(
(_) async => response({'error': 'signup_attention_required'}, 503),
),
);
addTearDown(service.close);
final provider = VerificationProvider(service);
addTearDown(provider.dispose);
await provider.send(email: 'ada@example.com', locale: 'en');
expect(provider.state.status, VerificationStatus.error);
expect(provider.state.error, VerificationError.needsAssistance);
await expectLater(
service.confirm(token: 'old-token'),
throwsA(
isA<VerificationException>().having(
(e) => e.code,
'code',
VerificationError.needsAssistance,
),
),
);
},
);
test(
'resend preserves server challenge and retries the same delivery key',
() async {
@@ -0,0 +1,51 @@
import '../models/identity.dart';
import '../models/limits.dart';
import '../transport/backend.dart';
abstract final class VerificationMapper {
static Limits limits(PolicyDTO dto) => Limits(
tier: AccountTier.values.byName(dto.tier),
rows: const [],
simulated: false,
profileRef: dto.profileRef,
entries: dto.entries
.map(
(v) => PolicyLimit(
operation: v.operation,
period: v.period,
currency: v.currency,
cap: v.cap,
remaining: v.remaining,
used: v.used,
reserved: v.reserved,
),
)
.toList(),
);
static Identity fromDTO(VerificationDTO dto) => Identity(
upgradePending: dto.upgradePending,
status: IdentityStatus.values.byName(dto.status),
reason: dto.reason == null || dto.reason!.isEmpty
? null
: ReviewReason.values.byName(dto.reason!),
submissionId: dto.id,
profile: IdentityProfile(
fullName: dto.profile.fullName,
dateOfBirth: dto.profile.dateOfBirth,
residenceCountry: dto.profile.residenceCountry,
),
);
static ProfileDTO profile(IdentityProfile value) => ProfileDTO(
value.fullName.trim(),
value.dateOfBirth,
value.residenceCountry,
);
static KycException failure(int status) => KycException(switch (status) {
400 || 413 || 415 || 422 => KycError.format,
401 => KycError.sessionExpired,
403 => KycError.permission,
409 => KycError.conflict,
429 => KycError.rateLimited,
_ => KycError.unavailable,
});
}
@@ -36,16 +36,40 @@ class Identity {
this.reason,
this.submissionId,
this.simulated = false,
this.profile = const IdentityProfile(),
this.upgradePending = false,
});
final IdentityStatus status;
final ReviewReason? reason;
final String? submissionId;
final bool simulated;
final IdentityProfile profile;
final bool upgradePending;
bool get canSubmit =>
status == IdentityStatus.unverified ||
status == IdentityStatus.changesRequested;
}
/// Customer assertions awaiting human review, distinct from a verified identity.
class IdentityProfile {
const IdentityProfile({
this.fullName = '',
this.dateOfBirth = '',
this.residenceCountry = '',
});
final String fullName, dateOfBirth, residenceCountry;
bool get isComplete {
final date = DateTime.tryParse(dateOfBirth);
return fullName.trim().isNotEmpty &&
fullName.length <= 200 &&
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(dateOfBirth) &&
date != null &&
date.toIso8601String().startsWith(dateOfBirth) &&
date.isBefore(DateTime.now()) &&
RegExp(r'^[A-Z]{2}$').hasMatch(residenceCountry);
}
}
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
class Photo {
Photo(Uint8List bytes)
@@ -33,8 +33,28 @@ class Limits {
required this.tier,
required List<VisibleLimit> rows,
required this.simulated,
}) : rows = List.unmodifiable(rows);
this.profileRef,
List<PolicyLimit> entries = const [],
}) : rows = List.unmodifiable(rows),
entries = List.unmodifiable(entries);
final AccountTier tier;
final List<VisibleLimit> rows;
final bool simulated;
final String? profileRef;
final List<PolicyLimit> entries;
}
/// Authoritative policy amounts in major units. Keep decimal strings intact,
/// including currencies whose precision differs from fiat's common two digits.
class PolicyLimit {
const PolicyLimit({
required this.operation,
required this.period,
required this.currency,
required this.cap,
required this.remaining,
required this.used,
required this.reserved,
});
final String operation, period, currency, cap, remaining, used, reserved;
}
@@ -22,6 +22,7 @@ class IdentityProvider extends ChangeNotifier {
Identity? record;
Photo? passport;
Photo? selfie;
IdentityProfile profile = const IdentityProfile();
KycError? error;
PhotoKind? photoError;
bool busy = false;
@@ -31,11 +32,13 @@ class IdentityProvider extends ChangeNotifier {
int step = 0;
bool _disposed = false;
String? _key;
bool get hasDraft => passport != null || selfie != null;
bool _profileTouched = false;
bool get hasDraft => passport != null || selfie != null || _profileTouched;
bool get canSubmit =>
!busy &&
!stale &&
confirmed &&
profile.isComplete &&
passport != null &&
selfie != null &&
(record?.canSubmit ?? false);
@@ -57,6 +60,7 @@ class IdentityProvider extends ChangeNotifier {
final result = await service.fetch();
if (_disposed) return;
record = result;
if (!editing) profile = result.profile;
stale = false;
await onStatusChanged?.call();
if (_disposed) return;
@@ -144,6 +148,7 @@ class IdentityProvider extends ChangeNotifier {
void next() {
if (busy ||
(step == 0 && !profile.isComplete) ||
(step == 0 && passport == null) ||
(step == 1 && selfie == null))
return;
@@ -152,6 +157,15 @@ class IdentityProvider extends ChangeNotifier {
_notify();
}
void updateProfile(IdentityProfile value) {
if (busy || !editing) return;
profile = value;
_profileTouched = true;
confirmed = false;
_key = null;
_notify();
}
void confirm(bool value) {
if (!busy) {
confirmed = value;
@@ -172,6 +186,7 @@ class IdentityProvider extends ChangeNotifier {
final op = _begin(JourneyStep.kycSubmission);
try {
final result = await service.submit(
profile: profile,
passport: passport!,
selfie: selfie!,
idempotencyKey: _key!,
@@ -217,6 +232,8 @@ class IdentityProvider extends ChangeNotifier {
}
void _clear() {
_profileTouched = false;
profile = const IdentityProfile();
passport = null;
selfie = null;
confirmed = false;
@@ -0,0 +1,23 @@
import '../models/identity.dart';
import '../services/contracts.dart';
/// Offers optional verification once per customer in this app session. Identity
/// dependency failures must never prevent an already authenticated basic login.
class IdentityOnboardingProvider {
IdentityOnboardingProvider(this.service);
final IdentityService service;
final _offered = <String>{};
Future<bool> offer(String customer) async {
if (customer.isEmpty || service.isSimulated || !_offered.add(customer))
return false;
try {
final identity = await service.fetch().timeout(
const Duration(seconds: 3),
);
return identity.status == IdentityStatus.unverified ||
identity.status == IdentityStatus.changesRequested;
} catch (_) {
return false;
}
}
}
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../mappers/backend.dart';
import '../models/identity.dart';
import '../models/limits.dart';
import '../transport/backend.dart';
import 'contracts.dart';
import 'http/client.dart';
/// Credentials are read for each operation, so a replaced session cannot leave
/// documents attached to a previously authenticated customer.
class BackendIdentityService implements IdentityService {
BackendIdentityService({
required this.baseUri,
required this.accessToken,
http.Client? client,
}) : _client = client ?? createClient() {
if (!baseUri.hasAuthority ||
baseUri.hasQuery ||
baseUri.hasFragment ||
baseUri.userInfo.isNotEmpty ||
(baseUri.scheme != 'https' &&
!(baseUri.scheme == 'http' &&
['localhost', '127.0.0.1'].contains(baseUri.host))))
throw ArgumentError('Valid identity API URL required');
}
final Uri baseUri;
final String? Function() accessToken;
final http.Client _client;
@override
bool get isSimulated => false;
Uri get _uri => baseUri.replace(
path: '${baseUri.path.replaceFirst(RegExp(r"/$"), "")}/accounts/identity',
);
Future<String> _send(http.BaseRequest request) async {
final token = accessToken();
if (token == null) throw const KycException(KycError.sessionExpired);
request.followRedirects = false;
request.headers.addAll({
'Authorization': 'Bearer $token',
if (kIsWeb) 'X-CSRF-Protection': '1',
});
try {
final response = await _client
.send(request)
.then(http.Response.fromStream)
.timeout(const Duration(seconds: 60));
if (token != accessToken())
throw const KycException(KycError.sessionExpired);
if (response.statusCode < 200 || response.statusCode >= 300)
throw VerificationMapper.failure(response.statusCode);
return response.body;
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.unavailable);
}
}
Future<T> _mapped<T>(Future<String> response, T Function(String) map) async {
try {
return map(await response);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.unavailable);
}
}
@override
Future<Identity> fetch() => _mapped(
_send(http.Request('GET', _uri)),
(body) => VerificationMapper.fromDTO(VerificationDTO.decode(body)),
);
Future<Limits> fetchLimits() => _mapped(
_send(
http.Request(
'GET',
_uri.replace(
path: _uri.path.replaceFirst(RegExp(r'/identity$'), '/limits'),
),
),
),
(body) => VerificationMapper.limits(PolicyDTO.decode(body)),
);
@override
Future<Identity> submit({
required IdentityProfile profile,
required Photo passport,
required Photo selfie,
required String idempotencyKey,
}) async {
if (!profile.isComplete) throw const KycException(KycError.incomplete);
final request = http.MultipartRequest('POST', _uri)
..fields.addAll({
'profile': jsonEncode(VerificationMapper.profile(profile).toJson()),
'idempotencyKey': idempotencyKey,
})
..files.addAll([
http.MultipartFile.fromBytes(
'document',
passport.bytes,
filename: 'document',
),
http.MultipartFile.fromBytes(
'selfie',
selfie.bytes,
filename: 'selfie',
),
]);
return _mapped(
_send(request),
(body) => VerificationMapper.fromDTO(VerificationDTO.decode(body)),
);
}
void close() => _client.close();
}
class BackendLimitsService implements LimitsService {
const BackendLimitsService(this.identity);
final BackendIdentityService identity;
@override
Future<Limits> fetch() => identity.fetchLimits();
}
@@ -8,6 +8,7 @@ abstract interface class IdentityService {
/// Backend adapters must scope the key to the authenticated account and reject
/// stale/duplicate review transitions. Retrying the same key is idempotent.
Future<Identity> submit({
required IdentityProfile profile,
required Photo passport,
required Photo selfie,
required String idempotencyKey,
@@ -27,6 +27,7 @@ class DummyIdentityService implements IdentityService {
@override
Future<Identity> submit({
required IdentityProfile profile,
required Photo passport,
required Photo selfie,
required String idempotencyKey,
@@ -35,7 +36,7 @@ class DummyIdentityService implements IdentityService {
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
if (!IdentityMapper.fromDTO(_record).canSubmit)
throw const KycException(KycError.conflict);
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
if (!profile.isComplete || passport.bytes.isEmpty || selfie.bytes.isEmpty)
throw const KycException(KycError.incomplete);
_lastKey = idempotencyKey;
_record = IdentityDTO('pending', id: idempotencyKey);
@@ -0,0 +1,4 @@
import 'package:http/browser_client.dart';
import 'package:http/http.dart' as http;
http.Client createClient() => BrowserClient()..withCredentials = true;
@@ -0,0 +1 @@
export 'native.dart' if (dart.library.js_interop) 'browser.dart';
@@ -0,0 +1,3 @@
import 'package:http/http.dart' as http;
http.Client createClient() => http.Client();
@@ -0,0 +1,74 @@
import 'dart:convert';
class ProfileDTO {
const ProfileDTO(this.fullName, this.dateOfBirth, this.residenceCountry);
factory ProfileDTO.fromJson(Map<String, dynamic> data) => ProfileDTO(
data['fullName'] as String? ?? '',
data['dateOfBirth'] as String? ?? '',
data['residenceCountry'] as String? ?? '',
);
final String fullName, dateOfBirth, residenceCountry;
Map<String, String> toJson() => {
'fullName': fullName,
'dateOfBirth': dateOfBirth,
'residenceCountry': residenceCountry,
};
}
class VerificationDTO {
const VerificationDTO(
this.status,
this.reason,
this.id,
this.profile, {
this.upgradePending = false,
});
factory VerificationDTO.decode(String body) {
final envelope = jsonDecode(body) as Map<String, dynamic>;
if (envelope['status'] != 'success')
throw const FormatException('Unaccepted verification');
final data = envelope['data'] as Map<String, dynamic>;
return VerificationDTO(
data['status'] as String,
data['reason'] as String?,
data['submissionId'] as String?,
ProfileDTO.fromJson(data['profile'] as Map<String, dynamic>),
upgradePending: data['upgradePending'] as bool? ?? false,
);
}
final String status;
final String? reason, id;
final ProfileDTO profile;
final bool upgradePending;
}
class PolicyDTO {
const PolicyDTO(this.tier, this.profileRef, this.entries);
factory PolicyDTO.decode(String body) {
final envelope = jsonDecode(body) as Map<String, dynamic>;
if (envelope['status'] != 'success')
throw const FormatException('Unaccepted limits');
final data = envelope['data'] as Map<String, dynamic>;
return PolicyDTO(
data['tier'] as String,
data['profileRef'] as String,
(data['entries'] as List)
.map((v) => LimitDTO.fromJson(v as Map<String, dynamic>))
.toList(),
);
}
final String tier, profileRef;
final List<LimitDTO> entries;
}
class LimitDTO {
LimitDTO.fromJson(Map<String, dynamic> d)
: operation = d['operation'] as String,
period = d['period'] as String,
currency = d['currency'] as String,
cap = d['cap'] as String,
remaining = d['remaining'] as String,
used = d['used'] as String,
reserved = d['reserved'] as String;
final String operation, period, currency, cap, remaining, used, reserved;
}
@@ -1,7 +1,7 @@
import '../models/identity.dart';
import '../models/limits.dart';
// Transport contracts for the stub. Replace with generated backend DTOs later.
// Transport fixtures for simulated services. Production owns its backend DTOs.
class IdentityDTO {
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
final String status;
@@ -1,7 +1,9 @@
export 'src/models/identity.dart';
export 'src/models/limits.dart';
export 'src/providers/identity.dart';
export 'src/providers/onboarding.dart';
export 'src/providers/limits.dart';
export 'src/services/contracts.dart';
export 'src/services/backend.dart';
export 'src/services/dummy.dart';
export 'src/services/photos.dart';
@@ -6,6 +6,7 @@ environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
http: ^1.6.0
flutter:
sdk: flutter
image: ^4.9.0
@@ -0,0 +1,161 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
String response({String status = 'pending'}) => jsonEncode({
'status': 'success',
'data': {
'status': status,
'submissionId': 'submission',
'profile': {
'fullName': 'Legal Name',
'dateOfBirth': '1990-04-23',
'residenceCountry': 'PL',
},
},
});
void main() {
test('an approved review with an unfinished upgrade stays pending and cannot resubmit', () async {
final payload = jsonDecode(response()) as Map<String, dynamic>;
(payload['data'] as Map<String, dynamic>)['upgradePending'] = true;
final service = BackendIdentityService(
baseUri: Uri.parse('https://api.example.test'),
accessToken: () => 'token',
client: MockClient((_) async => http.Response(jsonEncode(payload), 200)),
);
addTearDown(service.close);
final result = await service.fetch();
expect(result.status, IdentityStatus.pending);
expect(result.upgradePending, true);
expect(result.canSubmit, false);
});
test(
'uploads profile plus both files, credentials scoped to current session',
() async {
final service = BackendIdentityService(
baseUri: Uri.parse('https://api.example.test/api/v1'),
accessToken: () => 'session-token',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/accounts/identity');
expect(request.headers['Authorization'], 'Bearer session-token');
expect(request.followRedirects, false);
expect(
request.headers['content-type'],
contains('multipart/form-data'),
);
expect(request.body, contains('name="document"'));
expect(request.body, contains('name="selfie"'));
expect(request.body, contains('"fullName":"Legal Name"'));
expect(request.body, contains('stable-key'));
expect(request.body, isNot(contains('customer_ref')));
return http.Response(response(), 200);
}),
);
addTearDown(service.close);
final result = await service.submit(
profile: const IdentityProfile(
fullName: 'Legal Name',
dateOfBirth: '1990-04-23',
residenceCountry: 'PL',
),
passport: Photo(Uint8List.fromList([1])),
selfie: Photo(Uint8List.fromList([2])),
idempotencyKey: 'stable-key',
);
expect(result.status, IdentityStatus.pending);
expect(result.simulated, false);
},
);
test('session switch rejects a late response', () async {
var token = 'first';
final pending = Completer<http.Response>();
final service = BackendIdentityService(
baseUri: Uri.parse('https://api.example.test'),
accessToken: () => token,
client: MockClient((_) => pending.future),
);
addTearDown(service.close);
final result = service.fetch();
token = 'second';
pending.complete(http.Response(response(), 200));
await expectLater(
result,
throwsA(
isA<KycException>().having(
(e) => e.code,
'code',
KycError.sessionExpired,
),
),
);
});
test(
'production limits preserve exact decimals and never use demo rows',
() async {
final service = BackendIdentityService(
baseUri: Uri.parse('https://api.example.test'),
accessToken: () => 'token',
client: MockClient((request) async {
expect(request.url.path, '/accounts/limits');
return http.Response(
jsonEncode({
'status': 'success',
'data': {
'tier': 'verified',
'profileRef': 'verified-policy',
'entries': [
{
'operation': 'Transfer',
'period': 'day',
'currency': 'BTC',
'cap': '0.12345678',
'remaining': '0.10000001',
'used': '0.02',
'reserved': '0.00345677',
},
],
},
}),
200,
);
}),
);
addTearDown(service.close);
final result = await BackendLimitsService(service).fetch();
expect(result.rows, isEmpty);
expect(result.simulated, false);
expect(result.entries.single.cap, '0.12345678');
expect(result.tier, AccountTier.verified);
},
);
test(
'unavailable, conflict and expired sessions retain typed recovery',
() async {
for (final pair in [
(503, KycError.unavailable),
(409, KycError.conflict),
(401, KycError.sessionExpired),
]) {
final service = BackendIdentityService(
baseUri: Uri.parse('https://api.example.test'),
accessToken: () => 'token',
client: MockClient(
(_) async => http.Response('private upstream details', pair.$1),
),
);
addTearDown(service.close);
await expectLater(
service.fetch(),
throwsA(isA<KycException>().having((e) => e.code, 'code', pair.$2)),
);
}
},
);
}
@@ -37,6 +37,13 @@ class BrokenAnalytics implements AnalyticsService {
Future<void> draft(IdentityProvider p) async {
await p.refresh();
p.start();
p.updateProfile(
const IdentityProfile(
fullName: "Example Person",
dateOfBirth: "1990-04-23",
residenceCountry: "PL",
),
);
await p.pick(PhotoKind.passport);
p.next();
await p.pick(PhotoKind.selfie);
@@ -170,6 +177,11 @@ void main() {
1,
);
final replay = await service.submit(
profile: const IdentityProfile(
fullName: "Example Person",
dateOfBirth: "1990-04-23",
residenceCountry: "PL",
),
passport: photos.result!,
selfie: photos.result!,
idempotencyKey: p.record!.submissionId!,
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
class RealIdentity extends DummyIdentityService {
IdentityStatus status = IdentityStatus.unverified;
bool unavailable = false;
@override
bool get isSimulated => false;
@override
Future<Identity> fetch() async {
if (unavailable) throw const KycException(KycError.unavailable);
return Identity(status: status);
}
}
void main() {
test('offers optional verification once to a basic customer and allows later corrections', () async {
final service = RealIdentity(),
provider = IdentityOnboardingProvider(RealIdentity());
expect(await provider.offer('customer'), true);
expect(await provider.offer('customer'), false);
final subject = IdentityOnboardingProvider(service);
service.status = IdentityStatus.pending;
expect(await subject.offer('pending'), false);
service.status = IdentityStatus.approved;
expect(await subject.offer('approved'), false);
service.status = IdentityStatus.changesRequested;
expect(await subject.offer('correction'), true);
});
test(
'a failed optional identity dependency never blocks basic login',
() async {
final service = RealIdentity()..unavailable = true;
expect(
await IdentityOnboardingProvider(service).offer('customer'),
false,
);
},
);
}