Compare commits

...
Author SHA1 Message Date
3ec1f0516b feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Some checks failed
test-and-deploy / test (push) Failing after 12m55s
test-and-deploy / deploy-138 (push) Has been cancelled
Add receipt_format to stamp and verify. Omitted → text; receipt|PDF-Receipt → PDF;
JSON-Receipt → JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt.

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

Vendors pdfcert (+ slim statuslang, gofpdf) for CERT-01 PDF. Also lands welcome tool
and email-username helpers already wired into register/public paths.
2026-10-20 16:02:20 -04:00
13d1c5851b fix(mcp): require xVerae- commercial key when EDGE_URL set; batch via edge 2026-10-17 00:26:50 -04:00
21 changed files with 2087 additions and 138 deletions

View file

@ -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.

2
go.mod
View file

@ -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 (

15
go.sum
View file

@ -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=

View file

@ -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).

View file

@ -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
}

BIN
internal/pdfcert/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

330
internal/pdfcert/pdfcert.go Normal file
View file

@ -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

View file

@ -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")
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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,8 +88,8 @@ 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")
}
@ -86,7 +107,8 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
if in.OrgID != "" {
rec.OrgID = in.OrgID
}
return nil, ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}, nil
out = ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}
}
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
}
out = fromLocalVerify(vr)
} else if want == "" && d.Global != 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)
_, 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

View file

@ -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.<name>.bearer_token_file), " +
"or send Authorization: Bearer <access_token> 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

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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 " +
"<xVerae- key or VeraeTime JWT> — 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,14 +14,22 @@ 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: "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.",
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",
@ -37,7 +37,9 @@ func Register(s *mcp.Server, d *Deps) {
}, d.stampBatch)
mcp.AddTool(s, &mcp.Tool{
Name: "verify",
Description: "Check a receipt. want_scope=global asks api.veraetime.net (central source of truth).",
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.",
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)

View file

@ -9,12 +9,16 @@ type StampArgs struct {
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"`
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,9 +56,20 @@ 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"`
Artifacts []ReceiptArtifact `json:"artifacts,omitempty"`
}
// VerifyOut is the verify tool result.
@ -62,13 +77,18 @@ type VerifyOut struct {
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"`
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"`
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).

70
internal/tools/welcome.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

Some files were not shown because too many files have changed in this diff Show more