3 Commits
40 changed files with 1616 additions and 35 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 mounts, shared lifecycle and adding a future version. Customer profile endpoint
wiring remains follow-up work. wiring remains follow-up work.
Sendico integration is not wired. Its organization context belongs only to the Optional `api.sendico` connects sign-up to the public individual registry and
future public HTTP adapter. There is no unused public API snapshot in this module; immutable Facts intake. The adapter owns organization context and consumed wire
copy only the wire definitions consumed by an implemented adapter. contracts; local customers retain no platform tenancy. See
[configuration, recovery and measurement](../../docs/signup-facts.md).
## Validate ## Validate
+2
View File
@@ -3,10 +3,12 @@ package api
import ( import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config" mwa "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/notifications" "git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config" verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
) )
type Config struct { type Config struct {
Sendico *sendico.Config `yaml:"sendico"`
// nil preserves the legacy v1 mount; an explicit empty list installs no API. // nil preserves the legacy v1 mount; an explicit empty list installs no API.
Versions []Mount `yaml:"versions"` Versions []Mount `yaml:"versions"`
Notifications *notifications.Config `yaml:"notifications"` Notifications *notifications.Config `yaml:"notifications"`
@@ -2,16 +2,20 @@ package runtime_test
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os" "os"
"path/filepath"
"testing" "testing"
"time" "time"
"git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile" "git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime" sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model" sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
result = httptest.NewRecorder() result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil)) root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code) 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)
} }
+18
View File
@@ -8,11 +8,14 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/flow" "git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage" "git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers" "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/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery" "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store" factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -55,6 +58,18 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err return nil, nil, err
} }
deps.Auth.Sender = client 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.Auth.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("signup_facts")}
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db) records, err := factorstore.Open(logger, db)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -64,6 +79,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err 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") logger.Info("Product dependencies initialized")
return deps, cleanup, nil return deps, cleanup, nil
} }
+27 -3
View File
@@ -26,7 +26,13 @@ import (
const Cooldown = time.Minute const Cooldown = time.Minute
type SignupFacts interface {
Ensure(context.Context, *customer.Account) error
Ready(context.Context, bson.ObjectID) error
}
type Service struct { type Service struct {
Facts SignupFacts
Accounts account.DB Accounts account.DB
Customers accountservice.AccountService Customers accountservice.AccountService
Tokens verification.DB 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 { if err = s.Limit(ctx, ip, email); err != nil {
return nil, err 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) { if errors.Is(err, merrors.ErrDataConflict) {
existing, lookup := s.Accounts.GetByEmail(ctx, email) existing, lookup := s.Accounts.GetByEmail(ctx, email)
if lookup != nil { if lookup != nil {
return nil, lookup return nil, lookup
} }
// A retry after a queue failure may resume only with the original password. // 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") return nil, merrors.DataConflict("email already registered")
} }
a = existing a = existing
} else if err != nil { } else if err != nil {
return nil, err return nil, err
} }
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return nil, err
}
}
if raw != "" { if raw != "" {
u, _ := url.Parse(s.ActivationURL) u, _ := url.Parse(s.ActivationURL)
q := u.Query() q := u.Query()
@@ -132,14 +143,22 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
if a.Status != customer.AccountPendingVerification || a.IsArchived() { if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil return nil
} }
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return err
}
}
_, err = s.sendActivation(ctx, a, key) _, err = s.sendActivation(ctx, a, key)
return err 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) { func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 { if strings.TrimSpace(key) == "" || len(key) > 128 {
return time.Time{}, merrors.InvalidArgument("idempotency key required") 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 := 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())) req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
result, err := s.Tokens.CreateWithResult(ctx, req) 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() { if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil, merrors.Unauthorized("account cannot be activated") 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 nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
}) })
return err 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, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale) require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32)) 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) tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.sendActivation(context.Background(), a, "expired") _, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired) 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) return pr.loginRateLimited(details.RetryAfter)
} }
switch { 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): case errors.Is(err, verification.ErrTokenExpired):
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email") return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
case errors.Is(err, verification.ErrTokenAttemptsExceeded): case errors.Is(err, verification.ErrTokenAttemptsExceeded):
@@ -15,6 +15,7 @@ import (
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage" authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers" accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model" customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications" "git.sendico.io/tech/sxchnge/product/notifications"
session "git.sendico.io/tech/sxchnge/product/sessions/model" session "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
@@ -26,6 +27,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -34,6 +36,38 @@ type capturedMail struct {
fail bool 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 { func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
m.messages = append(m.messages, r) m.messages = append(m.messages, r)
if m.fail { if m.fail {
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
mail := &capturedMail{} 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"} 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()) 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. // Keep the real verification/customer stores and capture only session writes.
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}} sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
records, err := factorstore.Open(zap.NewNop(), factory) records, err := factorstore.Open(zap.NewNop(), factory)
@@ -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"} signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
w := post("signup", signup) 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, 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.Len(t, mail.messages, 1)
require.Equal(t, "auth.activation", mail.messages[0].Template) require.Equal(t, "auth.activation", mail.messages[0].Template)
require.Equal(t, "user@example.com", mail.messages[0].Email) 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") a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
require.NoError(t, err) require.NoError(t, err)
require.False(t, a.IsActive()) require.False(t, a.IsActive())
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
require.NoError(t, service.Facts.Ready(ctx, a.ID))
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"} login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
require.Equal(t, 403, post("login", login).Code) require.Equal(t, 403, post("login", login).Code)
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).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}) w = post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 202, w.Code, w.Body.String()) require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code) 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 uri_env: SXCHNGE_MONGO_URI
database: sxchnge database: sxchnge
api: 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. # Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
# versions: # versions:
# - version: v1 # - version: v1
+12 -1
View File
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
ctx context.Context, ctx context.Context,
acct *customermodel.Account, acct *customermodel.Account,
) (string, error) { ) (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 { if acct == nil {
return "", merrors.InvalidArgument("account is required") return "", merrors.InvalidArgument("account is required")
} }
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
if err := s.accountDB.Create(tx, saved); err != nil { if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err 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 err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
+3
View File
@@ -36,6 +36,9 @@ type AccountService interface {
ctx context.Context, ctx context.Context,
acct *customermodel.Account, acct *customermodel.Account,
) (verificationToken string, err error) ) (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( VerifyAccount(
ctx context.Context, ctx context.Context,
+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() }
+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)
}
}
}
+68
View File
@@ -0,0 +1,68 @@
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/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
}
return &mongoStore{records: repository.CreateRepository(source.Majority(d.Database), "signup_facts")}, 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
}
+81
View File
@@ -0,0 +1,81 @@
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"))
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)
}
@@ -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,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 | | 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/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 `/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` | | 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 operations. Tokens are stored before the RPC, so a process crash between those
steps requires user recovery; there is no product-side delivery outbox. 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 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 one transaction. A session-write failure leaves the code retryable; a lost HTTP
response after commit requires restarting password login. The adapter response after commit requires restarting password login. The adapter
+52
View File
@@ -828,3 +828,55 @@ existing mount requires reauthentication at its new cookie path. Notification
activation URLs remain configured frontend URLs. No live deployment, optional activation URLs remain configured frontend URLs. No live deployment, optional
security scans or merge were performed. The unrelated local notifications sender security scans or merge were performed. The unrelated local notifications sender
configuration remains uncommitted. See [API version installation](api-versions.md). 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.
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.
+176
View File
@@ -0,0 +1,176 @@
# 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.
## 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.
+1
View File
@@ -22,6 +22,7 @@ class AuthFeedback extends StatelessWidget {
AuthError.accountNotVerified => l.authVerifyEmail, AuthError.accountNotVerified => l.authVerifyEmail,
AuthError.invalidCredentials => l.authInvalidCredentials, AuthError.invalidCredentials => l.authInvalidCredentials,
AuthError.emailInUse => l.authEmailInUse, AuthError.emailInUse => l.authEmailInUse,
AuthError.signupNeedsAssistance => l.authSignupNeedsAssistance,
AuthError.unavailable => l.authUnavailable, AuthError.unavailable => l.authUnavailable,
AuthError.unexpected => l.authUnexpected, AuthError.unexpected => l.authUnexpected,
AuthError.factorLocked => l.factorLocked, AuthError.factorLocked => l.factorLocked,
+1
View File
@@ -1,5 +1,6 @@
{ {
"@@locale": "en", "@@locale": "en",
"authSignupNeedsAssistance": "We could not finish setting up your account. Please contact support.",
"appTitle": "SXCHNGE · Your next move", "appTitle": "SXCHNGE · Your next move",
"nextChapter": "YOUR NEXT CHAPTER", "nextChapter": "YOUR NEXT CHAPTER",
"loginHeading": "Hey, welcome\nback.", "loginHeading": "Hey, welcome\nback.",
+1
View File
@@ -1,5 +1,6 @@
{ {
"@@locale": "ru", "@@locale": "ru",
"authSignupNeedsAssistance": "Не удалось завершить создание аккаунта. Обратитесь в службу поддержки.",
"appTitle": "SXCHNGE · Ваш следующий шаг", "appTitle": "SXCHNGE · Ваш следующий шаг",
"nextChapter": "НОВАЯ ГЛАВА", "nextChapter": "НОВАЯ ГЛАВА",
"loginHeading": "Рады видеть\nвас снова.", "loginHeading": "Рады видеть\nвас снова.",
+12 -8
View File
@@ -122,8 +122,10 @@ class _VerificationPageState extends State<VerificationPage> {
(_provider.service as SimulationInfo).isSimulated) || (_provider.service as SimulationInfo).isSimulated) ||
state.isSimulated; state.isSimulated;
final sent = status == VerificationStatus.sent; final sent = status == VerificationStatus.sent;
final needsAssistance = state.error == VerificationError.needsAssistance;
final retryConfirmation = final retryConfirmation =
status == VerificationStatus.error && status == VerificationStatus.error &&
!needsAssistance &&
!_lastWasSend && !_lastWasSend &&
widget.token != null; widget.token != null;
final title = switch (status) { final title = switch (status) {
@@ -139,14 +141,16 @@ class _VerificationPageState extends State<VerificationPage> {
? l.verificationChecking ? l.verificationChecking
: l.verificationRequest, : l.verificationRequest,
}; };
final body = switch (status) { final body = needsAssistance
VerificationStatus.sent => l.verificationSent, ? l.authSignupNeedsAssistance
VerificationStatus.verified => l.verificationVerifiedBody, : switch (status) {
VerificationStatus.expired => l.verificationExpiredBody, VerificationStatus.sent => l.verificationSent,
VerificationStatus.invalid => l.verificationInvalidBody, VerificationStatus.verified => l.verificationVerifiedBody,
VerificationStatus.error => l.verificationInternalBody, VerificationStatus.expired => l.verificationExpiredBody,
_ => null, VerificationStatus.invalid => l.verificationInvalidBody,
}; VerificationStatus.error => l.verificationInternalBody,
_ => null,
};
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
+80
View File
@@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.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_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/main.dart';
@@ -41,6 +43,84 @@ class UnverifiedBackend extends BackendAuthServices {
} }
void main() { 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', ( testWidgets('backend mode gates workspace and recovers unverified login', (
tester, tester,
) async { ) async {
+31
View File
@@ -42,6 +42,37 @@ Future<void> openVerification(
} }
void main() { 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 { for (final scenario in {
'demo-valid': 'verified', 'demo-valid': 'verified',
'demo-expired': 'expired', 'demo-expired': 'expired',
@@ -2,6 +2,7 @@ import '../dto/backend.dart';
import '../models/auth.dart'; import '../models/auth.dart';
import '../models/session.dart'; import '../models/session.dart';
import '../models/two_factor.dart'; import '../models/two_factor.dart';
import '../models/verification.dart';
abstract final class BackendMapper { abstract final class BackendMapper {
static AuthResult signup(BackendDTO dto) { static AuthResult signup(BackendDTO dto) {
@@ -33,11 +34,19 @@ abstract final class BackendMapper {
static AuthException authFailure(int status, BackendDTO dto) => static AuthException authFailure(int status, BackendDTO dto) =>
AuthException(switch (dto.error) { AuthException(switch (dto.error) {
'email_in_use' => AuthError.emailInUse, 'email_in_use' => AuthError.emailInUse,
'signup_attention_required' => AuthError.signupNeedsAssistance,
'account_not_verified' => AuthError.accountNotVerified, 'account_not_verified' => AuthError.accountNotVerified,
_ when status == 401 => AuthError.invalidCredentials, _ when status == 401 => AuthError.invalidCredentials,
_ when status == 429 => AuthError.factorCooldown, _ when status == 429 => AuthError.factorCooldown,
_ => AuthError.unavailable, _ => 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) => static FactorException factorFailure(int status, BackendDTO dto) =>
FactorException(switch (dto.error) { FactorException(switch (dto.error) {
'expired' => FactorError.expired, 'expired' => FactorError.expired,
@@ -41,6 +41,7 @@ enum AuthError {
invalidCredentials, invalidCredentials,
accountNotVerified, accountNotVerified,
emailInUse, emailInUse,
signupNeedsAssistance,
unavailable, unavailable,
unexpected, unexpected,
factorLocked, factorLocked,
@@ -9,7 +9,7 @@ enum VerificationStatus {
error, error,
} }
enum VerificationError { expired, invalid, internal } enum VerificationError { expired, invalid, internal, needsAssistance }
class VerificationException implements Exception { class VerificationException implements Exception {
const VerificationException(this.code); const VerificationException(this.code);
@@ -22,8 +22,9 @@ class VerificationResult {
} }
class VerificationState { class VerificationState {
const VerificationState(this.status, {this.isSimulated = false}); const VerificationState(this.status, {this.isSimulated = false, this.error});
final VerificationStatus status; final VerificationStatus status;
final VerificationError? error;
final bool isSimulated; final bool isSimulated;
bool get isLoading => bool get isLoading =>
status == VerificationStatus.sending || status == VerificationStatus.sending ||
@@ -49,7 +49,9 @@ class VerificationProvider extends ChangeNotifier {
VerificationError.expired => VerificationStatus.expired, VerificationError.expired => VerificationStatus.expired,
VerificationError.invalid => VerificationStatus.invalid, VerificationError.invalid => VerificationStatus.invalid,
VerificationError.internal => VerificationStatus.error, VerificationError.internal => VerificationStatus.error,
VerificationError.needsAssistance => VerificationStatus.error,
}, },
error: code,
); );
} }
notifyListeners(); notifyListeners();
@@ -63,6 +63,7 @@ class BackendAuthServices
String path, String path,
Map<String, Object?> body, { Map<String, Object?> body, {
bool factor = false, bool factor = false,
bool verification = false,
}) async { }) async {
try { try {
final uri = baseUri.replace( final uri = baseUri.replace(
@@ -81,6 +82,7 @@ class BackendAuthServices
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
final dto = BackendDTO.decode(res.body); final dto = BackendDTO.decode(res.body);
if (res.statusCode < 200 || res.statusCode >= 300) { if (res.statusCode < 200 || res.statusCode >= 300) {
if (verification) throw BackendMapper.verificationFailure(dto);
throw factor throw factor
? BackendMapper.factorFailure(res.statusCode, dto) ? BackendMapper.factorFailure(res.statusCode, dto)
: BackendMapper.authFailure(res.statusCode, dto); : BackendMapper.authFailure(res.statusCode, dto);
@@ -90,7 +92,11 @@ class BackendAuthServices
rethrow; rethrow;
} on FactorException { } on FactorException {
rethrow; rethrow;
} on VerificationException {
rethrow;
} catch (_) { } catch (_) {
if (verification)
throw const VerificationException(VerificationError.internal);
if (factor) throw const FactorException(FactorError.unavailable); if (factor) throw const FactorException(FactorError.unavailable);
throw const AuthException(AuthError.unavailable); throw const AuthException(AuthError.unavailable);
} }
@@ -148,16 +154,17 @@ class BackendAuthServices
await _post('verification/send', { await _post('verification/send', {
'email': normalized, 'email': normalized,
'idempotencyKey': key, 'idempotencyKey': key,
}, factor: true); }, verification: true);
_retryKeys.remove('activation:$normalized'); _retryKeys.remove('activation:$normalized');
return VerificationMapper.fromDTO( return VerificationMapper.fromDTO(
const VerificationDTO(simulated: false), const VerificationDTO(simulated: false),
); );
} on FactorException catch (e) { } on VerificationException catch (e) {
if (e.code == FactorError.expired || e.code == FactorError.invalid) { if (e.code == VerificationError.expired ||
e.code == VerificationError.invalid) {
_retryKeys.remove('activation:$normalized'); _retryKeys.remove('activation:$normalized');
} }
throw const VerificationException(VerificationError.internal); rethrow;
} catch (_) { } catch (_) {
throw const VerificationException(VerificationError.internal); throw const VerificationException(VerificationError.internal);
} }
@@ -165,18 +172,8 @@ class BackendAuthServices
@override @override
Future<VerificationResult> confirm({required String token}) async { Future<VerificationResult> confirm({required String token}) async {
try { await _post('verification/confirm', {'token': token}, verification: true);
await _post('verification/confirm', {'token': token}, factor: true); return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
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,
});
}
} }
@override @override
@@ -60,6 +60,7 @@ class InstrumentedAuthServices
AuthError.invalidCredentials => JourneyFailure.invalidCredentials, AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
AuthError.emailInUse => JourneyFailure.emailInUse, AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable, AuthError.unavailable => JourneyFailure.unavailable,
AuthError.signupNeedsAssistance => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected, AuthError.unexpected => JourneyFailure.unexpected,
AuthError.factorLocked || AuthError.factorLocked ||
AuthError.factorCooldown => JourneyFailure.unavailable, AuthError.factorCooldown => JourneyFailure.unavailable,
@@ -69,6 +70,7 @@ class InstrumentedAuthServices
VerificationError.expired => JourneyFailure.expired, VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid, VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal, VerificationError.internal => JourneyFailure.internal,
VerificationError.needsAssistance => JourneyFailure.unavailable,
}, },
FactorException(code: FactorError.expired) => JourneyFailure.expired, FactorException(code: FactorError.expired) => JourneyFailure.expired,
FactorException() => JourneyFailure.unavailable, FactorException() => JourneyFailure.unavailable,
@@ -10,6 +10,43 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() { void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) => http.Response response(Map<String, Object?> data, [int status = 202]) =>
http.Response(jsonEncode({'data': data}), status); 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 { test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
final paths = <String>[]; final paths = <String>[];
final service = BackendAuthServices( 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( test(
'resend preserves server challenge and retries the same delivery key', 'resend preserves server challenge and retries the same delivery key',
() async { () async {