diff --git a/README.md b/README.md index 976e28b..2bbad7a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ MCP server for digests-only timestamping (public HTTP and local stdio). ## Background Public writes require caller Bearer (DEC-654). Optional VERAE_MCP_EDGE_URL bills via zap.verae.ai. +Account and auth are required for use. On connect, instructions tell the AI to welcome the user and call tool `welcome`, then `signup` / `login`. +Only `welcome`, `signup`, and `login` work without a bearer. `signup` / `login` return `access_token` (public HTTP never writes the host credential). Grok: bearer_token_file or local stdio. Job-complete is core NATS only. @@ -33,6 +35,8 @@ go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-serve # or -transport stdio for laptop host auth ``` +Optional `receipt_format` on `stamp` / `verify`: omit → text; `receipt` / `PDF-Receipt` → PDF; `JSON-Receipt` → JSON; `MD` / `MD-Receipt`; `CSV` / `CSV-Receipt`; `TSV` / `TSV-Receipt`. Stdio writes under `~/.verae/receipts`; public MCP returns `content_text` / `content_base64` only. + ## Contributing Questions: open an issue on the project Forgejo/GitHub remote, or contact Verae engineering. diff --git a/go.mod b/go.mod index 84810c7..ad235e8 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/nats-io/nats-server/v2 v2.15.0 github.com/nats-io/nats.go v1.51.0 + github.com/phpdave11/gofpdf v1.4.2 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e ) require ( diff --git a/go.sum b/go.sum index dd43c97..fe43004 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -8,6 +10,7 @@ github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= @@ -24,14 +27,25 @@ github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/phpdave11/gofpdf v1.4.2 h1:KPKiIbfwbvC/wOncwhrpRdXVj2CZTCFlw4wnoyjtHfQ= +github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= +github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -39,6 +53,7 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= diff --git a/internal/edgebill/client.go b/internal/edgebill/client.go index c9d9e71..a944611 100644 --- a/internal/edgebill/client.go +++ b/internal/edgebill/client.go @@ -21,8 +21,8 @@ type Client struct { Channel string } -// DefaultBaseURL is the public edge. -const DefaultBaseURL = "https://zap.verae.ai" +// DefaultBaseURL is the public commercial edge (non-Zapier). +const DefaultBaseURL = "https://services.verae.ai" // FromEnv builds a client when VERAE_MCP_EDGE_URL is set (non-empty). // Empty env disables edge billing (legacy direct VeraeTime only). diff --git a/internal/pdfcert/fallback.go b/internal/pdfcert/fallback.go new file mode 100644 index 0000000..af992dd --- /dev/null +++ b/internal/pdfcert/fallback.go @@ -0,0 +1,312 @@ +package pdfcert + +import ( + "bytes" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/statuslang" + "github.com/phpdave11/gofpdf" + qrcode "github.com/skip2/go-qrcode" +) + +// brandTitleFamily is the gofpdf family name when a brand TTF/OTF registers. +const brandTitleFamily = "VeraeBrand" + +// Brand tokens (CERT-01 / BrandingBook): plum, gold, ink, muted, ok. +const ( + plumR, plumG, plumB = 83, 27, 89 // #531B59 + goldR, goldG, goldB = 196, 168, 90 // #C4A85A + goldDR, goldDG, goldDB = 110, 87, 23 // #6E5717 + inkR, inkG, inkB = 29, 21, 52 // #1D1534 + mutedR, mutedG, mutedB = 94, 84, 112 // #5E5470 + okR, okG, okB = 14, 107, 58 // #0E6B3A + okBgR, okBgG, okBgB = 227, 245, 234 // #E3F5EA + okBdR, okBdG, okBdB = 185, 226, 200 // #B9E2C8 + linkR, linkG, linkB = 59, 42, 140 // #3B2A8C info +) + +// Minimal fallback when Node/jsPDF is unavailable. +// +// Fonts: PDF core faces by default (Times title, Helvetica body). +// When EmbedBrandFonts is on and BrandFontsDir has a .ttf/.otf, attempt +// gofpdf AddUTF8FontFromBytes for the title; on failure log and keep Times. +func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup string) ([]byte, error) { + pdf := gofpdf.New("P", "pt", "Letter", "") + pdf.SetCompression(false) + pdf.SetMargins(48, 48, 48) + pdf.SetAutoPageBreak(false, 0) + pdf.AddPage() + pageW, pageH := pdf.GetPageSize() + margin := 48.0 + + titleFamily := tryRegisterBrandTitleFont(pdf, in) + + // CERT-01 double frame: plum outer, gold inner + pdf.SetDrawColor(plumR, plumG, plumB) + pdf.SetLineWidth(1.5) + pdf.Rect(28, 28, pageW-56, pageH-56, "D") + pdf.SetDrawColor(goldR, goldG, goldB) + pdf.SetLineWidth(0.5) + pdf.Rect(34, 34, pageW-68, pageH-68, "D") + + if p, cleanup, err := materializeLogo(); err == nil { + pdf.ImageOptions(p, margin, 48, 120, 36, false, gofpdf.ImageOptions{ImageType: "PNG", ReadDpi: true}, 0, "") + if cleanup != nil { + defer cleanup() + } + } + + // Gold-d eyebrow (BrandingBook §12) — Helvetica core font (not Fraunces) + pdf.SetTextColor(goldDR, goldDG, goldDB) + pdf.SetFont("Helvetica", "B", 9) + pdf.SetXY(margin, 92) + pdf.CellFormat(pageW-margin*2, 11, "TIMESTAMP CERTIFICATE", "", 0, "C", false, 0, "") + + // Title — brand face when registered; else Times core serif stand-in + pdf.SetTextColor(inkR, inkG, inkB) + if titleFamily != "" { + pdf.SetFont(titleFamily, "B", 22) + } else { + pdf.SetFont("Times", "B", 22) + } + pdf.SetXY(margin, 106) + pdf.CellFormat(pageW-margin*2, 24, "Verae Timestamp Certificate", "", 0, "C", false, 0, "") + + pdf.SetTextColor(mutedR, mutedG, mutedB) + pdf.SetFont("Helvetica", "", 10) + pdf.SetXY(margin, 132) + pdf.CellFormat(pageW-margin*2, 12, "Proof that this exact document existed at the time below,", "", 0, "C", false, 0, "") + pdf.SetXY(margin, 144) + pdf.CellFormat(pageW-margin*2, 12, "recorded on the "+statuslang.ChainName+" (digests only)", "", 0, "C", false, 0, "") + + // Short centered gold rule (CERT-01) + ruleW := 120.0 + pdf.SetDrawColor(goldR, goldG, goldB) + pdf.SetLineWidth(2) + pdf.Line(pageW/2-ruleW/2, 162, pageW/2+ruleW/2, 162) + + // FINAL-only status banner (receipt-final / statuslang wording) + bannerY := 174.0 + bannerH := 40.0 + pdf.SetFillColor(okBgR, okBgG, okBgB) + pdf.SetDrawColor(okBdR, okBdG, okBdB) + pdf.SetLineWidth(1.5) + pdf.Rect(margin, bannerY, pageW-margin*2, bannerH, "FD") + pdf.SetTextColor(okR, okG, okB) + pdf.SetFont("Helvetica", "B", 12) + pdf.SetXY(margin+12, bannerY+8) + pdf.Cell(pageW-margin*2-24, 14, finalBannerTitle(merkle)) + pdf.SetFont("Helvetica", "", 9) + pdf.SetXY(margin+12, bannerY+24) + pdf.Cell(pageW-margin*2-24, 12, statuslang.EvidenceNote) + + if created == "" { + created = "—" + } + mh, b3, b3mh := ResolveDigests(in) + fields := [][2]string{ + {"Document", docName}, + } + if in.ByteLength > 0 { + fields = append(fields, [2]string{"Document size", fmt.Sprintf("%d bytes", in.ByteLength)}) + } + // CERT-01: digest lines only when data is available (never invent). + if mh != "" { + fields = append(fields, [2]string{"Content multihash", mh}) + } + if strings.TrimSpace(in.SHA256) != "" { + fields = append(fields, [2]string{"SHA-256 (hex)", in.SHA256}) + } + if b3mh != "" { + fields = append(fields, [2]string{"BLAKE3 multihash", b3mh}) + } + if b3 != "" { + fields = append(fields, [2]string{"BLAKE3 (hex)", b3}) + } + fields = append(fields, + [2]string{"Registration scope", scope}, + [2]string{"Original timestamp (UTC)", created}, + [2]string{"VeraeTime receipt (CID)", orDash(in.ReceiptID)}, + [2]string{"Merkle / block", merkle}, + [2]string{"Network", statuslang.ChainName + " (" + statuslang.NetworkName + ")"}, + [2]string{"Blockchain explorer", statuslang.ExplorerURL}, + [2]string{"Public lookup URL", lookup}, + ) + + isHTTP := func(s string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "http://") || + strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "https://") + } + + y := bannerY + bannerH + 18 + for _, fv := range fields { + pdf.SetFont("Helvetica", "B", 9) + pdf.SetTextColor(mutedR, mutedG, mutedB) + pdf.SetXY(margin, y) + pdf.Cell(pageW-margin*2, 11, fv[0]) + y += 12 + pdf.SetFont("Helvetica", "", 10) + linkURL := "" + if isHTTP(fv[1]) { + linkURL = strings.TrimSpace(fv[1]) + pdf.SetTextColor(linkR, linkG, linkB) + } else { + pdf.SetTextColor(inkR, inkG, inkB) + } + for _, ln := range pdf.SplitLines([]byte(fv[1]), pageW-margin*2-10) { + pdf.SetXY(margin, y) + if linkURL != "" { + pdf.CellFormat(pageW-margin*2-10, 12, string(ln), "", 0, "L", false, 0, linkURL) + } else { + pdf.Cell(pageW-margin*2-10, 12, string(ln)) + } + y += 12 + } + y += 8 + if y > pageH-220 { + break + } + } + + if qrPNG, err := qrcode.Encode(lookup, qrcode.Medium, 280); err == nil { + if qrFile, qclean, qerr := writeTempPNG(qrPNG); qerr == nil { + qrSize := 110.0 + qrX := pageW - margin - qrSize + qrY := pageH - margin - qrSize - 48 + pdf.ImageOptions(qrFile, qrX, qrY, qrSize, qrSize, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "") + pdf.LinkString(qrX, qrY, qrSize, qrSize, lookup) + pdf.SetFont("Helvetica", "B", 8) + pdf.SetTextColor(linkR, linkG, linkB) + pdf.SetXY(qrX, qrY+qrSize+4) + pdf.CellFormat(qrSize, 10, "Scan to verify", "", 0, "C", false, 0, lookup) + if qclean != nil { + defer qclean() + } + } + } + + // Final-only legal + digests-only (CERT-01 footer / BrandingBook) + pdf.SetFont("Helvetica", "", 8) + pdf.SetTextColor(mutedR, mutedG, mutedB) + pdf.SetXY(margin, pageH-72) + pdf.MultiCell(pageW-margin*2-130, 10, "Only the document's SHA-256 digest was submitted; the file was not uploaded. This certificate is final receipt evidence for the digest above. A jobId or pending hold is not proof. Anyone can verify this receipt for free — no account needed.", "", "L", false) + pdf.SetFont("Helvetica", "B", 9) + pdf.SetTextColor(plumR, plumG, plumB) + pdf.SetXY(margin, pageH-36) + pdf.Cell(55, 12, "Verae LLC · ") + pdf.SetTextColor(linkR, linkG, linkB) + pdf.CellFormat(40, 12, "verae.ai", "", 0, "L", false, 0, "https://verae.ai") + pdf.SetTextColor(plumR, plumG, plumB) + pdf.Cell(12, 12, " · ") + pdf.SetTextColor(linkR, linkG, linkB) + pdf.CellFormat(90, 12, "explorer.verae.ai", "", 0, "L", false, 0, "https://explorer.verae.ai/") + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// tryRegisterBrandTitleFont attempts gofpdf UTF-8 embed of a licensed brand +// serif when EmbedBrandFonts is on and BrandFontsDir has .ttf/.otf. +// Returns brandTitleFamily on success, "" on skip/failure (caller keeps Times). +// Never errors the PDF build — registration failure is a logged no-op. +func tryRegisterBrandTitleFont(pdf *gofpdf.Fpdf, in Input) string { + if pdf == nil || !EmbedBrandFontsEnabled(in) { + return "" + } + dir := BrandFontsDir() + face := findBrandFaceFile(dir) + if face == "" { + log.Printf("pdfcert: EmbedBrandFonts on but no .ttf/.otf in %q — keeping Times", dir) + return "" + } + ext := strings.ToLower(filepath.Ext(face)) + // gofpdf AddUTF8FontFromBytes parses TrueType tables; OTF-CFF often fails. + b, err := os.ReadFile(face) + if err != nil { + log.Printf("pdfcert: brand font read %s: %v — keeping Times", face, err) + return "" + } + pdf.AddUTF8FontFromBytes(brandTitleFamily, "B", b) + if err := pdf.Error(); err != nil { + log.Printf("pdfcert: gofpdf could not register brand font %s (%s): %v — keeping Times", face, ext, err) + pdf.ClearError() + return "" + } + // Regular style alias so SetFont(family, "", …) would also work if needed. + pdf.AddUTF8FontFromBytes(brandTitleFamily, "", b) + if err := pdf.Error(); err != nil { + pdf.ClearError() + // Bold alone is enough for the title. + } + log.Printf("pdfcert: registered brand title font from %s", face) + return brandTitleFamily +} + +// finalBannerTitle uses statuslang.Label("final", block) — BrandingBook chip text. +// Certificates are issued only for evidence states (IsEvidence); hold ≠ evidence. +func finalBannerTitle(merkle string) string { + s := strings.TrimSpace(merkle) + if n, err := strconv.Atoi(s); err == nil && n > 0 { + return statuslang.Label("final", n) + } + // Block unknown: BrandingBook #N placeholder (still final / evidence wording). + return statuslang.Label("final") +} + +func orDash(s string) string { + if s == "" { + return "—" + } + return s +} + +func materializeLogo() (path string, cleanup func(), err error) { + // Prefer tree-local assets, then walk for go.mod (packaging / tests). + var candidates []string + candidates = append(candidates, filepath.Join("assets", "VeraeFullLogo.png")) + if wd, e := os.Getwd(); e == nil { + dir := wd + for i := 0; i < 8; i++ { + candidates = append(candidates, filepath.Join(dir, "assets", "VeraeFullLogo.png")) + if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + for _, c := range candidates { + if st, e := os.Stat(c); e == nil && st.Size() > 0 { + return c, nil, nil + } + } + if len(embeddedLogo) == 0 { + return "", nil, fmt.Errorf("logo not found") + } + return writeTempPNG(embeddedLogo) +} + +func writeTempPNG(b []byte) (path string, cleanup func(), err error) { + f, err := os.CreateTemp("", "verae-png-*.png") + if err != nil { + return "", nil, err + } + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, err + } + f.Close() + return f.Name(), func() { _ = os.Remove(f.Name()) }, nil +} diff --git a/internal/pdfcert/logo.png b/internal/pdfcert/logo.png new file mode 100644 index 0000000..4c011e8 Binary files /dev/null and b/internal/pdfcert/logo.png differ diff --git a/internal/pdfcert/pdfcert.go b/internal/pdfcert/pdfcert.go new file mode 100644 index 0000000..b3baf89 --- /dev/null +++ b/internal/pdfcert/pdfcert.go @@ -0,0 +1,330 @@ +package pdfcert + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +//go:embed logo.png +var embeddedLogo []byte + +// Input for the official Verae Timestamp Certificate (CERT-01). +type Input struct { + DocumentName string + SHA256 string + // Multihash is the content multihash (f1220 + sha2-256 hex) when known. + // Empty → derive from SHA256 if valid; else omit (never invent). + Multihash string + // Blake3 is the optional BLAKE3-256 hex digest when provided upstream. + // Empty → omit (never invent / never compute from file bytes here). + Blake3 string + // Blake3Multihash is the blake3 multihash (f1e20 + hex) when known. + // Empty → derive from Blake3 if valid 64-hex; else omit. + Blake3Multihash string + ReceiptID string + CreatedAt string + Merkle string + Scope string + VerifyURL string + Channel string + ByteLength int64 + // EmbedBrandFonts requests PDF embed of a licensed brand serif when a + // .ttf/.otf is present under BrandFontsDir(). Default false: core + // Times/Helvetica only. Also via VERAE_EMBED_BRAND_FONTS=1|true|yes. + // gofpdf path attempts AddUTF8FontFromBytes; failure is a logged no-op. + EmbedBrandFonts bool +} + +// EmbedBrandFontsEnabled reports whether brand-font PDF embed is requested. +// Default false. True only when Input.EmbedBrandFonts is set or env +// VERAE_EMBED_BRAND_FONTS is 1/true/yes. When enabled, gofpdf may register a +// .ttf/.otf from BrandFontsDir(); failure is a logged no-op (core fonts). +func EmbedBrandFontsEnabled(in Input) bool { + if in.EmbedBrandFonts { + return true + } + v := strings.TrimSpace(os.Getenv("VERAE_EMBED_BRAND_FONTS")) + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// BrandFontsDir is the directory scanned for .ttf/.otf when EmbedBrandFonts is on. +// Override with VERAE_BRAND_FONTS_DIR; else site/printit.verae.ai/fonts from the +// module tree. Empty when neither is available (embed no-ops with a log). +func BrandFontsDir() string { + if d := strings.TrimSpace(os.Getenv("VERAE_BRAND_FONTS_DIR")); d != "" { + if st, err := os.Stat(d); err == nil && st.IsDir() { + return d + } + } + var candidates []string + if wd, err := os.Getwd(); err == nil { + dir := wd + for i := 0; i < 8; i++ { + candidates = append(candidates, filepath.Join(dir, "site", "printit.verae.ai", "fonts")) + if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + candidates = append(candidates, filepath.Join("site", "printit.verae.ai", "fonts")) + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && st.IsDir() { + abs, _ := filepath.Abs(c) + return abs + } + } + return "" +} + +// findBrandFaceFile returns the preferred .ttf/.otf under dir (Fraunces-named +// first, then any ttf, then otf). Empty when none present. +func findBrandFaceFile(dir string) string { + if dir == "" { + return "" + } + ents, err := os.ReadDir(dir) + if err != nil { + return "" + } + var frauncesTTF, anyTTF, frauncesOTF, anyOTF string + for _, e := range ents { + if e.IsDir() { + continue + } + name := e.Name() + low := strings.ToLower(name) + path := filepath.Join(dir, name) + switch { + case strings.HasSuffix(low, ".ttf"): + if strings.Contains(low, "fraunces") && frauncesTTF == "" { + frauncesTTF = path + } else if anyTTF == "" { + anyTTF = path + } + case strings.HasSuffix(low, ".otf"): + if strings.Contains(low, "fraunces") && frauncesOTF == "" { + frauncesOTF = path + } else if anyOTF == "" { + anyOTF = path + } + } + } + for _, p := range []string{frauncesTTF, anyTTF, frauncesOTF, anyOTF} { + if p != "" { + return p + } + } + return "" +} + +// ContentMultihash returns sha2-256 multihash hex (code 0x12, len 0x20 → f1220…) +// when sha256 is a 64-char hex digest; otherwise "" (caller omits the field). +func ContentMultihash(sha256 string) string { + return contentMultihashPrefixed("1220", sha256) +} + +// ContentBlake3Multihash returns blake3 multihash hex (code 0x1e, len 0x20 → f1e20…) +// when blake3 is a 64-char hex digest; otherwise "" (caller omits the field). +func ContentBlake3Multihash(blake3 string) string { + return contentMultihashPrefixed("1e20", blake3) +} + +// contentMultihashPrefixed wraps a 64-char hex digest as multibase-base16 multihash +// ("f" + code/len prefix + digest). Invalid/empty → "" (never invent). +func contentMultihashPrefixed(codeLen, hexDigest string) string { + s := strings.TrimSpace(hexDigest) + s = strings.TrimPrefix(strings.ToLower(s), "0x") + if len(s) != 64 { + return "" + } + for i := 0; i < len(s); i++ { + c := s[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return "" + } + } + return "f" + codeLen + s +} + +// ResolveDigests returns CERT multihash / blake3 display values from Input. +// Empty strings mean omit that line (never invent digests). +func ResolveDigests(in Input) (mh, blake3Hex, blake3MH string) { + mh = strings.TrimSpace(in.Multihash) + if mh == "" { + mh = ContentMultihash(in.SHA256) + } + blake3Hex = strings.TrimSpace(in.Blake3) + blake3MH = strings.TrimSpace(in.Blake3Multihash) + if blake3MH == "" { + blake3MH = ContentBlake3Multihash(in.Blake3) + } + return mh, blake3Hex, blake3MH +} + +// LookupURL builds a public VeraeTime verify deep link (same as explorer +// scan-bar → /time, which auto-fills receipt/sha256 and runs Lookup). +// https://explorer.verae.ai/time?receipt=…&sha256=… +func LookupURL(sha256, receiptID string) string { + sha256 = strings.TrimSpace(sha256) + sha256 = strings.TrimPrefix(strings.ToLower(sha256), "0x") + receiptID = strings.TrimSpace(receiptID) + u := "https://explorer.verae.ai/time" + sep := "?" + if receiptID != "" { + u += sep + "receipt=" + receiptID + sep = "&" + } + if sha256 != "" { + u += sep + "sha256=" + sha256 + } + return u +} + +// Build returns the branded CERT-01 certificate PDF (plum/gold, final-only +// evidence copy). Prefers platform/registration-pdf/build.mjs when Node is +// available; otherwise gofpdf fallback. +// +// Typography: default PDF standard fonts (Times title, Helvetica body). +// EmbedBrandFonts defaults false (env VERAE_EMBED_BRAND_FONTS off). When on, +// gofpdf fallback may register a licensed .ttf/.otf from BrandFontsDir(); +// failure is a logged no-op. Node path stays core faces (OPEN-ISSUES #7). +func Build(in Input) ([]byte, error) { + created := in.CreatedAt + if created == "" { + // Do not invent a ledger time — leave blank for the PDF field. + created = "" + } + scope := strings.ToUpper(strings.TrimSpace(in.Scope)) + if scope == "" { + scope = "GLOBAL" + } + lookup := in.VerifyURL + if lookup == "" || strings.Contains(strings.ToLower(lookup), "mcp.verae.ai") { + lookup = LookupURL(in.SHA256, in.ReceiptID) + } + merkle := in.Merkle + if merkle == "" { + merkle = "See ledger lookup via receipt CID" + } + docName := in.DocumentName + if docName == "" { + docName = "(digest only)" + } + + mh, b3, b3mh := ResolveDigests(in) + row := map[string]any{ + "file": docName, + "sha256": in.SHA256, + "scope": scope, + "receipt_id": in.ReceiptID, + "created_at": created, + "merkle": merkle, + "lookup_url": lookup, + "size_bytes": in.ByteLength, + } + // Omit digest fields when empty — never invent. + if mh != "" { + row["multihash"] = mh + } + if b3 != "" { + row["blake3"] = b3 + } + if b3mh != "" { + row["blake3_multihash"] = b3mh + } + + if b, err := buildWithNode(row); err == nil && len(b) > 1000 { + return b, nil + } + + return buildFallbackGofpdf(in, docName, created, scope, merkle, lookup) +} + +func buildWithNode(row map[string]any) ([]byte, error) { + script := findBuildScript() + if script == "" { + return nil, fmt.Errorf("registration-pdf build.mjs not found") + } + tmp, err := os.MkdirTemp("", "verae-regpdf-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + inPath := filepath.Join(tmp, "in.json") + outPath := filepath.Join(tmp, "out.pdf") + jb, err := json.Marshal(row) + if err != nil { + return nil, err + } + if err := os.WriteFile(inPath, jb, 0o600); err != nil { + return nil, err + } + cmd := exec.Command("node", script, inPath, outPath) + cmd.Dir = filepath.Dir(script) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("node build.mjs: %w (%s)", err, stderr.String()) + } + return os.ReadFile(outPath) +} + +func findBuildScript() string { + // Prefer tree-local build.mjs (repo / package) so CERT-01 edits win over + // a stale install under Application Support during tests and dev runs. + var candidates []string + if wd, err := os.Getwd(); err == nil { + dir := wd + for i := 0; i < 8; i++ { + candidates = append(candidates, filepath.Join(dir, "platform", "registration-pdf", "build.mjs")) + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + candidates = append(candidates, filepath.Join("platform", "registration-pdf", "build.mjs")) + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + candidates = append(candidates, + filepath.Join(dir, "registration-pdf", "build.mjs"), + filepath.Join(dir, "..", "registration-pdf", "build.mjs"), + filepath.Join(dir, "platform", "registration-pdf", "build.mjs"), + filepath.Join(dir, "..", "platform", "registration-pdf", "build.mjs"), + filepath.Join(dir, "..", "..", "platform", "registration-pdf", "build.mjs"), + ) + } + // Installed helper copy last (production path when no tree is present). + // No machine-specific absolute research paths — packaging resolves via go.mod walk above. + candidates = append(candidates, + "/Library/Application Support/Verae/PrintStamp/registration-pdf/build.mjs", + ) + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + abs, _ := filepath.Abs(c) + return abs + } + } + return "" +} + +// Ensure created stays empty when unknown — used by tests. +var _ = time.Now diff --git a/internal/server/public_test.go b/internal/server/public_test.go index 268f751..09c8bbe 100644 --- a/internal/server/public_test.go +++ b/internal/server/public_test.go @@ -36,11 +36,15 @@ func newFakeVT(t *testing.T) *fakeVT { w.Header().Set("Content-Type", "application/json") switch { case strings.HasPrefix(r.URL.Path, "/auth/login"): - json.NewEncoder(w).Encode(map[string]any{"token": "host-jwt", "username": "svc", "role": "admin", "expires_at": "2099-01-01T00:00:00Z"}) + json.NewEncoder(w).Encode(map[string]any{ + "token": "user-jwt", + "expiresAt": "2099-01-01T00:00:00Z", + "user": map[string]any{"username": "spam", "role": "user"}, + }) case r.URL.Path == "/auth/signup": // Self-serve signup is unauthenticated on VeraeTime. f.users.Add(1) - json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"}) + json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "spam", "role": "user"}) case !ok: w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"}) @@ -143,22 +147,30 @@ func TestPublicAnonymousWritesRefusedNoHostFallback(t *testing.T) { {"stamp", map[string]any{"sha256": dig, "scope": "global"}}, {"stamp", map[string]any{"sha256": dig}}, // local ledger on the server is a write too {"create_user", map[string]any{"username": "evil", "password": "pw123456", "role": "admin"}}, - {"login", map[string]any{"username": "admin", "password": "x"}}, } { if ok, out := call(t, s, c.tool, c.args); ok { t.Fatalf("%s anonymous succeeded: %s", c.tool, out) } } - // Public self-serve signup is allowed without a caller bearer (creates the account only). - if ok, out := call(t, s, "signup", map[string]any{"username": "spam", "password": "pw123456"}); !ok { - t.Fatalf("anonymous signup should succeed: %s", out) + // Public self-serve signup creates the account and returns access_token (no host persist). + okSignup, outSignup := call(t, s, "signup", map[string]any{"username": "spam@example.com", "password": "pw123456"}) + if !okSignup { + t.Fatalf("anonymous signup should succeed: %s", outSignup) + } + if !strings.Contains(outSignup, `"access_token":"user-jwt"`) { + t.Fatalf("signup must return access_token: %s", outSignup) } if vt.users.Load() != 1 { t.Fatalf("signup should hit /auth/signup once, users=%d", vt.users.Load()) } + // Public login returns a token and must not touch host auth. + okLogin, outLogin := call(t, s, "login", map[string]any{"username": "spam@example.com", "password": "pw123456"}) + if !okLogin || !strings.Contains(outLogin, `"access_token":"user-jwt"`) { + t.Fatalf("anonymous login should return access_token: %v %s", okLogin, outLogin) + } ok, out := call(t, s, "session", map[string]any{}) // Anonymous session is a tool error (not a silent valid:false) so agents see how to attach Bearer. - if ok || strings.Contains(out, `"valid":true`) || !strings.Contains(out, "needs your own Verae credential") { + if ok || strings.Contains(out, `"valid":true`) || !strings.Contains(out, "account and auth are required") { t.Fatalf("anonymous session must error with credential guidance: %v %s", ok, out) } if vt.stamps.Load() != 0 || vt.hostUse.Load() != 0 { @@ -192,36 +204,38 @@ func TestPublicKeyedWritesUseCallerCredential(t *testing.T) { if ok, out := call(t, s, "session", map[string]any{}); !ok || !strings.Contains(out, `"valid":true`) { t.Fatalf("keyed session: %s", out) } - if ok, _ := call(t, s, "login", map[string]any{"username": "a", "password": "b"}); ok { - t.Fatal("login must stay disabled on the public server") + if ok, out := call(t, s, "login", map[string]any{"username": "spam", "password": "pw"}); !ok || !strings.Contains(out, `"access_token":"user-jwt"`) { + t.Fatalf("public login should return access_token without host persist: %v %s", ok, out) } if vt.stamps.Load() != 1 || vt.hostUse.Load() != 0 { t.Fatalf("stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load()) } } -func TestPublicAnonymousReadsNeverLimited(t *testing.T) { +func TestPublicAnonymousReadsRequireAccount(t *testing.T) { vt := newFakeVT(t) s := publicSession(t, vt, "", "198.51.100.9") - // DEC-660: well past the old 30/min (burst 15) per client and in one burst: every anonymous read answers - for i := 0; i < 200; i++ { - tool, args := "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"} - if i%2 == 1 { - tool, args = "hash_report", map[string]any{"sha256": dig, "want_scope": "global"} - } - if ok, out := call(t, s, tool, args); !ok { - t.Fatalf("anonymous %s #%d refused: %s", tool, i, out) + // Account/auth required: anonymous verify and hash_report are refused. + for _, c := range []struct { + tool string + args map[string]any + }{ + {"verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}}, + {"hash_report", map[string]any{"sha256": dig, "want_scope": "global"}}, + {"stamp", map[string]any{"sha256": dig, "scope": "global"}}, + {"stamp_batch", map[string]any{"sha256s": []string{dig}, "scope": "global"}}, + } { + ok, out := call(t, s, c.tool, c.args) + if ok || !strings.Contains(out, "account and auth are required") { + t.Fatalf("anonymous %s: %v %s", c.tool, ok, out) } } - // the write lockdown stays: an anonymous stamp or stamp_batch is still refused - if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig, "scope": "global"}); ok || !strings.Contains(out, "needs your own Verae credential") { - t.Fatalf("anonymous stamp: %v %s", ok, out) + // welcome / signup / login remain open + if ok, out := call(t, s, "welcome", map[string]any{}); !ok || !strings.Contains(out, "Welcome to Verae") { + t.Fatalf("anonymous welcome: %v %s", ok, out) } - if ok, out := call(t, s, "stamp_batch", map[string]any{"sha256s": []string{dig}, "scope": "global"}); ok || !strings.Contains(out, "needs your own Verae credential") { - t.Fatalf("anonymous stamp_batch: %v %s", ok, out) - } - if vt.stamps.Load() != 0 || vt.batches.Load() != 0 { - t.Fatal("anonymous write reached VeraeTime") + if vt.stamps.Load() != 0 || vt.batches.Load() != 0 || vt.hostUse.Load() != 0 { + t.Fatal("anonymous use reached VeraeTime or host token") } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 07cd8cf..d5e9758 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -55,7 +55,7 @@ func TestStreamableHTTP_InitializeListStampLocal(t *testing.T) { if err != nil { t.Fatalf("tools/list: %v", err) } - wantTools := []string{"stamp", "verify", "hash_report", "signup", "login", "session", "create_user"} + wantTools := []string{"welcome", "stamp", "verify", "hash_report", "signup", "login", "session", "create_user"} got := map[string]bool{} for _, tool := range tools.Tools { got[tool.Name] = true diff --git a/internal/statuslang/status.go b/internal/statuslang/status.go new file mode 100644 index 0000000..f46cebc --- /dev/null +++ b/internal/statuslang/status.go @@ -0,0 +1,278 @@ +// Package statuslang holds exact Verae 2026 receipt status strings (BrandingBook). +// Only "final" (and "existing") is evidence — never present a jobId as proof. +package statuslang + +import ( + "fmt" + "strings" +) + +// Exact BrandingBook receipt status labels (brand-tokens status.*). +const ( + Queued = "Queued offline" + Hashing = "Hashing on device…" + Existing = "Already registered" + Pending = "Pending receipt" + Provisional = "Stamped, awaiting blockchain" + Failed = "Failed – retrying" + Action = "Failed – action needed" + + // Notify / tray short titles (not chip labels; still brand-aligned). + FinalReceiptReady = "Final receipt ready" + SignInToFinish = "Sign in to finish timestamp" + OutOfCredits = "Out of credits — add funds" + PrinterReady = "Printer ready" + + // EvidenceNote is the standing disclaimer for queue / tray UI. + EvidenceNote = "Only a final receipt is evidence — hold ids are local queue handles, not proof." + + // BrandingBook proof vocabulary (name the chain fully once per view). + ChainName = "Verae Substrate blockchain" + NetworkName = "VeraeNet" + VerifyBase = "https://verify.verae.ai" + ExplorerURL = "https://explorer.verae.ai/time" +) + +// BrandingBook status.desc strings (tooltips / secondary copy). +const ( + DescQueued = "Not yet sent. Will submit when online." + DescHashing = "SHA-256 computed locally; file never leaves the device." + DescExisting = "Hash was known: existing receipt returned instantly." + DescPending = "Submitted. Waiting for VeraeTime + Substrate." + DescProvisional = "VeraeTime receipt issued; not yet in a Substrate block." + DescIncluded = "In a Substrate block; waiting for GRANDPA finality." + DescFinal = "GRANDPA-finalized. Receipt + proof available. This is evidence." + DescFailed = "Submission failed; retrying with backoff (never resubmits blindly)." + DescAction = "Needs the user (e.g. key expired, 401)." +) + +func Included(block int) string { + return fmt.Sprintf("Included in block #%s (not final)", comma(block)) +} + +func Finalized(block int) string { + return fmt.Sprintf("Final: Substrate block #%s, finalized", comma(block)) +} + +// IncludedN / FinalizedN use literal "N" when the block number is unknown +// (matches BrandingBook / Swift ReceiptState when block is nil). +func IncludedN() string { + return "Included in block #N (not final)" +} + +func FinalizedN() string { + return "Final: Substrate block #N, finalized" +} + +// Label returns the exact BrandingBook chip string for a receipt state key. +// block is required for included/final when known; pass 0 (or omit) to use #N. +func Label(state string, block ...int) string { + var n int + have := false + if len(block) > 0 { + n = block[0] + have = n > 0 + } + switch strings.ToLower(strings.TrimSpace(state)) { + case "queued": + return Queued + case "hashing": + return Hashing + case "existing": + return Existing + case "pending": + return Pending + case "provisional": + return Provisional + case "included": + if have { + return Included(n) + } + return IncludedN() + case "final", "completed", "ok": + if have { + return Finalized(n) + } + return FinalizedN() + case "failed": + return Failed + case "action": + return Action + default: + return "" + } +} + +// Desc returns the BrandingBook status.desc for a state key. +func Desc(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "queued": + return DescQueued + case "hashing": + return DescHashing + case "existing": + return DescExisting + case "pending": + return DescPending + case "provisional": + return DescProvisional + case "included": + return DescIncluded + case "final", "completed", "ok": + return DescFinal + case "failed": + return DescFailed + case "action": + return DescAction + default: + return "" + } +} + +// IsEvidence is true only for final and already-registered (BrandingBook rule 3). +// Certificates, Download proof, and legal claims are allowed only in these states. +func IsEvidence(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "final", "completed", "ok", "existing": + return true + default: + return false + } +} + +// NotifyTitle is a short notification title for printit tray / OS banners. +func NotifyTitle(state string) string { + switch state { + case "queued": + return Queued + case "hashing": + return Hashing + case "existing": + return Existing + case "pending": + return Pending + case "provisional": + return Provisional + case "failed": + return Failed + case "action": + return Action + case "held", "signin": + return SignInToFinish + case "billing", "credits", "payment": + return OutOfCredits + case "final", "completed", "ok": + return FinalReceiptReady + default: + return "Verae Timestamp" + } +} + +// NotifyBody formats a short tray/banner body. +// For held/pending, extra is a local hold id (never evidence). +// For final, extra is the final receipt id (evidence). +// For failed, extra may be a short error hint. +func NotifyBody(state, title, extra string) string { + t := strings.TrimSpace(title) + if t == "" { + t = "document" + } + switch state { + case "held", "signin", "pending": + if extra != "" { + // Local hold id only — not a receipt / not proof. + return fmt.Sprintf("%s · %s · hold %s — sign in to stamp", t, Pending, short(extra)) + } + return fmt.Sprintf("%s · %s — sign in to stamp", t, Pending) + case "billing", "credits", "payment": + if extra != "" { + return fmt.Sprintf("%s · %s · hold %s — open Billing to add funds", t, OutOfCredits, short(extra)) + } + return fmt.Sprintf("%s · %s — open Billing to add funds", t, OutOfCredits) + case "failed", "action": + if extra != "" { + return fmt.Sprintf("%s · %s · %s", t, Failed, short(extra)) + } + return fmt.Sprintf("%s · %s", t, Failed) + case "final", "completed", "ok": + if extra != "" { + return fmt.Sprintf("%s · receipt %s", t, short(extra)) + } + return t + default: + if extra != "" { + return fmt.Sprintf("%s · %s", t, short(extra)) + } + return t + } +} + +// NotifyHeldJobs is the cancel / still-waiting banner when n jobs remain held. +func NotifyHeldJobs(n int) (title, body string) { + title = NotifyTitle("held") + if n <= 0 { + return title, Pending + } + return title, fmt.Sprintf("%d job(s) · %s — will stamp after you sign in", n, Pending) +} + +// TrayHeadline is the persistent status line for tray / menu / loopback health +// (PT-W3 / PT-06 substitute when a native NotifyIcon loop is not available). +// held is local pending count; keyOK means an xVerae- (or equivalent) key is stored; +// helperUp means the IPP loopback helper answered. +func TrayHeadline(held int, keyOK, helperUp bool) string { + switch { + case held > 0: + return NotifyTitle("held") + case !keyOK: + return SignInToFinish + case helperUp: + return PrinterReady + default: + return "Helper not running" + } +} + +// TrayDetail is a one-line body under TrayHeadline (brand labels; hold ≠ evidence). +func TrayDetail(held int, keyOK, helperUp bool) string { + parts := make([]string, 0, 4) + if helperUp { + parts = append(parts, PrinterReady) + } else { + parts = append(parts, "helper down") + } + if keyOK { + parts = append(parts, "key saved") + } else { + parts = append(parts, "no signing key") + } + if held > 0 { + parts = append(parts, fmt.Sprintf("%d held · %s", held, Pending)) + } else { + parts = append(parts, "queue empty") + } + return strings.Join(parts, " · ") +} + +func short(s string) string { + s = strings.TrimSpace(s) + if len(s) <= 24 { + return s + } + return s[:20] + "…" +} + +func comma(n int) string { + s := fmt.Sprintf("%d", n) + if n < 1000 { + return s + } + out := make([]byte, 0, len(s)+len(s)/3) + for i, c := range s { + if i != 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, byte(c)) + } + return string(out) +} diff --git a/internal/tools/bearer_test.go b/internal/tools/bearer_test.go index 006b6c8..19bd889 100644 --- a/internal/tools/bearer_test.go +++ b/internal/tools/bearer_test.go @@ -52,7 +52,7 @@ func TestBearerFromRequest(t *testing.T) { func TestErrWriteNeedsKeyMentionsJWTAndGrok(t *testing.T) { t.Parallel() msg := ErrWriteNeedsKey.Error() - for _, want := range []string{"Authorization: Bearer", "JWT", "bearer_token_file", "stdio"} { + for _, want := range []string{"account and auth are required", "Authorization: Bearer", "JWT", "bearer_token_file", "stdio", "signup", "login"} { if !strings.Contains(msg, want) { t.Fatalf("ErrWriteNeedsKey missing %q: %s", want, msg) } diff --git a/internal/tools/email_username.go b/internal/tools/email_username.go new file mode 100644 index 0000000..65ea01b --- /dev/null +++ b/internal/tools/email_username.go @@ -0,0 +1,20 @@ +package tools + +import ( + "fmt" + "strings" +) + +// requireEmailUsername enforces that Verae usernames are email addresses. +func requireEmailUsername(username string) error { + s := strings.TrimSpace(username) + at := strings.IndexByte(s, '@') + if at <= 0 || at >= len(s)-1 { + return fmt.Errorf("username must be an email address") + } + dot := strings.LastIndexByte(s, '.') + if dot <= at+1 || dot >= len(s)-1 { + return fmt.Errorf("username must be an email address") + } + return nil +} diff --git a/internal/tools/handlers.go b/internal/tools/handlers.go index 16ef37d..19c6f40 100644 --- a/internal/tools/handlers.go +++ b/internal/tools/handlers.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "os" "strings" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx" @@ -30,10 +31,22 @@ func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (str return authctx.Resolve(ctx, BearerFromRequest(req), d.RequireBearer, d.Keeper) } +// isCommercialEdgeKey reports tokens that edge apiKeyAuth can map to a prepaid customer. +func isCommercialEdgeKey(tok string) bool { + t := strings.TrimSpace(tok) + return strings.HasPrefix(t, "xVerae-") || + strings.HasPrefix(t, "key-") || + strings.HasPrefix(t, "zmw_") +} + func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs) (*mcp.CallToolResult, ReceiptsOut, error) { if err := assertDigestArgs(req); err != nil { return nil, ReceiptsOut{}, err } + kind, err := normalizeReceiptFormat(in.ReceiptFormat) + if err != nil { + return nil, ReceiptsOut{}, err + } scope := strings.TrimSpace(in.Scope) if scope == "" { scope = "local" @@ -43,14 +56,22 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs return nil, ReceiptsOut{}, err } } + var out ReceiptsOut switch scope { case "global": tok, err := d.writeBearer(ctx, req) if err != nil { return nil, ReceiptsOut{}, err } - // Single commercial meter: when Edge is configured, bill via zap.verae.ai. + // Single commercial meter: when Edge is configured, bill via services.verae.ai. + // Caller must present a commercial key (xVerae- / key- / zmw_), not a bare chain JWT, + // so settleStampBilling attributes the customer prepaid ledger. if d.Edge != nil { + if !isCommercialEdgeKey(tok) { + return nil, ReceiptsOut{}, fmt.Errorf( + "commercial stamp requires portal API key (xVerae-… or key-…); get one at https://customer.verae.ai/portal/ — bare VeraeTime JWT is not billed on MCP when VERAE_MCP_EDGE_URL is set", + ) + } if len(in.Metadata) > 0 || in.Restamp { return nil, ReceiptsOut{}, fmt.Errorf("metadata/restamp on edge-billed stamps is not supported yet; omit metadata or unset VERAE_MCP_EDGE_URL") } @@ -67,26 +88,27 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs CubeID: in.CubeID, OrgID: in.OrgID, } - return nil, ReceiptsOut{Receipts: []ReceiptDTO{dto}}, nil + out = ReceiptsOut{Receipts: []ReceiptDTO{dto}} + } else { + if d.Global == nil { + return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured") + } + rec, err := d.Global.StampWithMeta(ctx, in.SHA256, tok, veraetime.StampMeta{ + Metadata: in.Metadata, + Restamp: in.Restamp, + Message: in.Message, + }) + if err != nil { + return nil, ReceiptsOut{}, err + } + if in.CubeID != "" { + rec.CubeID = in.CubeID + } + if in.OrgID != "" { + rec.OrgID = in.OrgID + } + out = ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}} } - if d.Global == nil { - return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured") - } - rec, err := d.Global.StampWithMeta(ctx, in.SHA256, tok, veraetime.StampMeta{ - Metadata: in.Metadata, - Restamp: in.Restamp, - Message: in.Message, - }) - if err != nil { - return nil, ReceiptsOut{}, err - } - if in.CubeID != "" { - rec.CubeID = in.CubeID - } - if in.OrgID != "" { - rec.OrgID = in.OrgID - } - return nil, ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}, nil case "local", "shared": if len(in.Metadata) > 0 || in.Restamp { return nil, ReceiptsOut{}, fmt.Errorf("metadata history is stored on global stamps") @@ -98,10 +120,15 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs if err != nil { return nil, ReceiptsOut{}, err } - return nil, ReceiptsOut{Receipts: fromLocalList(recs)}, nil + out = ReceiptsOut{Receipts: fromLocalList(recs)} default: return nil, ReceiptsOut{}, fmt.Errorf("scope must be local, shared, or global") } + out, err = attachArtifacts(out, kind, !d.Public) + if err != nil { + return nil, ReceiptsOut{}, err + } + return nil, out, nil } // stampBatch stamps up to 500 digests in one call (DEC-659): global items are charged per new digest to the @@ -121,13 +148,39 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam } switch scope { case "global": - if d.Global == nil { - return nil, StampBatchOut{}, fmt.Errorf("global VeraeTime client not configured") - } tok, err := d.writeBearer(ctx, req) if err != nil { return nil, StampBatchOut{}, err } + // Prefer edge meter (per-digest) when configured — same prepaid path as single stamp. + if d.Edge != nil { + if !isCommercialEdgeKey(tok) { + return nil, StampBatchOut{}, fmt.Errorf( + "commercial stamp requires portal API key (xVerae-… or key-…); get one at https://customer.verae.ai/portal/", + ) + } + if len(in.Metadata) > 0 || in.Restamp { + return nil, StampBatchOut{}, fmt.Errorf("metadata/restamp on edge-billed batch not supported yet") + } + out := StampBatchOut{Count: len(in.SHA256s)} + for i, h := range in.SHA256s { + it := BatchItemDTO{Index: i, Hash: h, Status: "submitted"} + er, err := d.Edge.Stamp(ctx, h, tok) + if err != nil { + it.Status, it.Error = "failed", err.Error() + out.Failed++ + } else { + it.ReceiptID = er.ReceiptID + it.Status = "completed" + out.Created++ + } + out.Items = append(out.Items, it) + } + return nil, out, nil + } + if d.Global == nil { + return nil, StampBatchOut{}, fmt.Errorf("global VeraeTime client not configured") + } var metas []veraetime.StampMeta if len(in.Metadata) > 0 || in.Restamp { if len(in.Metadata) > 0 && len(in.Metadata) != len(in.SHA256s) { @@ -178,29 +231,84 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam } func (d *Deps) verify(ctx context.Context, req *mcp.CallToolRequest, in VerifyArgs) (*mcp.CallToolResult, VerifyOut, error) { + if err := d.requireAccount(ctx, req); err != nil { + return nil, VerifyOut{}, err + } if err := assertDigestArgs(req); err != nil { return nil, VerifyOut{}, err } + kind, err := normalizeReceiptFormat(in.ReceiptFormat) + if err != nil { + return nil, VerifyOut{}, err + } + receiptID := strings.TrimSpace(in.ReceiptID) + if body, err := d.resolveVerifyReceiptBody(in); err != nil { + return nil, VerifyOut{}, err + } else if body != "" { + id, err := parseReceiptIDFromBody(kind, body) + if err != nil { + return nil, VerifyOut{}, err + } + receiptID = id + } + if receiptID == "" { + return nil, VerifyOut{}, fmt.Errorf("receipt_id is required (or supply receipt / receipt_path)") + } want := strings.TrimSpace(in.WantScope) - useGlobal := want == "global" || looksLikeCID(in.ReceiptID) + useGlobal := want == "global" || looksLikeCID(receiptID) + var ( + toolRes *mcp.CallToolResult + out VerifyOut + ) if useGlobal { - return d.verifyGlobal(ctx, req, in.ReceiptID) - } - if d.Ledger != nil { - vr := d.Ledger.Verify(in.ReceiptID, want) + toolRes, out, err = d.verifyGlobal(ctx, req, receiptID) + } else if d.Ledger != nil { + vr := d.Ledger.Verify(receiptID, want) if vr.Valid { - return nil, fromLocalVerify(vr), nil - } - // Local miss: try VeraeTime when want_scope is empty (Python RoutingLedger). - if want == "" && d.Global != nil { - _, gOut, gErr := d.verifyGlobal(ctx, req, in.ReceiptID) + out = fromLocalVerify(vr) + } else if want == "" && d.Global != nil { + // Local miss: try VeraeTime when want_scope is empty (Python RoutingLedger). + _, gOut, gErr := d.verifyGlobal(ctx, req, receiptID) if gErr == nil && gOut.Valid { - return nil, gOut, nil + out = gOut + } else { + out = fromLocalVerify(vr) } + } else { + out = fromLocalVerify(vr) } - return nil, fromLocalVerify(vr), nil + } else { + toolRes, out, err = d.verifyGlobal(ctx, req, receiptID) } - return d.verifyGlobal(ctx, req, in.ReceiptID) + if err != nil { + return toolRes, out, err + } + if out.Valid && out.Receipt != nil { + art, aerr := buildArtifact(*out.Receipt, kind, !d.Public) + if aerr != nil { + return nil, VerifyOut{}, aerr + } + out.Artifact = &art + } + return toolRes, out, nil +} + +// resolveVerifyReceiptBody returns inline receipt content from Receipt or ReceiptPath. +// Public MCP rejects ReceiptPath (no host-disk reads for callers). +func (d *Deps) resolveVerifyReceiptBody(in VerifyArgs) (string, error) { + path := strings.TrimSpace(in.ReceiptPath) + inline := strings.TrimSpace(in.Receipt) + if path != "" { + if d.Public { + return "", fmt.Errorf("receipt_path is not supported on the public MCP; pass receipt inline or receipt_id") + } + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("receipt_path: %w", err) + } + return string(b), nil + } + return inline, nil } func (d *Deps) verifyGlobal(ctx context.Context, req *mcp.CallToolRequest, receiptID string) (*mcp.CallToolResult, VerifyOut, error) { @@ -219,6 +327,9 @@ func (d *Deps) verifyGlobal(ctx context.Context, req *mcp.CallToolRequest, recei } func (d *Deps) hashReport(ctx context.Context, req *mcp.CallToolRequest, in HashReportArgs) (*mcp.CallToolResult, ReceiptsOut, error) { + if err := d.requireAccount(ctx, req); err != nil { + return nil, ReceiptsOut{}, err + } if err := assertDigestArgs(req); err != nil { return nil, ReceiptsOut{}, err } @@ -284,10 +395,13 @@ func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolReques return out, nil } -func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) { +func (d *Deps) signup(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) { if d.Global == nil { return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured") } + if err := requireEmailUsername(in.Username); err != nil { + return nil, CreateUserOut{}, err + } // Public self-serve: POST /auth/signup needs no caller bearer (DEC-654 write lockdown // does not apply — this creates the credential the caller will use next). // Admin-provisioned users use create_user + admin bearer → POST /auth/users. @@ -295,21 +409,70 @@ func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUser if err != nil { return nil, CreateUserOut{}, err } - return nil, CreateUserOut{ID: res.ID, Username: res.Username, Role: res.Role}, nil + lr, token, err := d.Global.Authenticate(ctx, in.Username, in.Password) + if err != nil { + return nil, CreateUserOut{}, fmt.Errorf("account created but login failed: %w", err) + } + if !d.Public { + if err := d.persistCallerToken(in.Username, in.Password, token, lr.ExpiresAt); err != nil { + return nil, CreateUserOut{}, err + } + } + out := CreateUserOut{ + ID: res.ID, + Username: res.Username, + Role: res.Role, + AccessToken: token, + ExpiresAt: lr.ExpiresAt, + HowToUse: tokenHowToUse, + } + if lr.Username != "" { + out.Username = lr.Username + } + if lr.Role != "" { + out.Role = lr.Role + } + return nil, out, nil } func (d *Deps) login(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, LoginOut, error) { - if d.Public { - return nil, LoginOut{}, ErrLoginDisabled - } if d.Global == nil { return nil, LoginOut{}, fmt.Errorf("global VeraeTime client not configured") } - res, err := d.Global.Login(ctx, in.Username, in.Password) + // Always authenticate and return access_token so MCP clients (esp. public HTTP) can + // set Authorization / bearer_token_file. Persist to host auth.properties only when + // not Public — public login must never replace the ns1 service credential. + res, token, err := d.Global.Authenticate(ctx, in.Username, in.Password) if err != nil { return nil, LoginOut{}, err } - return nil, LoginOut{Username: res.Username, Role: res.Role, ExpiresAt: res.ExpiresAt}, nil + if !d.Public { + if err := d.persistCallerToken(in.Username, in.Password, token, res.ExpiresAt); err != nil { + return nil, LoginOut{}, err + } + } + return nil, LoginOut{ + Username: res.Username, + Role: res.Role, + ExpiresAt: res.ExpiresAt, + AccessToken: token, + HowToUse: tokenHowToUse, + }, nil +} + +func (d *Deps) persistCallerToken(username, password, token, expiresAt string) error { + path := d.Global.AuthPath() + if path == "" { + path = veraetime.DefaultAuthPath() + } + return veraetime.WriteProperties(path, map[string]string{ + "access_token": token, + "id_token": token, + "auth_token": token, + "expires_at": expiresAt, + "username": username, + "password": password, + }) } func (d *Deps) session(ctx context.Context, req *mcp.CallToolRequest, _ EmptyArgs) (*mcp.CallToolResult, SessionOut, error) { @@ -341,6 +504,9 @@ func (d *Deps) createUser(ctx context.Context, req *mcp.CallToolRequest, in Crea if d.Global == nil { return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured") } + if err := requireEmailUsername(in.Username); err != nil { + return nil, CreateUserOut{}, err + } tok, err := d.writeBearer(ctx, req) if err != nil { return nil, CreateUserOut{}, err diff --git a/internal/tools/public.go b/internal/tools/public.go index 1f0f6e4..129dc69 100644 --- a/internal/tools/public.go +++ b/internal/tools/public.go @@ -1,13 +1,14 @@ package tools -// DEC-654 (D): on the public HTTP server (mcp.verae.ai) every write needs the caller's own credential - a VeraeTime -// JWT (access_token from login) or a Verae API key - checked at api.veraetime.net - and never falls back to this -// host's stored service login. Writes that need a caller bearer: stamp (any scope), create_user, session, -// stamp_batch. Public signup is open (POST /auth/signup, no bearer) so new customers can register, then stamp -// with their own JWT. login is refused on the public server (it would replace the host credential). -// Read-only verify and hash_report stay anonymous; they may use the host login and are never rate limited (DEC-660). +// DEC-654 (D): on the public HTTP server (mcp.verae.ai) account and auth are required for use. Every tool except +// welcome, signup, and login needs the caller's own credential — a VeraeTime JWT (access_token from signup/login) +// or a Verae API key — checked at api.veraetime.net — and never falls back to this host's stored service login. +// Tools that need a caller bearer: stamp, stamp_batch, verify, hash_report, session, create_user. +// Public signup and login are open (no bearer): they create/authenticate the account and return access_token so the +// client can set Authorization / bearer_token_file. They never write the host auth.properties on the public server. +// welcome is open so new users can read onboarding before signing up. // -// AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call. Grok Build: +// AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call after signup/login. Grok Build: // bearer_token_file = "~/.verae/mcp-bearer" # raw JWT only, not auth.properties // or point a local stdio MCP at this binary (Public=false) so the laptop host token is used. @@ -22,23 +23,21 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// ErrWriteNeedsKey is returned for an anonymous write on the public server. +// ErrWriteNeedsKey is returned when the public server is used without a caller account/credential. var ErrWriteNeedsKey = errors.New( - "this tool needs your own Verae credential on Authorization: Bearer. " + - "Use a VeraeTime JWT (access_token from `verae login` / ~/.verae/auth.properties) " + - "or a Verae API key from https://zap.verae.ai/portal/. " + - "Grok Build: set mcp_servers.verae-timestamping.bearer_token_file to a file that holds only the raw token " + - "(e.g. ~/.verae/mcp-bearer), then reconnect the MCP server; " + - "or run this binary with -transport stdio so the laptop host login is used", + "account and auth are required for use. " + + "Call tool welcome for onboarding, then signup (new account) or login (existing) to receive a VeraeTime JWT as access_token. " + + "Write that token to your client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) " + + "or use a Verae API key from https://zap.verae.ai/portal/, " + + "then send Authorization: Bearer on each tools/call. " + + "Or run this binary with -transport stdio so the laptop host login is used", ) -// ErrLoginDisabled is returned by the login tool on the public server. -var ErrLoginDisabled = errors.New( - "login is not available on the public HTTP server (it would replace the host credential). " + - "Sign in with `verae login` or at https://zap.verae.ai/portal/, put the JWT or API key in " + - "Authorization: Bearer on each MCP request (Grok: bearer_token_file), " + - "or use a local stdio MCP transport", -) +// tokenHowToUse is guidance returned with access_token from public signup/login. +const tokenHowToUse = "Store access_token as the only contents of your MCP bearer file " + + "(Grok: ~/.verae/mcp-bearer with mcp_servers..bearer_token_file), " + + "or send Authorization: Bearer on each tools/call. " + + "Do not paste the token into chat. Commercial global stamps also accept an xVerae- portal API key." // CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential). type CredentialChecker interface { @@ -60,11 +59,24 @@ func (d *Deps) writeBearer(ctx context.Context, req *mcp.CallToolRequest) (strin return tok, nil } -// readBearer is the caller's bearer, or (anonymous, never limited - DEC-660) the host login for read-only lookups. +// readBearer requires the caller's own credential on the public server (account/auth required for use). +// On stdio/local (Public=false) it may fall back to the host login via resolveBearer. func (d *Deps) readBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) { + if d.Public { + return d.writeBearer(ctx, req) + } return d.resolveBearer(ctx, req) } +// requireAccount gates any public tool that is not welcome/signup/login. +func (d *Deps) requireAccount(ctx context.Context, req *mcp.CallToolRequest) error { + if !d.Public { + return nil + } + _, err := d.writeBearer(ctx, req) + return err +} + type credEntry struct { ok bool exp time.Time diff --git a/internal/tools/receipt_format.go b/internal/tools/receipt_format.go new file mode 100644 index 0000000..7e9b6d9 --- /dev/null +++ b/internal/tools/receipt_format.go @@ -0,0 +1,462 @@ +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() +} diff --git a/internal/tools/receipt_format_test.go b/internal/tools/receipt_format_test.go new file mode 100644 index 0000000..135fb4b --- /dev/null +++ b/internal/tools/receipt_format_test.go @@ -0,0 +1,187 @@ +package tools + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeReceiptFormat(t *testing.T) { + cases := []struct { + in string + want string + err bool + }{ + {"", KindText, false}, + {" ", KindText, false}, + {"text", KindText, false}, + {"receipt", KindPDF, false}, + {"PDF-Receipt", KindPDF, false}, + {"pdf-receipt", KindPDF, false}, + {"PDF", KindPDF, false}, + {"JSON-Receipt", KindJSON, false}, + {"json", KindJSON, false}, + {"MD", KindMD, false}, + {"MD-Receipt", KindMD, false}, + {"markdown", KindMD, false}, + {"CSV", KindCSV, false}, + {"CSV-Receipt", KindCSV, false}, + {"csv", KindCSV, false}, + {"TSV", KindTSV, false}, + {"TSV-Receipt", KindTSV, false}, + {"tsv", KindTSV, false}, + {"docx", "", true}, + {"xml", "", true}, + } + for _, tc := range cases { + got, err := normalizeReceiptFormat(tc.in) + if tc.err { + if err == nil { + t.Fatalf("normalizeReceiptFormat(%q) expected error", tc.in) + } + continue + } + if err != nil { + t.Fatalf("normalizeReceiptFormat(%q): %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("normalizeReceiptFormat(%q)=%q want %q", tc.in, got, tc.want) + } + } +} + +func sampleDTO() ReceiptDTO { + return ReceiptDTO{ + ReceiptID: "QmWRJoKLTs56xFQLkhtvSQnWiQvebXGRqRdtbsjodm5HVX", + Hash: "01ec26e0bcdaf0271af7bb7810b1eae22b3220bc3c59b9cc7abfa1519edcbae1", + Scope: "global", + CreatedAt: "2026-09-23T14:46:08Z", + Merkle: "merkle-root-example", + } +} + +func TestBuildArtifactFormats(t *testing.T) { + dto := sampleDTO() + home := t.TempDir() + t.Setenv("HOME", home) + + for _, kind := range []string{KindText, KindJSON, KindMD, KindCSV, KindTSV, KindPDF} { + art, err := buildArtifact(dto, kind, true) + if err != nil { + t.Fatalf("buildArtifact(%s): %v", kind, err) + } + if art.Format != kind { + t.Fatalf("format=%q want %q", art.Format, kind) + } + if art.MediaType != mediaTypeForKind(kind) { + t.Fatalf("%s media_type=%q", kind, art.MediaType) + } + if art.Path == "" { + t.Fatalf("%s: expected path under HOME", kind) + } + if _, err := os.Stat(art.Path); err != nil { + t.Fatalf("%s path missing: %v", kind, err) + } + switch kind { + case KindPDF: + if art.ContentB64 == "" { + t.Fatal("pdf: empty content_base64") + } + b, err := base64.StdEncoding.DecodeString(art.ContentB64) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(b), "%PDF") { + t.Fatalf("pdf does not start with %%PDF") + } + case KindJSON: + if !strings.Contains(art.ContentText, `"format": "JSON-Receipt"`) { + t.Fatalf("json missing format marker: %s", art.ContentText[:min(80, len(art.ContentText))]) + } + if !strings.Contains(art.ContentText, dto.ReceiptID) { + t.Fatal("json missing receipt_id") + } + case KindMD: + if !strings.Contains(art.ContentText, "| receipt_id |") { + t.Fatalf("md missing table header: %s", art.ContentText) + } + case KindCSV: + if !strings.HasPrefix(art.ContentText, "receipt_id,") { + t.Fatalf("csv header: %q", art.ContentText) + } + if !strings.Contains(art.ContentText, dto.ReceiptID) { + t.Fatal("csv missing id") + } + case KindTSV: + if !strings.HasPrefix(art.ContentText, "receipt_id\t") { + t.Fatalf("tsv header: %q", art.ContentText) + } + case KindText: + if !strings.Contains(art.ContentText, "receipt_id: "+dto.ReceiptID) { + t.Fatal("text missing receipt_id line") + } + } + } +} + +func TestBuildArtifactPublicNoDisk(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + art, err := buildArtifact(sampleDTO(), KindJSON, false) + if err != nil { + t.Fatal(err) + } + if art.Path != "" { + t.Fatalf("public must not set path, got %q", art.Path) + } + ents, _ := os.ReadDir(filepath.Join(home, ".verae", "receipts")) + if len(ents) != 0 { + t.Fatalf("public wrote %d files under receipts", len(ents)) + } + if art.ContentText == "" { + t.Fatal("expected content_text") + } +} + +func TestParseReceiptIDRoundTrip(t *testing.T) { + dto := sampleDTO() + cases := []string{KindText, KindJSON, KindMD, KindCSV, KindTSV} + for _, kind := range cases { + art, err := buildArtifact(dto, kind, false) + if err != nil { + t.Fatal(err) + } + id, err := parseReceiptIDFromBody(kind, art.ContentText) + if err != nil { + t.Fatalf("parse %s: %v\nbody:\n%s", kind, err, art.ContentText) + } + if id != dto.ReceiptID { + t.Fatalf("parse %s: got %q want %q", kind, id, dto.ReceiptID) + } + } + // PDF via base64 + art, err := buildArtifact(dto, KindPDF, false) + if err != nil { + t.Fatal(err) + } + id, err := parseReceiptIDFromBody(KindPDF, art.ContentB64) + if err != nil { + t.Fatalf("parse pdf: %v", err) + } + if id != dto.ReceiptID { + t.Fatalf("parse pdf: got %q want %q", id, dto.ReceiptID) + } +} + +func TestAttachArtifacts(t *testing.T) { + out := ReceiptsOut{Receipts: []ReceiptDTO{sampleDTO()}} + got, err := attachArtifacts(out, KindCSV, false) + if err != nil { + t.Fatal(err) + } + if len(got.Artifacts) != 1 || got.Artifacts[0].Format != KindCSV { + t.Fatalf("%+v", got.Artifacts) + } +} diff --git a/internal/tools/register.go b/internal/tools/register.go index 16d4c4f..574a829 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -2,14 +2,6 @@ package tools import "github.com/modelcontextprotocol/go-sdk/mcp" -const instructions = "Digests-only timestamping. " + - "Public HTTP (mcp.verae.ai / mcp.veraetime.net): stamp writes need Authorization: Bearer " + - " — no host-login fallback. " + - "When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai (same billing as PrintIt/Zapier). " + - "New accounts: tool signup (POST /auth/signup) or portal createUser; admin create_user for operators. " + - "login is disabled on the public HTTP server. " + - "Grok Build: bearer_token_file with raw xVerae- or JWT, or -transport stdio. Do not paste tokens into chat." - // NewServer builds an MCP server with the full Verae timestamping tool set. func NewServer(d *Deps) *mcp.Server { s := mcp.NewServer(&mcp.Implementation{ @@ -22,22 +14,32 @@ func NewServer(d *Deps) *mcp.Server { return s } -// Register adds stamp, stamp_batch, verify, hash_report, signup, login, session, create_user. +// Register adds welcome, stamp, stamp_batch, verify, hash_report, signup, login, session, create_user. func Register(s *mcp.Server, d *Deps) { if d == nil { d = &Deps{} } mcp.AddTool(s, &mcp.Tool{ - Name: "stamp", - Description: "Commit a SHA-256. scope=local is the leaf file ledger. scope=global posts the digest to api.veraetime.net and reuses the first CID unless restamp is true. Optional metadata is sent as a verae-meta-git/1 history; blank values do not delete fields.", + Name: "welcome", + Description: "Show the Verae welcome and onboarding text: what timestamping is, what this MCP does, " + + "and how to signup or login. Call this when a user connects without an account or asks what Verae is.", + }, d.welcome) + mcp.AddTool(s, &mcp.Tool{ + Name: "stamp", + Description: "Commit a SHA-256. scope=local is the leaf file ledger. scope=global posts the digest to api.veraetime.net and reuses the first CID unless restamp is true. " + + "Optional metadata is sent as a verae-meta-git/1 history; blank values do not delete fields. " + + "Optional receipt_format: omit=text; receipt|PDF-Receipt=PDF; JSON-Receipt=JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt. " + + "Always returns receipts[]; also returns artifacts[] in the chosen format (stdio writes ~/.verae/receipts; public MCP returns content only).", }, d.stamp) mcp.AddTool(s, &mcp.Tool{ Name: "stamp_batch", Description: "Commit up to 500 SHA-256 digests at once. scope=global (default) needs your Verae API key and charges each new digest to your account (your own repeats are free); returns one receipt entry per digest, in order, with the credits charged and your balance.", }, d.stampBatch) mcp.AddTool(s, &mcp.Tool{ - Name: "verify", - Description: "Check a receipt. want_scope=global asks api.veraetime.net (central source of truth).", + Name: "verify", + Description: "Check a receipt. want_scope=global asks api.veraetime.net (central source of truth). " + + "Pass receipt_id, or supply receipt / receipt_path with receipt_format (text|JSON-Receipt|MD|CSV|TSV|PDF-Receipt) to extract the id. " + + "On success, re-emits an artifact in the requested format.", }, d.verify) mcp.AddTool(s, &mcp.Tool{ Name: "hash_report", @@ -45,13 +47,16 @@ func Register(s *mcp.Server, d *Deps) { }, d.hashReport) mcp.AddTool(s, &mcp.Tool{ Name: "signup", - Description: "Self-serve create a VeraeTime user (POST /auth/signup, role=user). " + - "No Authorization required on the public server. Does not return a JWT — " + - "use a client login or portal to obtain a bearer for stamp.", + Description: "Self-serve create a VeraeTime user (POST /auth/signup, role=user), then return access_token. " + + "Username must be an email address (a welcome email is sent to that address). " + + "No Authorization required. Write access_token to your MCP bearer file (or Authorization header) before stamp. " + + "On stdio/local transport the token is also stored on this host.", }, d.signup) mcp.AddTool(s, &mcp.Tool{ - Name: "login", - Description: "Log in to api.veraetime.net. Stores the JWT on this host and does not return it. Disabled on the public HTTP server.", + Name: "login", + Description: "Log in to api.veraetime.net and return access_token. Username is the account email address. " + + "On the public HTTP server the host credential is never replaced — only the tool result carries the token. " + + "On stdio/local transport the token is also stored on this host (auth.properties).", }, d.login) mcp.AddTool(s, &mcp.Tool{ Name: "session", @@ -60,6 +65,7 @@ func Register(s *mcp.Server, d *Deps) { mcp.AddTool(s, &mcp.Tool{ Name: "create_user", Description: "Admin create a VeraeTime user (POST /auth/users / Swagger Auth/createUser). " + + "Username must be an email address (a Welcome to Verae email is sent). " + "Requires Authorization: Bearer of an admin. Used by operators and edge provision; " + "self-serve MCP clients should use signup.", }, d.createUser) diff --git a/internal/tools/types.go b/internal/tools/types.go index 3ba8481..8fb5620 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -2,19 +2,23 @@ package tools // StampArgs is the stamp tool input. type StampArgs struct { - SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"` - Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"` - CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"` - OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"` - Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional plain object or a verae-meta-git/1 history. Blank values do not delete fields. Global scope only."` - Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append metadata onto the previous global stamp for this digest"` - Message string `json:"message,omitempty" jsonschema:"optional history commit message"` + SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"` + Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"` + CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"` + OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"` + Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional plain object or a verae-meta-git/1 history. Blank values do not delete fields. Global scope only."` + Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append metadata onto the previous global stamp for this digest"` + Message string `json:"message,omitempty" jsonschema:"optional history commit message"` + ReceiptFormat string `json:"receipt_format,omitempty" jsonschema:"optional receipt presentation: omit=text; receipt|PDF-Receipt=PDF; JSON-Receipt=JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt"` } // VerifyArgs is the verify tool input. type VerifyArgs struct { - ReceiptID string `json:"receipt_id" jsonschema:"receipt or certificate id"` - WantScope string `json:"want_scope,omitempty" jsonschema:"optional minimum scope (local|shared|global)"` + ReceiptID string `json:"receipt_id,omitempty" jsonschema:"receipt or certificate id (optional when Receipt or ReceiptPath supplies it)"` + WantScope string `json:"want_scope,omitempty" jsonschema:"optional minimum scope (local|shared|global)"` + ReceiptFormat string `json:"receipt_format,omitempty" jsonschema:"how to interpret Receipt/ReceiptPath and/or re-emit artifact: omit=text; receipt|PDF-Receipt=PDF; JSON-Receipt=JSON; MD|CSV|TSV"` + Receipt string `json:"receipt,omitempty" jsonschema:"optional inline receipt body (text/JSON/MD/CSV/TSV or base64 PDF)"` + ReceiptPath string `json:"receipt_path,omitempty" jsonschema:"optional local path to a receipt file (stdio only; rejected on public MCP)"` } // HashReportArgs is the hash_report tool input. @@ -26,13 +30,13 @@ type HashReportArgs struct { // AuthUserPass are credentials for signup/login. type AuthUserPass struct { - Username string `json:"username" jsonschema:"VeraeTime username"` + Username string `json:"username" jsonschema:"Email address used as VeraeTime username (required; must be an email)"` Password string `json:"password" jsonschema:"VeraeTime password"` } // CreateUserArgs creates a VeraeTime user (admin bearer required). type CreateUserArgs struct { - Username string `json:"username" jsonschema:"new username"` + Username string `json:"username" jsonschema:"Email address used as the new username (required; must be an email)"` Password string `json:"password" jsonschema:"new password"` Role string `json:"role" jsonschema:"user or admin"` } @@ -52,23 +56,39 @@ type ReceiptDTO struct { RestampOf string `json:"restamp_of,omitempty"` } +// ReceiptArtifact is a rendered receipt in the requested presentation format. +type ReceiptArtifact struct { + Format string `json:"format"` // text|pdf|json|md|csv|tsv + MediaType string `json:"media_type"` // MIME type + Filename string `json:"filename,omitempty"` + Path string `json:"path,omitempty"` // written under ~/.verae/receipts when stdio + ContentText string `json:"content_text,omitempty"` // text/JSON/MD/CSV/TSV body + ContentB64 string `json:"content_base64,omitempty"` // PDF (and large bodies if needed) +} + // ReceiptsOut wraps a receipt list. type ReceiptsOut struct { - Receipts []ReceiptDTO `json:"receipts"` + Receipts []ReceiptDTO `json:"receipts"` + Artifacts []ReceiptArtifact `json:"artifacts,omitempty"` } // VerifyOut is the verify tool result. type VerifyOut struct { - Valid bool `json:"valid"` - Error string `json:"error,omitempty"` - Receipt *ReceiptDTO `json:"receipt,omitempty"` + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` + Receipt *ReceiptDTO `json:"receipt,omitempty"` + Artifact *ReceiptArtifact `json:"artifact,omitempty"` } -// LoginOut is returned by login (never includes JWT). +// LoginOut is returned by login. On the public HTTP server it includes access_token +// so the client can set Authorization / bearer_token_file. Stdio login also returns +// the token and persists it on the host. type LoginOut struct { - Username string `json:"username"` - Role string `json:"role"` - ExpiresAt string `json:"expires_at"` + Username string `json:"username"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` + AccessToken string `json:"access_token,omitempty"` + HowToUse string `json:"how_to_use,omitempty"` } // SessionOut is returned by session. @@ -80,10 +100,14 @@ type SessionOut struct { } // CreateUserOut is returned by signup and create_user. +// Signup includes access_token so a new account can stamp without a separate login. type CreateUserOut struct { - ID string `json:"id,omitempty"` - Username string `json:"username"` - Role string `json:"role"` + ID string `json:"id,omitempty"` + Username string `json:"username"` + Role string `json:"role"` + AccessToken string `json:"access_token,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + HowToUse string `json:"how_to_use,omitempty"` } // StampBatchArgs is the stamp_batch tool input (DEC-659). diff --git a/internal/tools/welcome.go b/internal/tools/welcome.go new file mode 100644 index 0000000..33fb3e9 --- /dev/null +++ b/internal/tools/welcome.go @@ -0,0 +1,70 @@ +package tools + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Welcome message shown on connect (via instructions) and by the welcome tool. +// Keep this the source of truth for onboarding copy. +const welcomeMessage = `Welcome to Verae. + +Account and auth are required for use of this MCP. Before stamp, verify, hash_report, or session, you must create an account or sign in and attach your access_token. + +Verae Timestamping proves that a specific digital object existed at a point in time. You hash the file (or data) locally — Verae never needs the file bytes, only the digest — then commit that digest. The result is a receipt you can verify later (and share) without revealing the original content. + +This MCP server is how AI clients (Grok, Claude, Cursor, and others) and local tools talk to Verae: +• welcome — onboarding (no account required) +• signup / login — create an account or sign in; you receive an access_token (required next) +• stamp / stamp_batch — commit one or many SHA-256 digests (local ledger or global VeraeTime) +• verify / hash_report — check a receipt or list history for a digest +• session — confirm your bearer is still valid + +Public HTTP (mcp.verae.ai): every tool except welcome, signup, and login needs Authorization: Bearer (VeraeTime JWT or xVerae- API key). There is no host-login fallback. Put access_token in your client's bearer file (Grok: bearer_token_file → ~/.verae/mcp-bearer) or send Authorization on each tools/call. Do not paste tokens into chat. + +Important: the username must be an email address (welcome mail is sent there on signup). + +Next step — create or sign in to your account: +1. New here? Call tool signup with your email as username and a password. It creates the account, emails a Welcome to Verae message, and returns access_token. +2. Already registered? Call tool login with your email and password to receive access_token. +3. Save the token in your MCP client, call session to confirm, then stamp or verify. + +Global stamps may meter prepaid credits when edge billing is enabled.` + +const instructions = "Welcome to Verae Timestamping MCP (mcp.verae.ai). " + + "Account and auth are required for use. Username must be an email address. " + + "On first connect, or whenever the user has no Verae account / bearer, greet them warmly: " + + "welcome them to Verae, briefly explain digests-only timestamping (hash locally; stamp digests; get a verifiable receipt), " + + "explain that this MCP requires signup or login before stamp/verify/session, " + + "tell them the username must be an email address (a Welcome to Verae email is sent on signup), " + + "then ask them to sign in (tool login) or create an account (tool signup). " + + "Prefer calling tool welcome to show the full onboarding text, then guide signup or login. " + + "Only welcome, signup, and login work without a bearer. " + + "After signup/login, save access_token to the client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) or Authorization header — never paste tokens into chat. " + + "When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai. " + + "Admin create_user is for operators only." + +// WelcomeOut is returned by the welcome tool. +type WelcomeOut struct { + Title string `json:"title"` + Message string `json:"message"` + NextSteps []string `json:"next_steps"` + ToolsHint []string `json:"tools_hint"` + HasAccount string `json:"ask"` +} + +func (d *Deps) welcome(_ context.Context, _ *mcp.CallToolRequest, _ EmptyArgs) (*mcp.CallToolResult, WelcomeOut, error) { + return nil, WelcomeOut{ + Title: "Welcome to Verae", + Message: welcomeMessage, + NextSteps: []string{ + "New user: call tool signup with your email as username and a password → receive access_token (Welcome email is sent)", + "Existing user: call tool login with your email and password → receive access_token", + "Save access_token to your MCP bearer file or Authorization header (do not paste into chat)", + "Call tool session to confirm, then stamp a SHA-256 digest", + }, + ToolsHint: []string{"welcome", "signup", "login", "session", "stamp", "stamp_batch", "verify", "hash_report"}, + HasAccount: "Account and auth are required for use. The username must be an email address. Do you already have a Verae account? Sign in with login, or create one with signup.", + }, nil +} diff --git a/internal/tools/welcome_test.go b/internal/tools/welcome_test.go new file mode 100644 index 0000000..a3bc713 --- /dev/null +++ b/internal/tools/welcome_test.go @@ -0,0 +1,36 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +func TestWelcomeMessageMentionsSignupLogin(t *testing.T) { + t.Parallel() + for _, want := range []string{"Welcome to Verae", "signup", "login", "access_token", "digest"} { + if !strings.Contains(welcomeMessage, want) { + t.Fatalf("welcomeMessage missing %q", want) + } + } + for _, want := range []string{"Welcome to Verae", "signup", "login", "tool welcome"} { + if !strings.Contains(instructions, want) { + t.Fatalf("instructions missing %q", want) + } + } +} + +func TestWelcomeTool(t *testing.T) { + t.Parallel() + d := &Deps{} + _, out, err := d.welcome(context.Background(), nil, EmptyArgs{}) + if err != nil { + t.Fatal(err) + } + if out.Title != "Welcome to Verae" || !strings.Contains(strings.ToLower(out.Message), "timestamp") { + t.Fatalf("unexpected welcome: %+v", out) + } + if len(out.NextSteps) < 3 || out.HasAccount == "" { + t.Fatalf("next_steps/ask incomplete: %+v", out) + } +} diff --git a/internal/veraetime/client.go b/internal/veraetime/client.go index c4c60fa..2469cfe 100644 --- a/internal/veraetime/client.go +++ b/internal/veraetime/client.go @@ -466,13 +466,24 @@ func (c *Client) authenticate(ctx context.Context, username, password string) (L } user, _ := body["user"].(map[string]any) uname := str(user, "username") + if uname == "" { + uname = str(body, "username") + } if uname == "" { uname = username } + expires := str(body, "expiresAt") + if expires == "" { + expires = str(body, "expires_at") + } + role := str(user, "role") + if role == "" { + role = str(body, "role") + } return LoginResult{ Username: uname, - Role: str(user, "role"), - ExpiresAt: str(body, "expiresAt"), + Role: role, + ExpiresAt: expires, }, token, nil }