33 Commits
Author SHA1 Message Date
Stephan D 4b22f6ce12 Record empty-account message delivery PR (#66) 2026-10-23 12:13:40 +02:00
Stephan D b5c4772168 Explain missing currency account setup (#66) 2026-10-23 12:13:10 +02:00
tech e84d2d1ad3 Merge pull request 'Restore signup, email delivery and local recovery' (#59) from identity-facts-57 into main
Reviewed-on: #59
2026-10-20 18:00:23 +00:00
tech fa7b75d1b8 Merge pull request 'Complete verification links and prefill login with the verified email' (#61) from email-verification-60 into main
Reviewed-on: #61
2026-10-20 18:00:05 +00:00
Stephan D a587cbbc24 Record verification validation and local deployment (#60) 2026-10-19 14:54:53 +02:00
Stephan D 5f3b443e7b Complete verification links and preserve verified login email (#60) 2026-10-19 14:34:00 +02:00
tech 30f06c2f8f Merge pull request 'Enable Safari credential autofill for signup and login' (#58) from auth-56 into main 2026-10-19 11:02:42 +00:00
Stephan D 4c4b639321 Record private local signup request outcomes (#57) 2026-10-19 12:41:26 +02:00
Stephan D 13cc9c302e Recover local Sendico session after BFF network restarts (#57) 2026-10-19 12:34:37 +02:00
Stephan D 021bd04324 Reap orphaned frontend HTTPS probe processes (#57) 2026-10-18 13:54:30 +02:00
Stephan D 4844fb5bc9 Record verified local API recovery after Docker restart (#57) 2026-10-17 23:41:41 +02:00
Stephan D 3aeea9aa77 Record healthy SMTP deployment and passing validation (#57) 2026-10-17 23:38:10 +02:00
Stephan D 69fba8df70 Record SMTP deployment blocker and remaining restart (#57) 2026-10-17 21:17:39 +02:00
Stephan D b36eb72c23 Use Sendico SMTP mailbox for local notifications (#57) 2026-10-17 20:51:28 +02:00
Stephan D 3e4e496037 Log redacted email provider errors and diagnose sender configuration (#57) 2026-10-17 11:39:05 +02:00
Stephan D c1d28e705e Record signup fix delivery and recovery limitation 2026-10-17 07:30:50 +02:00
Stephan D abca15f047 Fix signup registration receipts and activation worker lifetime (#57) 2026-10-17 07:30:33 +02:00
Stephan D c6ab4680fd Record autofill validation and delivery link 2026-10-17 07:24:55 +02:00
Stephan D 86b6aafe7d Enable credential autofill for signup and login (#56) 2026-10-17 07:24:27 +02:00
Stephan D bd675357bb Record payout destination delivery and validation links 2026-10-16 12:02:01 +02:00
Stephan D 24af794112 Add saved payout destination UX and service contracts (#54) 2026-10-16 12:01:33 +02:00
Stephan D a5689876ff Connect local SXCHNGE to the Sendico stand 2026-10-16 10:19:59 +02:00
Stephan D 34d8ef18d1 Record quotation and payment integration delivery links 2026-10-16 09:58:12 +02:00
Stephan D 7a6510d1a8 Connect customer quotations and payment execution to Sendico (#50) 2026-10-16 09:57:28 +02:00
Stephan D 5d67c25c8e Document reconciled Sendico review dependencies 2026-10-15 09:43:26 +02:00
Stephan D b303285b8b Record identity verification delivery links 2026-10-15 02:23:24 +02:00
Stephan D 1354551ae8 Submit customer identity proofs and show reviewed limits 2026-10-15 02:22:28 +02:00
Stephan D 7bf8b8087a Record Facts intake delivery and validation (#46) 2026-10-15 00:55:33 +02:00
Stephan D a584c8189a Connect sign-up to Sendico Facts intake (#46) 2026-10-15 00:55:19 +02:00
tech 30f0455122 Merge pull request 'Run local infrastructure and product images with Vault-managed secrets' (#43) from dev-env-42 into main
Reviewed-on: #43
2026-09-13 08:35:01 +00:00
tech 9f9525df56 Merge pull request 'Install product API versions independently with shared services' (#45) from product-api-44 into dev-env-42
Reviewed-on: #45
2026-09-13 08:34:33 +00:00
Stephan D 259f84e0a1 Record API version delivery and validation 2026-09-13 10:27:08 +02:00
Stephan D d04c8b12ab Make product API versions independently installable 2026-09-13 10:26:06 +02:00
259 changed files with 13937 additions and 788 deletions

No files matched your search

+7
View File
@@ -54,6 +54,9 @@ package-notifications-interface: generate-notifications
# Local product stack. Releases are resolved afresh, then pinned by digest.
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
ifneq ($(wildcard .local/dev/sendico/settings.env),)
COMPOSE += -f infra/local/sendico/compose.yml
endif
.PHONY: build up down logs ps test-dev
build:
sh infra/local/tools.sh build
@@ -92,3 +95,7 @@ email:
test-dev-tooling:
sh infra/local/tools.sh test
.PHONY: sendico
sendico:
sh infra/local/tools.sh sendico
+11 -9
View File
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
localization, email verification and recovery screens, and URL-based navigation
with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real
account or session is created, no email is delivered, and no analytics data is
transmitted by those app services. The backend now contains locally owned copies
of Sendico’s BFF runtime, auth/session stack and account service. The app is not
connected to that backend or to Sendico yet.
The default preview uses dummy services. Configuring the existing backend URL
and client ID selects authenticated BFF adapters for auth, identity, limits and
wallet quotations/payment execution. Optional Sendico configuration and dedicated
customer financial resources are required for real operations; analytics still
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
## Repository structure
@@ -256,7 +256,9 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The copied runtime installs local authentication transport and initializes its
account service. Signup/profile endpoints, verification delivery/completion,
Flutter HTTP adapters and live Sendico operations remain to be wired. See the
[backend run guide](backend/README.md) for local MongoDB configuration.
The runtime installs local authentication, verification delivery, identity and
wallet transports. Public Sendico adapters support independent customer facts,
reviewed identity/limits and quotation/execution through the BFF. Deployment and
financial-resource provisioning are separate; backend account-language saving
and additional funding/recipient onboarding remain follow-up work. See the
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
+6
View File
@@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's
builders, dynamic-template dependency, notification events and unused producer
were removed. Resend is a new driver using the same contract.
SMTP is an owned adapter using Go's standard `net/smtp`, verified `crypto/tls`
connections and standard MIME encoders. Its implicit-TLS connection configuration
matches Sendico's existing mail client, without importing its modules or copying
its keepalive/reconnect behavior. Local mailbox credentials are provisioned into
SXCHNGE Vault explicitly; the application does not read Sendico files or secrets.
Shared messaging was selected from that same Sendico revision's
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
+54 -3
View File
@@ -12,12 +12,35 @@ pending-work state atomically in one document through the shared DB wrapper.
The outbox dispatcher publishes opaque IDs using shared messaging's durable
transport, adapted from Sendico. A durable NATS worker claims the notification
with an atomic conditional patch and saves the outcome before acknowledging.
`DriverConfig` selects the email driver: `sendgrid` or `resend`.
`DriverConfig` selects the email driver: `sendgrid`, `resend` or `smtp`.
Email adapters only map rendered email models to provider wire payloads. SendGrid
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
subject, text and HTML, bounded timeouts and safe error classification.
The owned SMTP adapter uses authenticated implicit TLS (normally port 465),
verified server certificates and context-bounded connections. It sends MIME
multipart/alternative with quoted-printable UTF-8 text/HTML and encoded subjects.
It uses Go's standard SMTP client and does not import Sendico modules or share a
mutable connection between workers. Configure it with:
```yaml
email:
driver: smtp
settings:
host: mail.example.com
port: 465
username: notifications@example.com
password_env: SMTP_PASSWORD
sender_address: notifications@example.com
sender_name: SXCHNGE
network_timeout: 10
```
`network_timeout` must be 1–20 seconds; the worker's expiry/shutdown context also
bounds every operation. Only the password belongs in Vault. There is no plaintext
fallback or certificate-verification bypass.
## Run
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
@@ -75,13 +98,41 @@ remaining status and request fingerprints expire through a seven-day TTL.
- Resend receives the notification ID as its provider idempotency key. Its window
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
delivery is not promised.
- SMTP acceptance requires the final DATA 250 reply. A later QUIT failure cannot
undo acceptance. Explicit SMTP 4xx replies become retryable
`provider_temporarily_unavailable`; 5xx replies are terminal (authentication
failures retain `provider_authentication_failed`). Transport/TLS errors and a
lost final DATA reply become UNKNOWN. No immediate reconnect/resend occurs.
A stable Message-ID is included for correlation, not an exactly-once promise.
- Changing the configured driver fails queued jobs pinned to the old driver with
`driver_changed`. Drain queued jobs before changing sender credentials/config.
- Callers authorize recipients and preferences. No direct customer-database access,
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
Structured logs report notification IDs, driver, state and attempt count; message
content, template values, addresses and provider response bodies are excluded.
Structured logs report notification IDs, driver, state and attempt count. Provider
HTTP outcomes include `provider_status`; failures also include `provider_error`
and `provider_message` when available. The adapter reads at most 16 KiB plus an
overflow byte and extracts only the Resend `name`/`message` or first SendGrid
`errors[].message`. Diagnostic messages are bounded to 512 Unicode characters
plus an ellipsis. Credentials, idempotency keys, outgoing request string values,
email addresses, URLs, long token-like strings and numeric verification/account
values embedded in the request are redacted; controls and formatting characters
are normalized. Malformed, incomplete and oversized error
responses produce a diagnostic fallback instead of raw content. Successful
response bodies are not logged. Diagnostic fields are transient: they are neither
persisted in notifications nor exposed in gRPC responses. The existing bounded
failure codes and delivery/retry decisions remain unchanged. Transport failures
include a sanitized cause. Provider outcome logging happens before the result
write so a storage failure cannot discard the only provider diagnostic.
Message content, template values, addresses and raw provider bodies must remain
excluded from logs. Provider text is sanitized external diagnostic data, never
an instruction. Use `provider_status`, `provider_error` and `provider_message`
together: the generic `provider_authentication_failed` category combines HTTP
401 and 403, including sender/domain authorization failures. A sending-only
Resend key is valid for sending, but cannot read the provider's log API.
SMTP outcomes use `provider_smtp_status` for protocol reply codes and the same
sanitized provider error/message fields, including password/auth-payload redaction.
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
## Validate and distribute
+1 -3
View File
@@ -45,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
defer cancel()
_ = client.Disconnect(cleanup)
}()
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
repo, err := store.New(logger, client.Database())
if err != nil {
return err
@@ -57,7 +55,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
}
defer rt.Close()
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
if err != nil {
return err
}
+5 -1
View File
@@ -35,9 +35,13 @@ func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin
}
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil {
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if err := admin.EnsureConsumerResources(startup, settings, consumer); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
}
// NATS retains this context and unsubscribes when it ends. The worker must
// live until service shutdown, independently of the provisioning deadline.
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
@@ -10,6 +10,7 @@ import (
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type outbox struct{ jobs []model.Notification }
@@ -23,6 +24,50 @@ type publisher struct {
records []reliable.Record
}
type consumerTransport struct {
reliable.Transport
reliable.Subscription
ctx context.Context
}
func (t *consumerTransport) OpenConsumer(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) (reliable.Subscription, error) {
t.ctx = ctx
return t.Subscription, nil
}
type consumerAdmin struct {
reliable.Admin
ctx context.Context
}
func (a *consumerAdmin) EnsureConsumerResources(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) error {
a.ctx = ctx
return nil
}
func TestSubscriptionOutlivesProvisioning(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
transport := &consumerTransport{}
admin := &consumerAdmin{}
if _, err := New(ctx, transport, admin, &outbox{}, nil, zap.NewNop(), DefaultConfig()); err != nil {
t.Fatal(err)
}
if admin.ctx.Err() != context.Canceled {
t.Fatal("provisioning context was not released")
}
if _, ok := admin.ctx.Deadline(); !ok {
t.Fatal("provisioning has no deadline")
}
if err := transport.ctx.Err(); err != nil {
t.Fatalf("subscription ended with provisioning: %v", err)
}
cancel()
if transport.ctx.Err() != context.Canceled {
t.Fatal("subscription does not follow service shutdown")
}
}
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
if p.fail {
return merrors.ServiceUnavailable("offline")
+1 -1
View File
@@ -13,7 +13,7 @@ messaging:
reconnect_wait: 2
templates: "templates.example.json"
email:
driver: resend # sendgrid or resend
driver: resend # sendgrid or resend; see README for authenticated SMTP settings
settings:
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
sender_address: "notifications@example.com"
+9
View File
@@ -19,6 +19,7 @@ type Driver string
const (
SendGrid Driver = "sendgrid"
Resend Driver = "resend"
SMTP Driver = "smtp"
)
type Config = sharedmodel.DriverConfig[Driver]
@@ -33,6 +34,11 @@ type Outcome struct {
State model.State
Code string
RetryAfter time.Duration
// Provider diagnostics are sanitized operational data, not public failures.
HTTPStatus int
SMTPStatus int
ProviderError string
ProviderMessage string
}
type Sender interface {
Send(context.Context, string, model.Email) Outcome
@@ -46,6 +52,9 @@ type client struct {
}
func New(config Config) (Sender, error) {
if config.Driver == SMTP {
return newSMTP(config)
}
var s Settings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
+97
View File
@@ -0,0 +1,97 @@
package email
import (
"encoding/json"
"regexp"
"sort"
"strings"
"unicode"
)
const maxErrorBody = 16 << 10
const maxErrorMessage = 512
var (
errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`)
addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`)
urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`)
numbers = regexp.MustCompile(`[0-9]{4,}`)
)
// Only the documented human-readable error fields cross the adapter boundary.
// Full bodies, headers and request data must never reach logs or public failures.
func providerFailure(body, request []byte, secrets ...string) (string, string) {
var dto struct {
Name string `json:"name"`
Message string `json:"message"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &dto); err != nil {
return "", "Provider error details unavailable: invalid JSON response"
}
if dto.Message == "" && len(dto.Errors) > 0 {
dto.Message = dto.Errors[0].Message
}
// Providers can echo any scalar string from their request DTOs. Strip those
// values before truncation, in addition to credentials and embedded tokens.
var payload any
if json.Unmarshal(request, &payload) == nil {
for _, value := range requestStrings(payload) {
secrets = append(secrets, value)
// Rendered login/verification codes and numeric account references
// can be echoed as fragments of an otherwise longer email body.
secrets = append(secrets, numbers.FindAllString(value, -1)...)
}
}
name := safeErrorText(dto.Name, secrets)
if !errorName.MatchString(name) {
name = ""
}
message := safeErrorText(dto.Message, secrets)
if message == "" {
message = "Provider did not include an error message"
}
return name, message
}
func requestStrings(value any) []string {
var values []string
switch value := value.(type) {
case string:
values = append(values, value)
case []any:
for _, item := range value {
values = append(values, requestStrings(item)...)
}
case map[string]any:
for _, item := range value {
values = append(values, requestStrings(item)...)
}
}
return values
}
func safeErrorText(text string, secrets []string) string {
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
var replacements []string
for _, secret := range secrets {
if secret != "" {
replacements = append(replacements, secret, "[redacted]")
}
}
text = strings.NewReplacer(replacements...).Replace(text)
text = addresses.ReplaceAllString(text, "[redacted-address]")
text = urls.ReplaceAllString(text, "[redacted-url]")
text = tokens.ReplaceAllString(text, "[redacted-token]")
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r)
}), " ")
runes := []rune(text)
if len(runes) > maxErrorMessage {
text = string(runes[:maxErrorMessage]) + "…"
}
return text
}
@@ -0,0 +1,90 @@
package email
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
"git.sendico.io/tech/sxchnge/notifications/model"
)
func TestProviderErrorDiagnostics(t *testing.T) {
for _, tc := range []struct {
name, body, code, message string
}{
{"resend", `{"name":"validation_error","message":"The sxchnge.com domain is not verified"}`, "validation_error", "The sxchnge.com domain is not verified"},
{"sendgrid", `{"errors":[{"message":"The sender identity is not verified","field":"from","help":"private URL"}]}`, "", "The sender identity is not verified"},
{"no message", `{"unrecognized":"private response"}`, "", "Provider did not include an error message"},
{"malformed", `{"message":"private response"`, "", "Provider error details unavailable: invalid JSON response"},
{"wrong type", `{"message":{"secret":"private response"}}`, "", "Provider error details unavailable: invalid JSON response"},
{"trailing", `{"message":"private response"} {}`, "", "Provider error details unavailable: invalid JSON response"},
{"oversized", `{"message":"` + strings.Repeat("x", maxErrorBody) + `"}`, "", "Provider error details unavailable: incomplete or oversized response"},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
c := client{http: server.Client(), endpoint: server.URL}
out := c.post(context.Background(), "", struct{}{})
if out.State != model.Failed || out.Code != "provider_authentication_failed" || out.HTTPStatus != 403 || out.ProviderError != tc.code || out.ProviderMessage != tc.message {
t.Fatalf("unexpected provider outcome: %+v", out)
}
})
}
}
func TestProviderDiagnosticsRedactRequestDataBeforeTruncation(t *testing.T) {
key := "re_credential-secret"
request := []byte(`{"from":"Sender <sender@example.com>","to":["recipient@example.com"],"personalizations":[{"dynamic_template_data":{"secret":"short-private-value"}}],"subject":"Private subject","text":"Private text","html":"<p>Private HTML</p>","content":[{"value":"Your verification code is 123456; account 9876543210"}]}`)
token := strings.Repeat("a", 43)
message := "Credential " + key + "; recipient@example.com; Private subject; Private text; <p>Private HTML</p>; short-private-value; https://example.com/verify?token=" + token + "; " + token + "; 123456; 9876543210; job-secret\n\r\x00\u202eforged-log"
body, err := json.Marshal(map[string]string{"name": "validation_error", "message": message})
if err != nil {
t.Fatal(err)
}
name, result := providerFailure(body, request, key, "job-secret")
if name != "validation_error" || !strings.Contains(result, "forged-log") || !strings.Contains(result, "[redacted]") {
t.Fatal("lost useful diagnostic", name, result)
}
for _, forbidden := range []string{key, "recipient@example.com", "Private subject", "Private text", "Private HTML", "short-private-value", "https://", token, "123456", "9876543210", "job-secret", "\n", "\r", "\x00", "\u202e"} {
if strings.Contains(result, forbidden) {
t.Fatalf("diagnostic contains sensitive value or control %q", forbidden)
}
}
body, err = json.Marshal(map[string]string{"name": "validation_error", "message": strings.Repeat("ą", maxErrorMessage+20) + key})
if err != nil {
t.Fatal(err)
}
_, result = providerFailure(body, request, key)
if !utf8.ValidString(result) || utf8.RuneCountInString(result) != maxErrorMessage+1 || !strings.HasSuffix(result, "…") || strings.Contains(result, "re_") {
t.Fatal("invalid or unbounded diagnostic")
}
}
func TestSuccessfulResponsesDoNotExposeProviderContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"name":"private","message":"private provider response"}`)
}))
defer server.Close()
c := client{http: server.Client(), endpoint: server.URL}
if out := c.post(context.Background(), "", struct{}{}); out.State != model.Accepted || out.HTTPStatus != 200 || out.ProviderMessage != "" || out.ProviderError != "" {
t.Fatal("success leaked provider content", out)
}
}
func TestTransportFailureHasSanitizedDiagnostic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
c := client{http: http.DefaultClient, key: "credential-secret", endpoint: "https://example.com/emails?credential=credential-secret"}
out := c.post(ctx, "job-secret", struct{}{})
if out.State != model.Unknown || !strings.Contains(out.ProviderMessage, "context canceled") || strings.Contains(out.ProviderMessage, "credential-secret") || strings.Contains(out.ProviderMessage, "https://") {
t.Fatal("transport cause missing or unsafe", out)
}
}
+17 -8
View File
@@ -28,14 +28,15 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
}
resp, err := c.http.Do(req)
if err != nil {
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
}
defer resp.Body.Close()
// Discard a bounded response without logging provider-controlled content.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
// Never pass raw provider bodies through the outcome or log them directly.
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
outcome := Outcome{HTTPStatus: resp.StatusCode}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return Outcome{State: model.Accepted}
outcome.State = model.Accepted
case resp.StatusCode == 429:
delay := time.Minute
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
@@ -43,12 +44,20 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
delay = time.Until(date)
}
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay}
outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
case resp.StatusCode >= 500 || resp.StatusCode == 408:
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
case resp.StatusCode == 401 || resp.StatusCode == 403:
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
default:
return Outcome{State: model.Failed, Code: "provider_rejected"}
outcome.State, outcome.Code = model.Failed, "provider_rejected"
}
if outcome.State != model.Accepted {
if readErr != nil || len(details) > maxErrorBody {
outcome.ProviderMessage = "Provider error details unavailable: incomplete or oversized response"
} else {
outcome.ProviderError, outcome.ProviderMessage = providerFailure(details, body, c.key, key)
}
}
return outcome
}
+49
View File
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"crypto/sha256"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
func smtpMessage(s smtpSettings, key string, email model.Email) ([]byte, error) {
if !ValidAddress(email.Address) || strings.ContainsAny(email.Subject, "\r\n") || (email.Text == "" && email.HTML == "") {
return nil, merrors.InvalidArgument("invalid email content")
}
var body bytes.Buffer
parts := multipart.NewWriter(&body)
for _, part := range []struct{ kind, value string }{{"text/plain", email.Text}, {"text/html", email.HTML}} {
if part.value == "" {
continue
}
writer, err := parts.CreatePart(textproto.MIMEHeader{"Content-Type": {part.kind + "; charset=utf-8"}, "Content-Transfer-Encoding": {"quoted-printable"}})
if err != nil {
return nil, merrors.InternalWrap(err, "create email MIME part")
}
encoded := quotedprintable.NewWriter(writer)
if _, err = encoded.Write([]byte(part.value)); err != nil {
return nil, merrors.InternalWrap(err, "encode email MIME part")
}
if err = encoded.Close(); err != nil {
return nil, merrors.InternalWrap(err, "finish email MIME part")
}
}
if err := parts.Close(); err != nil {
return nil, merrors.InternalWrap(err, "finish email MIME body")
}
from := (&mail.Address{Name: s.SenderName, Address: s.SenderAddress}).String()
to := (&mail.Address{Address: email.Address}).String()
domain := s.SenderAddress[strings.LastIndex(s.SenderAddress, "@")+1:]
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%x@%s>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("utf-8", email.Subject), time.Now().UTC().Format(time.RFC1123Z), sha256.Sum256([]byte(key)), domain, parts.Boundary())
return append([]byte(headers), body.Bytes()...), nil
}
+122
View File
@@ -0,0 +1,122 @@
package email
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"io"
"net"
"net/smtp"
"net/textproto"
"os"
"strconv"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/go-viper/mapstructure/v2"
)
type smtpSettings struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
PasswordEnv string `mapstructure:"password_env"`
SenderAddress string `mapstructure:"sender_address"`
SenderName string `mapstructure:"sender_name"`
NetworkTimeout int `mapstructure:"network_timeout"`
}
type smtpSender struct {
settings smtpSettings
password string
tls *tls.Config
}
func newSMTP(config Config) (Sender, error) {
var s smtpSettings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
return nil, merrors.InternalWrap(err, "create SMTP settings decoder")
}
if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 {
return nil, merrors.InvalidArgument("invalid SMTP settings")
}
password := os.Getenv(s.PasswordEnv)
if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") {
return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid")
}
return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil
}
// SMTP uses a fresh authenticated TLS connection per attempt. No automatic
// resend follows an uncertain result, since SMTP has no idempotency guarantee.
func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome {
body, err := smtpMessage(s.settings, key, email)
if err != nil {
return Outcome{State: model.Failed, Code: "invalid_payload"}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second)
defer cancel()
dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port)))
if err != nil {
return s.failure(err, key, email)
}
defer conn.Close()
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
deadline, _ := ctx.Deadline()
if err = conn.SetDeadline(deadline); err != nil {
return s.failure(err, key, email)
}
client, err := smtp.NewClient(conn, s.settings.Host)
if err == nil {
err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host))
}
if err == nil {
err = client.Mail(s.settings.SenderAddress)
}
if err == nil {
err = client.Rcpt(email.Address)
}
if err != nil {
return s.failure(err, key, email)
}
writer, err := client.Data()
if err != nil {
return s.failure(err, key, email)
}
if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil {
err = writer.Close()
}
if err != nil {
return s.failure(err, key, email)
}
// DATA's final 250 is acceptance. A later QUIT error cannot undo it.
_ = client.Quit()
return Outcome{State: model.Accepted, SMTPStatus: 250}
}
func (s *smtpSender) failure(err error, key string, email model.Email) Outcome {
secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text}
secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password)))
secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...)
out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)}
var reply *textproto.Error
if errors.As(err, &reply) {
out.SMTPStatus = reply.Code
if reply.Code >= 400 && reply.Code < 500 {
out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute
} else if reply.Code >= 500 && reply.Code < 600 {
out.State, out.Code = model.Failed, "provider_rejected"
if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 {
out.Code = "provider_authentication_failed"
}
}
}
return out
}
+223
View File
@@ -0,0 +1,223 @@
package email
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http/httptest"
"net/mail"
"net/textproto"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
)
type smtpFixture struct {
settings smtpSettings
root *x509.CertPool
body chan []byte
auth atomic.Bool
}
func smtpServer(t *testing.T, reject string, code int) *smtpFixture {
t.Helper()
certServer := httptest.NewTLSServer(nil)
cert := certServer.TLS.Certificates[0]
root := x509.NewCertPool()
root.AddCert(certServer.Certificate())
certServer.Close()
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
host, port, _ := net.SplitHostPort(listener.Addr().String())
n, _ := strconv.Atoi(port)
f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)}
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
if reject == "greeting" {
_ = conn.(*tls.Conn).Handshake()
_, _ = io.Copy(io.Discard, conn)
return
}
c := textproto.NewConn(conn)
if c.PrintfLine("220 localhost ESMTP") != nil {
return
}
for {
line, err := c.ReadLine()
if err != nil {
return
}
command := strings.SplitN(line, " ", 2)[0]
if command == reject {
_ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code)
continue
}
switch command {
case "EHLO":
_ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
case "AUTH":
want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password"))
f.auth.Store(line == want)
_ = c.PrintfLine("235 authenticated")
case "MAIL", "RCPT":
_ = c.PrintfLine("250 OK")
case "DATA":
_ = c.PrintfLine("354 send message")
body, err := c.ReadDotBytes()
if err != nil {
return
}
f.body <- body
if reject == "final" {
_ = c.PrintfLine("%d final rejection", code)
} else if reject == "disconnect" {
return
} else {
_ = c.PrintfLine("250 queued")
}
case "QUIT":
// A lost QUIT reply must not turn accepted DATA into UNKNOWN.
return
default:
_ = c.PrintfLine("500 unexpected command")
}
}
}()
return f
}
func (f *smtpFixture) sender() *smtpSender {
return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}}
}
func TestSMTPAcceptanceAndMIME(t *testing.T) {
f := smtpServer(t, "", 0)
sender := f.sender()
email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "<p>Confirm code 123456</p>"}
out := sender.Send(context.Background(), "notification-id", email)
if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() {
t.Fatal("SMTP acceptance/authentication failed", out)
}
msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body)))
if err != nil {
t.Fatal(err)
}
subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
if err != nil || subject != email.Subject {
t.Fatal("wrong MIME subject", subject, err)
}
if msg.Header.Get("From") != "\"SXCHNGE\" <sender@example.com>" || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") {
t.Fatal("invalid sender/message identifier")
}
kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil || kind != "multipart/alternative" {
t.Fatal("wrong MIME type", kind, err)
}
parts := multipart.NewReader(msg.Body, params["boundary"])
for _, want := range []string{email.Text, email.HTML} {
part, err := parts.NextPart()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(part)
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want {
t.Fatal("incorrect MIME content", err)
}
}
}
func TestSMTPFailureSafety(t *testing.T) {
for _, tc := range []struct {
stage string
code int
state model.State
failure string
}{
{"AUTH", 535, model.Failed, "provider_authentication_failed"},
{"RCPT", 550, model.Failed, "provider_rejected"},
{"final", 451, model.RetryWait, "provider_temporarily_unavailable"},
{"disconnect", 0, model.Unknown, "provider_outcome_unknown"},
} {
t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) {
f := smtpServer(t, tc.stage, tc.code)
out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"})
if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code {
t.Fatal("incorrect SMTP classification", out)
}
if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") {
t.Fatal("SMTP rejection leaked private data")
}
if tc.code == 451 && out.RetryAfter <= 0 {
t.Fatal("temporary rejection did not schedule retry")
}
})
}
}
func TestSMTPCertificateAndCancellation(t *testing.T) {
t.Run("untrusted certificate", func(t *testing.T) {
f := smtpServer(t, "", 0)
sender := f.sender()
sender.tls.RootCAs = x509.NewCertPool()
if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() {
t.Fatal("untrusted TLS accepted", out)
}
})
t.Run("cancel blocked greeting", func(t *testing.T) {
f := smtpServer(t, "greeting", 0)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
started := time.Now()
if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second {
t.Fatal("SMTP ignored cancellation", out)
}
})
}
func TestSMTPConfigAndHeaderValidation(t *testing.T) {
t.Setenv("TEST_SMTP_PASSWORD", "test-password")
valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10}
if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil {
t.Fatal(err)
}
for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} {
settings := map[string]any{}
for key, item := range valid {
settings[key] = item
}
settings[field] = value
if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil {
t.Fatalf("invalid setting %s accepted", field)
}
}
if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil {
t.Fatal("header injection accepted")
}
}
func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) {
f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"}
auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a"))
out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{})
if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") {
t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage)
}
}
+19 -4
View File
@@ -5,6 +5,7 @@ import (
"errors"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
@@ -23,6 +24,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
if err != nil {
return false, err
}
var outcome email.Outcome
switch {
case !n.ExpiresAt.After(now):
n.State = model.Expired
@@ -36,7 +38,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
deadline = n.ExpiresAt
}
sendCtx, cancel := context.WithDeadline(ctx, deadline)
outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
outcome = s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
cancel()
n.State = outcome.State
n.FailureCode = outcome.Code
@@ -62,14 +64,27 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
// Persist the result even when shutdown canceled the provider request.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
if outcome.HTTPStatus != 0 {
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
}
if outcome.SMTPStatus != 0 {
fields = append(fields, zap.Int("provider_smtp_status", outcome.SMTPStatus))
}
if outcome.ProviderError != "" {
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
}
if outcome.ProviderMessage != "" {
fields = append(fields, zap.String("provider_message", outcome.ProviderMessage))
}
// Keep the provider outcome observable even if its local result write fails.
if n.State == model.Accepted {
s.logger.Info("Email accepted by provider", fields...)
} else {
s.logger.Warn("Email delivery attempt finished", fields...)
}
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
return true, nil
}
+44 -3
View File
@@ -2,18 +2,23 @@ package service
import (
"context"
"errors"
"fmt"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type workerRepo struct {
Repository
job model.Notification
saved *model.Notification
job model.Notification
saved *model.Notification
finishErr error
}
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
@@ -21,7 +26,10 @@ func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notificat
n := r.job
return &n, nil
}
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil }
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error {
r.saved = n
return r.finishErr
}
type workerSender struct {
calls int
@@ -62,3 +70,36 @@ func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
})
}
}
func TestWorkerLogsProviderFailureOnce(t *testing.T) {
for _, failPersistence := range []bool{false, true} {
t.Run(fmt.Sprint(failPersistence), func(t *testing.T) {
core, logs := observer.New(zap.WarnLevel)
repo := &workerRepo{job: model.Notification{Driver: "resend", ExpiresAt: time.Now().Add(time.Hour), Attempts: 1, Email: model.Email{Address: "private@example.com", Subject: "Private subject", Text: "Private text"}}}
if failPersistence {
repo.finishErr = merrors.ServiceUnavailable("storage offline")
}
sender := &workerSender{outcome: email.Outcome{State: model.Failed, Code: "provider_authentication_failed", HTTPStatus: 403, ProviderError: "validation_error", ProviderMessage: "The sxchnge.com domain is not verified"}}
svc := New(repo, nil, sender, "resend", zap.New(core))
if _, err := svc.Dispatch(context.Background(), "id"); !errors.Is(err, repo.finishErr) {
t.Fatal("unexpected persistence error", err)
}
entries := logs.All()
if len(entries) != 1 || entries[0].Message != "Email delivery attempt finished" {
t.Fatal("expected one provider outcome log", entries)
}
fields := entries[0].ContextMap()
if fields["provider_status"] != int64(403) || fields["provider_error"] != "validation_error" || fields["provider_message"] != "The sxchnge.com domain is not verified" {
t.Fatal("provider diagnostic missing", fields)
}
if repo.saved.FailureCode != "provider_authentication_failed" {
t.Fatal("public failure code changed", repo.saved.FailureCode)
}
for _, field := range []string{"address", "email", "subject", "text", "html", "parameters"} {
if _, ok := fields[field]; ok {
t.Fatalf("private notification field %s logged", field)
}
}
})
}
}
+15 -4
View File
@@ -28,14 +28,25 @@ Signup and email verification delivery/completion are available when
`api.notifications` is configured. Flutter selects the backend using
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
[auth notification setup and contracts](../../docs/auth-notifications.md).
Profile HTTP wiring remains follow-up work.
API versions are installed explicitly by the product composition. See
[version installation and compatibility](../../docs/api-versions.md) for selection,
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
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
validate `shared`. See [source provenance](../PROVENANCE.md).
## Customer quotations and payments
With `api.sendico`, runtime composes the customer-scoped payment service and
public HTTP provider adapter. Flutter backend mode selects this path automatically;
missing provider configuration never returns demo money. See
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
+32 -14
View File
@@ -1,23 +1,41 @@
package api
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/product/auth/config"
"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/payments"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
)
type API interface {
LifecycleContext() context.Context
Logger() mlogger.Logger
DomainProvider() domainprovider.DomainProvider
Config() *Config
ProfileConfig() ConfigProvider
DBFactory() db.Factory
RuntimeComponent(name string) any
Register() Register
// Dependencies are initialized once and shared by installed transports.
type Dependencies struct {
Stores *storage.Stores
Customers accountservice.AccountService
Auth *flow.Service
Factors *twofactor.Service
Identity *identity.Service
Payments *payments.Service
Token middleware.TokenConfig
Signature middleware.Signature
LoginLimits middleware.LoginRateLimits
}
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
// Scope belongs to one installation. Router paths are relative to Mount.
type Scope struct {
Router chi.Router
Mount string
URLs domainprovider.DomainProvider
Logger mlogger.Logger
}
type Installer func(Scope, *Dependencies) error
type Mount struct {
Version string `yaml:"version"`
Path string `yaml:"path"`
}
+4
View File
@@ -3,10 +3,14 @@ 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"`
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
-13
View File
@@ -1,13 +0,0 @@
package api
import (
"git.sendico.io/tech/sxchnge/product/auth/response"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service"
)
type Register interface {
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
}
+13 -17
View File
@@ -1,36 +1,32 @@
package api
import "git.sendico.io/tech/sxchnge/shared/service"
import (
"context"
type ConfigProvider interface {
SharedConfig() *Config
}
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/logging"
)
type ConfigProvider interface{ SharedConfig() *Config }
type ConfigFactory func() ConfigProvider
type RuntimeComponents map[string]any
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
type RuntimeFinishFunc func(API) error
// Bootstrap opens process-owned resources. Its cleanup is called once even if
// installation fails. On error, bootstrap must return cleanup or clean its own
// resources before returning, never both.
type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error)
type RuntimeConfig struct {
Name string
Services []ServiceFactory
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
}
type ServiceFactory struct {
Name mservice.Type
Factory MicroServiceFactoryT
Bootstrap Bootstrap
Versions map[string]Installer
}
func (cfg RuntimeConfig) RuntimeName() string {
if cfg.Name == "" {
return "edge"
return "product"
}
return cfg.Name
}
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
if cfg.ConfigFactory != nil {
return cfg.ConfigFactory()
+30
View File
@@ -0,0 +1,30 @@
// Package v1 composes the product's first public HTTP contract.
package v1
import (
"git.sendico.io/tech/sxchnge/product/app/api"
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors"
)
func Install(scope api.Scope, deps *api.Dependencies) error {
if deps == nil || deps.Stores == nil {
return merrors.InvalidArgument("v1 auth dependencies are required")
}
flows := public.Flows{Onboarding: deps.Auth}
if deps.Factors != nil {
flows.Factors = deps.Factors
}
// Signature is resolved at bootstrap, not by each version from the environment.
routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows)
if deps.Factors != nil {
factors.Install(routes, deps.Factors, scope.Logger)
}
identity.Install(routes, deps.Identity, scope.Logger)
paymenthttp.Install(routes, deps.Payments, scope.Logger)
return nil
}
+10 -108
View File
@@ -2,26 +2,12 @@ package httptransport
import (
"net/http"
"os"
"path"
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/flow"
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
public "git.sendico.io/tech/sxchnge/product/auth/http/public"
"git.sendico.io/tech/sxchnge/product/auth/response"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountservice "git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/product/twofactor"
delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
@@ -34,33 +20,17 @@ import (
)
type Middleware struct {
notifications *notifications.Client
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
epdispatcher *httprouter.Dispatcher
}
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
mw.epdispatcher.Handler(service, endpoint, method, handler)
}
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
}
func (mw *Middleware) Finish() {
mw.metrics.Finish()
if mw.notifications != nil {
_ = mw.notifications.Close()
if mw.metrics != nil {
mw.metrics.Finish()
}
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for application traffic only. Scrapes and health probes are
@@ -112,15 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
}
func CreateMiddleware(
logger mlogger.Logger,
db db.Factory,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *middleware.AuthStorageConfig,
debug bool,
notificationConfig ...*notifications.Config,
) (*Middleware, error) {
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
}
@@ -130,72 +92,12 @@ func CreateMiddleware(
if err := config.CORS.Validate(); err != nil {
return nil, err
}
loginRateLimits, err := config.ResolveLoginRateLimits()
if err != nil {
logger.Error("Invalid login rate limit configuration", zap.Error(err))
return nil, err
}
p := &Middleware{
logger: logger.Named("middleware"),
router: apiRouter,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
p.installMiddleware(config, debug)
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
var err error
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
if err != nil {
return nil, err
}
var authFlow *flow.Service
var factors *twofactor.Service
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
c := notificationConfig[0]
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
if err != nil {
return nil, err
}
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
if err = authFlow.Validate(); err != nil {
return nil, err
}
p.notifications, err = notifications.Open(*c)
if err != nil {
return nil, err
}
authFlow.Sender = p.notifications
records, err := factorstore.Open(p.logger, db)
if err != nil {
_ = p.notifications.Close()
return nil, err
}
factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications})
if err != nil {
_ = p.notifications.Close()
return nil, err
}
}
flows := public.Flows{Onboarding: authFlow}
if factors != nil {
flows.Factors = factors
}
p.epdispatcher = httprouter.NewDispatcher(
p.logger,
p.router,
stores.Accounts,
stores.RefreshTokens,
stores.LoginAttempts,
config,
loginRateLimits,
flows,
)
if factors != nil {
factorhttp.Install(p.epdispatcher, factors, p.logger)
}
return p, nil
}
+4 -13
View File
@@ -1,21 +1,12 @@
// Package profile selects the locally owned components of the copied BFF.
// Package profile selects the product's supported API implementations.
package profile
import (
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/app/api/v1"
"git.sendico.io/tech/sxchnge/product/app/runtime"
)
const Accounts = "accounts"
func Runtime() api.RuntimeConfig {
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
}
func bootstrap(a api.API) (api.RuntimeComponents, error) {
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
if err != nil {
return nil, err
}
return api.RuntimeComponents{Accounts: accounts}, nil
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
}
+9 -57
View File
@@ -2,68 +2,20 @@ package runtime
import (
"context"
"sync"
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health"
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
)
type Microservices = []mservice.MicroService
// APIImp represents the structure of the APIImp
type APIImp struct {
lifecycleCtx context.Context
logger mlogger.Logger
db db.Factory
domain domainprovider.DomainProvider
config *api.Config
profile api.ConfigProvider
runtime api.RuntimeConfig
components api.RuntimeComponents
services Microservices
health amr.Health
mw *httptransport.Middleware
type instance struct {
logger mlogger.Logger
health amr.Health
mw *httptransport.Middleware
cleanup func(context.Context) error
once sync.Once
finishErr error
}
func (a *APIImp) LifecycleContext() context.Context {
return a.lifecycleCtx
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
func (a *APIImp) ProfileConfig() api.ConfigProvider {
return a.profile
}
func (a *APIImp) DBFactory() db.Factory {
return a.db
}
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
return a.domain
}
func (a *APIImp) Register() api.Register {
return a.mw
}
func (a *APIImp) RuntimeComponent(name string) any {
if a == nil || a.components == nil {
return nil
}
return a.components[name]
}
func (a *APIImp) Name() string {
return "api"
}
func (a *instance) Name() string { return "api" }
+54 -77
View File
@@ -6,9 +6,12 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health"
"git.sendico.io/tech/sxchnge/product/app/health/status"
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
@@ -16,93 +19,67 @@ import (
)
func CreateAPI(
lifecycleCtx context.Context,
ctx context.Context,
logger mlogger.Logger,
config *api.Config,
profileConfig api.ConfigProvider,
db db.Factory,
rootRouter *chi.Mux,
apiRouter *chi.Mux,
rootRouter, apiRouter *chi.Mux,
debug bool,
runtime api.RuntimeConfig,
) (mservice.MicroService, error) {
p := &APIImp{
lifecycleCtx: lifecycleCtx,
logger: logger.Named("api"),
config: config,
profile: profileConfig,
db: db,
runtime: runtime,
) (_ mservice.MicroService, result error) {
if config == nil || config.Mw == nil {
return nil, merrors.InvalidArgument("API middleware configuration is required")
}
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
endpoint := os.Getenv(config.Mw.EndPointEnv)
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
if err != nil {
return nil, err
}
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
return nil, err
if runtime.Bootstrap == nil {
return nil, merrors.InvalidArgument("runtime bootstrap is required")
}
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}
func (a *APIImp) installRuntimeBasics(
logger mlogger.Logger,
db db.Factory,
rootRouter *chi.Mux,
apiRouter *chi.Mux,
debug bool,
) error {
var err error
if a.domain, err = domainprovider.CreateDomainProvider(
a.logger,
a.config.Mw.DomainEnv,
a.config.Mw.APIProtocolEnv,
a.config.Mw.EndPointEnv,
); err != nil {
a.logger.Error("Failed to initialize domain provider")
return err
}
a.logger.Info("Domain provider installed")
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
return err
}
if a.mw, err = httptransport.CreateMiddleware(
logger,
db,
apiRouter,
a.config.Mw,
a.config.AuthStorage,
debug,
a.config.Notifications,
); err != nil {
a.logger.Error("Failed to create middleware", zap.Error(err))
return err
}
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
return nil
}
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
if a.runtime.Bootstrap != nil {
components, err := a.runtime.Bootstrap(a)
// Resolve every URL before opening resources or publishing routes.
scopes := make([]api.Scope, len(mounts))
for i, mount := range mounts {
urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path)
if err != nil {
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
return err
return nil, err
}
a.components = components
a.logger.Info("Runtime profile bootstrap complete",
zap.String("runtime", a.runtime.RuntimeName()),
zap.Int("components", len(components)))
scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))}
scopes[i].Router.Use(browser.Scope(mount.Path))
}
return nil
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
defer func() {
if result != nil {
if err := a.Finish(context.Background()); err != nil {
a.logger.Warn("Failed startup resource cleanup", zap.Error(err))
}
}
}()
a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug)
if err != nil {
return nil, err
}
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
a.cleanup = cleanup
if err != nil {
return nil, err
}
for i, mount := range mounts {
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
}
}
// Attach only after every installer succeeds.
for i, mount := range mounts {
apiRouter.Mount(mount.Path, scopes[i].Router)
scopes[i].Logger.Info("API version installed")
}
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
if err != nil {
return nil, err
}
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
a.health.SetStatus(health.SSRunning)
return a, nil
}
+23 -1
View File
@@ -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"
@@ -50,7 +54,7 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes)
@@ -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)
}
+12 -20
View File
@@ -4,28 +4,20 @@ import (
"context"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"go.uber.org/zap"
)
func (a *APIImp) Finish(ctx context.Context) error {
a.health.SetStatus(health.SSTerminating)
a.health.Finish()
a.mw.Finish()
var lastError error
for i := len(a.services) - 1; i >= 0; i-- {
if err := (a.services[i]).Finish(ctx); err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing service",
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
} else {
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
func (a *instance) Finish(ctx context.Context) error {
a.once.Do(func() {
if a.health != nil {
a.health.SetStatus(health.SSTerminating)
a.health.Finish()
}
}
if a.runtime.Finish != nil {
if err := a.runtime.Finish(a); err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
if a.cleanup != nil {
a.finishErr = a.cleanup(ctx)
}
}
return lastError
if a.mw != nil {
a.mw.Finish()
}
})
return a.finishErr
}
+58
View File
@@ -0,0 +1,58 @@
package runtime
import (
"path"
"strings"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors"
)
func validMount(value string) bool {
if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value {
return false
}
for _, c := range value {
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) {
continue
}
return false
}
return true
}
func overlaps(a, b string) bool {
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
}
func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) {
if !validMount(endpoint) {
return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint")
}
if configured == nil {
configured = []api.Mount{{Version: "v1", Path: endpoint}}
}
seen := map[string]bool{}
for i, mount := range configured {
if supported[mount.Version] == nil {
return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions")
}
if seen[mount.Version] {
return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions")
}
seen[mount.Version] = true
if !validMount(mount.Path) {
return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions")
}
for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} {
if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") {
return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions")
}
}
for _, previous := range configured[:i] {
if overlaps(mount.Path, previous.Path) {
return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions")
}
}
}
return configured, nil
}
+190
View File
@@ -0,0 +1,190 @@
package runtime
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func testConfig(t *testing.T) *api.Config {
t.Helper()
data, err := os.ReadFile("../../config.example.yml")
require.NoError(t, err)
var raw struct {
API api.Config `yaml:"api"`
}
require.NoError(t, yaml.Unmarshal(data, &raw))
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
return &raw.API
}
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
initialized, closed := 0, 0
deps := &api.Dependencies{}
installer := func(version string) api.Installer {
return func(scope api.Scope, got *api.Dependencies) error {
require.Same(t, deps, got)
link, err := scope.URLs.GetAPILink("accounts")
require.NoError(t, err)
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
})
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
return nil
}
}
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
initialized++
return deps, func(context.Context) error { closed++; return nil }, nil
}}
root, routes := chi.NewRouter(), chi.NewRouter()
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
require.NoError(t, err)
root.Mount("/", routes)
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
require.Equal(t, http.StatusOK, result.Code)
require.Equal(t, expected, result.Body.String())
}
t.Setenv("API_ENDPOINT", "/wrong/global")
for _, mount := range config.Versions {
for _, method := range []string{http.MethodPost, http.MethodDelete} {
result := httptest.NewRecorder()
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
root.ServeHTTP(result, request)
require.Equal(t, http.StatusOK, result.Code)
cookies := result.Result().Cookies()
require.Len(t, cookies, 1)
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
if method == http.MethodDelete {
require.Equal(t, -1, cookies[0].MaxAge)
}
}
}
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
require.Equal(t, 404, result.Code)
require.Equal(t, 1, initialized)
var wg sync.WaitGroup
results := make(chan error, 3)
for range 3 {
wg.Go(func() { results <- srv.Finish(context.Background()) })
}
wg.Wait()
close(results)
for err := range results {
require.NoError(t, err)
}
require.Equal(t, 1, closed)
}
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
for name, mounts := range map[string][]api.Mount{
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
"control": {{Version: "v1", Path: "/api/\x01"}},
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
} {
t.Run(name, func(t *testing.T) {
config := testConfig(t)
config.Versions = mounts
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
t.Fatal("bootstrap must not run")
return nil, nil, nil
}})
require.ErrorIs(t, err, merrors.ErrInvalidArg)
})
}
}
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
closed := 0
cause := merrors.InvalidArgument("test installation failure")
routes := chi.NewRouter()
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
},
Versions: map[string]api.Installer{
"v1": func(scope api.Scope, _ *api.Dependencies) error {
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
return nil
},
"v2": func(api.Scope, *api.Dependencies) error { return cause },
}})
require.True(t, errors.Is(err, cause))
require.Equal(t, 1, closed)
result := httptest.NewRecorder()
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
require.Equal(t, 404, result.Code)
}
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{}
root, routes := chi.NewRouter(), chi.NewRouter()
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
return &api.Dependencies{}, nil, nil
}})
require.NoError(t, err)
defer srv.Finish(context.Background())
root.Mount("/", routes)
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
require.Equal(t, status, result.Code, target)
}
}
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
config := testConfig(t)
config.Versions = []api.Mount{}
closed := 0
cause := merrors.Internal("bootstrap failure")
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
return nil, func(context.Context) error { closed++; return nil }, cause
}})
require.ErrorIs(t, err, cause)
require.Equal(t, 1, closed)
}
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
cause := merrors.Internal("cleanup failure")
closed := 0
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
require.ErrorIs(t, srv.Finish(context.Background()), cause)
require.ErrorIs(t, srv.Finish(context.Background()), cause)
require.Equal(t, 1, closed)
}
+84 -26
View File
@@ -1,39 +1,97 @@
package runtime
import (
"context"
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/health/status"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service"
"git.sendico.io/tech/sxchnge/product/auth/config"
"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/payments"
"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"
)
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
limits, err := config.Mw.ResolveLoginRateLimits()
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
return nil, nil, err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) installServices() error {
for _, service := range a.runtime.Services {
if service.Factory == nil {
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
if err != nil {
return nil, nil, err
}
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
if err != nil {
return nil, nil, err
}
deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits}
var client *notifications.Client
cleanup := func(context.Context) error {
if client != nil {
return client.Close()
}
if err := a.addMicroservice(service.Factory); err != nil {
return err
return nil
}
defer func() {
if result != nil {
if err := cleanup(context.Background()); err != nil {
logger.Warn("Partial bootstrap cleanup failed", zap.Error(err))
}
}
}()
if c := config.Notifications; c != nil {
deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL}
if err := deps.Auth.Validate(); err != nil {
return nil, nil, err
}
client, err = notifications.Open(*c)
if err != nil {
return nil, nil, err
}
deps.Auth.Sender = client
if config.Sendico != nil {
provider, err := sendico.Open(*config.Sendico)
if err != nil {
return nil, nil, err
}
bindings, err := identity.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity
paymentStore, err := payments.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db)
if err != nil {
return nil, nil, err
}
deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client})
if err != nil {
return nil, nil, err
}
}
a.health.SetStatus(health.SSRunning)
return nil
if deps.Payments != nil {
deps.Payments.Factors = deps.Factors
}
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
}
@@ -5,6 +5,7 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors"
"os"
"strings"
"testing"
)
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
t.Fatal(err)
}
}
func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) {
data, err := os.ReadFile("../../../config.example.yml")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, setting string
wantNil bool
wantLength int
}{
{name: "omitted", wantNil: true},
{name: "disabled", setting: " versions: []\n"},
{name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1},
} {
t.Run(tc.name, func(t *testing.T) {
instance := &Imp{runtime: api.RuntimeConfig{}}
configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1)
if err := instance.decodeConfig([]byte(configured)); err != nil {
t.Fatal(err)
}
versions := instance.config.API.SharedConfig().Versions
if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength {
t.Fatalf("unexpected versions: %#v", versions)
}
if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") {
t.Fatalf("lost version configuration: %#v", versions)
}
})
}
}
@@ -112,7 +112,7 @@ func (i *Imp) Start() error {
rootRouter := chi.NewRouter()
apiRouter := chi.NewRouter()
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
i.db.CloseConnection()
return err
}
@@ -10,6 +10,7 @@ type DomainProvider interface {
GetAPILink(linkElem ...string) (string, error)
}
// CreateDomainProvider accepts a resolved host, protocol and installation mount.
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
}
@@ -2,8 +2,8 @@ package domainproviderimp
import (
"net/url"
"os"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap"
)
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
return url.JoinPath(dp.apiEndpoint, linkElem...)
}
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) {
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) {
if protocol != "http" && protocol != "https" {
return nil, merrors.InvalidArgument("API protocol must be http or https")
}
parsed, err := url.Parse(protocol + "://" + domain)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin")
}
if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, merrors.InvalidArgument("API domain must be a host with optional port")
}
p := new(DomainProviderImp)
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
var err error
apiPath := os.Getenv(apiPointEnv)
p.domain = protocol + "://" + domain
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
return nil, err
@@ -0,0 +1,27 @@
package domainproviderimp
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestExplicitOriginAndMount(t *testing.T) {
provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2")
require.NoError(t, err)
link, err := provider.GetAPILink("accounts", "me")
require.NoError(t, err)
require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link)
link, err = provider.GetFullLink("signup")
require.NoError(t, err)
require.Equal(t, "https://localhost:8082/signup", link)
}
func TestInvalidOrigin(t *testing.T) {
for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} {
_, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1")
require.Error(t, err, domain)
}
_, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1")
require.Error(t, err)
}
@@ -0,0 +1,41 @@
package architecture
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
for _, dir := range []string{"../app/runtime", "../app/http"} {
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, im := range file.Imports {
name, err := strconv.Unquote(im.Path.Value)
if err != nil {
return err
}
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
}
+39 -7
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)
@@ -160,10 +179,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
return result.ExpiresAt, err
}
func (s *Service) Activate(ctx context.Context, raw string) error {
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
if len(raw) != 43 {
return verification.ErorrTokenNotFound()
return "", verification.ErorrTokenNotFound()
}
var email string
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil {
@@ -176,7 +196,19 @@ 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")
}
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
if s.Facts != nil {
if err := s.Facts.Ready(tx, a.ID); err != nil {
return nil, err
}
}
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
return nil, err
}
email = a.Login
return nil, nil
})
return err
if err != nil {
return "", err
}
return email, nil
}
+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)
@@ -9,8 +9,8 @@ import (
"testing"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
accounts.customer.Status = status
routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true
@@ -6,9 +6,9 @@ import (
"net/http"
"strings"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
@@ -4,7 +4,7 @@ import (
"net/http"
"git.sendico.io/tech/sxchnge/product/auth/config"
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
func Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
@@ -1,12 +1,10 @@
package router
import (
"os"
"git.sendico.io/tech/sxchnge/product/auth/config"
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
"git.sendico.io/tech/sxchnge/product/auth/response"
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store"
api "git.sendico.io/tech/sxchnge/shared/http"
@@ -34,13 +32,15 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
d.protected.PendingAccountHandler(service, endpoint, method, handler)
}
func NewDispatcher(
func Install(
logger mlogger.Logger,
router chi.Router,
endpoint string,
db account.DB,
rtdb refreshtokens.DB,
attempts ratelimits.Store,
config *middleware.Config,
token *middleware.TokenConfig,
signature middleware.Signature,
loginRateLimits middleware.LoginRateLimits,
flows ...rpublic.Flows,
) *Dispatcher {
@@ -49,13 +49,11 @@ func NewDispatcher(
}
d.logger.Debug("Installing endpoints middleware...")
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
})
return d
@@ -7,7 +7,7 @@ import (
"net/http"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
)
@@ -4,7 +4,7 @@ import (
"net/http"
"path"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
@@ -33,7 +33,7 @@ func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
}
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
return path.Join(er.apiEndpoint, service, handler)
return path.Join("/", er.apiEndpoint, service, handler)
}
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
@@ -5,8 +5,8 @@ import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"time"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"github.com/go-chi/jwtauth/v5"
)
@@ -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")
}
@@ -9,9 +9,9 @@ import (
"strings"
clientauth "git.sendico.io/tech/sxchnge/product/auth/client"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/response"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/errors"
@@ -9,8 +9,8 @@ import (
"time"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
@@ -13,7 +13,7 @@ import (
"time"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap"
@@ -11,7 +11,7 @@ import (
"testing"
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5"
@@ -183,7 +183,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
PrivateKey: []byte("test-secret"),
}
router := NewRouter(
router := Install(
zap.NewNop(),
"/",
&testAccountDB{result: account},
@@ -4,7 +4,7 @@ import (
"encoding/json"
"net/http"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/http/response"
@@ -4,7 +4,7 @@ import (
"net/http"
"time"
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -6,8 +6,8 @@ import (
"net/http"
"time"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/service"
@@ -47,7 +47,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
Algorithm: "HS256",
PrivateKey: []byte("browser-refresh-test-secret"),
}
return NewRouter(
return Install(
zap.NewNop(),
"/api/v1",
&testAccountDB{result: account},
@@ -6,7 +6,7 @@ import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap"
@@ -5,8 +5,8 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/flow"
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
"git.sendico.io/tech/sxchnge/product/auth/response"
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/twofactor"
@@ -41,7 +41,7 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
pr.imp.InstallHandler(service, endpoint, method, handler)
}
func NewRouter(
func Install(
logger mlogger.Logger,
apiEndpoint string,
db account.DB,
@@ -8,8 +8,8 @@ import (
"net/http"
"time"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
@@ -11,9 +11,9 @@ import (
"net/http"
"time"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
session "git.sendico.io/tech/sxchnge/product/sessions/model"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
@@ -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):
@@ -137,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
email, err := pr.flow.Activate(r.Context(), req.Token)
if err != nil {
return pr.flowError(err)
}
pr.logger.Info("Account activation completed")
return response.Accepted(pr.logger, map[string]bool{"verified": true})
return response.Accepted(pr.logger, struct {
Verified bool `json:"verified"`
Email string `json:"email"`
}{Verified: true, Email: email})
}
func challengeScope(raw string) string {
h := sha256.Sum256([]byte(raw))
@@ -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)
@@ -71,7 +109,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
require.NoError(t, err)
routes := chi.NewRouter()
NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
Install(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
post := func(path string, body any) *httptest.ResponseRecorder {
raw, err := json.Marshal(body)
require.NoError(t, err)
@@ -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,12 +148,37 @@ 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)
invalid := post("verification/confirm", map[string]string{"token": "invalid"})
require.Equal(t, 400, invalid.Code)
require.NotContains(t, invalid.Body.String(), a.Login)
guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard
blocked := post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 503, blocked.Code)
require.NotContains(t, blocked.Body.String(), a.Login)
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)
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
var confirmed struct {
Data struct {
Verified bool `json:"verified"`
Email string `json:"email"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
require.True(t, confirmed.Data.Verified)
require.Equal(t, a.Login, confirmed.Data.Email)
used := post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 400, used.Code)
require.NotContains(t, used.Body.String(), a.Login)
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status)
@@ -1,7 +1,7 @@
package router
import (
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service"
)
+11
View File
@@ -9,6 +9,17 @@ 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
# path: /api/v1
middleware:
domain_env: SXCHNGE_DOMAIN
api_endpoint_env: SXCHNGE_API_ENDPOINT
+21 -3
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) {
@@ -243,6 +254,13 @@ func NewAccountService(
if err != nil {
return nil, err
}
return &service{logger: logger, config: config, accountDB: stores.Accounts,
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
return FromStores(l, config, stores, dbf.TransactionFactory())
}
func FromStores(l mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (*service, error) {
if config == nil || stores == nil || transactions == nil {
return nil, merrors.InvalidArgument("customer dependencies are required")
}
return &service{logger: l.Named("account_service"), config: config, accountDB: stores.Accounts,
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: transactions}, nil
}
+11 -1
View File
@@ -2,11 +2,13 @@ package accountservice
import (
"context"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/auth/config"
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -34,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,
@@ -61,3 +66,8 @@ func NewAccountService(
) (AccountService, error) {
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
}
// FromStores composes a customer service using already initialized storage.
func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) {
return accountserviceimp.FromStores(logger, config, stores, transactions)
}
+48
View File
@@ -0,0 +1,48 @@
package identityhttp
import "git.sendico.io/tech/sxchnge/product/identity"
type profileDTO struct {
FullName string `json:"fullName"`
DateOfBirth string `json:"dateOfBirth"`
ResidenceCountry string `json:"residenceCountry"`
}
func (p profileDTO) model() identity.Profile {
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
}
type verificationDTO struct {
Status string `json:"status"`
UpgradePending bool `json:"upgradePending"`
Reason string `json:"reason,omitempty"`
SubmissionID string `json:"submissionId,omitempty"`
Profile profileDTO `json:"profile"`
}
func mapVerification(v identity.Verification) verificationDTO {
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
}
type limitDTO struct {
Operation string `json:"operation"`
Period string `json:"period"`
Currency string `json:"currency"`
Cap string `json:"cap"`
Remaining string `json:"remaining"`
Used string `json:"used"`
Reserved string `json:"reserved"`
}
type limitsDTO struct {
Tier string `json:"tier"`
ProfileRef string `json:"profileRef"`
Entries []limitDTO `json:"entries"`
}
func mapLimits(v identity.Limits) limitsDTO {
entries := make([]limitDTO, 0, len(v.Entries))
for _, r := range v.Entries {
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
}
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
}
+107
View File
@@ -0,0 +1,107 @@
package identityhttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/identity"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *identity.Service
logger mlogger.Logger
}
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
h := &router{service, logger.Named("identity_http")}
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
}
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Limits(r.Context(), a)
return h.result(mapLimits(v), err)
}
func (h *router) result(data any, err error) http.HandlerFunc {
if err == nil {
return response.Ok(h.logger, data)
}
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
}
}
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
case errors.Is(err, merrors.ErrDataConflict):
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
case errors.Is(err, merrors.ErrAccessDenied):
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
default:
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
}
}
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, err := h.service.Verification(r.Context(), a)
return h.result(mapVerification(v), err)
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
// Read multipart as a bounded stream. Never spill identity documents to disk.
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
reader, err := r.MultipartReader()
if err != nil {
return h.result(nil, merrors.InvalidArgument("multipart required"))
}
fields := map[string][]byte{}
for {
part, readErr := reader.NextPart()
if readErr == io.EOF {
break
}
if readErr != nil {
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
}
name := part.FormName()
limit := int64(8192)
switch name {
case "document", "selfie":
limit = identity.MaxPhotoBytes
case "profile", "idempotencyKey":
default:
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("unexpected field"))
}
if _, exists := fields[name]; exists {
_ = part.Close()
return h.result(nil, merrors.InvalidArgument("duplicate field"))
}
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
_ = part.Close()
if readErr != nil || int64(len(data)) > limit {
return h.result(nil, merrors.InvalidArgument("field too large"))
}
fields[name] = data
}
var p profileDTO
if err = json.Unmarshal(fields["profile"], &p); err != nil {
return h.result(nil, merrors.InvalidArgument("profile required"))
}
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
return h.result(mapVerification(v), err)
}
@@ -0,0 +1,20 @@
package identityhttp
import (
"net/http/httptest"
"testing"
"time"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestThrottleDoesNotBecomeGenericUnavailable(t *testing.T) {
h := router{logger: zap.NewNop()}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/accounts/identity", nil)
h.result(nil, merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", 120*time.Second, "temporary limit"))(w, r)
require.Equal(t, 429, w.Code)
require.Equal(t, "120", w.Header().Get("Retry-After"))
}
+9
View File
@@ -0,0 +1,9 @@
package identity
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var requests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_signup_facts_total", Help: "Sign-up facts handoff attempts; acceptance is not verification."}, []string{"outcome"})
var duration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_signup_facts_duration_seconds", Help: "Sign-up facts handoff latency including retries."})
+39
View File
@@ -0,0 +1,39 @@
// Package identity owns the durable sign-up facts handoff, not KYC decisions.
package identity
import (
"time"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Facts is a snapshot of local sign-up assertions. It never contains credentials.
type Facts struct {
FullName string `bson:"fullName"`
Email string `bson:"email"`
CustomerRef string `bson:"customerRef"`
}
type Receipt struct {
SubmissionRef string `bson:"submissionRef"`
SubjectRef string `bson:"subjectRef"`
AcceptedAt time.Time `bson:"acceptedAt"`
}
// Binding uses the local customer ID as its primary key. Namespace is an opaque
// adapter configuration fingerprint; no platform tenancy enters the customer model.
type Binding struct {
storable.Base `bson:",inline"`
Namespace string `bson:"namespace"`
RegistrationRequest string `bson:"registrationRequest"`
Facts Facts `bson:"facts"`
Registering bool `bson:"registering"`
RegisteringAt time.Time `bson:"registeringAt"`
SubjectRef string `bson:"subjectRef"`
Receipt *Receipt `bson:"receipt,omitempty"`
}
func (*Binding) Collection() string { return "signup_facts" }
func sourceRef(id bson.ObjectID) string { return "sxchnge:signup:" + id.Hex() }
+31
View File
@@ -0,0 +1,31 @@
package identity
import (
"bytes"
"image"
_ "image/jpeg"
_ "image/png"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
const MaxPhotoBytes = 10 << 20
// ValidatePhoto repeats client checks at the trust boundary and bounds decoder
// memory before decoding pixels. File names and client MIME types are ignored.
func ValidatePhoto(data []byte) error {
if len(data) == 0 || len(data) > MaxPhotoBytes {
return merrors.InvalidArgument("photo must be between 1 byte and 10 MiB")
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "jpeg" && format != "png") {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
if config.Width < 600 || config.Height < 600 || int64(config.Width)*int64(config.Height) > 40000000 {
return merrors.InvalidArgument("photo dimensions must be at least 600 by 600 and at most 40 megapixels")
}
if _, _, err = image.Decode(bytes.NewReader(data)); err != nil {
return merrors.InvalidArgument("readable JPEG or PNG required")
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package identity
import (
"context"
"errors"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Provider interface {
Namespace() string
Register(context.Context, string) (string, error)
Submit(context.Context, string, string, Facts) (Receipt, error)
}
type Service struct {
Store Store
Provider Provider
Logger mlogger.Logger
}
func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") }
// Ensure completes only after a provider receipt has been persisted. Registration
// has no idempotency contract, so its durable claim is never automatically expired.
func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) {
if a == nil || a.ID.IsZero() {
return merrors.InvalidArgument("customer required")
}
started := time.Now()
requestRef := ""
outcome := "unavailable"
defer func() {
requests.WithLabelValues(outcome).Inc()
duration.Observe(time.Since(started).Seconds())
fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)}
if result != nil {
s.Logger.Warn("Sign-up facts handoff incomplete", fields...)
} else {
s.Logger.Info("Sign-up facts handoff completed", fields...)
}
}()
namespace := s.Provider.Namespace()
b, err := s.Store.Get(ctx, a.ID)
if errors.Is(err, merrors.ErrNoData) {
b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}}
b.SetID(a.ID)
if err = s.Store.Create(ctx, b); err != nil {
if !errors.Is(err, merrors.ErrDataConflict) {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts")
}
b, err = s.Store.Get(ctx, a.ID)
}
}
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts")
}
requestRef = b.RegistrationRequest
if b.Namespace != namespace {
outcome = "attention_required"
return merrors.FailedPrecondition("sign-up provider binding changed")
}
if b.Receipt != nil {
outcome = "replayed"
return nil
}
if b.SubjectRef == "" {
claimed, err := s.Store.Claim(ctx, a.ID, namespace)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration")
}
if !claimed {
// Another request may have completed while this request was claiming.
b, err = s.Store.Get(ctx, a.ID)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts")
}
if b.SubjectRef == "" {
if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) {
return unavailable()
}
outcome = "attention_required"
return merrors.FailedPrecondition("individual registration requires reconciliation")
}
} else {
subject, err := s.Provider.Register(ctx, b.RegistrationRequest)
if err != nil {
outcome = "attention_required"
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation")
}
// Persist even if the browser left while Sendico accepted registration.
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
err = s.Store.Bind(saveCtx, a.ID, namespace, subject)
cancel()
if err != nil {
outcome = "attention_required"
s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject))
return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding")
}
b.SubjectRef = subject
}
}
receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts")
}
if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() {
return unavailable()
}
if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt")
}
outcome = "accepted"
return nil
}
// Ready checks confirmed intake without external I/O inside activation transactions.
func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error {
b, err := s.Store.Get(ctx, id)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt")
}
if b.Namespace != s.Provider.Namespace() || b.Receipt == nil {
return unavailable()
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package identity
import (
"context"
"sync"
"testing"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type memoryStore struct {
mu sync.Mutex
rows map[bson.ObjectID]Binding
bindErr, acceptErr error
}
func (s *memoryStore) Get(_ context.Context, id bson.ObjectID) (*Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.rows[id]
if !ok {
return nil, merrors.NoData("missing")
}
return &b, nil
}
func (s *memoryStore) Create(_ context.Context, b *Binding) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.rows[b.ID]; ok {
return merrors.DataConflict("duplicate")
}
s.rows[b.ID] = *b
return nil
}
func (s *memoryStore) Claim(_ context.Context, id bson.ObjectID, namespace string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
b := s.rows[id]
if b.Namespace != namespace || b.Registering || b.SubjectRef != "" {
return false, nil
}
b.Registering = true
b.RegisteringAt = time.Now()
s.rows[id] = b
return true, nil
}
func (s *memoryStore) Bind(_ context.Context, id bson.ObjectID, namespace, subject string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.bindErr != nil {
return s.bindErr
}
b := s.rows[id]
b.SubjectRef = subject
b.Registering = false
s.rows[id] = b
return nil
}
func (s *memoryStore) Accept(_ context.Context, id bson.ObjectID, namespace string, r Receipt) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.acceptErr != nil {
return s.acceptErr
}
b := s.rows[id]
b.Receipt = &r
s.rows[id] = b
return nil
}
type providerStub struct {
registrations, submissions int
registerErr, submitErr error
snapshots []Facts
sources []string
entered, release chan struct{}
}
func (*providerStub) Namespace() string { return "provider-1" }
func (p *providerStub) Register(context.Context, string) (string, error) {
p.registrations++
if p.entered != nil {
close(p.entered)
<-p.release
}
return "subject-1", p.registerErr
}
func (p *providerStub) Submit(_ context.Context, subject, source string, f Facts) (Receipt, error) {
p.submissions++
p.snapshots = append(p.snapshots, f)
p.sources = append(p.sources, source)
return Receipt{SubjectRef: subject, SubmissionRef: "submission-1", AcceptedAt: time.Now()}, p.submitErr
}
func fixture() (*Service, *memoryStore, *providerStub, *customer.Account) {
store := &memoryStore{rows: map[bson.ObjectID]Binding{}}
provider := &providerStub{}
a := &customer.Account{}
a.ID = bson.NewObjectID()
a.Name = "Ada Lovelace"
a.Login = "ada@example.com"
a.Password = "never transmit"
return &Service{Store: store, Provider: provider, Logger: zap.NewNop()}, store, provider, a
}
func TestIntakeRetryUsesPersistedSubjectSnapshotAndStableSource(t *testing.T) {
s, store, p, a := fixture()
p.submitErr = merrors.ServiceUnavailable("upstream")
require.Error(t, s.Ensure(context.Background(), a))
require.Error(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.registrations)
b, err := store.Get(context.Background(), a.ID)
require.NoError(t, err)
require.Equal(t, "subject-1", b.SubjectRef)
require.Nil(t, b.Receipt)
a.Name = "changed retry"
p.submitErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.NoError(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.registrations)
require.Equal(t, 2, p.submissions)
require.Equal(t, p.snapshots[0], p.snapshots[1])
require.Equal(t, "Ada Lovelace", p.snapshots[1].FullName)
require.Equal(t, p.sources[0], p.sources[1])
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 2, p.submissions)
other := a.Copy()
other.ID = bson.NewObjectID()
require.Error(t, s.Ready(context.Background(), other.ID))
}
func TestLostAcceptanceReceiptIsSafelyReplayed(t *testing.T) {
s, store, p, a := fixture()
store.acceptErr = merrors.ServiceUnavailable("storage")
require.Error(t, s.Ensure(context.Background(), a))
store.acceptErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 1, p.registrations)
require.Equal(t, 2, p.submissions)
require.Equal(t, p.sources[0], p.sources[1])
}
func TestAmbiguousRegistrationNeverAutomaticallyRegistersAgain(t *testing.T) {
for _, bindFailure := range []bool{false, true} {
t.Run(map[bool]string{false: "request", true: "binding"}[bindFailure], func(t *testing.T) {
s, store, p, a := fixture()
if bindFailure {
store.bindErr = merrors.ServiceUnavailable("storage")
} else {
p.registerErr = merrors.ServiceUnavailable("lost response")
}
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
b := store.rows[a.ID]
b.RegisteringAt = time.Now().Add(-time.Minute)
store.rows[a.ID] = b
store.bindErr = nil
p.registerErr = nil
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
require.Equal(t, 1, p.registrations)
require.Zero(t, p.submissions)
})
}
}
func TestConcurrentRequestCannotRegisterSameCustomerTwice(t *testing.T) {
s, _, p, a := fixture()
p.entered = make(chan struct{})
p.release = make(chan struct{})
done := make(chan error, 1)
go func() { done <- s.Ensure(context.Background(), a) }()
<-p.entered
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrServiceUnavailable)
close(p.release)
require.NoError(t, <-done)
require.Equal(t, 1, p.registrations)
require.NoError(t, s.Ensure(context.Background(), a))
require.Equal(t, 1, p.submissions)
}
func TestNamespaceChangeFailsClosed(t *testing.T) {
s, store, p, a := fixture()
require.NoError(t, s.Ensure(context.Background(), a))
b := store.rows[a.ID]
b.Namespace = "other-account-or-organization"
store.rows[a.ID] = b
require.ErrorIs(t, s.Ensure(context.Background(), a), merrors.ErrFailedPrecondition)
require.Error(t, s.Ready(context.Background(), a.ID))
require.Equal(t, 1, p.submissions)
}
func TestHandoffLogsExcludeAssertionsAndCredentials(t *testing.T) {
s, _, p, a := fixture()
core, logs := observer.New(zap.InfoLevel)
s.Logger = zap.New(core)
p.submitErr = merrors.ServiceUnavailable(a.Login + " " + a.Password)
require.Error(t, s.Ensure(context.Background(), a))
p.submitErr = nil
require.NoError(t, s.Ensure(context.Background(), a))
require.Len(t, logs.All(), 2)
for _, entry := range logs.All() {
require.NotContains(t, entry.Message, a.Login)
require.NotContains(t, entry.Message, a.Name)
fields := entry.ContextMap()
require.Equal(t, a.ID.Hex(), fields["account_ref"])
require.NotEmpty(t, fields["request_ref"])
for _, value := range fields {
require.NotEqual(t, a.Login, value)
require.NotEqual(t, a.Name, value)
require.NotEqual(t, a.Password, value)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package identity
import (
"context"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store interface {
Get(context.Context, bson.ObjectID) (*Binding, error)
Create(context.Context, *Binding) error
Claim(context.Context, bson.ObjectID, string) (bool, error)
Bind(context.Context, bson.ObjectID, string, string) error
Accept(context.Context, bson.ObjectID, string, Receipt) error
}
type mongoStore struct{ records repository.Repository }
func OpenStore(factory db.Factory) (Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
records := repository.CreateRepository(source.Majority(d.Database), "signup_facts")
if err = records.CreateIndex(&ri.Definition{Name: "provider_subject_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "subjectRef", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("subjectRef"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{records: records}, nil
}
func (s *mongoStore) Get(ctx context.Context, id bson.ObjectID) (*Binding, error) {
b := &Binding{}
err := s.records.Get(ctx, id, b)
return b, err
}
func (s *mongoStore) Create(ctx context.Context, b *Binding) error {
return s.records.Insert(ctx, b, nil)
}
func scoped(id bson.ObjectID, namespace string) repository.FilterQuery {
return repository.IDFilter(id).Filter(repository.Field("namespace"), namespace)
}
func (s *mongoStore) Claim(ctx context.Context, id bson.ObjectID, namespace string) (bool, error) {
query := scoped(id, namespace).Filter(repository.Field("registering"), false).Filter(repository.Field("subjectRef"), "")
return s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("registering"), true).Set(repository.Field("registeringAt"), time.Now().UTC()), &Binding{})
}
func (s *mongoStore) Bind(ctx context.Context, id bson.ObjectID, namespace, subject string) error {
query := scoped(id, namespace).Filter(repository.Field("registering"), true).Filter(repository.Field("subjectRef"), "")
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("subjectRef"), subject).Set(repository.Field("registering"), false), &Binding{})
if err != nil {
return err
}
if !matched {
return unavailable()
}
return nil
}
func (s *mongoStore) Accept(ctx context.Context, id bson.ObjectID, namespace string, receipt Receipt) error {
query := scoped(id, namespace).Filter(repository.Field("subjectRef"), receipt.SubjectRef)
matched, err := s.records.PatchOneByFilter(ctx, query, repository.Patch().Set(repository.Field("receipt"), receipt), &Binding{})
if err != nil {
return err
}
if !matched {
return unavailable()
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package identity
import (
"context"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableBindingClaimsAndIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
// The handoff store must override unsafe caller defaults before claiming
// ownership of a non-idempotent external operation.
q.Set("w", "0")
q.Set("readPreference", "secondary")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "facts"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
b := &Binding{Namespace: "configured-source", Facts: Facts{FullName: "Ada", Email: "ada@example.com"}}
b.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, b))
require.ErrorIs(t, store.Create(ctx, b), merrors.ErrDataConflict)
var wg sync.WaitGroup
var wins atomic.Int32
for range 12 {
wg.Go(func() {
claimed, err := store.Claim(ctx, b.ID, b.Namespace)
require.NoError(t, err)
if claimed {
wins.Add(1)
}
})
}
wg.Wait()
require.EqualValues(t, 1, wins.Load())
stored, err := store.Get(ctx, b.ID)
require.NoError(t, err)
require.True(t, stored.Registering)
require.False(t, stored.RegisteringAt.IsZero())
require.Error(t, store.Bind(ctx, b.ID, "other-source", "subject"))
require.NoError(t, store.Bind(ctx, b.ID, b.Namespace, "subject"))
duplicate := &Binding{Namespace: b.Namespace}
duplicate.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, duplicate))
claimedDuplicate, err := store.Claim(ctx, duplicate.ID, duplicate.Namespace)
require.NoError(t, err)
require.True(t, claimedDuplicate)
require.ErrorIs(t, store.Bind(ctx, duplicate.ID, duplicate.Namespace, "subject"), merrors.ErrDataConflict)
require.Error(t, store.Bind(ctx, b.ID, b.Namespace, "different-subject"))
receipt := Receipt{SubjectRef: "other-subject", SubmissionRef: "submission", AcceptedAt: time.Now().UTC().Truncate(time.Millisecond)}
require.Error(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
receipt.SubjectRef = "subject"
require.NoError(t, store.Accept(ctx, b.ID, b.Namespace, receipt))
// A separately opened store sees the durable receipt and cannot reclaim registration.
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.Get(ctx, b.ID)
require.NoError(t, err)
require.Equal(t, receipt, *stored.Receipt)
claimed, err := reopened.Claim(ctx, b.ID, b.Namespace)
require.NoError(t, err)
require.False(t, claimed)
_, err = reopened.Get(ctx, bson.NewObjectID())
require.ErrorIs(t, err, merrors.ErrNoData)
}
+159
View File
@@ -0,0 +1,159 @@
package identity
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
)
// Profile holds unverified customer assertions. Email and customer reference are
// always supplied by the authenticated backend, never accepted from the browser.
type Profile struct {
FullName string
DateOfBirth string
ResidenceCountry string
}
func (p Profile) Validate() error {
birth, err := time.Parse("2006-01-02", p.DateOfBirth)
if strings.TrimSpace(p.FullName) == "" || len(p.FullName) > 800 || err != nil || !birth.Before(time.Now()) || len(p.ResidenceCountry) != 2 || p.ResidenceCountry[0] < 'A' || p.ResidenceCountry[0] > 'Z' || p.ResidenceCountry[1] < 'A' || p.ResidenceCountry[1] > 'Z' {
return merrors.InvalidArgument("complete identity profile required")
}
return nil
}
type Verification struct {
Status string
UpgradePending bool
Reason string
SubmissionID string
Profile Profile
}
// VerificationProvider consumes reviewed state through the platform public API.
// It cannot accept a browser-selected subject, policy profile or approval.
type VerificationProvider interface {
Verification(context.Context, string) (Verification, error)
SubmitVerification(context.Context, string, string, Profile, Facts, []byte, []byte) (Verification, error)
}
// Decimal amounts are transported losslessly in their currency's major unit.
// No fiat scale or illustrative client caps are applied to provider policy.
type Limit struct {
Operation string
Period string
Currency string
Cap string
Remaining string
Used string
Reserved string
}
type Limits struct {
Tier string
ProfileRef string
Entries []Limit
}
type LimitsProvider interface {
Limits(context.Context, string) (Limits, error)
}
func (s *Service) Limits(ctx context.Context, a *customer.Account) (Limits, error) {
b, err := s.binding(ctx, a)
if err != nil {
return Limits{}, err
}
p, ok := s.Provider.(LimitsProvider)
if !ok {
return Limits{}, unavailable()
}
return p.Limits(ctx, b.SubjectRef)
}
func (s *Service) binding(ctx context.Context, a *customer.Account) (*Binding, error) {
if s == nil || s.Provider == nil || s.Store == nil {
return nil, merrors.ServiceUnavailable("identity verification unavailable")
}
if a == nil || a.ID.IsZero() {
return nil, merrors.InvalidArgument("customer required")
}
b, err := s.Store.Get(ctx, a.ID)
if errors.Is(err, merrors.ErrNoData) || err == nil && b.Receipt == nil {
if err = s.Ensure(ctx, a); err != nil {
return nil, err
}
b, err = s.Store.Get(ctx, a.ID)
}
if err != nil {
return nil, err
}
if b.Namespace != s.Provider.Namespace() || b.SubjectRef == "" || b.Receipt == nil || b.Receipt.SubjectRef != b.SubjectRef {
return nil, merrors.FailedPrecondition("identity binding requires reconciliation")
}
return b, nil
}
func (s *Service) Verification(ctx context.Context, a *customer.Account) (Verification, error) {
b, err := s.binding(ctx, a)
if err != nil {
return Verification{}, err
}
provider, ok := s.Provider.(VerificationProvider)
if !ok {
return Verification{}, unavailable()
}
v, err := provider.Verification(ctx, b.SubjectRef)
if err == nil && v.Profile.FullName == "" {
v.Profile.FullName = a.Name
}
return v, err
}
func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, key string, p Profile, document, selfie []byte) (Verification, error) {
if len(key) < 16 || len(key) > 128 {
return Verification{}, merrors.InvalidArgument("valid idempotency key required")
}
if err := p.Validate(); err != nil {
return Verification{}, err
}
for _, photo := range [][]byte{document, selfie} {
if err := ValidatePhoto(photo); err != nil {
return Verification{}, err
}
}
b, err := s.binding(ctx, a)
if err != nil {
return Verification{}, err
}
provider, ok := s.Provider.(VerificationProvider)
if !ok {
return Verification{}, unavailable()
}
digest := sha256.Sum256([]byte(a.ID.Hex() + "\x00" + key))
facts := b.Facts
facts.Email = a.Login
facts.CustomerRef = a.ID.Hex()
v, err := provider.SubmitVerification(ctx, b.SubjectRef, "sxchnge:identity:"+hex.EncodeToString(digest[:]), p, facts, document, selfie)
if err != nil {
s.Logger.Warn("Identity proof submission incomplete", zap.String("account_ref", a.ID.Hex()))
return Verification{}, err
}
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
return v, nil
}
// Subject resolves the authenticated customer's confirmed provider binding.
// Only server-side services may use this resource; clients cannot select it.
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
b, err := s.binding(ctx, a)
if err != nil {
return "", err
}
return b.SubjectRef, nil
}
@@ -0,0 +1,84 @@
package identity
import (
"bytes"
"context"
"image"
"image/png"
"testing"
"github.com/stretchr/testify/require"
)
type proofProvider struct {
*providerStub
subject, key string
profile Profile
facts Facts
calls int
}
func (p *proofProvider) Verification(_ context.Context, subject string) (Verification, error) {
p.subject = subject
return Verification{Status: "unverified"}, nil
}
func (p *proofProvider) SubmitVerification(_ context.Context, subject, key string, profile Profile, facts Facts, _, _ []byte) (Verification, error) {
p.subject = subject
p.key = key
p.profile = profile
p.facts = facts
p.calls++
return Verification{Status: "pending", SubmissionID: "receipt"}, nil
}
func proofImage(t *testing.T, width, height int) []byte {
t.Helper()
var body bytes.Buffer
require.NoError(t, png.Encode(&body, image.NewNRGBA(image.Rect(0, 0, width, height))))
return body.Bytes()
}
func TestVerificationUsesBoundSubjectAndStableCustomerKey(t *testing.T) {
s, _, base, a := fixture()
p := &proofProvider{providerStub: base}
s.Provider = p
ctx := t.Context()
result, err := s.Verification(ctx, a)
require.NoError(t, err)
require.Equal(t, a.Name, result.Profile.FullName)
require.Equal(t, "subject-1", p.subject)
photo := proofImage(t, 600, 600)
profile := Profile{FullName: "Legal Customer Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
key := p.key
require.Equal(t, a.ID.Hex(), p.facts.CustomerRef)
require.Equal(t, a.Login, p.facts.Email)
require.Equal(t, profile, p.profile)
_, err = s.SubmitVerification(ctx, a, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
require.Equal(t, key, p.key)
require.Equal(t, 1, base.registrations)
other := a.Copy()
other.ID[0]++
_, err = s.SubmitVerification(ctx, other, "0123456789012345", profile, photo, photo)
require.NoError(t, err)
require.NotEqual(t, key, p.key)
}
func TestVerificationValidationBeforeExternalHandoff(t *testing.T) {
s, _, base, a := fixture()
p := &proofProvider{providerStub: base}
s.Provider = p
photo := proofImage(t, 600, 600)
for _, profile := range []Profile{{}, {FullName: "Name", DateOfBirth: "1990-02-30", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "2990-04-23", ResidenceCountry: "PL"}, {FullName: "Name", DateOfBirth: "1990-04-23", ResidenceCountry: "USA"}} {
_, err := s.SubmitVerification(t.Context(), a, "0123456789012345", profile, photo, photo)
require.Error(t, err)
}
require.Zero(t, p.calls)
require.Zero(t, base.registrations)
require.Error(t, ValidatePhoto([]byte("not an image")))
require.Error(t, ValidatePhoto(proofImage(t, 599, 600)))
require.Error(t, ValidatePhoto(photo[:len(photo)-20]))
require.Error(t, ValidatePhoto(make([]byte, MaxPhotoBytes+1)))
var unavailableService *Service
_, err := unavailableService.Verification(t.Context(), a)
require.Error(t, err)
}
+84
View File
@@ -0,0 +1,84 @@
package paymenthttp
import (
"git.sendico.io/tech/sxchnge/product/payments"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
"time"
)
type requestDTO struct {
Kind string `json:"kind"`
Source string `json:"source"`
Destination string `json:"destination"`
Recipient string `json:"recipient"`
Amount int64 `json:"amount"`
}
func (d requestDTO) model() payments.Request {
return payments.Request{Kind: d.Kind, Source: d.Source, Destination: d.Destination, Recipient: d.Recipient, Amount: d.Amount}
}
func fromRequest(r payments.Request) requestDTO {
return requestDTO{r.Kind, r.Source, r.Destination, r.Recipient, r.Amount}
}
type quoteDTO struct {
ID string `json:"id"`
Request requestDTO `json:"request"`
Fee int64 `json:"fee"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
ExpiresAt time.Time `json:"expiresAt"`
Numerator int64 `json:"rateNumerator"`
Denominator int64 `json:"rateDenominator"`
}
func fromQuote(q payments.Quote) quoteDTO {
return quoteDTO{q.ID, fromRequest(q.Request), q.Fee, q.Debit, q.Credit, q.ExpiresAt, q.RateNumerator, q.RateDenominator}
}
type operationDTO struct {
ID string `json:"id"`
Quote quoteDTO `json:"quote"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
func fromOperation(o payments.Operation) operationDTO {
return operationDTO{o.ID, fromQuote(o.Quote), o.Status, o.CreatedAt}
}
type accountDTO struct {
ID string `json:"id"`
Currency string `json:"currency"`
Precision int `json:"precision"`
Available int64 `json:"available"`
PayoutMethod string `json:"payoutMethod"`
PayoutLabel string `json:"payoutLabel"`
CanFund bool `json:"canFund"`
}
type snapshotDTO struct {
Accounts []accountDTO `json:"accounts"`
Operations []operationDTO `json:"operations"`
}
func fromSnapshot(s payments.Snapshot) snapshotDTO {
v := snapshotDTO{Accounts: []accountDTO{}, Operations: []operationDTO{}}
for _, a := range s.Accounts {
v.Accounts = append(v.Accounts, accountDTO{a.ID, a.Currency, a.Precision, a.Available, a.PayoutMethod, a.PayoutLabel, a.FundingMethod != ""})
}
for i := len(s.Operations) - 1; i >= 0; i-- {
v.Operations = append(v.Operations, fromOperation(s.Operations[i]))
}
return v
}
type challengeDTO struct {
ID string `json:"challengeId"`
Target string `json:"target"`
ExpiresAt time.Time `json:"expiresAt"`
ResendAt time.Time `json:"resendAt"`
}
func fromChallenge(c factor.Challenge) challengeDTO {
return challengeDTO{c.ID, c.Target, c.ExpiresAt, c.ResendAt}
}
+164
View File
@@ -0,0 +1,164 @@
package paymenthttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/payments"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *payments.Service
logger mlogger.Logger
}
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
h := &router{s, logger.Named("payment_http")}
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
}
func decode(r *http.Request, dst any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
d.DisallowUnknownFields()
if d.Decode(dst) != nil {
return payments.InvalidAmount
}
var extra any
if d.Decode(&extra) != io.EOF {
return payments.InvalidAmount
}
return nil
}
func (h *router) result(data any, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err == nil {
response.Ok(h.logger, data)(w, r)
return
}
code := "unavailable"
status := http.StatusServiceUnavailable
var f payments.Failure
if errors.As(err, &f) {
code = string(f)
if f != payments.Unavailable && f != payments.Uncertain {
status = http.StatusBadRequest
}
}
switch {
case errors.Is(err, verification.ErrTokenExpired):
code = "factorExpired"
status = 400
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
code = "factorLocked"
status = 400
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
code = "factorInvalid"
status = 400
}
if throttle, ok := verification.Throttle(err); ok {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
code = "factorCooldown"
status = 429
}
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
}
}
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, e := h.service.Fetch(r.Context(), a)
return h.result(fromSnapshot(v), e)
}
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d requestDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Quote(r.Context(), a, d.model())
return h.result(fromQuote(v), e)
}
type actionDTO struct {
Quote string `json:"quoteId"`
Key string `json:"idempotencyKey"`
Challenge string `json:"challengeId"`
Code string `json:"code"`
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
return h.result(fromOperation(v), e)
}
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Reconcile(r.Context(), a, d.Key)
if v == nil {
return h.result(nil, e)
}
return h.result(fromOperation(*v), e)
}
func requestIP(r *http.Request) string {
if ip := ipguard.ClientIP(r); ip != nil {
return ip.String()
}
return "unresolved"
}
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
return h.result(struct {
Key string `json:"idempotencyKey"`
}{d.Key}, e)
}
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
return h.result(struct {
Locale string `json:"locale"`
}{a.Locale}, nil)
}
@@ -0,0 +1,36 @@
package paymenthttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
recorder := httptest.NewRecorder()
h.fetch(request, a, nil)(recorder, request)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
require.NotContains(t, recorder.Body.String(), "7500")
}
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
recorder := httptest.NewRecorder()
h.quote(request, a, nil)(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package payments owns customer quotation ownership and durable execution intent.
// Money and settlement remain authoritative in the provider.
package payments
import (
"context"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Account struct {
ID, Currency string
Precision int
Available int64
PayoutMethod, PayoutLabel, FundingMethod string
}
type Request struct {
Kind, Source, Destination, Recipient string
Amount int64
}
type Quote struct {
ID string
Request Request
Fee, Debit, Credit int64
ExpiresAt time.Time
RateNumerator, RateDenominator int64
}
type Operation struct {
ID string
Quote Quote
Status string
CreatedAt time.Time
}
type Snapshot struct {
Accounts []Account
Operations []Operation
}
type Record struct {
storable.Base `bson:",inline"`
Customer bson.ObjectID `bson:"customer"`
Namespace string `bson:"namespace"`
Subject string `bson:"subject"`
Quote Quote `bson:"quote"`
ProviderQuote string `bson:"providerQuote"`
Key string `bson:"key"`
Payment string `bson:"payment"`
}
func (*Record) Collection() string { return "payment_quotes" }
type Provider interface {
Namespace() string
Accounts(context.Context, string) ([]Account, error)
Quote(context.Context, string, Request, []Account) (Quote, error)
Execute(context.Context, string, string) (RemoteOperation, error)
Payment(context.Context, string) (RemoteOperation, error)
}
type RemoteOperation struct {
ID, QuoteRef, Status string
CreatedAt time.Time
}
type Identity interface {
Subject(context.Context, *customer.Account) (string, error)
}
type Failure string
func (e Failure) Error() string { return string(e) }
const (
Unavailable Failure = "unavailable"
InvalidAmount Failure = "invalidAmount"
InvalidRecipient Failure = "invalidRecipient"
InsufficientFunds Failure = "insufficientFunds"
LimitExceeded Failure = "limitExceeded"
ExpiredQuote Failure = "expiredQuote"
Unsupported Failure = "unsupported"
NotFound Failure = "notFound"
Uncertain Failure = "uncertain"
FactorRequired Failure = "factorRequired"
)
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more