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.
462 lines
12 KiB
Go
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()
|
|
}
|