Plan independent BFF and adapt selected Sendico foundations (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:11:10 +02:00
1 parent 1e191fca42
commit 45666d33fc
17 files changed
+854 -15

No files matched your search

+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)
}
}
}