// Package edgebill stamps digests through the commercial edge (zap.verae.ai) // so MCP global stamps share one meter with PrintIt / Zapier / Node-RED. package edgebill import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) // Client posts digests to the edge /v1/timestamp API. type Client struct { BaseURL string HTTPClient *http.Client Channel string } // DefaultBaseURL is the public edge. const DefaultBaseURL = "https://zap.verae.ai" // FromEnv builds a client when VERAE_MCP_EDGE_URL is set (non-empty). // Empty env disables edge billing (legacy direct VeraeTime only). func FromEnv() *Client { u := strings.TrimRight(strings.TrimSpace(os.Getenv("VERAE_MCP_EDGE_URL")), "/") if u == "" { return nil } ch := strings.TrimSpace(os.Getenv("VERAE_MCP_BILLING_CHANNEL")) if ch == "" { ch = "mcp" } return &Client{ BaseURL: u, HTTPClient: &http.Client{Timeout: 90 * time.Second}, Channel: ch, } } // Receipt is a minimal stamp result compatible with tools.ReceiptDTO mapping. type Receipt struct { ReceiptID string Hash string Scope string CreatedAt string Merkle string } // Stamp posts sha256 to edge /v1/timestamp with the caller credential. // bearer may be an xVerae- / key- API key or a VeraeTime JWT (sent as Bearer). func (c *Client) Stamp(ctx context.Context, sha256, bearer string) (*Receipt, error) { if c == nil || c.BaseURL == "" { return nil, fmt.Errorf("edgebill: not configured") } h := strings.ToLower(strings.TrimSpace(sha256)) h = strings.TrimPrefix(h, "sha256:") payload, _ := json.Marshal(map[string]any{ "sha256": h, "digest": h, "data": h, "hashAlg": "SHA256", "wait": true, "restamp": false, }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/timestamp", bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("x-billing-channel", c.Channel) req.Header.Set("User-Agent", "verae-mcp-timestamping/edgebill") tok := strings.TrimSpace(bearer) if tok != "" { if strings.HasPrefix(tok, "xVerae-") || strings.HasPrefix(tok, "key-") || strings.HasPrefix(tok, "zmw_") { req.Header.Set("x-api-key", tok) } else { req.Header.Set("Authorization", "Bearer "+tok) // Edge also accepts Bearer xVerae- in some paths; keep x-api-key empty. } } hc := c.HTTPClient if hc == nil { hc = http.DefaultClient } res, err := hc.Do(req) if err != nil { return nil, err } defer res.Body.Close() raw, _ := io.ReadAll(res.Body) if res.StatusCode >= 300 { return nil, fmt.Errorf("edgebill: stamp HTTP %d: %s", res.StatusCode, truncate(raw, 300)) } var body map[string]any _ = json.Unmarshal(raw, &body) cid := str(body, "certificate") if cid == "" { cid = str(body, "receipt_id") } if cid == "" { if recs, ok := body["receipts"].([]any); ok && len(recs) > 0 { if m, ok := recs[0].(map[string]any); ok { cid = str(m, "certificate") if cid == "" { cid = str(m, "receipt_id") } } } } if cid == "" { return nil, fmt.Errorf("edgebill: response missing certificate: %s", truncate(raw, 200)) } return &Receipt{ ReceiptID: cid, Hash: h, Scope: "global", CreatedAt: firstNonEmpty(str(body, "completedAt"), str(body, "created_at"), str(body, "timestamp")), Merkle: firstNonEmpty(str(body, "blockHash"), str(body, "merkle")), }, nil } func str(m map[string]any, k string) string { if m == nil { return "" } v, ok := m[k] if !ok || v == nil { return "" } switch t := v.(type) { case string: return t default: return fmt.Sprint(t) } } func firstNonEmpty(ss ...string) string { for _, s := range ss { if strings.TrimSpace(s) != "" { return s } } return "" } func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "…" }