feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Add receipt_format to stamp and verify. Omitted → text; receipt|PDF-Receipt → PDF; JSON-Receipt → JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt. Stamp always returns receipts[] and adds artifacts[] in the chosen format. Stdio writes ~/.verae/receipts (0600); public MCP returns content_text/content_base64 only. Verify accepts inline receipt or receipt_path (stdio), parses id, and re-emits an artifact. Vendors pdfcert (+ slim statuslang, gofpdf) for CERT-01 PDF. Also lands welcome tool and email-username helpers already wired into register/public paths.
This commit is contained in:
parent
13d1c5851b
commit
3ec1f0516b
10 changed files with 2040 additions and 132 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
20
internal/tools/email_username.go
Normal file
20
internal/tools/email_username.go
Normal 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
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx"
|
||||
|
|
@ -42,6 +43,10 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
|
|||
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"
|
||||
|
|
@ -51,6 +56,7 @@ 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)
|
||||
|
|
@ -82,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")
|
||||
|
|
@ -113,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
|
||||
|
|
@ -219,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) {
|
||||
|
|
@ -260,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
|
||||
}
|
||||
|
|
@ -325,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.
|
||||
|
|
@ -336,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) {
|
||||
|
|
@ -382,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
462
internal/tools/receipt_format.go
Normal file
462
internal/tools/receipt_format.go
Normal 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()
|
||||
}
|
||||
187
internal/tools/receipt_format_test.go
Normal file
187
internal/tools/receipt_format_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
70
internal/tools/welcome.go
Normal file
70
internal/tools/welcome.go
Normal 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
|
||||
}
|
||||
36
internal/tools/welcome_test.go
Normal file
36
internal/tools/welcome_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue