verae-mcp-timestamping-serv.../internal/tools/receipt_format.go
George Lambert 3ec1f0516b
Some checks failed
test-and-deploy / test (push) Failing after 12m55s
test-and-deploy / deploy-138 (push) Has been cancelled
feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Add receipt_format to stamp and verify. Omitted → text; receipt|PDF-Receipt → PDF;
JSON-Receipt → JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt.

Stamp always returns receipts[] and adds artifacts[] in the chosen format.
Stdio writes ~/.verae/receipts (0600); public MCP returns content_text/content_base64 only.
Verify accepts inline receipt or receipt_path (stdio), parses id, and re-emits an artifact.

Vendors pdfcert (+ slim statuslang, gofpdf) for CERT-01 PDF. Also lands welcome tool
and email-username helpers already wired into register/public paths.
2026-10-20 16:02:20 -04:00

462 lines
12 KiB
Go

package tools
import (
"encoding/base64"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/pdfcert"
)
// Canonical receipt_format kinds after normalizeReceiptFormat.
const (
KindText = "text"
KindPDF = "pdf"
KindJSON = "json"
KindMD = "md"
KindCSV = "csv"
KindTSV = "tsv"
)
var allowedReceiptFormats = []string{
"(omit)", "text",
"receipt", "PDF-Receipt", "pdf",
"JSON-Receipt", "json",
"MD", "MD-Receipt", "markdown",
"CSV", "CSV-Receipt",
"TSV", "TSV-Receipt",
}
// normalizeReceiptFormat maps user aliases to a canonical kind.
// Empty → text. Case-insensitive. Unknown → error listing allowed values.
func normalizeReceiptFormat(s string) (string, error) {
v := strings.TrimSpace(s)
if v == "" {
return KindText, nil
}
switch strings.ToLower(v) {
case "text", "txt", "plain", "plain-text", "plaintext":
return KindText, nil
case "receipt", "pdf", "pdf-receipt", "pdf_receipt":
return KindPDF, nil
case "json", "json-receipt", "json_receipt":
return KindJSON, nil
case "md", "md-receipt", "md_receipt", "markdown", "markdown-receipt":
return KindMD, nil
case "csv", "csv-receipt", "csv_receipt":
return KindCSV, nil
case "tsv", "tsv-receipt", "tsv_receipt":
return KindTSV, nil
default:
return "", fmt.Errorf("invalid receipt_format %q; allowed: %s", s, strings.Join(allowedReceiptFormats, ", "))
}
}
func mediaTypeForKind(kind string) string {
switch kind {
case KindPDF:
return "application/pdf"
case KindJSON:
return "application/json"
case KindMD:
return "text/markdown"
case KindCSV:
return "text/csv"
case KindTSV:
return "text/tab-separated-values"
default:
return "text/plain"
}
}
func extForKind(kind string) string {
switch kind {
case KindPDF:
return ".pdf"
case KindJSON:
return ".json"
case KindMD:
return ".md"
case KindCSV:
return ".csv"
case KindTSV:
return ".tsv"
default:
return ".txt"
}
}
func defaultVerifyURL(dto ReceiptDTO) string {
if dto.Hash != "" {
return pdfcert.LookupURL(dto.Hash, dto.ReceiptID)
}
if dto.ReceiptID != "" {
return "https://verify.verae.ai/?cid=" + dto.ReceiptID
}
return "https://verify.verae.ai/"
}
// receiptFieldOrder is the stable column/row order for text/md/csv/tsv.
var receiptFieldOrder = []struct {
Key string
Value func(ReceiptDTO, string) string
}{
{"receipt_id", func(d ReceiptDTO, _ string) string { return d.ReceiptID }},
{"hash", func(d ReceiptDTO, _ string) string { return d.Hash }},
{"scope", func(d ReceiptDTO, _ string) string { return d.Scope }},
{"created_at", func(d ReceiptDTO, _ string) string { return d.CreatedAt }},
{"merkle", func(d ReceiptDTO, _ string) string { return d.Merkle }},
{"cube_id", func(d ReceiptDTO, _ string) string { return d.CubeID }},
{"org_id", func(d ReceiptDTO, _ string) string { return d.OrgID }},
{"restamp_of", func(d ReceiptDTO, _ string) string { return d.RestampOf }},
{"verify_url", func(d ReceiptDTO, u string) string { return u }},
}
func renderText(dto ReceiptDTO) string {
verify := defaultVerifyURL(dto)
var b strings.Builder
b.WriteString("Verae Timestamp Receipt\n")
b.WriteString("=======================\n")
for _, f := range receiptFieldOrder {
v := f.Value(dto, verify)
if v == "" && (f.Key == "cube_id" || f.Key == "org_id" || f.Key == "restamp_of") {
continue
}
fmt.Fprintf(&b, "%s: %s\n", f.Key, v)
}
return b.String()
}
func renderJSON(dto ReceiptDTO) (string, error) {
verify := defaultVerifyURL(dto)
doc := map[string]any{
"format": "JSON-Receipt",
"sha256": dto.Hash,
"verify_url": verify,
"receipts": []ReceiptDTO{dto},
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return "", err
}
return string(b) + "\n", nil
}
func renderMD(dto ReceiptDTO) string {
verify := defaultVerifyURL(dto)
var headers, seps, vals []string
for _, f := range receiptFieldOrder {
v := f.Value(dto, verify)
if v == "" && (f.Key == "cube_id" || f.Key == "org_id" || f.Key == "restamp_of") {
continue
}
headers = append(headers, f.Key)
seps = append(seps, "---")
vals = append(vals, mdEscapeCell(v))
}
var b strings.Builder
b.WriteString("# Verae Timestamp Receipt\n\n")
b.WriteString("| " + strings.Join(headers, " | ") + " |\n")
b.WriteString("| " + strings.Join(seps, " | ") + " |\n")
b.WriteString("| " + strings.Join(vals, " | ") + " |\n")
return b.String()
}
func mdEscapeCell(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func renderDelimited(dto ReceiptDTO, comma rune) (string, error) {
verify := defaultVerifyURL(dto)
var headers, vals []string
for _, f := range receiptFieldOrder {
v := f.Value(dto, verify)
if v == "" && (f.Key == "cube_id" || f.Key == "org_id" || f.Key == "restamp_of") {
continue
}
headers = append(headers, f.Key)
vals = append(vals, v)
}
var buf strings.Builder
w := csv.NewWriter(&buf)
w.Comma = comma
if err := w.Write(headers); err != nil {
return "", err
}
if err := w.Write(vals); err != nil {
return "", err
}
w.Flush()
if err := w.Error(); err != nil {
return "", err
}
return buf.String(), nil
}
func renderPDF(dto ReceiptDTO) ([]byte, error) {
return pdfcert.Build(pdfcert.Input{
SHA256: dto.Hash,
ReceiptID: dto.ReceiptID,
CreatedAt: dto.CreatedAt,
Merkle: dto.Merkle,
Scope: dto.Scope,
VerifyURL: defaultVerifyURL(dto),
Channel: "mcp",
})
}
// buildArtifact renders one receipt into the requested kind and optionally writes
// under ~/.verae/receipts when writeDisk is true (stdio). Public MCP passes false.
func buildArtifact(dto ReceiptDTO, kind string, writeDisk bool) (ReceiptArtifact, error) {
art := ReceiptArtifact{
Format: kind,
MediaType: mediaTypeForKind(kind),
Filename: safeReceiptFilename(dto.ReceiptID, dto.Hash) + extForKind(kind),
}
switch kind {
case KindText:
art.ContentText = renderText(dto)
case KindJSON:
s, err := renderJSON(dto)
if err != nil {
return art, err
}
art.ContentText = s
case KindMD:
art.ContentText = renderMD(dto)
case KindCSV:
s, err := renderDelimited(dto, ',')
if err != nil {
return art, err
}
art.ContentText = s
case KindTSV:
s, err := renderDelimited(dto, '\t')
if err != nil {
return art, err
}
art.ContentText = s
case KindPDF:
b, err := renderPDF(dto)
if err != nil {
return art, err
}
art.ContentB64 = base64.StdEncoding.EncodeToString(b)
default:
return art, fmt.Errorf("unsupported receipt kind %q", kind)
}
if writeDisk {
path, err := writeReceiptFile(art)
if err != nil {
return art, err
}
art.Path = path
}
return art, nil
}
func safeReceiptFilename(receiptID, hash string) string {
base := strings.TrimSpace(receiptID)
if base == "" {
base = strings.TrimSpace(hash)
}
if base == "" {
base = "receipt"
}
var b strings.Builder
for _, r := range base {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
s := b.String()
if len(s) > 80 {
s = s[:80]
}
return s
}
func writeReceiptFile(art ReceiptArtifact) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".verae", "receipts")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, art.Filename)
var data []byte
if art.ContentB64 != "" {
data, err = base64.StdEncoding.DecodeString(art.ContentB64)
if err != nil {
return "", err
}
} else {
data = []byte(art.ContentText)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
return path, nil
}
func attachArtifacts(out ReceiptsOut, kind string, writeDisk bool) (ReceiptsOut, error) {
if len(out.Receipts) == 0 {
return out, nil
}
arts := make([]ReceiptArtifact, 0, len(out.Receipts))
for _, dto := range out.Receipts {
art, err := buildArtifact(dto, kind, writeDisk)
if err != nil {
return out, err
}
arts = append(arts, art)
}
out.Artifacts = arts
return out, nil
}
// --- verify: parse receipt body to extract receipt_id ---
var (
reReceiptIDLine = regexp.MustCompile(`(?i)(?:receipt[_ ]?id|certificate|cid)\s*[:=]\s*(\S+)`)
reCIDToken = regexp.MustCompile(`\b(Qm[1-9A-HJ-NP-Za-km-z]{44}|bafy[a-z0-9]{50,})\b`)
)
// parseReceiptIDFromBody extracts a receipt/certificate id from inline content.
func parseReceiptIDFromBody(kind, body string) (string, error) {
body = strings.TrimSpace(body)
if body == "" {
return "", fmt.Errorf("empty receipt body")
}
switch kind {
case KindJSON:
return parseReceiptIDJSON(body)
case KindCSV, KindTSV:
comma := ','
if kind == KindTSV {
comma = '\t'
}
return parseReceiptIDDelimited(body, comma)
case KindPDF:
// body may be raw PDF bytes as string, or base64
text := body
if strings.HasPrefix(body, "%PDF") {
text = extractPDFTextBestEffort([]byte(body))
} else if b, err := base64.StdEncoding.DecodeString(body); err == nil && len(b) > 4 && string(b[:4]) == "%PDF" {
text = extractPDFTextBestEffort(b)
}
return parseReceiptIDText(text)
default: // text, md
return parseReceiptIDText(body)
}
}
func parseReceiptIDJSON(body string) (string, error) {
var raw any
if err := json.Unmarshal([]byte(body), &raw); err != nil {
return "", fmt.Errorf("JSON-Receipt parse: %w", err)
}
switch v := raw.(type) {
case map[string]any:
if id := stringField(v, "receipt_id", "certificate", "cid", "ReceiptID"); id != "" {
return id, nil
}
if recs, ok := v["receipts"].([]any); ok && len(recs) > 0 {
if m, ok := recs[0].(map[string]any); ok {
if id := stringField(m, "receipt_id", "certificate", "cid"); id != "" {
return id, nil
}
}
}
}
return "", fmt.Errorf("JSON-Receipt: no receipt_id found")
}
func stringField(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
}
return ""
}
func parseReceiptIDDelimited(body string, comma rune) (string, error) {
r := csv.NewReader(strings.NewReader(body))
r.Comma = comma
r.FieldsPerRecord = -1
rows, err := r.ReadAll()
if err != nil {
return "", fmt.Errorf("delimited receipt parse: %w", err)
}
if len(rows) < 2 {
// single row of values without header — try first cell that looks like a CID
if len(rows) == 1 {
for _, cell := range rows[0] {
if id := strings.TrimSpace(cell); looksLikeCID(id) {
return id, nil
}
}
}
return "", fmt.Errorf("delimited receipt: need header + data row")
}
headers := rows[0]
data := rows[1]
idx := -1
for i, h := range headers {
switch strings.ToLower(strings.TrimSpace(h)) {
case "receipt_id", "certificate", "cid":
idx = i
}
}
if idx >= 0 && idx < len(data) && strings.TrimSpace(data[idx]) != "" {
return strings.TrimSpace(data[idx]), nil
}
for _, cell := range data {
if id := strings.TrimSpace(cell); looksLikeCID(id) {
return id, nil
}
}
return "", fmt.Errorf("delimited receipt: no receipt_id column")
}
func parseReceiptIDText(body string) (string, error) {
if m := reReceiptIDLine.FindStringSubmatch(body); len(m) > 1 {
return strings.TrimSpace(m[1]), nil
}
if m := reCIDToken.FindStringSubmatch(body); len(m) > 1 {
return m[1], nil
}
return "", fmt.Errorf("could not find receipt_id in receipt body")
}
// extractPDFTextBestEffort pulls printable ASCII runs from a PDF byte stream.
// Enough for CERT-01 label/value pairs; not a full PDF parser.
func extractPDFTextBestEffort(pdf []byte) string {
var b strings.Builder
run := 0
for _, c := range pdf {
if c >= 32 && c < 127 {
b.WriteByte(c)
run++
} else if run > 0 {
b.WriteByte('\n')
run = 0
}
}
return b.String()
}