Author SHA1 Message Date
Stephan D 62df9eac5f Merge remote-tracking branch 'origin/main' into wallet-66 2026-10-23 12:17:10 +02:00
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 464bd2d565 Merge pull request 'Select residence country by searchable name' (#65) from kyc-64 into main 2026-10-23 10:12:13 +00:00
Stephan D f1ff40d072 Record country selector delivery PR (#64) 2026-10-23 11:48:34 +02:00
Stephan D 417e6d96b6 Select residence country by searchable name (#64) 2026-10-23 11:47:59 +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
67 changed files with 2481 additions and 238 deletions

No files matched your search

+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 builders, dynamic-template dependency, notification events and unused producer
were removed. Resend is a new driver using the same contract. 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 Shared messaging was selected from that same Sendico revision's
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`, `api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.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 The outbox dispatcher publishes opaque IDs using shared messaging's durable
transport, adapted from Sendico. A durable NATS worker claims the notification transport, adapted from Sendico. A durable NATS worker claims the notification
with an atomic conditional patch and saves the outcome before acknowledging. 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 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 uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
subject, text and HTML, bounded timeouts and safe error classification. 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 ## Run
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with 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 - Resend receives the notification ID as its provider idempotency key. Its window
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
delivery is not promised. 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 - Changing the configured driver fails queued jobs pinned to the old driver with
`driver_changed`. Drain queued jobs before changing sender credentials/config. `driver_changed`. Drain queued jobs before changing sender credentials/config.
- Callers authorize recipients and preferences. No direct customer-database access, - Callers authorize recipients and preferences. No direct customer-database access,
bulk campaigns, provider failover, SMS, push or Telegram delivery is included. bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
Structured logs report notification IDs, driver, state and attempt count; message Structured logs report notification IDs, driver, state and attempt count. Provider
content, template values, addresses and provider response bodies are excluded. 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. Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
## Validate and distribute ## Validate and distribute
+1 -3
View File
@@ -45,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
defer cancel() defer cancel()
_ = client.Disconnect(cleanup) _ = client.Disconnect(cleanup)
}() }()
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
repo, err := store.New(logger, client.Database()) repo, err := store.New(logger, client.Database())
if err != nil { if err != nil {
return err return err
@@ -57,7 +55,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
} }
defer rt.Close() defer rt.Close()
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger) 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 { if err != nil {
return err 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} 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} 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") 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) subscription, err := transport.OpenConsumer(ctx, settings, consumer)
if err != nil { if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker") return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
@@ -10,6 +10,7 @@ import (
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable" "git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
type outbox struct{ jobs []model.Notification } type outbox struct{ jobs []model.Notification }
@@ -23,6 +24,50 @@ type publisher struct {
records []reliable.Record 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 { func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
if p.fail { if p.fail {
return merrors.ServiceUnavailable("offline") return merrors.ServiceUnavailable("offline")
+1 -1
View File
@@ -13,7 +13,7 @@ messaging:
reconnect_wait: 2 reconnect_wait: 2
templates: "templates.example.json" templates: "templates.example.json"
email: email:
driver: resend # sendgrid or resend driver: resend # sendgrid or resend; see README for authenticated SMTP settings
settings: settings:
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
sender_address: "notifications@example.com" sender_address: "notifications@example.com"
+9
View File
@@ -19,6 +19,7 @@ type Driver string
const ( const (
SendGrid Driver = "sendgrid" SendGrid Driver = "sendgrid"
Resend Driver = "resend" Resend Driver = "resend"
SMTP Driver = "smtp"
) )
type Config = sharedmodel.DriverConfig[Driver] type Config = sharedmodel.DriverConfig[Driver]
@@ -33,6 +34,11 @@ type Outcome struct {
State model.State State model.State
Code string Code string
RetryAfter time.Duration RetryAfter time.Duration
// Provider diagnostics are sanitized operational data, not public failures.
HTTPStatus int
SMTPStatus int
ProviderError string
ProviderMessage string
} }
type Sender interface { type Sender interface {
Send(context.Context, string, model.Email) Outcome Send(context.Context, string, model.Email) Outcome
@@ -46,6 +52,9 @@ type client struct {
} }
func New(config Config) (Sender, error) { func New(config Config) (Sender, error) {
if config.Driver == SMTP {
return newSMTP(config)
}
var s Settings var s Settings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"}) decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil { 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) resp, err := c.http.Do(req)
if err != nil { 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() defer resp.Body.Close()
// Discard a bounded response without logging provider-controlled content. // Never pass raw provider bodies through the outcome or log them directly.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
outcome := Outcome{HTTPStatus: resp.StatusCode}
switch { switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300: case resp.StatusCode >= 200 && resp.StatusCode < 300:
return Outcome{State: model.Accepted} outcome.State = model.Accepted
case resp.StatusCode == 429: case resp.StatusCode == 429:
delay := time.Minute delay := time.Minute
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 { 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 { } else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
delay = time.Until(date) 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: 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: 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: 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" "errors"
"time" "time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap" "go.uber.org/zap"
@@ -23,6 +24,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
var outcome email.Outcome
switch { switch {
case !n.ExpiresAt.After(now): case !n.ExpiresAt.After(now):
n.State = model.Expired n.State = model.Expired
@@ -36,7 +38,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
deadline = n.ExpiresAt deadline = n.ExpiresAt
} }
sendCtx, cancel := context.WithDeadline(ctx, deadline) 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() cancel()
n.State = outcome.State n.State = outcome.State
n.FailureCode = outcome.Code 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. // Persist the result even when shutdown canceled the provider request.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel() 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)} 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 { if n.State == model.Accepted {
s.logger.Info("Email accepted by provider", fields...) s.logger.Info("Email accepted by provider", fields...)
} else { } else {
s.logger.Warn("Email delivery attempt finished", fields...) s.logger.Warn("Email delivery attempt finished", fields...)
} }
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
return true, nil return true, nil
} }
+44 -3
View File
@@ -2,18 +2,23 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"testing" "testing"
"time" "time"
"git.sendico.io/tech/sxchnge/notifications/email" "git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
) )
type workerRepo struct { type workerRepo struct {
Repository Repository
job model.Notification job model.Notification
saved *model.Notification saved *model.Notification
finishErr error
} }
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil } 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 n := r.job
return &n, nil 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 { type workerSender struct {
calls int 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)
}
}
})
}
}
+12 -4
View File
@@ -179,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()}}) 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 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 { 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) { _, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw) token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil { if err != nil {
@@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
return nil, err return nil, err
} }
} }
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)) 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
} }
@@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
if err := decodeFlow(r, &req); err != nil { if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err) 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) return pr.flowError(err)
} }
pr.logger.Info("Account activation completed") 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 { func challengeScope(raw string) string {
h := sha256.Sum256([]byte(raw)) h := sha256.Sum256([]byte(raw))
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
require.NoError(t, service.Facts.Ready(ctx, a.ID)) require.NoError(t, service.Facts.Ready(ctx, a.ID))
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"} login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
require.Equal(t, 403, post("login", login).Code) require.Equal(t, 403, post("login", login).Code)
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code) 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} guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard service.Facts = guard
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code) 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) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.False(t, a.IsActive()) require.False(t, a.IsActive())
guard.block = false guard.block = false
w = post("verification/confirm", map[string]string{"token": activation}) w = post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 202, w.Code, w.Body.String()) require.Equal(t, 202, w.Code, w.Body.String())
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code) require.Equal(t, "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) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status) require.Equal(t, customer.AccountActive, a.Status)
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) { func (c *Client) post(ctx context.Context, resource, requestRef string, payload any, expectedStatus int) (receiptDTO, error) {
var receipt receiptDTO var receipt receiptDTO
token, err := c.token() token, err := c.token()
if err != nil { if err != nil {
@@ -38,7 +38,7 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
} }
defer func() { _ = response.Body.Close() }() defer func() { _ = response.Body.Close() }()
// Never decode or expose upstream error bodies, which can contain assertions. // Never decode or expose upstream error bodies, which can contain assertions.
if response.StatusCode != http.StatusOK { if response.StatusCode != expectedStatus {
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted") return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
} }
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385)) decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
@@ -54,14 +54,14 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
} }
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) { func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
dto, err := c.post(ctx, "individuals", requestRef, nil) dto, err := c.post(ctx, "individuals", requestRef, nil, http.StatusCreated)
if err != nil { if err != nil {
return "", err return "", err
} }
return mapSubject(dto) return mapSubject(dto)
} }
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) { func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts)) dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts), http.StatusOK)
if err != nil { if err != nil {
return identity.Receipt{}, err return identity.Receipt{}, err
} }
@@ -48,6 +48,7 @@ func TestSignupWireContractAndCredentialRotation(t *testing.T) {
if strings.Contains(r.URL.Path, "/individuals/") { if strings.Contains(r.URL.Path, "/individuals/") {
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID")) require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
require.Empty(t, body) require.Empty(t, body)
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject) _, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
} else { } else {
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body)) require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
@@ -95,6 +96,36 @@ func TestRejectInvalidConfiguration(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestSignupReceiptsRequireOperationStatus(t *testing.T) {
for _, status := range []int{200, 201, 202, 204, 206, 300, 403, 409, 503} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00Z","submission_ref":%q,"accepted_at":"2026-10-07T10:30:00Z"}}`, subject, submission)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
ref, err := client.Register(context.Background(), "registration-request")
if status == http.StatusCreated {
require.NoError(t, err)
require.Equal(t, subject, ref)
} else {
require.Error(t, err)
require.Empty(t, ref)
}
receipt, err := client.Submit(context.Background(), subject, "key", identity.Facts{})
if status == http.StatusOK {
require.NoError(t, err)
require.Equal(t, submission, receipt.SubmissionRef)
} else {
require.Error(t, err)
require.Empty(t, receipt)
}
})
}
}
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) { func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -113,7 +144,11 @@ func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status) status := tc.status
if status == http.StatusOK && strings.Contains(r.URL.Path, "/individuals/") {
status = http.StatusCreated
}
w.WriteHeader(status)
_, _ = io.WriteString(w, tc.body) _, _ = io.WriteString(w, tc.body)
})) }))
defer server.Close() defer server.Close()
+33 -17
View File
@@ -27,10 +27,17 @@ project directory. Dockerfiles are in `infra/images/`. Runtime and startup files
are grouped by service under `infra/local/`. The stack consists of Vault, Mongo, are grouped by service under `infra/local/`. The stack consists of Vault, Mongo,
NATS, notifications, BFF and Caddy serving the frontend. NATS, notifications, BFF and Caddy serving the frontend.
The frontend container uses Compose `init: true` to reap orphaned `ssl_client`
children created by BusyBox's HTTPS `wget` health probe. Keep this enabled:
Caddy alone does not reap those children when it runs as PID 1, and repeated
probes can accumulate thousands of zombie processes. Recreate the frontend
container when applying this setting; restarting an old container does not
change its init configuration.
On first startup, bootstrap initializes/unseals persistent Vault, provisions On first startup, bootstrap initializes/unseals persistent Vault, provisions
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
until Resend credentials exist. Infrastructure remains available for provisioning. until the SMTP mailbox password exists. Infrastructure remains available for provisioning.
After all services pass their startup health checks and the public Caddy CA is After all services pass their startup health checks and the public Caddy CA is
exported, `make up` prints a Sendico-style completion message with the frontend, exported, `make up` prints a Sendico-style completion message with the frontend,
@@ -56,7 +63,7 @@ kv/
├── services/ ├── services/
│ ├── bff/jwt # jwt │ ├── bff/jwt # jwt
│ └── notifications/ │ └── notifications/
│ └── email/resend # api_key only │ └── email/smtp # password only
└── infrastructure/ └── infrastructure/
├── nats/auth # token; read by NATS and notifications ├── nats/auth # token; read by NATS and notifications
└── mongo/bootstrap # local administrator and replica-set bootstrap material └── mongo/bootstrap # local administrator and replica-set bootstrap material
@@ -74,29 +81,35 @@ There are no frontend certificates, notification certificates or generic `tls`
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
on the trusted private Docker network without caller authentication. on the trusted private Docker network without caller authentication.
## Resend ## Local SMTP mail
Open [Vault](http://127.0.0.1:18200) and edit Open [Vault](http://127.0.0.1:18200) and edit
`kv/services/notifications/email/resend`: `kv/services/notifications/email/smtp`:
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| `api_key` | Your Resend API key | | `password` | Password for the configured SMTP mailbox |
The driver and sender settings belong in `infra/local/notifications/runtime.yml`: The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
```yaml ```yaml
email: email:
driver: resend driver: smtp
settings: settings:
api_key_env: EMAIL_API_KEY host: mail.sendico.io
sender_address: "your-verified-sender@example.com" port: 465
username: no-reply@sendico.io
password_env: SMTP_PASSWORD
network_timeout: 10
sender_address: no-reply@sendico.io
sender_name: "SXCHNGE" sender_name: "SXCHNGE"
``` ```
Set your verified address in that file and rebuild. It is intentionally empty by The local stack uses Sendico's existing `no-reply@sendico.io` mailbox with
default, so notifications cannot start with an unconfigured sender. Vault Agent authenticated implicit TLS on port 465 and certificate verification enabled.
injects only the API key; it does not generate email configuration. Vault Agent injects only the password; host, username and sender are non-secret
runtime settings. Credentials are provisioned into SXCHNGE's own Vault; the
notification service does not read Sendico configuration or secrets at runtime.
Local administrator login material is in private `.local/dev/admin/init.json`. Local administrator login material is in private `.local/dev/admin/init.json`.
Product containers cannot access it. Alternatively use the hidden terminal prompt: Product containers cannot access it. Alternatively use the hidden terminal prompt:
@@ -106,12 +119,15 @@ make email
make build && make up make build && make up
``` ```
No placeholder key or delivery fallback is installed. Startup validates presence; No placeholder password or delivery fallback is installed. Startup validates
it does not send an email or validate domain ownership with Resend. Updating the configuration and secret presence; the SMTP connection is opened on demand.
API key causes Agent to restart notifications. Driver/sender changes require an Updating the password causes Agent to restart notifications. Driver/sender
image rebuild. Existing API keys survive bootstrap. Migration removes legacy changes require an image rebuild. Old Resend secrets remain in Vault history
driver/sender fields from the current Vault secret with a version-checked write; but are not used or readable by the notifications AppRole. HTTP providers remain
the preceding version is preserved in Vault history and the private archive. available for other deployments; switching one requires matching Agent/policy
and runtime configuration. SMTP has no provider idempotency guarantee: explicit
4xx rejections retry, 5xx rejections fail, and ambiguous transport outcomes require
operator reconciliation before another send.
## Browser HTTPS and internal connections ## Browser HTTPS and internal connections
+36
View File
@@ -105,6 +105,27 @@ docker compose --project-directory . -f infra/local/compose.yml \
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico -f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
``` ```
A BFF container restart can leave its session worker in the old shared network
namespace. The worker checks Vault DNS every ten seconds, with a five-second
lookup limit. Three consecutive failures log a credential-free recovery reason
and terminate the session process; Docker's restart policy then attaches it to
the BFF's current namespace. A successful lookup resets the counter, so transient
DNS failures do not immediately restart the worker.
Container recreation changes the BFF container ID as well as its namespace.
Bootstrap and credential-rotation tooling recreate BFF and its configured session
worker together. Compose also marks the session dependency for restart when BFF
is explicitly updated. For a manual forced recreation, select both services:
```sh
docker compose --project-directory . -f infra/local/compose.yml \
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env \
up -d --no-deps --force-recreate --wait bff sendico
```
The parent process cleans up its bridge, DNS monitor and renewal delay on exit.
This recovery retains the configured provider URL, account and replay namespace.
`make test-dev` checks seven healthy services, verified browser TLS, the Vault `make test-dev` checks seven healthy services, verified browser TLS, the Vault
layout and least-privilege identities, Mongo transactions/lease rotation, and layout and least-privilege identities, Mongo transactions/lease rotation, and
authenticated Sendico currency and ledger reads through the same loopback authenticated Sendico currency and ledger reads through the same loopback
@@ -130,6 +151,21 @@ and the established CA. A subsequent verified TLS check must succeed.
## Acceptance and operator UX review ## Acceptance and operator UX review
Recovery follow-up for [task #57](https://git.sendico.io/tech/sxchnge/issues/57) /
[PR #59](https://git.sendico.io/tech/sxchnge/pulls/59), validated on 2026-10-19:
`make test-dev-tooling`, `make test-dev-rotation`, Compose configuration validation,
`sh infra/local/tools.sh verify` and `git diff --check` pass. A BFF-only restart
triggered automatic session restart and restored matching network namespaces;
paired recreation also finished healthy. Rotation observed three real Vault
pool replacements, an unchanged BFF process and 534 transaction-backed requests
with zero failures. The final check verifies all seven SXCHNGE services, browser
HTTPS, Vault/database boundaries and authenticated Sendico currency/ledger reads.
An earlier check failed because Sendico's ledger announcement connection was
closed; restoring Discovery and its service announcers in dependency order
recovered that existing stand. No Sendico source or financial data was changed,
and no email or payment was sent. This follow-up changes shell/Compose tooling;
it does not rerun the earlier full Go/Flutter suite for unchanged product source.
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`, Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product `make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
sites, Sendico Backoffice and both API health endpoints returned HTTP 200; sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
+216 -4
View File
@@ -5,8 +5,191 @@ Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
customer creation, runtime, shared database source and Flutter error feedback. customer creation, runtime, shared database source and Flutter error feedback.
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`. Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
## Registration response correction
Follow-up [#57](https://git.sendico.io/tech/sxchnge/issues/57), branch
`identity-facts-57`, delivered in [PR #59](https://git.sendico.io/tech/sxchnge/pulls/59)
against `main` (implementation `abca15f`), corrects the registration success status
to HTTP 201 Created. The PR remains unmerged.
Sendico's individual creation handler returns 201; the original adapter and test
fixture incorrectly required 200. A successful registration therefore left a
pending customer with an unresolved claim and `signup_attention_required`.
The adapter now checks the exact status per operation before validating receipts:
201 for registration and 200 for Facts intake. Other statuses, including other
2xx responses, remain failures. Malformed receipts and uncertain registrations
still require the existing recovery rules.
On 2026-10-17, the affected local signup was reconciled using its registration
correlation in Sendico logs and an authenticated organization-scoped individual
GET. A conditional majority write bound that confirmed individual to the existing
local claim, preserving the saved facts, namespace and registration request.
The rebuilt BFF resumed the public activation-resend flow: Facts intake returned
a persisted receipt and the endpoint returned 202 after activation-email queue
acceptance. No replacement individual was registered. The account remains
pending until the customer confirms the email; queue acceptance does not prove
inbox delivery.
Recovery also exposed a notification worker lifetime issue: its NATS subscription
retained the 20-second startup context and was automatically unsubscribed after
that deadline. Resource provisioning now has its own bounded context, while the
subscription follows the service lifetime and closes at shutdown. A regression
test checks that releasing provisioning leaves the subscription context active,
and service cancellation still ends it.
The rebuilt worker processed the queued activation message. The live Resend
provider rejected it with `provider_authentication_failed` (HTTP 401 or 403),
leaving delivery in `failed` after one attempt. Later diagnostics below identify
the unverified sender domain as the actual blocker. Correct domain authorization
before activation resend with a new idempotency key. Credential rotation is not
required by that error; if a separate rotation is needed, use `make email` and
allow Vault Agent to refresh/restart the notification process. Do not replay the
failed notification as a success or mark the customer active on their behalf.
Validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and canonical
frontend suites), `make lint-backend`, focused provider/identity race tests and
notification bus/app race tests. The HTTP 201 regression first failed against the
old adapter and passed after correction. An earlier full run hit a database
credential-rotation timing failure at `rotation_test.go:249` during the Docker
build; the isolated race-test retry and both subsequent full runs passed.
Local BFF and notifications images were rebuilt with fresh upstream manifests
and resolved stable digest pins; the BFF, session sidecar and notifications
services are healthy. NATS shows the worker still waiting for deliveries beyond
the startup deadline, with no pending or unacknowledged jobs. Live email delivery
is blocked by provider authorization as described above. No security scans were
requested or run. Unrelated notifications runtime settings are excluded from
the task commits.
### Provider diagnostics follow-up
The same task/PR adds transient provider diagnostics to the worker's existing
structured outcome log: `provider_status`, `provider_error` and
`provider_message`. The adapter extracts bounded Resend/SendGrid error fields and
redacts credentials, request values, addresses, URLs and token/code fragments.
Transport errors have a sanitized cause; invalid or oversized responses have a
safe fallback. Diagnostics remain outside stored notification models and public
gRPC responses. The outcome is logged even when its result write fails. See
the [notification logging contract](../backend/notifications/README.md).
Runtime inspection confirmed `email.driver: resend`, an API key in Vault, and
sender `noreply@sxchnge.com`. A live diagnostic notification used Resend's
documented `delivered+sxchnge-config@resend.dev` test mailbox, without retrying
the customer's email. The rebuilt worker logged HTTP 403,
`provider_error: validation_error`, and the sanitized message:
`The sxchnge.com domain is not verified. Please, add and verify your domain on [redacted-url]`.
The precise blocker is sender-domain verification, not missing local Resend
configuration. A sending-only key is appropriate for sending; its inability to
read Resend's logs API does not explain the original send failure. Verify the
configured domain in Resend (and its required DNS records), or explicitly choose
an already verified sender domain. No sender/domain/DNS setting was changed by
this follow-up; the customer's account remains pending email confirmation.
Follow-up validation passed: `GOFLAGS=-p=2 make test`, `make lint-backend`, and
`go -C backend/notifications test -race ./email ./service -count=1`. Coverage
includes Resend/SendGrid messages, malformed/trailing/oversized response bodies,
credential/content/code redaction, Unicode-safe truncation, sanitized transport
errors, and exactly one provider outcome log even when persistence fails. The
notifications image was rebuilt from fresh stable digest pins and deployed
healthy; the live test verified both the diagnostic log and the unchanged generic
gRPC failure response. No security scan or real customer email retry was run.
## Behavior and boundaries ## Behavior and boundaries
### Local mail client
The user subsequently selected Sendico's existing mailbox instead of Resend for
local notifications. The authoritative Sendico configuration resolves to
`no-reply@sendico.io`, `mail.sendico.io:465`, authenticated implicit TLS and a
10-second network timeout. SXCHNGE's owned `smtp` adapter consumes rendered email
models through the existing sender contract and queue; it does not import or
access Sendico business modules, databases or messaging. The password was copied
once, without printing it, from the running Sendico notification environment into
`services/notifications/email/smtp` in SXCHNGE Vault. Only `password` is stored
there; host, port, username, display name and sender remain in runtime YAML.
The SMTP adapter verifies certificates, authenticates before submission, sends
UTF-8 MIME alternatives, and requires the final DATA 250 reply for acceptance.
SMTP 4xx replies retry through the existing worker policy; 5xx replies fail;
ambiguous transport outcomes remain UNKNOWN and are not immediately resent.
Message-ID is correlation only, not a provider idempotency guarantee. Existing
failed Resend notifications are not replayed or rewritten. The unverified
`sxchnge.com` Resend domain is no longer the selected local delivery path.
Live verified-TLS SMTP authentication and NOOP succeeded with reply 250 using
the password from SXCHNGE Vault, without submitting email. SMTP adapter race
tests, `make lint-backend`, `make test-dev-tooling` and `make test-dev-infra`
pass, including canonical Vault layout, service permission isolation, database
credential issuance/renewal/revocation and transactions. The final
`GOFLAGS=-p=2 make test` passes all backend race tests and frontend suites.
Earlier attempts hit Docker archive-copy/reaper timeouts, Go proxy TLS failures
and stalled cache extraction/container startup. The user authorized restarting
Docker Desktop. After recovery, the normal Compose `build --pull notifications`
succeeded with the resolved stable digest pins; no checks were disabled. The
generator stage shares the existing BuildKit Go module/build caches and retains
the pinned tools and canonical Make generation command. A discarded fallback
compiler attempt also failed to access its checksum-state directory; it was not
used for the deployed artifact.
The matching Vault Agent policy/template were applied, and notifications was
recreated healthy. Its running image matches the rebuilt image
`sha256:d4cbc0cb9005e9cef9381abdf2a13f672104d160536ee6d77ac73a909c0a8638`,
and its startup log confirms `email_driver: smtp`. No customer email was sent or
failed Resend job replayed; SMTP authentication is not a recipient-delivery
check. Sendico's Vault sealed during the Docker restart and was unsealed with
its existing local key, without reinitialization or exposing credentials.
Its affected services were restarted in dependency order. The local Sendico
session sidecar shares the BFF network namespace, so it was restarted after the
BFF to restore Docker DNS and the loopback bridge. Final
`sh infra/local/tools.sh verify` passes: seven healthy services, verified HTTPS
for frontend/proxied BFF, authenticated Sendico currency/ledger reads and the
Vault/database checks. No Sendico repository files were changed.
On October 18, the frontend had 15,897 processes, including 15,888 unreaped
`ssl_client` zombie children of Caddy PID 1. The three-second HTTPS health
probe used BusyBox `wget`, which left those children behind. This confirmed a
recurring local infrastructure leak, independently of SMTP authentication.
Compose now enables `init: true` for frontend, and only that container was
recreated. PID 1 is now `docker-init`; 40 consecutive HTTPS probes completed
with zero zombie processes and a bounded count of 15 processes afterward.
Compose configuration validation and `make test-dev-tooling` pass. The API
health endpoint returns HTTP 200; a diagnostic request using the existing
account and a generated incorrect password returned HTTP 400 `email_in_use`
without creating an account or sending email.
The contemporaneous full local verification attempt is blocked by MongoDB's
five-second health-check timeout under continuing host load; it is not recorded
as passing. The exact response to the reported Safari attempt was not captured,
and the frontend's generic unavailable text also covers some HTTP 400 input
errors. A fresh user retry is needed to confirm the visible signup outcome.
On October 19 the failing API was directly observed returning HTTP 502. The BFF
had restarted while the session worker retained its old network namespace;
`/proc/self/ns/net` differed between them, and the worker could not resolve
Vault. Sendico's public API itself returned HTTP 200. Restarting only the worker
reattached its namespace and restored SXCHNGE API HTTP 200. This is a confirmed
cause of that 502, separate from the previous process leak and host-load finding.
The session now monitors bounded DNS failures and exits after three consecutive
failures so Docker can reattach it automatically. Bootstrap and rotation pair
BFF/session recreation, with Compose dependency restart enabled. Offline shell
fixtures cover transient-failure reset, sustained-failure recovery, safe parent
targeting and conditional service pairing. A live BFF-only restart automatically
restarted the session and restored matching namespaces, fresh session readiness
and API HTTP 200. A second live test recreated BFF and its session together and
confirmed healthy services with matching namespaces. No provider bindings,
customer records, credentials or Sendico source files were changed.
Final October 19 validation passes: `make test-dev-tooling`, Compose config
validation, `make test-dev-rotation` (three real rotations, unchanged BFF process,
534 transaction-backed requests and zero failures), and
`sh infra/local/tools.sh verify`. The last confirms all seven healthy services,
verified frontend/BFF HTTPS, Vault/database checks and authenticated Sendico
currency/ledger reads. An initial ledger-read failure was resolved by restoring
Sendico Discovery and its announcers in their documented dependency order after
closed NATS connections. No email was sent during validation. The recovery fix
and its validation are included in [PR #59](https://git.sendico.io/tech/sxchnge/pulls/59).
With `api.sendico` configured, the product BFF creates the local pending customer With `api.sendico` configured, the product BFF creates the local pending customer
and activation token atomically, then registers an independent individual through and activation token atomically, then registers an independent individual through
Sendico's public HTTP API and submits the sign-up assertions. It persists the Sendico's public HTTP API and submits the sign-up assertions. It persists the
@@ -18,7 +201,7 @@ backfilled or blocked at login.
The adapter consumes these existing Sendico contracts: The adapter consumes these existing Sendico contracts:
- `POST /individuals/{organization_ref}`, empty body, HTTP 200 registration - `POST /individuals/{organization_ref}`, empty body, HTTP 201 registration
receipt containing `subject_ref` and `registered_at`. receipt containing `subject_ref` and `registered_at`.
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt - `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
containing `submission_ref`, `subject_ref` and `accepted_at`. containing `submission_ref`, `subject_ref` and `accepted_at`.
@@ -73,9 +256,10 @@ the adapter rereads it on every request. Startup rejects missing, expired or
mismatched credentials and rejects Sendico configuration without notifications. mismatched credentials and rejects Sendico configuration without notifications.
HTTPS is required except for loopback HTTP test/development endpoints. Redirects HTTPS is required except for loopback HTTP test/development endpoints. Redirects
are rejected and each HTTP call has a four-second timeout. Non-200 responses, are rejected and each HTTP call has a four-second timeout. Unexpected response
malformed/truncated receipts, invalid identifiers, wrong subjects and unsuccessful statuses (registration requires 201; Facts intake requires 200), malformed/truncated
envelopes never count as acceptance. Upstream error bodies are not surfaced. receipts, invalid identifiers, wrong subjects and unsuccessful envelopes never
count as acceptance. Upstream error bodies are not surfaced.
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
activation for previously pending customers; use activation resend to submit activation for previously pending customers; use activation resend to submit
@@ -85,6 +269,34 @@ adapter fingerprint and blocks existing bindings for explicit reconciliation.
Token rotation for the same account retains that fingerprint. Plan such changes Token rotation for the same account retains that fingerprint. Plan such changes
as migrations; never reset a binding just to bypass the check. as migrations; never reset a binding just to bypass the check.
## Local signup request diagnosis
The October 19 follow-up screenshot still shows the generic unavailable message
after the network recovery. That screenshot alone does not establish a 503:
the frontend also maps some rejected inputs and browser transport failures to
that message. At the follow-up check the health endpoint returned 200, and BFF
signup counters contained only the operator's two deliberately invalid probes.
The user's current browser failure has not yet been reproduced or resolved.
Local Caddy now logs requests to exactly `/api/v1/accounts/signup` under
`http.log.access.signup`, including HTTP status and duration. The complete
request object, response headers and user identifier are removed before log
encoding, so paths/queries, cookies, authorization, addresses and user-supplied
headers cannot enter these access records. Bodies are never logged. Other
requests, including health probes and email confirmation URLs, are skipped.
Inspect with:
```sh
docker logs --since 5m sxchnge-local-frontend-1 2>&1 |
jq -Rrc 'fromjson? | select(.logger=="http.log.access.signup")'
```
Validation: Caddy configuration validation and reload passed; a deliberately
invalid signup returned 400 and produced the expected status/duration record.
Sentinel query, cookie and custom-header values were absent from its log.
The frontend was rebuilt with freshly resolved stable image pins. No real
signup or customer email was submitted during this diagnostic work.
## Persistence, retries and recovery ## Persistence, retries and recovery
The local `signup_facts` collection uses the customer ID as `_id`, so its unique The local `signup_facts` collection uses the customer ID as `_id`, so its unique
+147
View File
@@ -149,3 +149,150 @@ text uses a normal text node to remain exposed to assistive technology.
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
`onboarding-12`. The final browser accessibility tree exposes the recipient text, `onboarding-12`. The final browser accessibility tree exposes the recipient text,
demo status, status heading and recovery actions. PR is open for review. demo status, status heading and recovery actions. PR is open for review.
## Browser credential autofill
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
commit `86b6aaf`. Open for review; no merge or deployment is included.
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
declares their purpose explicitly:
| Input | Flutter hints | Browser autocomplete |
| --- | --- | --- |
| Signup name | `name` with the name keyboard | `name` |
| Account email | `username`, then `email`, with the email keyboard | `username` |
| Login password | `password` | `current-password` |
| Signup password and confirmation | `newPassword` on both | `new-password` |
Flutter uses the first hint on web and Apple platforms. Email is the credential
username; the email keyboard remains configured separately. Visibility toggles
keep the password role, so autofill does not depend on detecting masked input.
The confirmation field participates in the same group as the first password,
allowing the browser to fill both without copying one controller into the other.
Signup acceptance finalizes autofill before clearing password controllers and
navigating to email verification. Login finalizes on success; when a second
factor is required, it waits for verification and login completion. The password
remains only in the existing form controller during that challenge, then clears
on completed navigation or cancellation. Invalid input, rejected credentials and
failed verification do not request saving. Switching auth mode, abandoning the
form or leaving for unverified-account recovery cancels the autofill context.
Provider/service/model boundaries and existing request/outcome telemetry are
unchanged; no field events or credential telemetry are added. Browser popup
display and pre-submit autofill abandonment remain outside current measurement.
### Verification and release check
Focused widget tests send a platform autofill response with all four signup
values and check EN/RU controller updates, grouped credential metadata, visibility
changes, mode switching and abandonment. Auth tests cover validation failures,
service rejection/retry, stale responses, signup/login acceptance, unverified
recovery, invalid second-factor codes and cancellation without premature saving.
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
Native Safari verification remains a release check: computer-control permission
was unavailable in this session. On the deployed HTTPS origin with Safari
AutoFill enabled, focus the signup email and password fields, choose a suggested
strong password, verify that both password fields populate, then confirm that
login offers the saved credential. Repeat after showing/hiding passwords and
switching modes. Browser settings, stored credentials and browser heuristics
determine whether suggestions appear; automated Flutter tests cannot establish
that the system popup was displayed or that credentials were saved.
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
`make test-frontend` passes all 212 tests across the app and shared packages.
`flutter build web` passes. The built page's default rendering path was inspected
in the in-app browser: login exposes `username`/`current-password` in one HTML
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
This verifies the browser metadata, not Safari's credential popup. With Flutter
web accessibility semantics enabled, unfocused semantic inputs initially use
framework-default autocomplete values until focused; password-manager behavior
with assistive technology still requires a native browser audit.
## Verification links and login handoff
Task: [#60](https://git.sendico.io/tech/sxchnge/issues/60), branch
`email-verification-60`. Email links use `/verify-email?token=…`; the web entry
point installs Flutter's path URL strategy before initializing the application.
This matches Caddy's existing SPA fallback and preserves the path and query on a
fresh page load. Previously the default hash strategy could start at login and
never submit the activation token. Unknown routes retain the canonical login
fallback; invalid, expired and failed confirmation attempts remain on the
verification page with the existing recovery actions.
Activation consumes the token, checks the pending account and accepted signup
facts, and activates the account in the existing database transaction. Only
after a successful commit does the API return `{verified: true, email: ...}`.
Failure responses do not disclose the account email. Responses retain the
shared no-store policy. The frontend backend DTO and mapper produce a
`VerificationResult`; the verification provider exposes that result only in the
verified state and clears it on reset, loading and failure. A verified response
from an older backend without the optional email can still show success.
Success stays visible until the user chooses **Continue to login**. This replaces
the misleading workspace action that could redirect an unauthenticated visitor
back to login. The verified email travels through transient router extras into
the editable login controller; the password starts empty and receives focus.
Normal password and second-factor checks remain required. The email is not
added to URLs, serialized history, persistent storage or analytics. A link opened
in another tab or device can receive the verified address from the API without
depending on the originating signup form's memory.
Existing requested/succeeded/failed email-confirmation journey signals remain
the measurement source. Success continues to mean that the confirmation service
returned a verified result, with simulated outcomes kept separate. No field or
prefill event is needed. Existing limits around pre-submit abandonment,
cross-device journey correlation and the in-memory analytics sink still apply.
Regression coverage includes transactional activation and rollback, returned
email and failure privacy, used-token rejection, backend response mapping,
provider identity clearing, EN/RU success-to-login navigation, editable prefill,
empty focused password, and exclusion of email from serialized navigation state.
The supplied private diagnostic token is not a test fixture or documentation
input; it is kept out of repository sources and tracker records.
### Validation and local release (#60)
Delivery: [PR #61](https://git.sendico.io/tech/sxchnge/pulls/61), implementation
commit `5f3b443`. Reviewed the complete backend and frontend contribution against
their module guidelines and the UX/observability checklists. No task-caused
review findings remain. Existing recovery, loading and accessible status states
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
pass. Browser startup was checked against a separately built demo with synthetic
tokens: direct success remains visible, Continue to login shows the verified
email with an empty focused password, and an expired link offers resend/back.
Native Safari was unavailable to automation, so browser-specific helper behavior
is not claimed as verified by this check.
On 2026-10-19, changed-file Dart formatting, `flutter analyze` in both affected
packages (`frontend/app`, `frontend/packages/sxchnge_auth`), `make test-frontend`
(217 tests), `GOFLAGS=-p=1 make test`, and `make lint-backend` passed. The focused
transactional activation integration test also passed independently. Initial
test-container startup and image-resolution attempts failed while Docker was
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
passed; no assertions, timeouts or gates were weakened. No security scan was
requested or run.
The task branch was integrated locally into the existing `identity-facts-57`
stand checkout, preserving its unrelated configuration. Fresh stable image
versions/digests were resolved successfully with `sh infra/local/tools.sh images`.
Compose rebuilt BFF and frontend with `build --pull bff frontend`, then recreated
them and the Sendico session sidecar (which shares BFF's network namespace).
Docker restart recovery also unsealed Sendico's existing Vault with its saved
local key and restarted affected existing services in dependency order.
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
the served JavaScript matches the new frontend image with SHA-256
`b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21`.
A live synthetic invalid token returns HTTP 400 without an email. The supplied
real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main
branch merge.
+27
View File
@@ -302,6 +302,33 @@ that contradicts displayed balances or usage without explaining the fixtures.
- Keep unrelated accounts/history accessible during a pending operation. Do not - Keep unrelated accounts/history accessible during a pending operation. Do not
block the whole product behind an exchange spinner. block the whole product behind an exchange spinner.
## Empty currency accounts (task #66)
The shared empty-account message used on Accounts and transfer screens explains
that no currency accounts have been set up and directs the customer to support
to request setup. English and Russian no longer suggest that refreshing creates
accounts or imply that automatic provisioning is underway. This follows the
existing support guidance for missing funding and payout setup. The application
has no configured support contact channel; the message therefore provides text
guidance without a nonfunctional contact button or invented contact details.
UX review: the copy names the missing resource and the next step, retains the
existing wrapping text and accessible reading order, and does not alter loading,
error or populated-state behavior. Existing manual refresh remains available for
accounts provisioned externally. This copy-only change adds no controls or
journey outcomes and requires no new analytics events. Existing transaction
journey signals and their production measurement limitations below still apply;
support resolution and external account provisioning are not measured in-app.
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
changed, so Dart formatting was not applicable. Existing responsive workspace
tests cover Russian at 320px with enlarged text; this copy-only change does not
add a dedicated empty-state rendering or native screen-reader test. No security
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
Merge and deployment remain separate.
## Journey measurement plan ## Journey measurement plan
Reuse the typed analytics abstraction and maintain a separate journey per active Reuse the typed analytics abstraction and maintain a separate journey per active
+44
View File
@@ -167,3 +167,47 @@ This is a display correction within the existing KYC journey, so no additional
analytics events or financial data collection are introduced. Tests cover mapping analytics events or financial data collection are introduced. Tests cover mapping
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
usage display at 320px with 1.6 text scaling. usage display at 320px with 1.6 text scaling.
## Residence country selection — task #64
Task [#64](https://git.sendico.io/tech/sxchnge/issues/64), branch `kyc-64`.
Delivery: [PR #65](https://git.sendico.io/tech/sxchnge/pulls/65).
The profile now shows a country name and opens a searchable country list instead
of asking customers to type a two-letter code. Names follow the English/Russian
app locale. Search accepts localized or English names and two-letter codes,
ignoring case and surrounding whitespace. Selection updates only
`IdentityProfile.residenceCountry` with the country code; the existing provider,
service, mapper and transport contracts remain unchanged.
The `country_picker` dependency supplies bundled country data, code lookup and
translations through `CountryService` and `CountryLocalizations`. Flutter Material
controls provide the search interface, explicit no-results recovery, minimum
touch targets, keyboard selection, a selected-state checkmark, Back and Escape.
The package's built-in list omits explicit no-results feedback and uses shorter
row targets, so its data/localizations are reused with standard Material controls.
There is no remote lookup, copied country table or extra application provider.
Cancelling preserves the current profile, prefilled codes display names, busy
state disables opening the selector, and the search controller is disposed with
its dialog. The list is a data catalogue; backend eligibility remains authoritative.
This change stays within the existing KYC preparation/submission journey and its
measurement definitions above. No selector events, queries, names or country
codes are added to telemetry. Existing acceptance/failure/retry/abandonment
signals and documented server-side review measurement gaps still apply.
Focused widget checks cover name/code search, code-only updates, reselection,
cancel, no-results recovery, clearing search, saved values, locale changes,
Russian names, disabled state, keyboard activation/selection/Escape and labeled
touch targets. A long country name fits at 320px with 1.6 text scale. Existing
EN/RU verification journeys now select a country and assert the profile code
before continuing through document submission. Native screen-reader and device
keyboard testing remain release checks; widget semantics checks do not establish
those device outcomes.
Validation: five changed Dart files passed the format check; `flutter analyze`
in `frontend/app` reported no issues; `make test-frontend` passed all 222 tests;
and `flutter build web` passed, including its Wasm compatibility dry run. Browser
smoke testing on the local demo opened the selector, searched for Poland and
confirmed that the profile displays Poland after selection. Focused and full
journey tests verify the stored `PL` code. Generated localization/build outputs
remain ignored and uncommitted.
+150 -119
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -18,7 +19,8 @@ import 'validation.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget { class AuthForm extends StatefulWidget {
const AuthForm({super.key}); const AuthForm({super.key, this.email});
final String? email;
@override @override
State<AuthForm> createState() => _AuthFormState(); State<AuthForm> createState() => _AuthFormState();
@@ -32,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
bool _showPassword = false; bool _showPassword = false;
bool _showConfirmation = false; bool _showConfirmation = false;
bool _submitted = false; bool _submitted = false;
final _email = TextEditingController(); late final _email = TextEditingController(text: widget.email);
final _name = TextEditingController(); final _name = TextEditingController();
AuthProvider get _provider => AuthProvider get _provider =>
@@ -63,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) { void _switch(bool signUp) {
if (signUp == _signUp) return; if (signUp == _signUp) return;
FocusScope.of(context).unfocus();
TextInput.finishAutofillContext(shouldSave: false);
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange); context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset(); context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset(); context.read<SignupProvider>().reset();
@@ -95,6 +99,7 @@ class _AuthFormState extends State<AuthForm> {
return; return;
} }
final email = _email.text.trim(); final email = _email.text.trim();
TextInput.finishAutofillContext();
_password.clear(); _password.clear();
_confirmation.clear(); _confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email); context.goNamed(AppRoutes.verificationSent, extra: email);
@@ -107,13 +112,13 @@ class _AuthFormState extends State<AuthForm> {
); );
if (!mounted || _signUp) return; if (!mounted || _signUp) return;
if (login.state.error == AuthError.accountNotVerified) { if (login.state.error == AuthError.accountNotVerified) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear(); _password.clear();
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim()); context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
return; return;
} }
final challenge = login.state.result?.challenge; final challenge = login.state.result?.challenge;
if (challenge != null) { if (challenge != null) {
_password.clear();
final grant = await verifyFactor( final grant = await verifyFactor(
context, context,
challenge: challenge, challenge: challenge,
@@ -123,6 +128,8 @@ class _AuthFormState extends State<AuthForm> {
); );
if (!mounted || _signUp) return; if (!mounted || _signUp) return;
if (grant == null) { if (grant == null) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
login.reset(); login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation); context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return; return;
@@ -137,9 +144,13 @@ class _AuthFormState extends State<AuthForm> {
!_signUp && !_signUp &&
identical(login.state.result, accepted) && identical(login.state.result, accepted) &&
login.state.status == AuthStatus.success) { login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
_password.clear();
context.go(offer ? '/identity?onboarding=1' : '/accounts'); context.go(offer ? '/identity?onboarding=1' : '/accounts');
} }
} }
} else if (login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
} }
} }
} }
@@ -195,150 +206,170 @@ class _AuthFormState extends State<AuthForm> {
expandedInsets: EdgeInsets.zero, expandedInsets: EdgeInsets.zero,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Form( AutofillGroup(
key: _form, // Leaving an unfinished form must not offer to save its credentials.
autovalidateMode: _submitted onDisposeAction: AutofillContextAction.cancel,
? AutovalidateMode.onUserInteraction child: Form(
: AutovalidateMode.disabled, key: _form,
onChanged: () { autovalidateMode: _submitted
if (_provider.state.status == AuthStatus.success || ? AutovalidateMode.onUserInteraction
_provider.state.status == AuthStatus.error) { : AutovalidateMode.disabled,
_provider.reset(); onChanged: () {
} if (_provider.state.status == AuthStatus.success ||
}, _provider.state.status == AuthStatus.error) {
child: Column( _provider.reset();
crossAxisAlignment: CrossAxisAlignment.stretch, }
children: [ },
if (_signUp) ...[ child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_signUp) ...[
TextFormField(
key: const ValueKey('name'),
controller: _name,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.name,
prefixIcon: Icon(Icons.person_outline),
errorMaxLines: 3,
),
textInputAction: TextInputAction.next,
keyboardType: TextInputType.name,
autofillHints: const [AutofillHints.name],
textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l),
),
const SizedBox(height: 20),
],
TextFormField( TextFormField(
key: const ValueKey('name'), key: const ValueKey('email'),
controller: _name, controller: _email,
enabled: !state.isLoading, enabled: !state.isLoading,
decoration: InputDecoration( decoration: InputDecoration(
labelText: l.name, labelText: l.email,
prefixIcon: Icon(Icons.person_outline), hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3, errorMaxLines: 3,
), ),
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.words, autocorrect: false,
validator: (value) => AuthValidation.name(value, l), validator: (value) => AuthValidation.email(value, l),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
],
TextFormField(
key: const ValueKey('email'),
controller: _email,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.email,
hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3,
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
validator: (value) => AuthValidation.email(value, l),
),
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _password,
obscureText: !_showPassword,
autocorrect: false,
enableSuggestions: false,
textInputAction: _signUp
? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration(
labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showPassword ? l.hidePassword : l.showPassword,
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.password(value, l, signUp: _signUp),
),
if (_signUp) ...[
const SizedBox(height: 20),
TextFormField( TextFormField(
key: const ValueKey('confirmation'), key: const ValueKey('password'),
enabled: !state.isLoading, enabled: !state.isLoading,
controller: _confirmation, controller: _password,
obscureText: !_showConfirmation, autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword,
autofillHints: [
_signUp
? AutofillHints.newPassword
: AutofillHints.password,
],
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: TextInputAction.done, textInputAction: _signUp
onFieldSubmitted: (_) => _submit(), ? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration( decoration: InputDecoration(
labelText: l.confirmPassword, labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3, errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: _showConfirmation tooltip: _showPassword ? l.hidePassword : l.showPassword,
? l.hideConfirmation onPressed: () =>
: l.showConfirmation, setState(() => _showPassword = !_showPassword),
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
icon: Icon( icon: Icon(
_showConfirmation _showPassword
? Icons.visibility_off_outlined ? Icons.visibility_off_outlined
: Icons.visibility_outlined, : Icons.visibility_outlined,
), ),
), ),
), ),
validator: (value) => validator: (value) =>
AuthValidation.confirmation(value, _password.text, l), AuthValidation.password(value, l, signUp: _signUp),
), ),
], if (_signUp) ...[
const SizedBox(height: 28), const SizedBox(height: 20),
FilledButton( TextFormField(
key: const ValueKey('submit'), key: const ValueKey('confirmation'),
onPressed: state.isLoading || state.status == AuthStatus.success enabled: !state.isLoading,
? null controller: _confirmation,
: _submit, obscureText: !_showConfirmation,
child: Padding( autofillHints: const [AutofillHints.newPassword],
padding: const EdgeInsets.symmetric(vertical: 4), autocorrect: false,
child: Row( enableSuggestions: false,
mainAxisAlignment: MainAxisAlignment.center, textInputAction: TextInputAction.done,
children: [ onFieldSubmitted: (_) => _submit(),
Flexible( decoration: InputDecoration(
child: Text( labelText: l.confirmPassword,
state.isLoading prefixIcon: const Icon(Icons.lock_outline),
? l.authWorking errorMaxLines: 3,
: (_signUp ? l.createAccount : l.letsGo), suffixIcon: IconButton(
textAlign: TextAlign.center, tooltip: _showConfirmation
? l.hideConfirmation
: l.showConfirmation,
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
icon: Icon(
_showConfirmation
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
), ),
), ),
const SizedBox(width: 12), ),
state.isLoading validator: (value) =>
? const SizedBox( AuthValidation.confirmation(value, _password.text, l),
width: 20, ),
height: 20, ],
child: CircularProgressIndicator(strokeWidth: 2), const SizedBox(height: 28),
) FilledButton(
: const Icon(Icons.arrow_forward, size: 20), key: const ValueKey('submit'),
], onPressed:
state.isLoading || state.status == AuthStatus.success
? null
: _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
state.isLoading
? l.authWorking
: (_signUp ? l.createAccount : l.letsGo),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.arrow_forward, size: 20),
],
),
), ),
), ),
), ],
], ),
), ),
), ),
if (state.status == AuthStatus.success || if (state.status == AuthStatus.success ||
+3 -2
View File
@@ -10,7 +10,8 @@ import 'form.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthPage extends StatefulWidget { class AuthPage extends StatefulWidget {
const AuthPage({super.key}); const AuthPage({super.key, this.email});
final String? email;
@override @override
State<AuthPage> createState() => _AuthPageState(); State<AuthPage> createState() => _AuthPageState();
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
Center( Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey), child: AuthForm(key: _formKey, email: widget.email),
), ),
), ),
], ],
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:country_picker/country_picker.dart';
import '../generated/i18n/app_localizations.dart';
class CountryField extends StatelessWidget {
const CountryField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final country = CountryService().findByCode(value);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycCountry, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
Semantics(
label: l.kycCountry,
child: OutlinedButton(
onPressed: !enabled
? null
: () async {
final code = await showDialog<String>(
context: context,
builder: (_) => _CountrySearch(selected: value),
);
if (context.mounted && code != null) onChanged(code);
},
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Row(
children: [
Expanded(
child: Text(
country == null
? l.kycCountrySelect
: _name(context, country),
),
),
const SizedBox(width: 8),
const Icon(Icons.expand_more),
],
),
),
),
],
);
}
}
String _name(BuildContext context, Country country) =>
country.getTranslatedName(context) ?? country.name;
class _CountrySearch extends StatefulWidget {
const _CountrySearch({required this.selected});
final String selected;
@override
State<_CountrySearch> createState() => _CountrySearchState();
}
class _CountrySearchState extends State<_CountrySearch> {
final _query = TextEditingController();
final _countries = {
for (final country in CountryService().getAll())
country.countryCode: country,
}.values.toList();
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Focus(
autofocus: true,
onKeyEvent: (_, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => Navigator.of(context).pop()),
title: TextField(
key: const ValueKey('country-search'),
controller: _query,
autofocus: true,
decoration: InputDecoration(
labelText: l.kycCountrySearch,
prefixIcon: const Icon(Icons.search),
),
onChanged: (_) => setState(() {}),
),
toolbarHeight: 96,
actions: [
if (_query.text.isNotEmpty)
IconButton(
tooltip: l.kycCountryClearSearch,
icon: const Icon(Icons.clear),
onPressed: () => setState(_query.clear),
),
],
),
body: _list(context),
),
),
);
}
Widget _list(BuildContext context) {
final search = _query.text.trim().toLowerCase();
final countries = _countries.where((country) {
return _name(context, country).toLowerCase().contains(search) ||
country.name.toLowerCase().contains(search) ||
country.countryCode.toLowerCase() == search;
}).toList()..sort((a, b) => _name(context, a).compareTo(_name(context, b)));
if (countries.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(AppLocalizations.of(context)!.kycCountryEmpty),
),
);
}
return ListView.builder(
itemCount: countries.length,
itemBuilder: (context, index) {
final country = countries[index];
final isSelected = country.countryCode == widget.selected;
return ListTile(
title: Text(_name(context, country)),
selected: isSelected,
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () => Navigator.of(context).pop(country.countryCode),
);
},
);
}
}
+5 -9
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'country.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class ProfileForm extends StatelessWidget { class ProfileForm extends StatelessWidget {
@@ -48,17 +50,11 @@ class ProfileForm extends StatelessWidget {
onChanged: (value) => update(birth: value.trim()), onChanged: (value) => update(birth: value.trim()),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( CountryField(
key: const ValueKey('kyc-country'), key: const ValueKey('kyc-country'),
initialValue: p.profile.residenceCountry, value: p.profile.residenceCountry,
enabled: !p.busy, enabled: !p.busy,
maxLength: 2, onChanged: (value) => update(country: value),
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: l.kycCountry,
helperText: l.kycCountryHelp,
),
onChanged: (value) => update(country: value.trim().toUpperCase()),
), ),
if (!p.profile.isComplete) Text(l.kycProfileRequired), if (!p.profile.isComplete) Text(l.kycProfileRequired),
], ],
+7 -3
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Send a new verification email", "verificationRequest": "Send a new verification email",
"verificationRetry": "Retry verification", "verificationRetry": "Retry verification",
"verificationBack": "Back to login", "verificationBack": "Back to login",
"verificationContinue": "Continue to login",
"verificationHint": "No email? Check your spam folder or request another below.", "verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemoLink": "Continue to confirmation", "verificationDemoLink": "Continue to confirmation",
"verificationStep": "CONFIRM YOUR EMAIL", "verificationStep": "CONFIRM YOUR EMAIL",
@@ -159,7 +160,7 @@
"wRecent": "Recent activity", "wRecent": "Recent activity",
"wSeeAll": "See all", "wSeeAll": "See all",
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.", "wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
"wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.", "wEmptyAccounts": "No currency accounts have been set up for you yet. Contact support to request account setup.",
"wRefresh": "Refresh", "wRefresh": "Refresh",
"wSource": "From account", "wSource": "From account",
"wDestination": "To account", "wDestination": "To account",
@@ -270,8 +271,11 @@
"kycBirth": "Date of birth", "kycBirth": "Date of birth",
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23", "kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
"kycCountry": "Country of residence", "kycCountry": "Country of residence",
"kycCountryHelp": "Two-letter country code, for example PL or DE", "kycCountrySelect": "Select a country",
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.", "kycCountrySearch": "Search countries",
"kycCountryEmpty": "No countries found. Try another name or country code.",
"kycCountryClearSearch": "Clear search",
"kycProfileRequired": "Enter your full name, a valid date of birth and select your country of residence to continue.",
"kycContinue": "Continue to my account", "kycContinue": "Continue to my account",
"kycOperationMinimum": "Per operation minimum", "kycOperationMinimum": "Per operation minimum",
"kycOperationMaximum": "Per operation maximum", "kycOperationMaximum": "Per operation maximum",
+7 -3
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Отправить новое письмо", "verificationRequest": "Отправить новое письмо",
"verificationRetry": "Повторить подтверждение", "verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу", "verificationBack": "Вернуться ко входу",
"verificationContinue": "Перейти ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemoLink": "Перейти к подтверждению", "verificationDemoLink": "Перейти к подтверждению",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
@@ -159,7 +160,7 @@
"wRecent": "Последние операции", "wRecent": "Последние операции",
"wSeeAll": "Смотреть все", "wSeeAll": "Смотреть все",
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.", "wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
"wRefresh": "Обновить", "wRefresh": "Обновить",
"wSource": "Со счёта", "wSource": "Со счёта",
"wDestination": "На счёт", "wDestination": "На счёт",
@@ -270,8 +271,11 @@
"kycBirth": "Дата рождения", "kycBirth": "Дата рождения",
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23", "kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
"kycCountry": "Страна проживания", "kycCountry": "Страна проживания",
"kycCountryHelp": "Код страны из двух букв, например PL или DE", "kycCountrySelect": "Выберите страну",
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.", "kycCountrySearch": "Поиск страны",
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
"kycCountryClearSearch": "Очистить поиск",
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
"kycContinue": "Перейти в личный кабинет", "kycContinue": "Перейти в личный кабинет",
"kycOperationMinimum": "Минимум на операцию", "kycOperationMinimum": "Минимум на операцию",
"kycOperationMaximum": "Максимум на операцию", "kycOperationMaximum": "Максимум на операцию",
+9 -1
View File
@@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:country_picker/country_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart';
@@ -18,6 +22,7 @@ import 'routing/router.dart';
import 'generated/i18n/app_localizations.dart'; import 'generated/i18n/app_localizations.dart';
Future<void> main() async { Future<void> main() async {
usePathUrlStrategy();
final binding = WidgetsFlutterBinding.ensureInitialized(); final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales); final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL'); const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
@@ -192,7 +197,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: SxchngeTheme.light(), theme: SxchngeTheme.light(),
locale: locale.locale, locale: locale.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
CountryLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router, routerConfig: _router,
), ),
+3 -1
View File
@@ -135,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute( GoRoute(
path: '/', path: '/',
name: AppRoutes.login, name: AppRoutes.login,
builder: (context, state) => const AuthPage(), builder: (context, state) => AuthPage(
email: state.extra is String ? state.extra as String : null,
),
), ),
GoRoute( GoRoute(
path: '/verification-sent', path: '/verification-sent',
+13 -7
View File
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
if (verified) ...[ if (verified) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: () => context.go('/accounts'), key: const ValueKey('verification-continue'),
child: Text(l.wEnter), onPressed: () => context.goNamed(
AppRoutes.login,
extra: state.result?.verifiedEmail,
),
child: Text(l.verificationContinue),
),
],
if (!verified) ...[
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
), ),
], ],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
),
], ],
), ),
), ),
+17 -1
View File
@@ -49,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
country_picker:
dependency: "direct main"
description:
name: country_picker
sha256: "8ef721183bd7e0a6017c413693cdc68051daceef5790f5420c3b4378b5c69115"
url: "https://pub.dev"
source: hosted
version: "2.0.28"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@@ -185,7 +193,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
@@ -525,6 +533,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+3
View File
@@ -14,6 +14,8 @@ dependencies:
path: ../packages/sxchnge_analytics path: ../packages/sxchnge_analytics
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
sxchnge_auth: sxchnge_auth:
path: ../packages/sxchnge_auth path: ../packages/sxchnge_auth
sxchnge_theme: sxchnge_theme:
@@ -25,6 +27,7 @@ dependencies:
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
go_router: ^18.0.1 go_router: ^18.0.1
country_picker: ^2.0.28
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+38
View File
@@ -92,6 +92,14 @@ void main() {
findsOneWidget, findsOneWidget,
); );
expect(find.byKey(const ValueKey('verification-sent')), findsNothing); expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
tester tester
.widget<TextFormField>(find.byKey(const ValueKey('email'))) .widget<TextFormField>(find.byKey(const ValueKey('email')))
@@ -153,6 +161,14 @@ void main() {
'/verification-sent', '/verification-sent',
); );
expect(backend.sends, 1); expect(backend.sends, 1);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
find.textContaining('a verification email is on its way'), find.textContaining('a verification email is on its way'),
findsOneWidget, findsOneWidget,
@@ -198,6 +214,14 @@ void main() {
find.text('Сервис недоступен. Попробуйте ещё раз.'), find.text('Сервис недоступен. Попробуйте ещё раз.'),
findsOneWidget, findsOneWidget,
); );
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.tap(button); await tester.tap(button);
await tester.pump(); await tester.pump();
expect(service.calls, hasLength(2)); expect(service.calls, hasLength(2));
@@ -207,6 +231,12 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(find.byKey(const ValueKey('auth-error')), findsNothing); expect(find.byKey(const ValueKey('auth-error')), findsNothing);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, },
); );
@@ -237,6 +267,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget); expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
+28 -2
View File
@@ -55,6 +55,12 @@ void main() {
await submit(tester); await submit(tester);
expect(find.text('Enter your email address.'), findsOneWidget); expect(find.text('Enter your email address.'), findsOneWidget);
expect(find.text('Enter your password.'), findsOneWidget); expect(find.text('Enter your password.'), findsOneWidget);
expect(
tester.testTextInput.log.where(
(call) => call.method == 'TextInput.finishAutofillContext',
),
isEmpty,
);
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('email')), find.byKey(const ValueKey('email')),
'test@example.com', 'test@example.com',
@@ -79,6 +85,12 @@ void main() {
await tester.testTextInput.receiveAction(TextInputAction.done); await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, },
); );
@@ -104,12 +116,26 @@ void main() {
); );
await submit(tester); await submit(tester);
expect(find.text('Passwords do not match.'), findsOneWidget); expect(find.text('Passwords do not match.'), findsOneWidget);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('confirmation')), find.byKey(const ValueKey('confirmation')),
'example-password', 'example-password',
); );
await submit(tester); await submit(tester);
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
await tester.ensureVisible( await tester.ensureVisible(
find.byKey(const ValueKey('demo-confirmation')), find.byKey(const ValueKey('demo-confirmation')),
); );
@@ -119,8 +145,8 @@ void main() {
find.byKey(const ValueKey('verification-verified')), find.byKey(const ValueKey('verification-verified')),
findsOneWidget, findsOneWidget,
); );
await tester.ensureVisible(find.text('Back to login')); await tester.ensureVisible(find.text('Continue to login'));
await tester.tap(find.text('Back to login')); await tester.tap(find.text('Continue to login'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsNothing); expect(find.byKey(const ValueKey('confirmation')), findsNothing);
expect(find.text('example-password'), findsNothing); expect(find.text('example-password'), findsNothing);
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
Finder field(String key) => find.byKey(ValueKey(key));
EditableTextState editor(WidgetTester tester, String key) =>
tester.state<EditableTextState>(
find.descendant(of: field(key), matching: find.byType(EditableText)),
);
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'platform autofill fills the signup credential group ($language)',
(tester) async {
tester.view.physicalSize = const Size(1440, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
);
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final configuration = tester.testTextInput.setClientArgs!;
expect(
configuration['inputType']['name'],
'TextInputType.emailAddress',
);
final fields = (configuration['fields'] as List).cast<Map>();
expect(fields, hasLength(4));
expect(
fields.map((field) => field['autofill']['hints'].first),
containsAll(['name', 'username', 'newPassword', 'newPassword']),
);
const values = {
'name': 'Alex Example',
'email': 'alex@example.com',
'password': 'generated-example-password',
'confirmation': 'generated-example-password',
};
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall('TextInputClient.updateEditingStateWithTag', [
0,
{
for (final entry in values.entries)
editor(tester, entry.key).autofillId: TextEditingValue(
text: entry.value,
).toJSON(),
},
]),
),
(_) {},
);
await tester.pumpAndSettle();
for (final entry in values.entries) {
expect(
editor(tester, entry.key).currentTextEditingValue.text,
entry.value,
);
}
// A visibility change must retain both the supplied value and its role.
await tester.tap(
find.byTooltip(
language == 'ru' ? 'Показать пароль' : 'Show password',
),
);
await tester.pump();
expect(editor(tester, 'password').widget.obscureText, isFalse);
expect(editor(tester, 'password').widget.autofillHints, [
'newPassword',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
values['password'],
);
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final loginFields =
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
expect(loginFields, hasLength(2));
expect(loginFields.map((field) => field['autofill']['hints'].first), [
'username',
'password',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
isEmpty,
);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
},
);
}
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
await tester.pumpWidget(const SxchngeApp());
await tester.pumpAndSettle();
await tester.enterText(field('email'), 'alex@example.com');
await tester.enterText(field('password'), 'unfinished-password');
tester.testTextInput.log.clear();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
});
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:country_picker/country_picker.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final search = find.byKey(const ValueKey('country-search'));
final button = find.byType(OutlinedButton);
Future<void> mount(
WidgetTester tester,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
CountryLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, code, _) => CountryField(
value: code,
enabled: enabled,
onChanged: (code) => value.value = code,
),
),
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> open(WidgetTester tester) async {
await tester.tap(button);
await tester.pumpAndSettle();
}
testWidgets(
'name search stores code, reselection and cancel preserve value',
(tester) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(tester, value);
expect(find.text('Select a country'), findsOneWidget);
await open(tester);
await tester.enterText(search, 'polAND');
await tester.pumpAndSettle();
await tester.tap(find.text('Poland'));
await tester.pumpAndSettle();
expect(value.value, 'PL');
expect(find.text('Poland'), findsOneWidget);
expect(find.text('0/2'), findsNothing);
await open(tester);
await tester.enterText(search, ' de ');
await tester.pumpAndSettle();
await tester.tap(find.text('Germany'));
await tester.pumpAndSettle();
expect(value.value, 'DE');
expect(find.text('Germany'), findsOneWidget);
await open(tester);
await tester.enterText(search, 'poland');
await tester.pumpAndSettle();
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(value.value, 'DE');
expect(find.text('Germany'), findsOneWidget);
},
);
testWidgets('no-results state recovers through clear and Escape cancels', (
tester,
) async {
final value = ValueNotifier('PL');
addTearDown(value.dispose);
await mount(tester, value);
await open(tester);
await tester.enterText(search, 'no-such-country');
await tester.pumpAndSettle();
expect(find.textContaining('No countries found'), findsOneWidget);
expect(find.byType(ListTile), findsNothing);
expect(value.value, 'PL');
await tester.tap(find.byTooltip('Clear search'));
await tester.pumpAndSettle();
expect(find.textContaining('No countries found'), findsNothing);
expect(find.byType(ListTile), findsWidgets);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(search, findsNothing);
expect(value.value, 'PL');
});
testWidgets('saved code follows locale and Russian names are searchable', (
tester,
) async {
final value = ValueNotifier('PL');
addTearDown(value.dispose);
await mount(tester, value);
expect(find.text('Poland'), findsOneWidget);
await mount(tester, value, locale: const Locale('ru'));
expect(find.text('Польша'), findsOneWidget);
expect(value.value, 'PL');
await open(tester);
await tester.enterText(search, 'Германия');
await tester.pumpAndSettle();
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
expect(value.value, 'DE');
expect(find.text('Германия'), findsOneWidget);
});
testWidgets('busy field cannot open or change selection', (tester) async {
final value = ValueNotifier('PL');
addTearDown(value.dispose);
await mount(tester, value, enabled: false);
expect(tester.widget<OutlinedButton>(button).onPressed, isNull);
await tester.tap(button);
await tester.pumpAndSettle();
expect(search, findsNothing);
expect(value.value, 'PL');
});
testWidgets(
'keyboard and labeled touch targets work at 320px with large text',
(tester) async {
tester.view.physicalSize = const Size(320, 900);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final semantics = tester.ensureSemantics();
final value = ValueNotifier('GS');
addTearDown(value.dispose);
await mount(tester, value);
expect(tester, meetsGuideline(labeledTapTargetGuideline));
expect(tester, meetsGuideline(androidTapTargetGuideline));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(search, findsOneWidget);
await tester.enterText(search, 'GS');
await tester.pumpAndSettle();
expect(tester.widget<ListTile>(find.byType(ListTile)).selected, isTrue);
expect(tester, meetsGuideline(labeledTapTargetGuideline));
expect(tester, meetsGuideline(androidTapTargetGuideline));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(search, findsNothing);
expect(value.value, 'GS');
expect(tester.takeException(), isNull);
semantics.dispose();
},
);
}
+15 -1
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart'; import 'package:sxchnge_app/providers/locale.dart';
@@ -69,7 +70,20 @@ void main() {
await tap(t, find.byKey(const ValueKey('kyc-start'))); await tap(t, find.byKey(const ValueKey('kyc-start')));
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person'); await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23'); await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL'); await tap(
t,
find.descendant(
of: find.byKey(const ValueKey('kyc-country')),
matching: find.byType(OutlinedButton),
),
);
await t.enterText(find.byKey(const ValueKey('country-search')), 'PL');
await t.pumpAndSettle();
await tap(t, find.byType(ListTile));
expect(
t.widget<CountryField>(find.byKey(const ValueKey('kyc-country'))).value,
'PL',
);
await t.pumpAndSettle(); await t.pumpAndSettle();
await tap(t, find.byKey(const ValueKey('pick-passport'))); await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next'))); await tap(t, find.byKey(const ValueKey('kyc-next')));
+61
View File
@@ -42,6 +42,67 @@ Future<GoRouter> openRoute(
} }
void main() { void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'verified link keeps success visible and prefills login ($language)',
(tester) async {
tester.platformDispatcher.defaultRouteNameTestValue =
'/verify-email?token=demo-valid';
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
verificationService: const DummyVerificationService(
delay: Duration.zero,
verifiedEmail: 'private-alias@example.com',
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-verified')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('email')), findsNothing);
final router = GoRouter.of(
tester.element(find.byType(VerificationPage)),
);
expect(router.routeInformationProvider.value.uri.path, '/verify-email');
await tester.ensureVisible(
find.byKey(const ValueKey('verification-continue')),
);
await tester.tap(find.byKey(const ValueKey('verification-continue')));
await tester.pumpAndSettle();
final email = tester.widget<TextFormField>(
find.byKey(const ValueKey('email')),
);
expect(email.controller!.text, 'private-alias@example.com');
final password = tester.state<EditableTextState>(
find.descendant(
of: find.byKey(const ValueKey('password')),
matching: find.byType(EditableText),
),
);
expect(password.currentTextEditingValue.text, isEmpty);
expect(password.widget.focusNode.hasFocus, isTrue);
expect(router.routeInformationProvider.value.uri.toString(), '/');
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(
restored.state.toString(),
isNot(contains('private-alias@example.com')),
);
expect(restored.state.toString(), isNot(contains('demo-valid')));
await tester.enterText(
find.byKey(const ValueKey('email')),
'changed@example.com',
);
expect(email.controller!.text, 'changed@example.com');
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
},
);
}
testWidgets('unknown deep link redirects to canonical login URL', ( testWidgets('unknown deep link redirects to canonical login URL', (
tester, tester,
) async { ) async {
+39
View File
@@ -148,13 +148,38 @@ void main() {
await signIn(t); await signIn(t);
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal), analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
isEmpty, isEmpty,
); );
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm'); await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget); expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
final login = analytics.events final login = analytics.events
.where((e) => e.kind == JourneyKind.login) .where((e) => e.kind == JourneyKind.login)
.toList(); .toList();
@@ -172,6 +197,20 @@ void main() {
await signIn(t); await signIn(t);
await tapFactor(t, 'two-factor-cancel'); await tapFactor(t, 'two-factor-cancel');
expect(find.byKey(const ValueKey('email')), findsOneWidget); expect(find.byKey(const ValueKey('email')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect( expect(
t t
@@ -6,6 +6,8 @@ class BackendDTO {
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>, (jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
); );
final Map<String, dynamic> data; final Map<String, dynamic> data;
bool get verified => data['verified'] == true;
String? get email => data['email'] as String?;
String get error => data['error'] as String? ?? 'unavailable'; String get error => data['error'] as String? ?? 'unavailable';
String get userId => String get userId =>
data['userId'] as String? ?? data['userId'] as String? ??
@@ -6,6 +6,7 @@ class AuthDTO {
} }
class VerificationDTO { class VerificationDTO {
const VerificationDTO({required this.simulated}); const VerificationDTO({required this.simulated, this.email});
final bool simulated; final bool simulated;
final String? email;
} }
@@ -5,6 +5,13 @@ import '../models/two_factor.dart';
import '../models/verification.dart'; import '../models/verification.dart';
abstract final class BackendMapper { abstract final class BackendMapper {
static VerificationResult activation(BackendDTO dto) {
if (!dto.verified) {
throw const VerificationException(VerificationError.internal);
}
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
}
static AuthResult signup(BackendDTO dto) { static AuthResult signup(BackendDTO dto) {
if (dto.userId.isEmpty) throw const FormatException('Missing user'); if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult(userId: dto.userId, isSimulated: false); return AuthResult(userId: dto.userId, isSimulated: false);
@@ -9,5 +9,5 @@ abstract final class AuthMapper {
abstract final class VerificationMapper { abstract final class VerificationMapper {
static VerificationResult fromDTO(VerificationDTO dto) => static VerificationResult fromDTO(VerificationDTO dto) =>
VerificationResult(isSimulated: dto.simulated); VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
} }
@@ -17,15 +17,22 @@ class VerificationException implements Exception {
} }
class VerificationResult { class VerificationResult {
const VerificationResult({required this.isSimulated}); const VerificationResult({required this.isSimulated, this.verifiedEmail});
final bool isSimulated; final bool isSimulated;
final String? verifiedEmail;
} }
class VerificationState { class VerificationState {
const VerificationState(this.status, {this.isSimulated = false, this.error}); const VerificationState(
this.status, {
this.isSimulated = false,
this.error,
this.result,
});
final VerificationStatus status; final VerificationStatus status;
final VerificationError? error; final VerificationError? error;
final bool isSimulated; final bool isSimulated;
final VerificationResult? result;
bool get isLoading => bool get isLoading =>
status == VerificationStatus.sending || status == VerificationStatus.sending ||
status == VerificationStatus.checking; status == VerificationStatus.checking;
@@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier {
try { try {
final result = await operation(); final result = await operation();
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
_state = VerificationState(success, isSimulated: result.isSimulated); _state = VerificationState(
success,
isSimulated: result.isSimulated,
result: success == VerificationStatus.verified ? result : null,
);
} catch (error) { } catch (error) {
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
final code = error is VerificationException final code = error is VerificationException
@@ -172,8 +172,10 @@ class BackendAuthServices
@override @override
Future<VerificationResult> confirm({required String token}) async { Future<VerificationResult> confirm({required String token}) async {
await _post('verification/confirm', {'token': token}, verification: true); final dto = await _post('verification/confirm', {
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false)); 'token': token,
}, verification: true);
return BackendMapper.activation(dto);
} }
@override @override
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({ const DummyVerificationService({
this.delay = const Duration(milliseconds: 600), this.delay = const Duration(milliseconds: 600),
this.sendFailure, this.sendFailure,
this.verifiedEmail = 'alex@example.com',
}); });
@override @override
bool get isSimulated => true; bool get isSimulated => true;
final Duration delay; final Duration delay;
final VerificationError? sendFailure; final VerificationError? sendFailure;
final String verifiedEmail;
@override @override
Future<VerificationResult> send({ Future<VerificationResult> send({
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
switch (token) { switch (token) {
case 'demo-valid': case 'demo-valid':
return VerificationMapper.fromDTO( return VerificationMapper.fromDTO(
const VerificationDTO(simulated: true), VerificationDTO(simulated: true, email: verifiedEmail),
); );
case 'demo-expired': case 'demo-expired':
throw const VerificationException(VerificationError.expired); throw const VerificationException(VerificationError.expired);
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() { void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) => http.Response response(Map<String, Object?> data, [int status = 202]) =>
http.Response(jsonEncode({'data': data}), status); http.Response(jsonEncode({'data': data}), status);
test(
'activation maps only a confirmed response to the verified email',
() async {
var confirmed = true;
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/accounts/verification/confirm');
expect(jsonDecode(request.body), {'token': 'opaque-link'});
return response({
'verified': confirmed,
'email': 'alias@example.com',
});
}),
);
addTearDown(service.close);
final accepted = await service.confirm(token: 'opaque-link');
expect(accepted.verifiedEmail, 'alias@example.com');
expect(accepted.isSimulated, isFalse);
confirmed = false;
await expectLater(
service.confirm(token: 'opaque-link'),
throwsA(isA<VerificationException>()),
);
},
);
test(
'activation remains compatible with a verified response without email',
() async {
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((_) async => response({'verified': true})),
);
addTearDown(service.close);
expect(
(await service.confirm(token: 'opaque-link')).verifiedEmail,
isNull,
);
},
);
test( test(
'signup distinguishes support recovery and keeps the retry key', 'signup distinguishes support recovery and keeps the retry key',
() async { () async {
@@ -42,6 +42,12 @@ void main() {
addTearDown(provider.dispose); addTearDown(provider.dispose);
await provider.confirm(scenario.key); await provider.confirm(scenario.key);
expect(provider.state.status, scenario.value); expect(provider.state.status, scenario.value);
expect(
provider.state.result?.verifiedEmail,
scenario.value == VerificationStatus.verified
? 'alex@example.com'
: null,
);
expect( expect(
provider.state.isSimulated, provider.state.isSimulated,
scenario.value == VerificationStatus.verified, scenario.value == VerificationStatus.verified,
@@ -75,7 +81,23 @@ void main() {
service.pending.complete(const VerificationResult(isSimulated: true)); service.pending.complete(const VerificationResult(isSimulated: true));
await operation; await operation;
expect(provider.state.status, VerificationStatus.idle); expect(provider.state.status, VerificationStatus.idle);
expect(provider.state.result, isNull);
}); });
test(
'a later failed confirmation clears the previous verified identity',
() async {
final provider = VerificationProvider(
const DummyVerificationService(delay: Duration.zero),
);
addTearDown(provider.dispose);
await provider.confirm('demo-valid');
expect(provider.state.result?.verifiedEmail, 'alex@example.com');
final operation = provider.confirm('bad');
expect(provider.state.result, isNull);
await operation;
expect(provider.state.result, isNull);
},
);
test('dispose discards late exception', () async { test('dispose discards late exception', () async {
final service = PendingService(); final service = PendingService();
final provider = VerificationProvider(service); final provider = VerificationProvider(service);
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apk add --no-cache protobuf protobuf-dev make
WORKDIR /src WORKDIR /src
COPY Makefile ./ COPY Makefile ./
COPY backend ./backend COPY backend ./backend
RUN make generate-notifications RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod make generate-notifications
ARG MODULE ARG MODULE
WORKDIR /src/backend/${MODULE} WORKDIR /src/backend/${MODULE}
RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -o /out/service . RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -o /out/service .
+2
View File
@@ -119,6 +119,8 @@ services:
frontend: frontend:
image: sxchnge-local/frontend:dev image: sxchnge-local/frontend:dev
# Reap orphaned ssl_client children from BusyBox wget HTTPS health probes.
init: true
build: build:
context: . context: .
dockerfile: infra/images/frontend.Dockerfile dockerfile: infra/images/frontend.Dockerfile
+15
View File
@@ -5,6 +5,21 @@
https://localhost:8443 { https://localhost:8443 {
tls internal tls internal
# Record signup status/timing even when the API rejects a request before
# account creation. Drop the entire request and response headers so URLs,
# cookies, addresses and browser-provided values cannot enter access logs.
log signup {
format filter {
wrap json
fields {
request delete
resp_headers delete
user_id delete
}
}
}
@otherRequests not path /api/v1/accounts/signup
log_skip @otherRequests
root * /srv root * /srv
handle /api/* { handle /api/* {
reverse_proxy bff:8082 reverse_proxy bff:8082
+7 -3
View File
@@ -18,10 +18,14 @@ messaging:
reconnect_wait: 2 reconnect_wait: 2
templates: "/etc/sxchnge/templates.json" templates: "/etc/sxchnge/templates.json"
email: email:
driver: resend driver: smtp
settings: settings:
api_key_env: EMAIL_API_KEY host: mail.sendico.io
sender_address: "" # Set your verified Resend sender here, then run make build. port: 465
username: no-reply@sendico.io
password_env: SMTP_PASSWORD
network_timeout: 10
sender_address: no-reply@sendico.io
sender_name: "SXCHNGE" sender_name: "SXCHNGE"
# Use replicas: 3 and environment: production for a three-node NATS cluster. # Use replicas: 3 and environment: production for a three-node NATS cluster.
# mode: ops validates resources without modifying them; app provisions them. # mode: ops validates resources without modifying them; app provisions them.
+8 -8
View File
@@ -61,7 +61,7 @@ provision_agents() {
mkdir -p "$directory"; chmod 755 "$directory" mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in case "$service" in
bff) paths='services/bff/jwt';; bff) paths='services/bff/jwt';;
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';; notifications) paths='infrastructure/nats/auth services/notifications/email/smtp';;
nats) paths=infrastructure/nats/auth;; nats) paths=infrastructure/nats/auth;;
sendico) paths=services/bff/sendico;; sendico) paths=services/bff/sendico;;
esac esac
@@ -81,7 +81,7 @@ provision_agents() {
if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444 cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
if compose ps --status running --services "$service" | grep -qx "$service"; then if compose ps --status running --services "$service" | grep -qx "$service"; then
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service" recreate_services 120 "$service"
fi fi
fi fi
done done
@@ -105,13 +105,12 @@ up() {
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path" random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
fi fi
done done
printf '{"api_key":""}' | ensure services/notifications/email/resend
provision_mongo provision_mongo
provision_agents provision_agents
compose up -d --wait --wait-timeout 120 nats compose up -d --wait --wait-timeout 120 nats
retire_secrets retire_secrets
normalize_email_secret normalize_email_secret
kv services/notifications/email/resend | email_valid || fail 'Vault kv/services/notifications/email/resend needs api_key. Run make email; configure the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' kv services/notifications/email/smtp | smtp_valid || fail 'Vault kv/services/notifications/email/smtp needs the mailbox password. Run make email.'
compose up -d --wait --wait-timeout 180 compose up -d --wait --wait-timeout 180
export_ca export_ca
cat <<'MESSAGE' cat <<'MESSAGE'
@@ -134,7 +133,7 @@ MESSAGE
} }
email() { email() {
existing existing
printf 'Resend API key (hidden): ' >&2 printf 'SMTP mailbox password (hidden): ' >&2
tty_state=$(stty -g) tty_state=$(stty -g)
trap 'stty "$tty_state"; cleanup' EXIT trap 'stty "$tty_state"; cleanup' EXIT
stty -echo stty -echo
@@ -143,7 +142,8 @@ email() {
printf '\n' >&2 printf '\n' >&2
[ -n "$key" ] || fail 'A key is required' [ -n "$key" ] || fail 'A key is required'
printf '%s' "$key" > "$WORK/email-key" printf '%s' "$key" > "$WORK/email-key"
normalize_email_secret jq -n --rawfile password "$WORK/email-key" '{password:$password}' > "$WORK/smtp-secret"
jq -n --rawfile key "$WORK/email-key" '{api_key:$key}' | put services/notifications/email/resend smtp_valid < "$WORK/smtp-secret" || fail 'Invalid SMTP mailbox password'
echo 'API key saved in Vault. Set the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' put services/notifications/email/smtp < "$WORK/smtp-secret"
echo 'SMTP password saved in Vault. The notifications Agent reloads it automatically.'
} }
+14
View File
@@ -21,6 +21,19 @@ compose() {
docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@" docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"
fi fi
} }
# A recreated BFF has a new container ID. Recreate its namespace-sharing session
# in the same Compose operation so neither waits on the other's old namespace.
recreate_services() {
recreate_timeout=$1; shift
case " $* " in
*' bff '*)
if [ -f "$STATE/sendico/settings.env" ]; then
case " $* " in *' sendico '*) ;; *) set -- "$@" sendico;; esac
fi
;;
esac
compose up -d --no-deps --force-recreate --wait --wait-timeout "$recreate_timeout" "$@"
}
write() { write() {
target=$1; mode=${2:-600} target=$1; mode=${2:-600}
mkdir -p "$(dirname "$target")" mkdir -p "$(dirname "$target")"
@@ -64,6 +77,7 @@ ensure() (
mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; } mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; }
admin_auth() { kv infrastructure/mongo/bootstrap | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; } admin_auth() { kv infrastructure/mongo/bootstrap | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; }
email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S"))' >/dev/null; } email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S"))' >/dev/null; }
smtp_valid() { jq -e 'keys==["password"] and (.password|type=="string" and length>0 and (explode|all(.>0 and .!=10 and .!=13)))' >/dev/null; }
export_ca() { export_ca() {
compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644 compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644
} }
+4 -2
View File
@@ -10,7 +10,9 @@ rotation() (
for pid in $workers; do wait "$pid" || true; done for pid in $workers; do wait "$pid" || true; done
restore_failed=0 restore_failed=0
vault database/roles/bff POST < "$WORK/original-role" >/dev/null || { echo 'ERROR: restore BFF role TTL manually' >&2; restore_failed=1; } vault database/roles/bff POST < "$WORK/original-role" >/dev/null || { echo 'ERROR: restore BFF role TTL manually' >&2; restore_failed=1; }
for service in frontend bff; do restore_services='frontend bff'
if [ -f "$STATE/sendico/settings.env" ]; then restore_services='frontend sendico bff'; fi
for service in $restore_services; do
if ! grep -qx "$service" "$WORK/running"; then compose stop "$service"; compose rm -f "$service"; fi if ! grep -qx "$service" "$WORK/running"; then compose stop "$service"; compose rm -f "$service"; fi
done done
return "$restore_failed" return "$restore_failed"
@@ -19,7 +21,7 @@ rotation() (
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
jq '.+{default_ttl:"12s",max_ttl:"24s"}' "$WORK/original-role" | vault database/roles/bff POST >/dev/null jq '.+{default_ttl:"12s",max_ttl:"24s"}' "$WORK/original-role" | vault database/roles/bff POST >/dev/null
compose up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend recreate_services 90 bff frontend
export_ca export_ca
https -f https://localhost:5126/ > "$WORK/index" https -f https://localhost:5126/ > "$WORK/index"
grep -q flutter_bootstrap "$WORK/index" || fail 'Frontend bundle is not served' grep -q flutter_bootstrap "$WORK/index" || fail 'Frontend bundle is not served'
+43 -1
View File
@@ -4,10 +4,48 @@ set -o pipefail
# Shell syntax and static analysis cover the actual container shell scripts. # Shell syntax and static analysis cover the actual container shell scripts.
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh infra/local/sendico/network.sh
. infra/local/scripts/common.sh . infra/local/scripts/common.sh
. infra/local/scripts/images.sh . infra/local/scripts/images.sh
. infra/local/scripts/bootstrap.sh . infra/local/scripts/bootstrap.sh
# A transient DNS failure must reset the recovery counter, while sustained loss
# must terminate only the owning session process so Docker can reattach it.
(
. infra/local/sendico/network.sh
network_calls=0; network_recovered=0
sleep() { :; }
timeout() { [ "$1" = 5 ] || exit 1; shift; "$@"; }
getent() {
[ "$*" = 'hosts vault' ] || exit 1
network_calls=$((network_calls+1))
[ "$network_calls" = 3 ]
}
kill() {
[ "$*" = '-TERM 1234' ] && [ "$network_calls" = 6 ] || exit 1
network_recovered=1
}
watch_network 1234 2> "$WORK/network-recovery"
[ "$network_recovered" = 1 ] || exit 1
grep -q 'reattach BFF network' "$WORK/network-recovery"
for invalid_parent in '' 0 00 invalid; do
if watch_network "$invalid_parent"; then exit 1; fi
done
)
echo 'PASS: session network recovery tolerates transient DNS failures and targets its parent after sustained loss'
(
STATE="$WORK/recreate-state"; mkdir -p "$STATE/sendico"
compose() { printf '%s\n' "$*" > "$WORK/recreate-command"; }
recreate_services 90 bff frontend
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend' "$WORK/recreate-command"
touch "$STATE/sendico/settings.env"
recreate_services 90 bff frontend
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend sendico' "$WORK/recreate-command"
recreate_services 120 bff sendico
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 120 bff sendico' "$WORK/recreate-command"
recreate_services 120 notifications
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 120 notifications' "$WORK/recreate-command"
)
echo 'PASS: BFF recreation pairs its configured session exactly once and preserves unrelated service scope'
# Offline fixtures exercise stable selection, reject prereleases, and preserve pins. # Offline fixtures exercise stable selection, reject prereleases, and preserve pins.
get() { get() {
case "$1" in case "$1" in
@@ -29,6 +67,10 @@ if images >/dev/null 2>&1; then fail 'Offline resolution succeeded'; fi
[ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins' [ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins'
if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi
printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid
printf '{"password":"fixture-only"}' | smtp_valid
for invalid in '{"password":""}' '{"password":"bad\nvalue"}' '{"password":"bad\u0000value"}' '{"password":"fixture","username":"not-a-secret"}'; do
if printf '%s' "$invalid" | smtp_valid; then fail 'Invalid SMTP secret accepted'; fi
done
# Initialized Vault without matching local keys must fail without initializing it again. # Initialized Vault without matching local keys must fail without initializing it again.
vault() { printf '{"initialized":true}'; } vault() { printf '{"initialized":true}'; }
if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi
+2 -2
View File
@@ -61,11 +61,11 @@ layout() {
if [ -f "$STATE/sendico/settings.env" ]; then if [ -f "$STATE/sendico/settings.env" ]; then
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration' kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
fi fi
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault' kv services/notifications/email/smtp | jq -e 'keys==["password"]' >/dev/null || fail 'SMTP secret contains non-secret configuration'
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old" [ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
done done
for path in services/bff/jwt services/notifications/email/resend infrastructure/nats/auth infrastructure/mongo/bootstrap; do for path in services/bff/jwt services/notifications/email/smtp infrastructure/nats/auth infrastructure/mongo/bootstrap; do
vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path" vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path"
done done
echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent' echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent'
+3
View File
@@ -12,10 +12,13 @@ services:
restart: unless-stopped restart: unless-stopped
# Keep the adapter's loopback-only HTTP rule; forward to the public stand API. # Keep the adapter's loopback-only HTTP rule; forward to the public stand API.
network_mode: service:bff network_mode: service:bff
depends_on:
bff: {condition: service_started, restart: true}
entrypoint: [/bin/sh, /etc/sendico/session.sh] entrypoint: [/bin/sh, /etc/sendico/session.sh]
env_file: ./.local/dev/sendico/settings.env env_file: ./.local/dev/sendico/settings.env
volumes: volumes:
- ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro - ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro
- ./infra/local/sendico/network.sh:/etc/sendico/network.sh:ro
- ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro - ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro
- ./.local/dev/agents/sendico:/etc/agent:ro - ./.local/dev/agents/sendico:/etc/agent:ro
- sendico-session:/run/sendico - sendico-session:/run/sendico
+20
View File
@@ -0,0 +1,20 @@
# Restarting the network-owning BFF can strand this sidecar in its old namespace.
# Exiting the session process lets Docker rejoin the BFF's current namespace.
watch_network() {
network_parent=$1
case "$network_parent" in ''|*[!0-9]*) return 1;; esac
[ "$network_parent" -gt 0 ] || return 1
network_failures=0
while sleep 10; do
if timeout 5 getent hosts vault >/dev/null 2>&1; then
network_failures=0
continue
fi
network_failures=$((network_failures+1))
if [ "$network_failures" -ge 3 ]; then
echo 'Local Sendico network DNS unavailable; restarting session container to reattach BFF network.' >&2
kill -TERM "$network_parent" 2>/dev/null || true
return
fi
done
}
+15 -4
View File
@@ -1,13 +1,24 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
umask 077 umask 077
# shellcheck source=infra/local/sendico/network.sh
. /etc/sendico/network.sh
mkdir -p /private /run/sendico mkdir -p /private /run/sendico
chmod 755 /run/sendico chmod 755 /run/sendico
cd /private cd /private
bridge=''; monitor=''; delay=''
cleanup() {
for child in "$bridge" "$monitor" "$delay"; do
if [ -n "$child" ]; then kill "$child" 2>/dev/null || true; fi
done
}
trap cleanup EXIT
trap 'exit 0' INT TERM
socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 & socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 &
bridge=$! bridge=$!
trap 'kill "$bridge" 2>/dev/null || true' EXIT session_pid=$$
trap 'exit 0' INT TERM (trap - EXIT INT TERM; watch_network "$session_pid") &
monitor=$!
session() { session() {
jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \ jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \
@@ -36,10 +47,10 @@ session() {
while :; do while :; do
if session; then if session; then
echo 'Local Sendico session ready; next renewal in 20 minutes.' echo 'Local Sendico session ready; next renewal in 20 minutes.'
sleep 1200 & wait $! sleep 1200 & delay=$!; wait "$delay"
else else
echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2 echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2
rm -f request.json response.json token vault.header vault.json credentials.json login.json rm -f request.json response.json token vault.header vault.json credentials.json login.json
sleep 300 & wait $! sleep 300 & delay=$!; wait "$delay"
fi fi
done done
+2 -2
View File
@@ -22,8 +22,8 @@ env_template "SXCHNGE_NATS_URL" {
env_template "EMAIL_API_KEY" { env_template "SMTP_PASSWORD" {
contents = "{{ with secret \"kv/data/services/notifications/email/resend\" }}{{ .Data.data.api_key }}{{ end }}" contents = "{{ with secret \"kv/data/services/notifications/email/smtp\" }}{{ .Data.data.password }}{{ end }}"
error_on_missing_key = true error_on_missing_key = true
} }