verae-mcp-timestamping-serv.../pkg/tsnats/client.go

398 lines
10 KiB
Go

package tsnats
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/nats-io/nats.go"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
)
const defaultTimeout = 2 * time.Second
// Requester is the request/reply surface used by Stamp/Verify/HashReport.
// Inject a mock in unit tests; Connect wraps *nats.Conn.
type Requester interface {
Request(ctx context.Context, subj string, data []byte) ([]byte, error)
}
// MessageHandler receives raw message payloads from a core NATS subscription.
type MessageHandler func(data []byte)
// SubConn is the subscribe surface for SubscribeCompleted.
type SubConn interface {
Subscribe(subj string, cb MessageHandler) (Subscription, error)
}
// Subscription is returned by SubscribeCompleted.
type Subscription interface {
Unsubscribe() error
}
// Receipt is a timestamp receipt from request/reply or hash.report.
type Receipt struct {
ReceiptID string `json:"receipt_id"`
Hash string `json:"hash"`
Scope string `json:"scope"`
CubeID string `json:"cube_id,omitempty"`
OrgID string `json:"org_id,omitempty"`
CreatedAt string `json:"created_at"`
Merkle string `json:"merkle"`
RestampOf string `json:"restamp_of,omitempty"`
}
// VerifyResult is the outcome of Verify.
type VerifyResult struct {
Valid bool
Error string
Receipt *Receipt
}
// CompletedEvent is a fire-and-forget job-complete notification on SubjectCompleted.
type CompletedEvent struct {
JobID string `json:"jobId"`
ReceiptID string `json:"receipt_id"`
Hash string `json:"hash,omitempty"`
}
// StampOptions configures Stamp. Authorization/AccessToken are optional
// passthrough fields for embedded handlers (JSON keys authorization / access_token).
type StampOptions struct {
Scope string
CubeID string
OrgID string
Authorization string
AccessToken string
}
// VerifyOptions configures Verify.
type VerifyOptions struct {
WantScope string
Authorization string
AccessToken string
}
// ReportOptions configures HashReport.
type ReportOptions struct {
OrgID string
WantScope string
Authorization string
AccessToken string
}
// Option configures Client construction.
type Option func(*Client)
// WithTimeout sets the request timeout (default 2s).
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
if d > 0 {
c.timeout = d
}
}
}
// WithRequester injects a Requester (tests or custom transports).
func WithRequester(r Requester) Option {
return func(c *Client) { c.req = r }
}
// WithSubscriber injects a SubConn for SubscribeCompleted.
func WithSubscriber(s SubConn) Option {
return func(c *Client) { c.sub = s }
}
// Client is a digests-only NATS timestamping client (core NATS only; no JetStream).
type Client struct {
req Requester
sub SubConn
timeout time.Duration
nc *nats.Conn
}
// NewClient builds a Client with an injected Requester (unit tests).
func NewClient(r Requester, opts ...Option) *Client {
c := &Client{req: r, timeout: defaultTimeout}
for _, opt := range opts {
opt(c)
}
return c
}
// Connect dials a NATS URL and returns a Client using core request/reply and subscribe.
func Connect(url string, opts ...Option) (*Client, error) {
nc, err := nats.Connect(url)
if err != nil {
return nil, err
}
wrap := &natsAdapter{nc: nc}
c := &Client{req: wrap, sub: wrap, timeout: defaultTimeout, nc: nc}
for _, opt := range opts {
opt(c)
}
if c.req == nil {
c.req = wrap
}
if c.sub == nil {
c.sub = wrap
}
return c, nil
}
// Close closes the underlying NATS connection when opened via Connect.
func (c *Client) Close() {
if c.nc != nil {
c.nc.Close()
}
}
// Timeout returns the request timeout.
func (c *Client) Timeout() time.Duration {
return c.timeout
}
// Stamp publishes a digests-only stamp request on SubjectRequest.
func (c *Client) Stamp(ctx context.Context, sha256 string, opts StampOptions) ([]Receipt, error) {
h, err := digest.NormalizeSHA256(sha256)
if err != nil {
return nil, err
}
scope := opts.Scope
if scope == "" {
scope = "local"
}
payload := map[string]any{
"hash": h,
"scope": scope,
"cube_id": opts.CubeID,
"org_id": opts.OrgID,
}
putAuth(payload, opts.Authorization, opts.AccessToken)
raw, err := c.request(ctx, SubjectRequest, payload)
if err != nil {
return nil, err
}
return parseStampReply(raw)
}
// Verify asks SubjectVerify for a receipt by id.
func (c *Client) Verify(ctx context.Context, receiptID string, opts VerifyOptions) (VerifyResult, error) {
if receiptID == "" {
return VerifyResult{Valid: false, Error: "receipt_id required"}, nil
}
payload := map[string]any{
"receipt_id": receiptID,
"want_scope": opts.WantScope,
}
putAuth(payload, opts.Authorization, opts.AccessToken)
raw, err := c.request(ctx, SubjectVerify, payload)
if err != nil {
return VerifyResult{}, err
}
return parseVerifyReply(raw)
}
// HashReport asks SubjectReport for receipts for a digest.
func (c *Client) HashReport(ctx context.Context, sha256 string, opts ReportOptions) ([]Receipt, error) {
h, err := digest.NormalizeSHA256(sha256)
if err != nil {
return nil, err
}
payload := map[string]any{
"hash": h,
"org_id": opts.OrgID,
"want_scope": opts.WantScope,
}
putAuth(payload, opts.Authorization, opts.AccessToken)
raw, err := c.request(ctx, SubjectReport, payload)
if err != nil {
return nil, err
}
return parseReportReply(raw)
}
// SubscribeCompleted core-subscribes to SubjectCompleted (not JetStream).
func (c *Client) SubscribeCompleted(ctx context.Context, handler func(CompletedEvent)) (Subscription, error) {
if c.sub == nil {
return nil, fmt.Errorf("tsnats: no subscriber configured")
}
if handler == nil {
return nil, fmt.Errorf("tsnats: handler required")
}
_ = ctx
return c.sub.Subscribe(SubjectCompleted, func(data []byte) {
var ev CompletedEvent
if err := json.Unmarshal(data, &ev); err != nil {
return
}
handler(ev)
})
}
func (c *Client) request(ctx context.Context, subj string, payload map[string]any) ([]byte, error) {
if c.req == nil {
return nil, fmt.Errorf("tsnats: no requester configured")
}
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
ctx, cancel := withTimeout(ctx, c.timeout)
defer cancel()
return c.req.Request(ctx, subj, data)
}
func putAuth(payload map[string]any, authorization, accessToken string) {
if authorization != "" {
payload["authorization"] = authorization
}
if accessToken != "" {
payload["access_token"] = accessToken
}
}
func withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, d)
}
func parseStampReply(raw []byte) ([]Receipt, error) {
var asList []Receipt
if err := json.Unmarshal(raw, &asList); err == nil && (len(asList) > 0 || isJSONArray(raw)) {
return asList, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("tsnats: stamp reply: %w", err)
}
if errRaw, ok := obj["error"]; ok {
var msg string
_ = json.Unmarshal(errRaw, &msg)
if msg == "" {
msg = string(errRaw)
}
return nil, fmt.Errorf("%s", msg)
}
if _, hasReceipt := obj["receipt_id"]; hasReceipt {
var rec Receipt
if err := json.Unmarshal(raw, &rec); err != nil {
return nil, err
}
return []Receipt{rec}, nil
}
if jobRaw, ok := obj["jobId"]; ok {
var jobID string
_ = json.Unmarshal(jobRaw, &jobID)
return nil, fmt.Errorf("stamp pending jobId=%s; use SubscribeCompleted", jobID)
}
return nil, fmt.Errorf("tsnats: unrecognized stamp reply")
}
func parseVerifyReply(raw []byte) (VerifyResult, error) {
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return VerifyResult{Valid: false, Error: "invalid verify payload"}, nil
}
if v, ok := obj["valid"]; ok {
if b, _ := v.(bool); !b {
errMsg, _ := obj["error"].(string)
return VerifyResult{Valid: false, Error: errMsg}, nil
}
}
if errMsg, ok := obj["error"].(string); ok && errMsg != "" {
if valid, _ := obj["valid"].(bool); !valid {
return VerifyResult{Valid: false, Error: errMsg}, nil
}
}
if _, ok := obj["receipt_id"]; ok {
var rec Receipt
if err := json.Unmarshal(raw, &rec); err != nil {
return VerifyResult{Valid: false, Error: err.Error()}, nil
}
return VerifyResult{Valid: true, Receipt: &rec}, nil
}
return VerifyResult{Valid: false, Error: "unknown receipt"}, nil
}
func parseReportReply(raw []byte) ([]Receipt, error) {
var asList []Receipt
if err := json.Unmarshal(raw, &asList); err == nil && (len(asList) > 0 || isJSONArray(raw)) {
return asList, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, nil
}
if errRaw, ok := obj["error"]; ok {
var msg string
_ = json.Unmarshal(errRaw, &msg)
if msg == "" {
msg = string(errRaw)
}
return nil, fmt.Errorf("%s", msg)
}
if receiptsRaw, ok := obj["receipts"]; ok {
var receipts []Receipt
if err := json.Unmarshal(receiptsRaw, &receipts); err != nil {
return nil, err
}
return receipts, nil
}
if timelineRaw, ok := obj["timeline"]; ok {
var events []map[string]json.RawMessage
if err := json.Unmarshal(timelineRaw, &events); err != nil {
return nil, nil
}
var out []Receipt
for _, ev := range events {
recRaw, ok := ev["receipt"]
if !ok {
continue
}
var rec Receipt
if err := json.Unmarshal(recRaw, &rec); err != nil {
continue
}
out = append(out, rec)
}
return out, nil
}
return nil, nil
}
func isJSONArray(raw []byte) bool {
for _, b := range raw {
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
continue
}
return b == '['
}
return false
}
type natsAdapter struct {
nc *nats.Conn
}
func (a *natsAdapter) Request(ctx context.Context, subj string, data []byte) ([]byte, error) {
msg, err := a.nc.RequestWithContext(ctx, subj, data)
if err != nil {
return nil, err
}
return msg.Data, nil
}
func (a *natsAdapter) Subscribe(subj string, cb MessageHandler) (Subscription, error) {
sub, err := a.nc.Subscribe(subj, func(msg *nats.Msg) {
cb(msg.Data)
})
if err != nil {
return nil, err
}
return sub, nil
}