Submit customer identity proofs and show reviewed limits

This commit is contained in:
Stephan D committed 2026-10-15 02:22:28 +02:00
1 parent 7bf8b8087a
commit 1354551ae8
50 files changed
+1777 -67

No files matched your search

@@ -0,0 +1,184 @@
package sendico
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type profileDTO struct {
FullName string `json:"full_name"`
DateOfBirth string `json:"date_of_birth"`
ResidenceCountry string `json:"residence_country"`
Email string `json:"email"`
CustomerRef string `json:"external_customer_ref"`
}
type verificationDTO struct {
SubjectRef string `json:"subject_ref"`
SubmissionRef string `json:"submission_ref"`
Status string `json:"status"`
Reason string `json:"reason"`
Profile profileDTO `json:"profile"`
UpgradePending bool `json:"upgrade_pending"`
}
type policyDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profile_ref"`
Entries []limitDTO `json:"entries"`
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
if !validRef(subject) {
return merrors.InvalidArgument("invalid bound subject")
}
token, err := c.token()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
}
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// Uploads need a longer bound than small sign-up receipts. The base client is
// copied so concurrent sign-up operations keep their own four-second timeout.
client := *c.http
client.Timeout = 60 * time.Second
res, err := client.Do(req)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
}
defer func() { _ = res.Body.Close() }()
switch res.StatusCode {
case http.StatusOK:
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
return merrors.InvalidArgument("identity profile or photos were not accepted")
case http.StatusConflict:
return merrors.DataConflict("identity submission changed; refresh")
case http.StatusForbidden:
return merrors.PermissionDenied("identity resource unavailable")
case http.StatusTooManyRequests:
retry := 60
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
retry = seconds
}
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
default:
return merrors.ServiceUnavailable("identity provider unavailable")
}
var envelope struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
if err := decoder.Decode(&envelope); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
}
var extra any
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
return merrors.ServiceUnavailable("invalid identity response")
}
if err := json.Unmarshal(envelope.Data, dst); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
}
return nil
}
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
if dto.SubjectRef != subject {
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
}
switch dto.Status {
case "unverified", "pending", "approved", "changesRequested", "denied":
default:
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
}
if dto.Status == "approved" && dto.UpgradePending {
dto.Status = "pending"
}
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
}
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
if dto.Status != "unverified" {
if err := profile.Validate(); err != nil {
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
}
}
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
}
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
var dto verificationDTO
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
return identity.Verification{}, err
}
return mapVerification(dto, subject)
}
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
if err != nil {
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
}
if err = writer.WriteField("profile", string(profile)); err != nil {
return identity.Verification{}, err
}
if err = writer.WriteField("source_submission_ref", key); err != nil {
return identity.Verification{}, err
}
for i, data := range [][]byte{document, selfie} {
name := "document"
if i == 1 {
name = "selfie"
}
part, writeErr := writer.CreateFormFile(name, name)
if writeErr != nil {
return identity.Verification{}, writeErr
}
if _, writeErr = part.Write(data); writeErr != nil {
return identity.Verification{}, writeErr
}
}
if err = writer.Close(); err != nil {
return identity.Verification{}, err
}
var dto verificationDTO
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
return identity.Verification{}, err
}
return mapVerification(dto, subject)
}
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
var dto policyDTO
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
return identity.Limits{}, err
}
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
}
entries := make([]identity.Limit, 0, len(dto.Entries))
for _, v := range dto.Entries {
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
}
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
}
@@ -0,0 +1,79 @@
package sendico
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
)
func TestProofWireContractAndLimitsProjection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Contains(t, r.Header.Get("Authorization"), "Bearer ")
expected := "/api/v1/identity_facts/" + org + "/proofs/" + subject
if r.URL.Path == expected+"/limits" {
require.Equal(t, http.MethodGet, r.Method)
_, _ = io.WriteString(w, `{"status":"success","data":{"tier":"verified","profile_ref":"profile_personal_verified","entries":[{"operation":"Transfer","period":"day","currency":"BTC","cap":"0.12345678","remaining":"0.10000001","used":"0.02","reserved":"0.00345677"}]}}`)
return
}
require.Equal(t, expected, r.URL.Path)
if r.Method == http.MethodPost {
reader, err := r.MultipartReader()
require.NoError(t, err)
fields := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
require.NoError(t, err)
data, err := io.ReadAll(part)
require.NoError(t, err)
fields[part.FormName()] = string(data)
}
require.Equal(t, "document bytes", fields["document"])
require.Equal(t, "selfie bytes", fields["selfie"])
require.Equal(t, "stable-source", fields["source_submission_ref"])
var profile map[string]string
require.NoError(t, json.Unmarshal([]byte(fields["profile"]), &profile))
require.Equal(t, "Legal Name", profile["full_name"])
require.Equal(t, "local-customer", profile["external_customer_ref"])
require.Equal(t, "verified@example.test", profile["email"])
}
_, _ = io.WriteString(w, `{"status":"success","data":{"subject_ref":"`+subject+`","submission_ref":"`+submission+`","status":"approved","upgrade_pending":true,"profile":{"full_name":"Legal Name","date_of_birth":"1990-04-23","residence_country":"PL"}}}`)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
result, err := client.SubmitVerification(t.Context(), subject, "stable-source", identity.Profile{FullName: "Legal Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}, identity.Facts{Email: "verified@example.test", CustomerRef: "local-customer"}, []byte("document bytes"), []byte("selfie bytes"))
require.NoError(t, err)
require.Equal(t, "pending", result.Status)
require.True(t, result.UpgradePending)
policy, err := client.Limits(t.Context(), subject)
require.NoError(t, err)
require.Equal(t, "0.12345678", policy.Entries[0].Cap)
require.Equal(t, "verified", policy.Tier)
require.Equal(t, 4*time.Second, client.http.Timeout)
}
func TestProofRateLimitPreservesTypedRecoveryWithoutUpstreamBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "120")
w.WriteHeader(429)
_, _ = io.WriteString(w, "private identity assertions")
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
_, err = client.Verification(t.Context(), subject)
require.Error(t, err)
require.NotContains(t, err.Error(), "private identity assertions")
throttle, ok := merrors.ThrottleDetailsFrom(err)
require.True(t, ok)
require.Equal(t, 120*time.Second, throttle.RetryAfter)
}