Connect sign-up to Sendico Facts intake (#46)

This commit is contained in:
Stephan D committed 2026-10-15 00:55:19 +02:00
1 parent 30f0455122
commit a584c8189a
40 files changed
+1616 -35

No files matched your search

+4 -3
View File
@@ -33,9 +33,10 @@ API versions are installed explicitly by the product composition. See
mounts, shared lifecycle and adding a future version. Customer profile endpoint
wiring remains follow-up work.
Sendico integration is not wired. Its organization context belongs only to the
future public HTTP adapter. There is no unused public API snapshot in this module;
copy only the wire definitions consumed by an implemented adapter.
Optional `api.sendico` connects sign-up to the public individual registry and
immutable Facts intake. The adapter owns organization context and consumed wire
contracts; local customers retain no platform tenancy. See
[configuration, recovery and measurement](../../docs/signup-facts.md).
## Validate
+2
View File
@@ -3,10 +3,12 @@ package api
import (
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
)
type Config struct {
Sendico *sendico.Config `yaml:"sendico"`
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
Versions []Mount `yaml:"versions"`
Notifications *notifications.Config `yaml:"notifications"`
@@ -2,16 +2,20 @@ package runtime_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/profile"
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/shared/database"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-chi/chi/v5"
@@ -61,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
// Enabling the adapter validates credentials, composes once, and does not
// register any provider resource until a customer starts sign-up.
const source = "507f1f77bcf86cd799439011"
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
require.NoError(t, err)
file := filepath.Join(t.TempDir(), "credential")
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.NoError(t, err)
require.NotNil(t, dependencies.Auth.Facts)
require.NoError(t, finish(ctx))
example.API.Sendico.TokenFile = ""
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
example.API.Notifications = nil
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
require.Error(t, err)
}
+18
View File
@@ -8,11 +8,14 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
@@ -55,6 +58,18 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
deps.Auth.Sender = client
if config.Sendico != nil {
provider, err := sendico.Open(*config.Sendico)
if err != nil {
return nil, nil, err
}
bindings, err := identity.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.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)
if err != nil {
return nil, nil, err
@@ -64,6 +79,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
}
if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
}
logger.Info("Product dependencies initialized")
return deps, cleanup, nil
}
+27 -3
View File
@@ -26,7 +26,13 @@ import (
const Cooldown = time.Minute
type SignupFacts interface {
Ensure(context.Context, *customer.Account) error
Ready(context.Context, bson.ObjectID) error
}
type Service struct {
Facts SignupFacts
Accounts account.DB
Customers accountservice.AccountService
Tokens verification.DB
@@ -85,20 +91,25 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
if err = s.Limit(ctx, ip, email); err != nil {
return nil, err
}
raw, err := s.Customers.CreateAccount(ctx, a)
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookup := s.Accounts.GetByEmail(ctx, email)
if lookup != nil {
return nil, lookup
}
// A retry after a queue failure may resume only with the original password.
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
return nil, merrors.DataConflict("email already registered")
}
a = existing
} else if err != nil {
return nil, err
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return nil, err
}
}
if raw != "" {
u, _ := url.Parse(s.ActivationURL)
q := u.Query()
@@ -132,14 +143,22 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil
}
if s.Facts != nil {
if err := s.Facts.Ensure(ctx, a); err != nil {
return err
}
}
_, err = s.sendActivation(ctx, a, key)
return err
}
func (s *Service) activationKey(email, key string) string {
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
}
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
if strings.TrimSpace(key) == "" || len(key) > 128 {
return time.Time{}, merrors.InvalidArgument("idempotency key required")
}
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
secretKey := s.activationKey(a.Login, key)
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
result, err := s.Tokens.CreateWithResult(ctx, req)
@@ -176,6 +195,11 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
return nil, merrors.Unauthorized("account cannot be activated")
}
if s.Facts != nil {
if err := s.Facts.Ready(tx, a.ID); err != nil {
return nil, err
}
}
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
})
return err
+1 -1
View File
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
require.Equal(t, sender.messages[0], sender.messages[1])
require.Equal(t, "ru", sender.messages[0].Locale)
service.Secret = []byte(strings.Repeat("b", 32))
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
_, err = service.sendActivation(context.Background(), a, "expired")
require.ErrorIs(t, err, verification.ErrTokenExpired)
@@ -0,0 +1,29 @@
package public
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
response := httptest.NewRecorder()
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
require.Equal(t, http.StatusServiceUnavailable, response.Code)
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
var envelope struct {
Data struct {
Error string `json:"error"`
}
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
require.Equal(t, "signup_attention_required", envelope.Data.Error)
require.NotContains(t, response.Body.String(), "private upstream detail")
}
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
return pr.loginRateLimited(details.RetryAfter)
}
switch {
case errors.Is(err, merrors.ErrFailedPrecondition):
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
case errors.Is(err, verification.ErrTokenExpired):
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
@@ -15,6 +15,7 @@ import (
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
session "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
@@ -26,6 +27,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -34,6 +36,38 @@ type capturedMail struct {
fail bool
}
type signupProvider struct {
registrations, submissions int
fail bool
last identity.Facts
}
func (*signupProvider) Namespace() string { return "test-provider" }
func (p *signupProvider) Register(context.Context, string) (string, error) {
p.registrations++
return "subject", nil
}
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
p.submissions++
p.last = facts
if p.fail {
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
}
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
}
type activationGuard struct {
flow.SignupFacts
block bool
}
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
if g.block {
return merrors.ServiceUnavailable("intake receipt unavailable")
}
return g.SignupFacts.Ready(ctx, id)
}
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
m.messages = append(m.messages, r)
if m.fail {
@@ -64,6 +98,10 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
mail := &capturedMail{}
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
require.NoError(t, service.Validate())
bindings, err := identity.OpenStore(factory)
require.NoError(t, err)
provider := &signupProvider{fail: true}
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
// Keep the real verification/customer stores and capture only session writes.
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
records, err := factorstore.Open(zap.NewNop(), factory)
@@ -83,7 +121,22 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
}
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
w := post("signup", signup)
require.Equal(t, 503, w.Code, w.Body.String())
require.Empty(t, mail.messages)
require.Equal(t, 1, provider.registrations)
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
require.Empty(t, mail.messages)
// Only the original password can resume a pending local customer.
signup["password"] = "different password"
require.Equal(t, 400, post("signup", signup).Code)
signup["password"] = " correct password "
signup["name"] = "Changed retry name"
provider.fail = false
w = post("signup", signup)
require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 1, provider.registrations)
require.Equal(t, "Customer", provider.last.FullName)
require.Equal(t, "user@example.com", provider.last.Email)
require.Len(t, mail.messages, 1)
require.Equal(t, "auth.activation", mail.messages[0].Template)
require.Equal(t, "user@example.com", mail.messages[0].Email)
@@ -95,9 +148,18 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
require.NoError(t, err)
require.False(t, a.IsActive())
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
require.NoError(t, service.Facts.Ready(ctx, a.ID))
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
require.Equal(t, 403, post("login", login).Code)
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err)
require.False(t, a.IsActive())
guard.block = false
w = post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
+7
View File
@@ -9,6 +9,13 @@ database:
uri_env: SXCHNGE_MONGO_URI
database: sxchnge
api:
# Optional public Sendico facts integration. Enable with provisioned values;
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
# sendico:
# api_url: https://sendico.example/api/v1
# organization_ref: <sxchnge-organization-object-id>
# source_account_ref: <integration-account-object-id>
# token_file: /run/sxchnge/sendico-token
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
# versions:
# - version: v1
+12 -1
View File
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
ctx context.Context,
acct *customermodel.Account,
) (string, error) {
return s.createAccount(ctx, acct, "")
}
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
if len(key) < 32 || len(key) > 128 {
return "", merrors.InvalidArgument("server activation key required")
}
return s.createAccount(ctx, acct, key)
}
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
+3
View File
@@ -36,6 +36,9 @@ type AccountService interface {
ctx context.Context,
acct *customermodel.Account,
) (verificationToken string, err error)
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
// service replay an unsent initial token. The key must be server-secret-derived.
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
VerifyAccount(
ctx context.Context,
+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
}