PrintToVeraeTimeStamp/internal/veraeapi/client.go
George Lambert c6d998a16d fix: match Verae Timestamping.app registration PDF and receipt fields
Parse completedAt/certificate/result from Verae API; enrich merkle via
POST /api/verify (blockIndex). Generate certificate with the same jsPDF
layout as register.html export. Preserve original filename (e.g. .html).
2026-10-03 04:26:45 -04:00

379 lines
10 KiB
Go

package veraeapi
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
)
const (
defaultVeraeBase = "https://api.veraetime.net"
defaultZapierBase = "https://zap.verae.ai"
defaultPoll = 250 * time.Millisecond
defaultMaxPolls = 80
)
// Receipt is the stamp result used by the print helper.
type Receipt struct {
ReceiptID string
SHA256 string
CreatedAt string
Merkle string
Channel auth.Channel
BaseURL string
PDF []byte // registration / certificate PDF when available
PDFFilename string
RawResponse json.RawMessage
}
// Client stamps digests on Verae Time or the Zapier edge.
type Client struct {
Channel auth.Channel
VeraeBase string
ZapierBase string
VeraeToken string // JWT
XVeraeToken string
HTTPDo func(*http.Request) (*http.Response, error)
PollInterval time.Duration
MaxPolls int
}
func (c *Client) do(req *http.Request) (*http.Response, error) {
if c.HTTPDo != nil {
return c.HTTPDo(req)
}
return http.DefaultClient.Do(req)
}
func (c *Client) veraeBase() string {
if c.VeraeBase != "" {
return strings.TrimRight(c.VeraeBase, "/")
}
return defaultVeraeBase
}
func (c *Client) zapierBase() string {
if c.ZapierBase != "" {
return strings.TrimRight(c.ZapierBase, "/")
}
return defaultZapierBase
}
// Stamp submits digest and waits for a receipt. documentName is for PDF metadata.
func (c *Client) Stamp(digest, documentName string) (Receipt, error) {
switch c.Channel {
case auth.ChannelZapier:
return c.stampZapier(digest, documentName)
default:
return c.stampVerae(digest)
}
}
func (c *Client) stampVerae(digest string) (Receipt, error) {
base := c.veraeBase()
body, _ := json.Marshal(map[string]any{
"data": digest,
"digest": digest,
"hashAlg": "SHA256",
"restamp": false,
})
req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body))
if err != nil {
return Receipt{}, err
}
req.Header.Set("Content-Type", "application/json")
if c.VeraeToken != "" {
req.Header.Set("Authorization", "Bearer "+c.VeraeToken)
}
res, err := c.do(req)
if err != nil {
return Receipt{}, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return Receipt{}, err
}
parsed := map[string]any{}
_ = json.Unmarshal(raw, &parsed)
if res.StatusCode >= 300 {
return Receipt{}, fmt.Errorf("verae stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
}
cid, created, merkle := extractVeraeReceipt(parsed)
jobID := str(parsed["jobId"])
// Existing digest (or immediate certificate): prefer receipts[].completedAt
if cid != "" && (truthy(parsed["existing"]) || str(parsed["certificate"]) != "") {
rec := Receipt{
ReceiptID: cid,
SHA256: digest,
CreatedAt: created,
Merkle: merkle,
Channel: auth.ChannelVerae,
BaseURL: base,
RawResponse: raw,
}
return c.enrichFromHash(base, rec)
}
if jobID == "" {
return Receipt{}, fmt.Errorf("verae stamp: missing jobId in %s", truncate(raw, 400))
}
return c.pollVerae(base, digest, jobID, raw)
}
func (c *Client) pollVerae(base, digest, jobID string, firstRaw []byte) (Receipt, error) {
interval := c.PollInterval
if interval <= 0 {
interval = defaultPoll
}
max := c.MaxPolls
if max <= 0 {
max = defaultMaxPolls
}
statusURL := base + "/api/status/" + jobID
var last []byte = firstRaw
for i := 0; i < max; i++ {
time.Sleep(interval)
req, err := http.NewRequest(http.MethodGet, statusURL, nil)
if err != nil {
return Receipt{}, err
}
if c.VeraeToken != "" {
req.Header.Set("Authorization", "Bearer "+c.VeraeToken)
}
res, err := c.do(req)
if err != nil {
return Receipt{}, err
}
raw, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return Receipt{}, err
}
last = raw
parsed := map[string]any{}
_ = json.Unmarshal(raw, &parsed)
st := strings.ToLower(str(parsed["status"]))
if st == "failed" || st == "error" {
return Receipt{}, fmt.Errorf("verae job %s failed: %s", jobID, truncate(raw, 400))
}
// Status shape: { status:"completed", result:"Qm…", completedAt:"…" }
cid := str(parsed["result"])
if cid == "" {
cid, _, _ = extractVeraeReceipt(parsed)
}
if (cid != "" && (st == "completed" || st == "done")) || (st == "completed" && cid != "") {
created := firstNonEmpty(str(parsed["completedAt"]), str(parsed["timestamp"]), str(parsed["created_at"]))
merkle := firstNonEmpty(str(parsed["blockHash"]), str(parsed["merkle"]), str(parsed["blockIndex"]))
if meta, ok := parsed["metadata"].(map[string]any); ok {
created = firstNonEmpty(str(meta["timestamp"]), str(meta["completedAt"]), created)
merkle = firstNonEmpty(str(meta["blockIndex"]), str(meta["blockHash"]), merkle)
}
if cid == "" {
cid = jobID
}
rec := Receipt{
ReceiptID: cid,
SHA256: digest,
CreatedAt: created,
Merkle: merkle,
Channel: auth.ChannelVerae,
BaseURL: base,
RawResponse: last,
}
return c.enrichFromHash(base, rec)
}
if st == "completed" || st == "done" {
// completed but empty result — still try hash lookup
rec := Receipt{SHA256: digest, Channel: auth.ChannelVerae, BaseURL: base, RawResponse: last, CreatedAt: str(parsed["completedAt"])}
rec, err := c.enrichFromHash(base, rec)
if err == nil && rec.ReceiptID != "" {
return rec, nil
}
}
}
return Receipt{}, fmt.Errorf("verae job %s timed out; last=%s", jobID, truncate(last, 400))
}
// extractVeraeReceipt pulls CID + completedAt from stamp / receipts[] payloads.
func extractVeraeReceipt(parsed map[string]any) (cid, created, merkle string) {
cid = firstNonEmpty(str(parsed["certificate"]), str(parsed["receipt_id"]))
created = firstNonEmpty(str(parsed["completedAt"]), str(parsed["timestamp"]), str(parsed["created_at"]))
merkle = firstNonEmpty(str(parsed["blockHash"]), str(parsed["merkle"]), str(parsed["blockIndex"]))
for _, item := range asSlice(parsed["receipts"]) {
m, ok := item.(map[string]any)
if !ok {
continue
}
cid = firstNonEmpty(cid, str(m["certificate"]), str(m["receipt_id"]))
created = firstNonEmpty(created, str(m["completedAt"]), str(m["created_at"]), str(m["timestamp"]))
merkle = firstNonEmpty(merkle, str(m["blockHash"]), str(m["merkle"]), str(m["blockIndex"]))
}
return cid, created, merkle
}
func asSlice(v any) []any {
switch t := v.(type) {
case []any:
return t
default:
return nil
}
}
// enrichFromHash fills missing created_at / merkle / receipt via GET /api/hash/{digest}
// and POST /api/verify {certificate} (blockIndex → merkle).
func (c *Client) enrichFromHash(base string, rec Receipt) (Receipt, error) {
if rec.CreatedAt != "" && rec.Merkle != "" && rec.ReceiptID != "" {
return rec, nil
}
req, err := http.NewRequest(http.MethodGet, base+"/api/hash/"+rec.SHA256, nil)
if err == nil {
if c.VeraeToken != "" {
req.Header.Set("Authorization", "Bearer "+c.VeraeToken)
}
if res, err := c.do(req); err == nil {
raw, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode < 300 {
parsed := map[string]any{}
_ = json.Unmarshal(raw, &parsed)
cid, created, merkle := extractVeraeReceipt(parsed)
rec.ReceiptID = firstNonEmpty(rec.ReceiptID, cid)
rec.CreatedAt = firstNonEmpty(rec.CreatedAt, created)
rec.Merkle = firstNonEmpty(rec.Merkle, merkle)
}
}
}
if rec.ReceiptID != "" && rec.Merkle == "" {
body, _ := json.Marshal(map[string]string{"certificate": rec.ReceiptID})
vreq, err := http.NewRequest(http.MethodPost, base+"/api/verify", bytes.NewReader(body))
if err == nil {
vreq.Header.Set("Content-Type", "application/json")
if c.VeraeToken != "" {
vreq.Header.Set("Authorization", "Bearer "+c.VeraeToken)
}
if res, err := c.do(vreq); err == nil {
raw, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode < 300 {
parsed := map[string]any{}
_ = json.Unmarshal(raw, &parsed)
rec.CreatedAt = firstNonEmpty(rec.CreatedAt, str(parsed["timestamp"]), str(parsed["completedAt"]))
// blockIndex may be a number
rec.Merkle = firstNonEmpty(rec.Merkle, str(parsed["blockIndex"]), str(parsed["blockHash"]), str(parsed["merkle"]))
if rec.Merkle == "" {
if n, ok := parsed["blockIndex"].(float64); ok {
rec.Merkle = fmt.Sprintf("%.0f", n)
}
}
}
}
}
}
return rec, nil
}
func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
base := c.zapierBase()
body, _ := json.Marshal(map[string]any{
"sha256": digest,
"attachPdf": true,
"wait": true,
"documentName": documentName,
})
req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body))
if err != nil {
return Receipt{}, err
}
req.Header.Set("Content-Type", "application/json")
if c.XVeraeToken != "" {
req.Header.Set("x-api-key", c.XVeraeToken)
}
req.Header.Set("x-billing-channel", "api")
res, err := c.do(req)
if err != nil {
return Receipt{}, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return Receipt{}, err
}
if res.StatusCode >= 300 {
return Receipt{}, fmt.Errorf("zapier stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
}
parsed := map[string]any{}
_ = json.Unmarshal(raw, &parsed)
cid := firstNonEmpty(str(parsed["receipt_id"]), str(parsed["certificate"]))
var pdf []byte
if b64 := str(parsed["pdfBase64"]); b64 != "" {
pdf, err = decodeBase64(b64)
if err != nil {
return Receipt{}, fmt.Errorf("pdfBase64: %w", err)
}
}
return Receipt{
ReceiptID: cid,
SHA256: digest,
CreatedAt: firstNonEmpty(str(parsed["created_at"]), str(parsed["timestamp"]), time.Now().UTC().Format(time.RFC3339)),
Merkle: firstNonEmpty(str(parsed["merkle"]), str(parsed["blockHash"])),
Channel: auth.ChannelZapier,
BaseURL: base,
PDF: pdf,
PDFFilename: str(parsed["pdfFilename"]),
RawResponse: raw,
}, nil
}
func str(v any) string {
switch t := v.(type) {
case string:
return t
case fmt.Stringer:
return t.String()
default:
return ""
}
}
func truthy(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return t != "" && t != "false" && t != "0"
default:
return false
}
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
func decodeBase64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}