// Package sendico consumes only Sendico's public HTTP contracts. package sendico import ( "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "net/http" "net/url" "os" "strings" "time" merrors "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" ) type Config struct { APIURL string `yaml:"api_url"` OrganizationID string `yaml:"organization_ref"` SourceID string `yaml:"source_account_ref"` TokenFile string `yaml:"token_file"` } type Client struct { config Config base string namespace string http *http.Client } func Open(c Config) (*Client, error) { u, err := url.Parse(c.APIURL) if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) { return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)") } for _, ref := range []string{c.OrganizationID, c.SourceID} { id, err := bson.ObjectIDFromHex(ref) if err != nil || id.IsZero() || ref != id.Hex() { return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs") } } if strings.TrimSpace(c.TokenFile) == "" { return nil, merrors.InvalidArgument("Sendico token file required") } base := strings.TrimRight(c.APIURL, "/") hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID)) client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} if _, err := client.token(); err != nil { return nil, err } return client, nil } func (c *Client) Namespace() string { return c.namespace } // Rotation may replace this file, but must retain the authenticated account. // Claim inspection only protects the replay namespace from operator mistakes; // Sendico remains responsible for signature validation and authorization. func (c *Client) token() (string, error) { raw, err := os.ReadFile(c.config.TokenFile) if err != nil { return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential") } token := strings.TrimSpace(string(raw)) parts := strings.Split(token, ".") if len(token) > 16384 || len(parts) != 3 { return "", merrors.FailedPrecondition("invalid Sendico credential") } claims, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims") } var payload struct { ID string `json:"id"` Pending bool `json:"pending"` Exp int64 `json:"exp"` } if err := json.Unmarshal(claims, &payload); err != nil { return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims") } if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() { return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account") } return token, nil }