Connect signup and login verification to email notifications #39

Merged
tech merged 12 commits from auth-38 into main 2026-09-12 17:46:16 +00:00
17 changed files with 854 additions and 15 deletions
Showing only changes of commit 45666d33fc - Show all commits

No files matched your search

+12
View File
@@ -8,3 +8,15 @@ test-frontend:
cd frontend/packages/sxchnge_theme && flutter test
cd frontend/packages/sxchnge_theme/example && flutter test
cd frontend/app && flutter test
.PHONY: test-backend lint-backend run-backend
test-backend:
cd backend && go test -race ./...
lint-backend:
cd backend && test -z "$$(gofmt -l .)"
cd backend && go vet ./...
run-backend:
cd backend && go run ./cmd/bff
+18 -6
View File
@@ -11,8 +11,9 @@ with `go_router`. Its interface uses the shared theme's colors and typography.
**Authentication, email verification and analytics use dummy services.** No real
account or session is created, no email is delivered, and no analytics data is
transmitted. The backend is documentation only. Session/KYC access guards and
Sendico integration are not implemented.
transmitted. The backend has an independent Go operational shell; product services
are not connected. Session/KYC access guards and Sendico integration are not
implemented.
## Repository structure
@@ -23,7 +24,7 @@ Sendico integration are not implemented.
| [`sxchnge_theme`](frontend/packages/sxchnge_theme/README.md) | Reusable light/dark themes and bundled Onest font |
| [`sxchnge_auth`](frontend/packages/sxchnge_auth/README.md) | Shared auth contracts, providers, two-factor page and dummy services |
| [`sxchnge_analytics`](frontend/packages/sxchnge_analytics/README.md) | Typed journey events, recorder and bounded in-memory dummy analytics |
| [`backend/`](backend/README.md) | Planned BFF and Sendico integration boundaries |
| [`backend/`](backend/README.md) | Independent Go BFF starter, adapted foundations and provider integration plan |
| [`.agents/skills/`](.agents/skills) | Repository agent skills |
| [`docs/`](docs/implementation.md) | Implementation records and UX measurement contracts |
@@ -152,9 +153,9 @@ The intended integration is: **consumer app → sxchnge BFF → supported Sendic
- The sxchnge organization, backend integration principal and individual customer
references are distinct identities. Consumers must not share the integration
account's customer identity.
- The BFF should use a supported API contract rather than copying Sendico's BFF
or depending on its local internal modules. Integration credentials belong in
the backend.
- The BFF uses a supported provider API. Selected Sendico components may be
adapted into SXCHNGE-owned code; local Sendico module dependencies are not used.
Integration credentials belong in the backend.
Before backend integration, agree on the customer, KYC, eligibility/limits and
customer-scoped payment contracts for one complete transaction flow. These are
@@ -224,3 +225,14 @@ instructions also support authenticator challenges; actual TOTP enrollment,
validation and recovery remain backend integration work. See the
[two-factor UX contract](docs/ux/two-factor.md) and
[shared auth package](frontend/packages/sxchnge_auth/README.md).
## Backend integration plan
The [BFF architecture and Sendico service audit](docs/architecture/bff.md) defines
consumer identity, financial-provider boundaries, reusable components, contract
gaps and delivery phases. Selected Sendico code has been adapted with recorded
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
The starter exposes health and unconfigured readiness only. Authentication,
persistence and live financial operations remain the next implementation phases.
+15
View File
@@ -0,0 +1,15 @@
# BFF guidelines
Follow root AGENTS.md. This is an independent Go module; Sendico is an external
provider, not a local module dependency. Never add sibling-path replacements,
shared database access or internal Sendico discovery/messaging dependencies.
Keep HTTP DTOs/mappers, application models/use cases and provider adapters distinct.
Resolve ownership from the authenticated SXCHNGE principal and server-side mappings.
Only adapters may expose provider transport types internally. Adapted code belongs
to SXCHNGE; record source revision and changes in PROVENANCE.md.
Review the complete backend contribution before delivery. Run `make test-backend`
and `make lint-backend` at the repository root. Add behavioral tests for changed
boundaries and failure paths. Errors returned publicly must be stable typed codes;
never expose raw provider errors, secrets, document contents or credentials.
+38
View File
@@ -0,0 +1,38 @@
# Sendico reuse provenance
Source repository: `S_I/sendico`; revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`.
Inspected 2026-09-12. These are owned forks inside SXCHNGE, not runtime imports.
Source files below match the committed revision; local dependency edits were not used.
No generated Sendico SDK, shared database models, deployment configuration or credentials were copied.
## `internal/browser/policy.go`
- Source: [api/edge/shared/auth/browser/browser.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go)
- Source Git blob: `3af7b54aea277d91e0d8c3eba9ee84c155f4b624`
- Adaptation: Adapted cookie creation/clearing, origin validation and CSRF checks. Replaced Sendico names/errors/env path resolution with explicit SXCHNGE policy; checks do not depend on a client-controlled transport header. Host-only Secure/HttpOnly cookie, Lax same-site navigation, duplicate-cookie rejection and explicit expiry validation.
- Tests are SXCHNGE-specific behavioral checks for the adapted boundary.
## `internal/credential/token.go`
- Source: [api/edge/shared/storage/verification/hash.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go)
- Source Git blob: `36fadcf404bfc77608b28cca1e669a4a62f31774`
- Adaptation: Adapted generateMagic/tokenHash only: 32-byte crypto/rand, URL-safe encoding, SHA-256 digest. Added constant-time matching. Excluded deterministic token generation, Sendico models and all numeric OTP hashing.
- Tests are SXCHNGE-specific behavioral checks for the adapted boundary.
## `internal/evidence/key.go`
- Source: [api/edge/shared/storage/s3key/key.go](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go)
- Source Git blob: `e3cca16304d2c4cb9f256f510d5a1a30a21e22a6`
- Adaptation: Adapted relative object-key confinement. Replaced merrors with a local sentinel and rejected encoded sequences, backslashes and embedded control characters. Not a filesystem or customer-authorization utility.
- Tests are SXCHNGE-specific behavioral checks for the adapted boundary.
## Maintenance
The command, operational HTTP shell, module configuration and tests are new SXCHNGE code.
There is no automatic synchronization or promise of source compatibility with Sendico.
For an upstream fix, review the pinned source difference, port only the relevant behavior,
record a new provenance entry and run backend tests/vet. Provider contract changes belong
in the future Sendico adapter and its transport fixtures, not application models or widgets.
These primitives do not create sessions, deliver email, validate TOTP or upload documents.
Use them inside the durable flows described in the [BFF plan](../docs/architecture/bff.md).
+33 -9
View File
@@ -1,14 +1,38 @@
# SXCHNGE backend
# SXCHNGE BFF
Reserved for a small backend for frontend (BFF) using a supported Sendico API. No service, runtime, or dependencies have been initialized yet.
Independent Go module with an operational HTTP shell and selected, adapted
Sendico foundations. Sendico remains an external financial-services provider;
there are no Sendico module imports, shared databases or sibling-path dependencies.
See the [architecture and service plan](../docs/architecture/bff.md) and
[reuse provenance](PROVENANCE.md).
Planned responsibilities:
## Run and validate
- Consumer authentication and sessions, with a stable mapping to organization-scoped Sendico customer references.
- Consumer-authorized access to onboarding, quotes, orders, and transaction history.
- Product-specific orchestration and presentation of Sendico KYC, eligibility, and remaining-limit responses.
- Server-side integration credentials and request correlation with Sendico.
Requires Go 1.26 or later. From the repository root:
Sendico owns payment execution, authoritative KYC evidence and verification records, policy evaluation, and limit reservations/enforcement. Do not copy its existing BFF or import local Sendico implementation modules.
```sh
make test-backend
make lint-backend
make run-backend
```
Before implementation, agree on customer registration and identity mapping, customer KYC operations, eligibility/limits reads, customer-scoped payment access, and retry/idempotency behavior. Validate consumer isolation even when multiple customers use the same sxchnge organization and integration principal.
The default address is `127.0.0.1:8082`; override with `SXCHNGE_HTTP_ADDR`.
`GET /healthz` reports process health. `GET /readyz` deliberately returns **503**
with `not_configured`: persistence, authentication and Sendico are not connected.
No product routes return dummy success. The process handles SIGINT/SIGTERM with
bounded graceful shutdown. Production transport must terminate HTTPS.
## Present components
- `cmd/bff`, `internal/httpapi`: HTTP process, timeouts and operational endpoints.
- `internal/browser`: explicit HTTPS origin/CSRF policy and Secure HttpOnly
host-only session cookie. Apply this policy to every future browser mutation;
it is not yet connected to product routes because those do not exist.
- `internal/credential`: random opaque credentials and storage digests. Never use
this helper for passwords or short OTP codes.
- `internal/evidence`: confined server-generated object keys; not a substitute
for ownership, upload validation or private storage controls.
Only the standard library is required. Larger adapted flows, persistence,
provider DTOs/mappers, email and live integration follow the plan in separate
contributions. Source-derived components evolve independently inside this repo.
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.sendico.io/tech/sxchnge/bff/internal/httpapi"
)
func main() {
if err := run(); err != nil {
slog.Error("BFF stopped", "error", err)
os.Exit(1)
}
}
func run() error {
addr := os.Getenv("SXCHNGE_HTTP_ADDR")
if addr == "" {
addr = "127.0.0.1:8082"
}
server := &http.Server{Addr: addr, Handler: httpapi.New(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
failures := make(chan error, 1)
go func() { failures <- server.ListenAndServe() }()
slog.Info("BFF operational shell starting", "address", addr, "provider_configured", false)
select {
case err := <-failures:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
slog.Info("BFF shutting down")
return server.Shutdown(shutdown)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.sendico.io/tech/sxchnge/bff
go 1.26
+118
View File
@@ -0,0 +1,118 @@
// Adapted from Sendico edge/shared/auth/browser; see backend/PROVENANCE.md.
package browser
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
)
const (
SessionCookie = "__Host-sxchnge_session"
CSRFHeader = "X-CSRF-Protection"
CSRFValue = "1"
)
var (
ErrOrigin = errors.New("browser origin is not allowed")
ErrCSRF = errors.New("browser CSRF protection is required")
ErrSession = errors.New("browser session is missing or invalid")
)
type Policy struct{ origins map[string]struct{} }
// New accepts explicit HTTPS origins. Browser session transport is chosen by the
// server route; a client-controlled transport header cannot turn protection off.
func New(allowedOrigins []string) (*Policy, error) {
p := &Policy{origins: make(map[string]struct{}, len(allowedOrigins))}
for _, value := range allowedOrigins {
origin, ok := canonicalOrigin(value)
if !ok {
return nil, ErrOrigin
}
p.origins[origin] = struct{}{}
}
if len(p.origins) == 0 {
return nil, ErrOrigin
}
return p, nil
}
func canonicalOrigin(value string) (string, bool) {
u, err := url.Parse(value)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" {
return "", false
}
return "https://" + strings.ToLower(u.Host), true
}
// Validate must guard every browser-session mutation, including login/logout.
// It assumes CORS never grants credentialed access to untrusted origins.
func (p *Policy) Validate(r *http.Request) error {
if r == nil {
return ErrCSRF
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return nil
}
if r.Header.Get(CSRFHeader) != CSRFValue {
return ErrCSRF
}
origin, ok := canonicalOrigin(r.Header.Get("Origin"))
if !ok || p == nil {
return ErrOrigin
}
if _, allowed := p.origins[origin]; !allowed {
return ErrOrigin
}
return nil
}
func Credential(r *http.Request) (string, error) {
if r == nil {
return "", ErrSession
}
var value string
for _, c := range r.Cookies() {
if c.Name != SessionCookie {
continue
}
if value != "" || c.Value == "" {
return "", ErrSession
}
value = c.Value
}
if value == "" {
return "", ErrSession
}
return value, nil
}
func SetSession(w http.ResponseWriter, token string, now, expires time.Time) error {
age := int(expires.Sub(now).Seconds())
if token == "" || age < 1 {
return ErrSession
}
c := cookie(token)
c.Expires = expires.UTC()
c.MaxAge = age
if c.Valid() != nil {
return ErrSession
}
http.SetCookie(w, c)
return nil
}
func ClearSession(w http.ResponseWriter) {
c := cookie("")
c.Expires = time.Unix(1, 0).UTC()
c.MaxAge = -1
http.SetCookie(w, c)
}
func cookie(value string) *http.Cookie {
return &http.Cookie{Name: SessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}
}
+79
View File
@@ -0,0 +1,79 @@
package browser
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestMutationRequiresOriginAndHeader(t *testing.T) {
p, err := New([]string{"https://app.example.com"})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
origin, header, transport string
valid bool
}{
{"https://app.example.com", "1", "", true},
{"https://APP.example.com", "1", "", true},
{"https://evil.example.com", "1", "", false},
{"https://app.example.com.evil.test", "1", "", false},
{"https://app.example.com/path", "1", "", false},
{"https://app.example.com?query", "1", "", false},
{"https://app.example.com#fragment", "1", "", false},
{"https://app.example.com", "", "bearer", false},
{"", "1", "bearer", false},
{"null", "1", "cookie", false},
} {
r := httptest.NewRequest(http.MethodPost, "https://app.example.com/api/session", nil)
r.Header.Set("Origin", tc.origin)
r.Header.Set(CSRFHeader, tc.header)
r.Header.Set("X-Auth-Transport", tc.transport)
if got := p.Validate(r); (got == nil) != tc.valid {
t.Errorf("%+v: %v", tc, got)
}
}
if p.Validate(httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)) != nil {
t.Fatal("read rejected")
}
for _, origin := range []string{"*", "http://app.example.com", "https://user@app.example.com", "https://app.example.com/"} {
if _, err := New([]string{origin}); err == nil {
t.Errorf("accepted configuration %q", origin)
}
}
if _, err := New(nil); err == nil {
t.Fatal("empty origin policy accepted")
}
}
func TestSessionCookie(t *testing.T) {
now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC)
w := httptest.NewRecorder()
if err := SetSession(w, "opaque-token", now, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
c := w.Result().Cookies()[0]
if c.Name != SessionCookie || !c.Secure || !c.HttpOnly || c.Path != "/" || c.Domain != "" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 3600 {
t.Fatalf("bad cookie: %+v", c)
}
r := httptest.NewRequest(http.MethodGet, "https://app.example.com/api/session", nil)
r.AddCookie(c)
if value, err := Credential(r); err != nil || value != "opaque-token" {
t.Fatal("cookie not read")
}
r.AddCookie(c)
if _, err := Credential(r); err == nil {
t.Fatal("ambiguous credential accepted")
}
if err := SetSession(httptest.NewRecorder(), "token", now, now); err == nil {
t.Fatal("expired cookie accepted")
}
w = httptest.NewRecorder()
ClearSession(w)
c = w.Result().Cookies()[0]
if c.MaxAge != -1 || c.Value != "" || c.Path != "/" || !c.Secure {
t.Fatal("cookie not cleared safely")
}
}
+29
View File
@@ -0,0 +1,29 @@
// Adapted from Sendico verification/hash.go; see backend/PROVENANCE.md.
package credential
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
)
// New returns an opaque credential and its storage digest. Store only the digest.
// This is for high-entropy tokens, never passwords or short numeric OTP codes.
func New() (raw, digest string, err error) {
bytes := make([]byte, 32)
if _, err = rand.Read(bytes); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(bytes)
return raw, Digest(raw), nil
}
func Digest(raw string) string {
sum := sha256.Sum256([]byte(raw))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func Matches(raw, digest string) bool {
return raw != "" && subtle.ConstantTimeCompare([]byte(Digest(raw)), []byte(digest)) == 1
}
+24
View File
@@ -0,0 +1,24 @@
package credential
import (
"encoding/base64"
"testing"
)
func TestOpaqueCredential(t *testing.T) {
raw, digest, err := New()
if err != nil {
t.Fatal(err)
}
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(decoded) != 32 {
t.Fatal("credential must contain 256 random bits")
}
if raw == digest || !Matches(raw, digest) || Matches("other", digest) || Matches("", Digest("")) {
t.Fatal("invalid digest matching behavior")
}
other, _, err := New()
if err != nil || other == raw {
t.Fatal("credentials must be independently generated")
}
}
+43
View File
@@ -0,0 +1,43 @@
// Adapted from Sendico edge/shared/storage/s3key; see backend/PROVENANCE.md.
package evidence
import (
"errors"
"path"
"strings"
"unicode"
)
var ErrInvalidKey = errors.New("invalid evidence key")
// JoinKey confines a server-generated object reference to a relative prefix.
// It is not an ownership check or a local-filesystem path sanitizer. Inputs must
// not be URL-decoded or interpreted as filesystem paths after validation.
func JoinKey(prefix, reference string) (string, error) {
a, err := relative(prefix)
if err != nil {
return "", err
}
b, err := relative(reference)
if err != nil {
return "", err
}
return path.Join(a, b), nil
}
func relative(value string) (string, error) {
value = strings.TrimSpace(value)
if strings.ContainsAny(value, "\\%") || strings.ContainsFunc(value, unicode.IsControl) {
return "", ErrInvalidKey
}
for _, segment := range strings.Split(value, "/") {
if segment == ".." {
return "", ErrInvalidKey
}
}
clean := path.Clean(value)
if value == "" || clean == "." || path.IsAbs(clean) {
return "", ErrInvalidKey
}
return clean, nil
}
+18
View File
@@ -0,0 +1,18 @@
package evidence
import "testing"
func TestKeyConfinement(t *testing.T) {
for _, ref := range []string{"", ".", "..", "../other", "/other", "a/../../other", "a\\..\\other", "%2e%2e/other", "bad\x00key"} {
if _, err := JoinKey("customer/documents", ref); err == nil {
t.Errorf("accepted %q", ref)
}
if _, err := JoinKey(ref, "document"); err == nil {
t.Errorf("accepted prefix %q", ref)
}
}
got, err := JoinKey("customer/documents/", "passport/front")
if err != nil || got != "customer/documents/passport/front" {
t.Fatalf("got %q, %v", got, err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package httpapi
import (
"encoding/json"
"net/http"
)
// New is the operational shell. Product routes will be added only with their
// persistence, authentication and provider adapters, never simulated success.
func New() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
write(w, http.StatusOK, "ok")
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
write(w, http.StatusServiceUnavailable, "not_configured")
})
return mux
}
func write(w http.ResponseWriter, status int, value string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
}{value})
}
+17
View File
@@ -0,0 +1,17 @@
package httpapi
import (
"net/http/httptest"
"testing"
)
func TestOperationalShellDoesNotClaimReadiness(t *testing.T) {
handler := New()
for path, want := range map[string]int{"/healthz": 200, "/readyz": 503, "/api/v1/session": 404} {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Errorf("%s: %d, want %d", path, w.Code, want)
}
}
}
+300
View File
@@ -0,0 +1,300 @@
# Independent SXCHNGE BFF — task #34
## Decision and delivery scope
Build a small, independently deployed Go BFF in `backend/`. Adapt selected Sendico
code into SXCHNGE ownership; treat Sendico as an external financial provider through
a versioned partner contract. Use one BFF process initially, with a worker entrypoint
from the same module when durable asynchronous work is introduced. Do not reproduce
Sendico's service topology inside SXCHNGE.
This contribution delivers the architecture/reuse audit and a tested operational
starter. It does **not** implement account persistence, real authentication, email,
KYC uploads, money operations or a live Sendico adapter. The starter has health
and explicitly unconfigured readiness endpoints. Selected browser-session,
opaque-credential and evidence-key components are adapted now; the larger flows
below are planned extractions, not claims of completed integration.
Audit basis: local `S_I/sendico` checkout, branch `compliance-ingestor-2167`, commit
`8bb20dcd622dd8eb92d289b6b34411e7eccab083`, inspected 2026-09-12. The checkout has
unrelated dependency-manifest edits, which were not copied or modified. Findings
establish source capabilities at that revision, not deployment availability,
external API support, or production readiness. See [source references](#source-references)
and [reuse provenance](../../backend/PROVENANCE.md).
## Ownership and separation
```mermaid
flowchart LR
UI[Flutter web / mobile] --> BFF[SXCHNGE BFF\nconsumer API and authorization]
BFF --> DB[(SXCHNGE database\nsessions, mappings, commands)]
BFF --> MAIL[Email delivery adapter]
BFF --> ADAPTER[SXCHNGE Sendico adapter\nDTOs and mapping]
ADAPTER --> EDGE[Sendico partner API\nauthenticated customer scope]
EDGE --> FIN[Sendico ledger, quotes, payments\nlimits, compliance, inbound]
EDGE --> EVENTS[Signed callback delivery]
EVENTS --> BFF
```
| Owner | Responsibilities |
| --- | --- |
| SXCHNGE | Consumer accounts/passwords, email confirmation, sessions, language, MFA policy/enrollment, local customer mapping, user authorization, operation intents, idempotency/outbox/inbox, product DTOs and safe status explanations |
| Sendico | Canonical financial accounts and posted money, spendability rules, currency/rail catalog, quotes/rates/fees, execution/settlement, authoritative limits and reservations, KYC evidence/results, compliance decisions and review workflows |
| Provider boundary | Authenticated partner principal, organization and customer isolation, stable identifiers, versioned transport, error/status semantics, replay guarantees, signed events and recovery reads |
Sendico integration credentials stay exclusively in the BFF's secret configuration.
SXCHNGE users do not receive Sendico credentials, internal gRPC addresses, database
access, or membership/admin privileges on its integration organization. The BFF
never repairs balances by posting arbitrary ledger entries and never independently
reserves limits already owned by Sendico's payment execution.
### Three identities must stay distinct
Persist a server-owned mapping from `sxchnge_user_id` to a Sendico provider scope:
`organization_ref`, `customer_ref`, `compliance_subject_ref`, and, where Sendico's
ownership contracts require it, `ledger_owner_account_ref`. These fields are not
assumed equal. The partner integration principal is a fifth, separate identity.
Use unique indexes for local user/provider and provider organization/customer.
Provisioning is idempotent using a stable external customer reference.
Do not accept organization, customer, initiator or owner refs from browser payloads.
Resolve local account/quote/operation IDs under the authenticated user first; send
only mapped refs. Sendico must enforce the same scope independently. List filtering
is mandatory before results are returned; direct reads must return the same safe
not-found outcome for missing and inaccessible objects.
**Blocking contract gap:** the inspected protocols have customer-related fields,
but no customer provisioning RPC was found. Current ledger/payment ownership is
expressed with Sendico account refs; current personal-compliance HTTP routes resolve
organizations owned by the logged-in account. Neither is automatically a delegated
customer API for many SXCHNGE users under one organization. Establish a supported
mapping/provisioning facade on Sendico before connecting customer money or KYC.
Do not create a new Sendico organization per consumer to work around this.
## Reuse decisions
| Sendico starting point | Decision | SXCHNGE adaptation |
| --- | --- | --- |
| `edge/shared/auth/browser/browser.go` | Adapted now | Own `browser` package; fixed `__Host-sxchnge_session`, explicit HTTPS origins, server-selected cookie routes, no client-header CSRF bypass, same-site Lax cookie for return navigation; no Sendico error/config imports |
| `edge/shared/storage/verification/hash.go` | Selected functions adapted now | 32-byte random opaque credentials and SHA-256 storage digests; constant-time matching; no deterministic magic links or short-OTP hashing copied |
| `edge/shared/storage/s3key/key.go` | Adapted now | Own evidence object-key confinement and errors, additional encoded/backslash/control-character rejection; not an ownership check or local-filesystem sanitizer |
| `edge/shared/storage/verification/{create,consume,consume_update,db}.go` | Adapt in authentication phase | Account + purpose + operation binding, atomic consumption, expiry checked during reads, idempotent issuance, cooldown/attempt indexes. Replace model/repository/logging/notification dependencies with SXCHNGE contracts |
| `edge/shared/storage/refreshtokens/{filters,crud,revoke}.go` | Adapt model and persistence patterns in session phase | Session families/versioning, account binding, rotation and revocation; own database and credential format. Do not copy account-bound API-client login wholesale |
| `edge/shared/.../router/public/{login,refresh,logout}.go` | Adapt flow and tests | Pending login before session issuance; uniform credential failures; confirmed logout and refresh recovery. Preserve password input verbatim, unlike the inspected decoder's trim behavior |
| `edge/product/internal/server/paymentapiimp/{personal_source,visibility,quote,payquote}.go` | Adapt orchestration/authorization rules | Mandatory customer ownership even when partner has organization-wide rights; request-scoped deadlines, quote-bound execution, safe not-found projection. No BSON/permissions/discovery types in SXCHNGE models |
| `edge/product/internal/compliance/{read_service,upload_service,submit_service}.go` | Adapt orchestration pattern | Separate status/limits slices, bounded evidence ingestion, ownership, submission idempotency. Replace “account owns organization” assumption with provider customer mapping |
| `edge/callbacks/internal/signing/service.go` | Implement matching receiver after contract confirmation | Use approved wire algorithm and exact raw-body verification, bounded clock skew, event-ID inbox deduplication, reconciliation reads. Do not copy Vault-backed outbound sender into receiver |
| Ledger, limits, FX, AML/policy, gateways, treasury, KYC engines | External services | Call the partner facade; no cloned financial/compliance state machines |
| Sendico `pkg`, generic Mongo repositories, NATS/discovery bootstrapping, BFF router/DI graph | Do not import wholesale | Small local packages and explicit construction; no sibling Go replacements, shared database, submodule, automatic upstream sync or Sendico runtime dependencies |
Reuse includes meaningful tests and behavioral invariants, not just filenames.
Each extraction records its source commit and intentional deviations. Future
Sendico fixes are assessed and ported selectively through SXCHNGE PRs. Contract
fixtures/version checks protect the adapter as the products diverge.
## BFF structure
Use a single Go module and explicit constructor injection. The following is the
**target layout**; only the shell and three selected foundations exist today.
Create additional directories when their first use case is implemented.
```text
backend/
cmd/bff/ HTTP process and graceful shutdown
cmd/worker/ future outbox / provider reconciliation worker
internal/httpapi/ routes, HTTP DTOs, mappers, stable errors
internal/identity/ users, registration, email confirmation
internal/session/ authenticated principal, restore/rotate/revoke
internal/verification/ challenges, factors, operation approvals
internal/profile/ account preferences
internal/wallet/ accounts and spendable-balance projection
internal/operations/ quotes, commands, status, history
internal/compliance/ KYC and visible limits use cases
internal/provider/ narrow customer/financial provider ports
internal/sendico/ external DTOs, HTTP client, mappers, contract tests
internal/notification/ email delivery port and adapter
internal/storage/ SXCHNGE repositories and migrations/indexes
internal/jobs/ durable outbox, event inbox, reconciliation
internal/browser/ browser cookie and CSRF policy (exists)
internal/credential/ opaque credentials and digests (exists)
internal/evidence/ object key confinement (exists)
```
A handler decodes an HTTP DTO, validates it, maps to a use-case input, and supplies
the authenticated principal. Use cases own application models and repository/provider
ports. The Sendico adapter alone converts its external DTOs into these models.
The Flutter client retains DTO → mapper → service → model → provider → UI;
provider DTOs do not leak through either boundary. Avoid one interface per function
or an all-purpose “Sendico service” spanning unrelated capabilities.
Proposed persistence: a separate SXCHNGE MongoDB database/credentials, using native
driver repositories and replica-set transactions for critical local transitions.
This preserves useful Sendico persistence experience without importing its generic
repository module. Collections: users, sessions, challenges/factors, provider
mappings, quotes, operation commands, outbox and callback inbox. Unique indexes
scope email, token digest, issuance/idempotency keys and provider events. TTL indexes
clean up expired data; application checks enforce expiry immediately. Financial
balances and KYC decisions are not authoritative local records.
Start with the existing deployment's secret facility where available. Do not make
NATS, Redis, Sendico discovery or direct Vault imports prerequisites for the BFF.
Email has an independent adapter and durable delivery jobs; adopting an existing
mail provider does not require adopting Sendico notification/event envelopes.
## Required Sendico services and partner capabilities
The BFF should call a versioned partner HTTP API. The names below are **existing
internal services**, useful to implement that facade; they are not instructions to
expose unauthenticated gRPC or for the browser to call them. If a supported partner
API is already deployed, validate it against this checklist instead of creating a
second one. No deployed endpoint has been verified in this task.
| Capability needed by SXCHNGE | Existing source capability | Required partner behavior / gap |
| --- | --- | --- |
| Partner authentication and customer provisioning | Product account/API-client login and organization provisioning; customer fields in payment/KYC contracts | Confirm server-to-server grant/rotation/scopes. Existing `/login/api` is password + client-secret based, not evidence of OAuth client credentials. Add/confirm idempotent customer registration and canonical owner/subject mapping |
| Customer currency accounts | `LedgerService.ListAccounts`, `GetBalance`; treasury currency catalog | Enforce mapped customer scope; provision accounts through approved workflow. Return server-defined **available to spend**, currency precision, timestamp/version. `BalanceResponse.balance` alone does not define holds, pending debits or spendability |
| Rates, fees and quotes | `QuotationService.QuotePayment`, `QuotePayments`, `ListFXDirections` | Bind initiator and required source owner; return immutable fee/debit/credit/expiry quote snapshot, supported directions and exact money representation. An advisory quote is not execution authorization |
| Exchange and payout | `PaymentOrchestratorService.ExecutePayment`, `GetPayment`, `ListPayments` | Mandatory mapped organization/initiator/source-owner constraints; execute by accepted quote ref; scoped idempotency conflict/replay and lookup by client operation ref. Confirm FX ledger-to-ledger and payout rail availability |
| Top-up | `inbound.routes.RouteService`, payment methods and gateway capabilities; `inbound.crypto_deposits` for crypto | Return customer-bound funding instructions or hosted payment action. Confirm bank/card rails and incoming attribution. A top-up cannot immediately credit funds merely because the browser submitted a form |
| Limits and upgrade benefits | `LimitsService.ListVisibleLimits`, `CheckLimit`; reservation lifecycle used by payments | Customer-visible policy including used, remaining, period/reset and verified-tier projection. Policy chooses tier from authoritative KYC. Payments owns execution reservation/commit/release; BFF never double-reserves |
| KYC evidence and status | `KYCService.SubmitVerification`, `GetVerification`, `Reverify`, `ListVerifications`; existing edge compliance upload/submit | Delegated customer access, passport/selfie evidence roles, retention/access rules and immutable evidence IDs. Confirm manual review/correction/denial transitions and their public reason codes; public KYC proto alone does not expose a manual-review decision RPC |
| Backoffice review and eligibility | KYC plus `CaseManagementService`, Assessment and Policy | Confirm review routing and authoritative decision application; resolving a workflow case is not itself a KYC approval. Ensure KYC/tier changes feed payment eligibility, rather than assuming the separate KYC service already gates every payment |
| Status changes and reconciliation | Orchestrator reads, ledger statements, outbound `edge/callbacks` | Signed customer-scoped payment/KYC events, stable event IDs, at-least-once semantics, replay/ordering/version rules, retries and replayable status reads. Polling is a valid initial fallback; missing callbacks cannot leave funds permanently ambiguous |
The current limits README says reservations are in process, but source now requires
`RuntimeRepository` and applies durable mutations with conflict/retry handling.
Use implementation and contract tests as evidence; confirm deployed durability and
restart/multi-instance behavior before money rollout rather than repeating the
README claim.
## Client-facing contracts to implement
Paths here are proposed **SXCHNGE API**, not existing Sendico endpoints. Keep them
versioned under `/api/v1`. HTTP bodies use explicit DTOs; product error responses
carry a stable code, request ID and, where useful, `retry_after` or field codes.
No raw upstream exceptions or internal compliance evidence are returned.
| Client journey | Proposed BFF operations | Important contract |
| --- | --- | --- |
| Registration/email | `POST /registrations`, `/email-verifications`, `/email-verifications/confirm` | Idempotent registration/delivery; explicit unverified state; single-use expiring links; no session from merely creating an account |
| Login/session | `POST /sessions`, `POST /sessions/complete`, `GET /session`, `DELETE /session` | Credentials return pending challenge or confirmed session; only server can complete it. Restore, expiry, revocation, logout and frontend route guards |
| Recovery | `POST /password-resets`, `/password-resets/confirm` | Non-enumerating delivery, bounded retries and session revocation policy; do not reuse email-confirmation token purposes |
| 2FA | `GET /security`, `POST /security/challenges`, `POST /security/challenges/{id}/verify`, `/resend`, `PATCH /security` | User/session/purpose/operation-bound challenges and server-selected methods. Verified enable/disable; future TOTP setup is pending until a valid code confirms the secret |
| Preferences | `GET /profile`, `PATCH /profile` | Server-owned account language and validated mutable fields |
| Accounts/limits | `GET /accounts`, `/accounts/{id}`, `/limits` | Available balance only in primary UI; current usage and upgrade benefits from service; distinguish unavailable/stale from zero |
| Money | `POST /quotes`, `POST /operations`, `GET /operations`, `/operations/{id}`, `/operations/by-key/{key}` | Exact immutable quote, server confirmation proof, scoped idempotency and non-final acceptance. Funding instructions/hosted-action state where required |
| KYC | `GET /identity`, `POST /identity/uploads`, `POST /identity/submissions` | Bounded content validation and customer ownership; two evidence roles; idempotent submission; status, correction requests and denials |
Define money on the wire as decimal-string minor units plus currency and scale;
map into checked application types. Never use floating-point amounts or unchecked
JavaScript-sized integers. Existing Flutter models use integer minor units; their
mappers must reject unsupported precision/overflow. Never invent balances, fees,
limits or rates on provider timeout.
Current Flutter stubs need targeted extensions during integration: a session model/
provider and HTTP transport; typed funding methods/recipients instead of the current
free-text recipient; `requiresAction`/funding instructions and settlement-aware funds
status. Keep the three broad operation states if useful visually, but represent
awaiting funding, review, execution, cancellation/reversal and returned funds in
models so “failed” cannot incorrectly promise that money has already returned.
The current `GuardedLoginService` is a local stand-in: replace it with the BFF's
pending-login contract, not a production client that mints its own pending session.
There is no public endpoint that simply trusts `authorize(purpose, operation)`;
the BFF consumes proof as part of the protected server-side action.
## Critical transaction and identity flows
### Authentication and verification
Registration owns local credentials, confirms email, then idempotently provisions
or resumes the provider mapping. An incomplete provider mapping can yield an
explicit “account setup pending” state without losing the local registration.
No money access until mapping is confirmed; the optional unverified KYC state
still receives whatever basic policy Sendico returns.
Adapt Sendico's pending-login and durable consumption patterns into SXCHNGE-owned
models. Browser routes use an HttpOnly Secure host-only opaque session cookie;
unsafe requests require an exact allowed Origin plus the CSRF header, independently
of client transport hints. SameSite Lax permits safe return navigation from email;
state-changing confirmation still requires POST validation. Production sits behind
HTTPS. Native mobile uses a separate server-selected token transport with secure
platform storage and rotation/revocation, not browser-origin bypass flags.
Scope challenges by user, session/pending-login, purpose, operation ID and method.
Use independent purposes for login, email, password reset and setting changes.
Use durable attempt/cooldown state across instances; a resend must not reset the
attempt budget. High-entropy token digests can reuse the extracted helper; numeric
codes need a separately keyed verifier and strict attempt controls, never this
helper as a password/OTP hash. Future TOTP secrets require encryption, confirmed
enrollment, bounded clock drift/replay controls and recovery before availability.
### Money execution without duplicate payment
1. Resolve the authenticated customer and source/destination ownership server-side.
2. Obtain a Sendico quote carrying the mapped initiator and mandatory source-owner
constraint. Persist its local ID, user, provider ref, expiry and exact snapshot.
3. Bind the user's verification to that quote and action. A changed amount,
recipient, account or expired quote requires fresh review and authorization.
4. In one **local database transaction**, validate/consume approval and insert the
immutable operation command, scoped idempotency key, request digest and outbox
job. Reusing the key with another payload is a conflict. Replaying the same key
returns the same command before checking an already-consumed approval.
5. A worker submits the stored command using the same provider idempotency key.
Sendico rechecks ownership, quote validity, eligibility, spendability and limits.
A BFF approval does not override provider policy.
6. If a response is lost, reconcile by the provider/client operation reference;
retry only under Sendico's documented idempotency contract. Never create a new
key merely to escape uncertainty. A crash after approval consumption is recovered
by the persisted outbox, not by asking for and spending another authorization.
7. Persist observed status/version and correlate events; update balances/limits from
authoritative reads. Pending/manual review is not completion, and failed/reversed
status must describe whether funds actually returned. Callback duplicates and
out-of-order events must not regress terminal state; ambiguous order triggers a read.
There is no cross-database atomic transaction with Sendico. Durable commands,
idempotent provider execution and reconciliation are the explicit bridge. This
behavior must be proven with an “accepted upstream, response lost” contract test.
### KYC and operational observability
Use customer-bound private evidence references; validate size/type/content and
read access before forwarding to the provider. Do not pass arbitrary public URLs,
local file paths or untrusted object keys. If uploads stage in SXCHNGE, lifecycle,
retention, orphan cleanup and delete responsibility must be explicit; prefer
provider-issued scoped upload intents when supported. Passport and selfie are
separate evidence roles in one idempotent submission. Backoffice decisions remain
in Sendico; project only safe actionable reasons and the resulting limits.
Correlate requests, local commands and provider IDs on the server with structured
logs. Record provider latency/errors, outbox lag, reconciliation age, callback
retries and final outcomes. Exclude passwords, codes, cookies, documents, recipient
payloads and secrets. Existing dummy analytics remains a client sink; backend
settlement evidence supplies real outcomes, not browser “submitted” events.
## Implementation sequence and acceptance gates
| Phase | Deliverable | Exit evidence |
| --- | --- | --- |
| 0 — this task | Audit, reuse choices, independent shell and primitives | Unit/race tests, Go formatting/vet, no Sendico imports/replacements, source provenance |
| 1 — provider contract agreement | Customer provisioning/mapping, scoped access, spendability, quote/payment replay, KYC review and callback contract | Sandbox fixtures/API schema and two-customer isolation tests; determine whether existing partner edge can be extended |
| 2 — own authentication | Mongo repositories, email outbox, signup/email/login/session/2FA/recovery plus Flutter adapters/guards | Restart-safe consumption and delivery, session isolation/revocation, no session before required factor, web/mobile transport tests |
| 3 — read-only provider integration | Customer mapping, currency accounts, profile, limits, KYC status | Available-money semantics, no cross-customer list/direct reads, safe unknown/error handling, contract version validation |
| 4 — first money slice | One supported top-up rail followed by exchange and payout, recipient/funding UI extensions | Provider-confirmed credit/debit, fees and expiry; idempotent concurrency and lost-response recovery; funds explanations match actual statuses |
| 5 — evidence/review integration | Passport/selfie uploads, backoffice review, correction/deny, policy propagation | Customer isolation, evidence lifecycle, review-driven limits change and execution policy enforcement |
| 6 — rollout | Deployment, migrations, secret rotation, health/readiness, reconciliation operations and event delivery | Sandbox end-to-end runs, restart/recovery drills, ownership tests on every public resource and explicit supported-rail configuration |
Authentication work can proceed while the provider contract is agreed. Do not block
it on cloning financial services. Conversely, no live money rollout until the scope,
spendability, idempotency and settlement contracts are verified. Keep each phase in
its own task/PR; Sendico-side facade work is a separate provider contribution.
## Source references
All paths below are in the audited Sendico revision; links pin that revision.
- [Browser transport](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/auth/browser/browser.go), [verification hashing](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/hash.go), [evidence keys](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/s3key/key.go).
- [Pending/API login](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/internal/transport/http/router/public/login.go), [session binding](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/refreshtokens/filters.go), [verification indexes](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/shared/storage/verification/db.go).
- [Ledger contract](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/ledger/v1/ledger.proto), [payment visibility](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/paymentapiimp/visibility.go), [personal source constraints](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/paymentapiimp/personal_source.go).
- [Quotation](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/payments/quotation/v2/quotation.proto), [orchestration](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/payments/orchestration/v2/orchestration.proto), [inbound routes](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/inbound/routes/v1/routes.proto).
- [Limits projection](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/limits/v1/limits.proto), [durable mutation implementation](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/limits/internal/service/limits/reservation_mutation_persistence.go), [required runtime storage](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/limits/internal/service/limits/service.go).
- [Personal-compliance ownership](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/product/internal/server/accountapiimp/compliance.go), [KYC](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/compliance/kyc/v1/kyc.proto), [case workflow](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/proto/workflow/case/v1/case.proto), [callback signing](https://git.sendico.io/S_I/sendico/src/commit/8bb20dcd622dd8eb92d289b6b34411e7eccab083/api/edge/callbacks/internal/signing/service.go).
+33
View File
@@ -362,3 +362,36 @@ See [the UX and measurement contract](ux/two-factor.md) and
Delivery: [PR #33](https://git.sendico.io/tech/sxchnge/pulls/33) targets `main`;
committed and pushed, unmerged. Issue #32 and PR #33 metadata verified: frontend
label, tech owner and SXCHNGE project. No stacked dependencies.
## Independent BFF plan and Sendico reuse — task #34
Audited `S_I/sendico` at `8bb20dcd622dd8eb92d289b6b34411e7eccab083` without
modifying its unrelated working-tree dependency edits. The
[BFF plan](architecture/bff.md) records source-backed reuse choices, independent
ownership, target structure, proposed Mongo persistence, client/API mapping,
required provider services and phased acceptance criteria. Source availability
is distinguished from supported/deployed external APIs.
The main provider gaps are delegated customer provisioning/owner mapping, a
spendable-balance projection, customer-scoped compliance access/manual review,
and verified payment/callback replay contracts. Existing quotation/orchestration
owner constraints and durable limits mutation code can underpin the provider
facade. Sendico's personal-compliance ownership assumptions and stale limits
README are called out explicitly. Sendico remains authoritative for financial
execution, limits and compliance; the BFF does not clone those engines.
Created a separate Go module and operational shell under `backend/`, plus adapted
browser cookie/CSRF policy, opaque credential generation/digests and evidence-key
confinement. [Provenance](../backend/PROVENANCE.md) pins source blobs and deviations.
No Sendico imports, sibling replacements, external Go modules, shared database
access or live provider calls. The starter exposes health and deliberately 503
readiness; authentication, persistence and financial product routes remain planned.
Root/backend README and backend guidelines describe the current scope.
Validation: `make test-backend` (race-enabled tests across four packages),
`make lint-backend` (gofmt/vet), `go build` and `git diff --check` passed.
CLI smoke verified health 200, readiness 503, unimplemented session 404 and SIGTERM
exit 0. Module/dependency inspection confirms standard-library-only dependencies.
Pinned source paths, source blobs and local documentation links were checked.
Reviewed all new backend files and the task diff. No Flutter code changed;
frontend tests and security scans were not run for this backend starter/plan task.