Public server: anonymous verify/hash_report never rate limited (DEC-660); stamp_batch tool charged per item with the caller's key (DEC-659); 402 balance message passed through (DEC-658)
This commit is contained in:
parent
e24d466f2b
commit
ec899f5e11
7 changed files with 201 additions and 106 deletions
|
|
@ -22,6 +22,7 @@ type fakeVT struct {
|
|||
stamps atomic.Int32
|
||||
hostUse atomic.Int32
|
||||
users atomic.Int32
|
||||
batches atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeVT(t *testing.T) *fakeVT {
|
||||
|
|
@ -42,6 +43,22 @@ func newFakeVT(t *testing.T) *fakeVT {
|
|||
case r.URL.Path == "/api/status/mcp-credential-check":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "job not found"})
|
||||
case r.URL.Path == "/api/batch/timestamp":
|
||||
var body struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if len(body.Items) == 1 && body.Items[0]["digest"] == strings.Repeat("c", 64) {
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "payment required: Insufficient prepaid balance: this request costs 10 credits but only 0 are available", "code": "PAYMENT_REQUIRED"})
|
||||
return
|
||||
}
|
||||
f.batches.Add(1)
|
||||
res := []map[string]any{}
|
||||
for i, it := range body.Items {
|
||||
res = append(res, map[string]any{"index": i, "digest": it["digest"], "jobId": "job-" + string(rune('a'+i)), "status": "submitted", "existing": false, "credits": 10})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"count": len(res), "created": len(res), "existing": 0, "failed": 0, "totalCredits": 10 * len(res), "balanceCredits": 480, "results": res})
|
||||
case r.URL.Path == "/api/timestamp":
|
||||
f.stamps.Add(1)
|
||||
json.NewEncoder(w).Encode(map[string]any{"job_id": "j1", "status": "completed", "certificate": "QmTestCid", "merkle_root": "ab", "created_at": "2026-10-10T00:00:00Z"})
|
||||
|
|
@ -172,28 +189,44 @@ func TestPublicKeyedWritesUseCallerCredential(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublicAnonymousReadsAllowedAndRateLimited(t *testing.T) {
|
||||
func TestPublicAnonymousReadsNeverLimited(t *testing.T) {
|
||||
vt := newFakeVT(t)
|
||||
s := publicSession(t, vt, "", "198.51.100.9")
|
||||
if ok, out := call(t, s, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok {
|
||||
t.Fatalf("anonymous verify: %s", out)
|
||||
// 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, "hash_report", map[string]any{"sha256": dig, "want_scope": "global"}); !ok {
|
||||
t.Fatalf("anonymous hash_report: %s", out)
|
||||
}
|
||||
limited := false
|
||||
for i := 0; i < 40; i++ {
|
||||
if ok, out := call(t, s, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok && strings.Contains(out, "too many") {
|
||||
limited = true
|
||||
break
|
||||
if ok, out := call(t, s, tool, args); !ok {
|
||||
t.Fatalf("anonymous %s #%d refused: %s", tool, i, out)
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Fatal("anonymous reads were not rate limited")
|
||||
// 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)
|
||||
}
|
||||
// another client is unaffected
|
||||
s2 := publicSession(t, vt, "", "198.51.100.10")
|
||||
if ok, out := call(t, s2, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok {
|
||||
t.Fatalf("other client limited: %s", 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicStampBatchWithKey(t *testing.T) {
|
||||
vt := newFakeVT(t)
|
||||
s := publicSession(t, vt, "good-key", "198.51.100.9")
|
||||
ok, out := call(t, s, "stamp_batch", map[string]any{"sha256s": []string{dig, strings.Repeat("b", 64)}, "scope": "global"})
|
||||
if !ok || !strings.Contains(out, `"total_credits":20`) || !strings.Contains(out, `"balance_credits":480`) || !strings.Contains(out, `"index":1`) {
|
||||
t.Fatalf("stamp_batch: %v %s", ok, out)
|
||||
}
|
||||
if vt.batches.Load() != 1 || vt.hostUse.Load() != 0 {
|
||||
t.Fatalf("batches=%d host=%d", vt.batches.Load(), vt.hostUse.Load())
|
||||
}
|
||||
// 402 from VeraeTime/zap comes back as a clear message
|
||||
ok, out = call(t, s, "stamp_batch", map[string]any{"sha256s": []string{strings.Repeat("c", 64)}, "scope": "global"})
|
||||
if ok || !strings.Contains(out, "Insufficient prepaid balance") {
|
||||
t.Fatalf("stamp_batch 402: %v %s", ok, out)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ func New(opts Options) (*Server, error) {
|
|||
RequireBearer: opts.RequireBearer,
|
||||
}
|
||||
if opts.Public {
|
||||
deps.Public, deps.Checker, deps.Limiter = true, global, tools.NewLimiter()
|
||||
deps.Public, deps.Checker = true, global
|
||||
}
|
||||
mcpServer := tools.NewServer(deps)
|
||||
// Behind Caddy (or any reverse proxy) the bind is loopback while Host is
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ type Deps struct {
|
|||
Global *veraetime.Client
|
||||
Keeper *veraetime.Keeper
|
||||
RequireBearer bool
|
||||
// Public: the public HTTP server (DEC-654): writes need the caller's own credential, anonymous reads are limited.
|
||||
// Public: the public HTTP server (DEC-654): writes need the caller's own credential; anonymous reads are
|
||||
// never limited (DEC-660).
|
||||
Public bool
|
||||
Checker CredentialChecker
|
||||
Limiter *Limiter
|
||||
}
|
||||
|
||||
func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||
|
|
@ -74,6 +74,62 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
|
|||
}
|
||||
}
|
||||
|
||||
// stampBatch stamps up to 500 digests in one call (DEC-659): global items are charged per new digest to the
|
||||
// caller's own Verae account (own repeats free; the balance is checked for the whole batch first).
|
||||
func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in StampBatchArgs) (*mcp.CallToolResult, StampBatchOut, error) {
|
||||
if len(in.SHA256s) == 0 || len(in.SHA256s) > 500 {
|
||||
return nil, StampBatchOut{}, fmt.Errorf("sha256s must hold 1..500 digests")
|
||||
}
|
||||
scope := strings.TrimSpace(in.Scope)
|
||||
if scope == "" {
|
||||
scope = "global"
|
||||
}
|
||||
if d.Public {
|
||||
if _, err := d.writeBearer(ctx, req); err != nil {
|
||||
return nil, StampBatchOut{}, err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
br, err := d.Global.StampBatch(ctx, in.SHA256s, tok)
|
||||
if err != nil {
|
||||
return nil, StampBatchOut{}, err
|
||||
}
|
||||
out := StampBatchOut{Count: br.Count, Created: br.Created, Existing: br.Existing, Failed: br.Failed, TotalCredits: br.TotalCredits, BalanceCredits: br.BalanceCredits}
|
||||
for _, it := range br.Results {
|
||||
out.Items = append(out.Items, BatchItemDTO{Index: it.Index, Hash: it.Digest, JobID: it.JobID, Status: it.Status, Existing: it.Existing, ReceiptID: it.Certificate, Credits: it.Credits, Error: it.Error})
|
||||
}
|
||||
return nil, out, nil
|
||||
case "local", "shared":
|
||||
if d.Ledger == nil {
|
||||
return nil, StampBatchOut{}, fmt.Errorf("ledger not configured")
|
||||
}
|
||||
out := StampBatchOut{Count: len(in.SHA256s)}
|
||||
for i, h := range in.SHA256s {
|
||||
recs, err := d.Ledger.Stamp(h, scope, "", "")
|
||||
it := BatchItemDTO{Index: i, Hash: h, Status: "submitted"}
|
||||
if err != nil {
|
||||
it.Status, it.Error = "failed", err.Error()
|
||||
out.Failed++
|
||||
} else if len(recs) > 0 {
|
||||
it.ReceiptID = recs[len(recs)-1].ReceiptID
|
||||
out.Created++
|
||||
}
|
||||
out.Items = append(out.Items, it)
|
||||
}
|
||||
return nil, out, nil
|
||||
default:
|
||||
return nil, StampBatchOut{}, fmt.Errorf("scope must be global, local, or shared")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Deps) verify(ctx context.Context, req *mcp.CallToolRequest, in VerifyArgs) (*mcp.CallToolResult, VerifyOut, error) {
|
||||
if err := assertDigestArgs(req); err != nil {
|
||||
return nil, VerifyOut{}, err
|
||||
|
|
|
|||
|
|
@ -3,15 +3,14 @@ package tools
|
|||
// DEC-654 (D): on the public HTTP server (mcp.verae.ai) every write needs the caller's own credential - a Verae API
|
||||
// key or a VeraeTime login/OAuth bearer, checked at api.veraetime.net - and never falls back to this host's stored
|
||||
// service login. Writes: stamp (any scope; global stamps are billed to the key's customer), signup, create_user,
|
||||
// session; login (it would replace the host credential) is refused. Read-only verify and hash_report stay
|
||||
// anonymous for the public pages (receipt links, explorer); only those use the host login, rate limited per client.
|
||||
// session, stamp_batch; login (it would replace the host credential) is refused. Read-only verify and hash_report
|
||||
// stay anonymous for the public pages (receipt links, explorer); only those use the host login, and they are never
|
||||
// rate limited (DEC-660, George 2026-10-10: "never limit").
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -27,9 +26,6 @@ var ErrWriteNeedsKey = errors.New("this tool needs your own Verae credential: se
|
|||
var ErrLoginDisabled = errors.New("login is not available on the public server; sign in at https://zap.verae.ai/portal/ " +
|
||||
"(or `verae login`) and send the Verae API key as a bearer token")
|
||||
|
||||
// ErrRateLimited is returned when anonymous reads exceed the per-client budget.
|
||||
var ErrRateLimited = errors.New("too many anonymous lookups; retry in a minute or send a Verae API key")
|
||||
|
||||
// CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential).
|
||||
type CredentialChecker interface {
|
||||
CheckCredential(ctx context.Context, bearer string) error
|
||||
|
|
@ -50,13 +46,8 @@ func (d *Deps) writeBearer(ctx context.Context, req *mcp.CallToolRequest) (strin
|
|||
return tok, nil
|
||||
}
|
||||
|
||||
// readBearer is the caller's bearer, or (anonymous, rate limited) the host login for read-only lookups.
|
||||
// readBearer is the caller's bearer, or (anonymous, never limited - DEC-660) the host login for read-only lookups.
|
||||
func (d *Deps) readBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||
if d.Public && BearerFromRequest(req) == "" {
|
||||
if d.Limiter != nil && !d.Limiter.Allow(clientKey(req)) {
|
||||
return "", ErrRateLimited
|
||||
}
|
||||
}
|
||||
return d.resolveBearer(ctx, req)
|
||||
}
|
||||
|
||||
|
|
@ -100,75 +91,3 @@ func (d *Deps) checkCred(ctx context.Context, tok string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientKey is the client address Caddy put last in X-Forwarded-For (Caddy overwrites untrusted values).
|
||||
func clientKey(req *mcp.CallToolRequest) string {
|
||||
if req == nil || req.Extra == nil || req.Extra.Header == nil {
|
||||
return "unknown"
|
||||
}
|
||||
hd := http.Header(req.Extra.Header)
|
||||
if xff := hd.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
if ip := strings.TrimSpace(hd.Get("X-Real-IP")); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Limiter is a per-client token bucket plus a global cap for anonymous reads.
|
||||
type Limiter struct {
|
||||
PerMinute float64 // per client
|
||||
Burst float64
|
||||
GlobalPerMin float64
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
global bucket
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// NewLimiter: 30/min per client (burst 15), 600/min overall.
|
||||
func NewLimiter() *Limiter {
|
||||
return &Limiter{PerMinute: 30, Burst: 15, GlobalPerMin: 600, buckets: map[string]*bucket{}, now: time.Now}
|
||||
}
|
||||
|
||||
func (l *Limiter) take(b *bucket, perMin, burst float64, now time.Time) bool {
|
||||
if b.last.IsZero() {
|
||||
b.tokens, b.last = burst, now
|
||||
}
|
||||
b.tokens += now.Sub(b.last).Minutes() * perMin
|
||||
if b.tokens > burst {
|
||||
b.tokens = burst
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// Allow reports whether client may make one more anonymous read now.
|
||||
func (l *Limiter) Allow(client string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
if len(l.buckets) > 50000 {
|
||||
l.buckets = map[string]*bucket{}
|
||||
}
|
||||
b := l.buckets[client]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
l.buckets[client] = b
|
||||
}
|
||||
if !l.take(b, l.PerMinute, l.Burst, now) {
|
||||
return false
|
||||
}
|
||||
return l.take(&l.global, l.GlobalPerMin, l.GlobalPerMin, now)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func NewServer(d *Deps) *mcp.Server {
|
|||
return s
|
||||
}
|
||||
|
||||
// Register adds stamp, verify, hash_report, signup, login, session, create_user.
|
||||
// Register adds stamp, stamp_batch, verify, hash_report, signup, login, session, create_user.
|
||||
func Register(s *mcp.Server, d *Deps) {
|
||||
if d == nil {
|
||||
d = &Deps{}
|
||||
|
|
@ -29,6 +29,10 @@ func Register(s *mcp.Server, d *Deps) {
|
|||
Name: "stamp",
|
||||
Description: "Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID.",
|
||||
}, 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).",
|
||||
|
|
|
|||
|
|
@ -82,3 +82,32 @@ type CreateUserOut struct {
|
|||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// StampBatchArgs is the stamp_batch tool input (DEC-659).
|
||||
type StampBatchArgs struct {
|
||||
SHA256s []string `json:"sha256s" jsonschema:"SHA-256 digests (64 hex each), 1..500"`
|
||||
Scope string `json:"scope,omitempty" jsonschema:"global (default; charged per new digest to your Verae account) or local"`
|
||||
}
|
||||
|
||||
// BatchItemDTO is one item of a stamp_batch answer.
|
||||
type BatchItemDTO struct {
|
||||
Index int `json:"index"`
|
||||
Hash string `json:"hash"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Existing bool `json:"existing"`
|
||||
ReceiptID string `json:"receipt_id,omitempty"`
|
||||
Credits int `json:"credits"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StampBatchOut is the stamp_batch result: one receipt entry per input digest, in order.
|
||||
type StampBatchOut struct {
|
||||
Count int `json:"count"`
|
||||
Created int `json:"created"`
|
||||
Existing int `json:"existing"`
|
||||
Failed int `json:"failed"`
|
||||
TotalCredits int `json:"total_credits"`
|
||||
BalanceCredits *int `json:"balance_credits,omitempty"`
|
||||
Items []BatchItemDTO `json:"items"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -609,8 +609,62 @@ func authErr(status int, body map[string]any) error {
|
|||
|
||||
func httpErr(op string, status int, body map[string]any) error {
|
||||
detail := str(body, "error")
|
||||
if status == http.StatusPaymentRequired && detail != "" {
|
||||
return fmt.Errorf("%s", strings.TrimPrefix(detail, "payment required: ")) // DEC-658: the clear 402 message as is
|
||||
}
|
||||
if detail == "" {
|
||||
return fmt.Errorf("veraetime: %s HTTP %d", op, status)
|
||||
}
|
||||
return fmt.Errorf("veraetime: %s HTTP %d: %s", op, status, detail)
|
||||
}
|
||||
|
||||
// BatchItem is one item of a VeraeTime batch answer (DEC-659).
|
||||
type BatchItem struct {
|
||||
Index int `json:"index"`
|
||||
Digest string `json:"digest"`
|
||||
JobID string `json:"jobId"`
|
||||
Status string `json:"status"`
|
||||
Existing bool `json:"existing"`
|
||||
Certificate string `json:"certificate"`
|
||||
Credits int `json:"credits"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// BatchResult is VeraeTime's per-item answer to POST /api/batch/timestamp with a Verae API key.
|
||||
type BatchResult struct {
|
||||
Count int `json:"count"`
|
||||
Created int `json:"created"`
|
||||
Existing int `json:"existing"`
|
||||
Failed int `json:"failed"`
|
||||
TotalCredits int `json:"totalCredits"`
|
||||
BalanceCredits *int `json:"balanceCredits"`
|
||||
Results []BatchItem `json:"results"`
|
||||
}
|
||||
|
||||
// StampBatch posts SHA-256 digests (restamp=false) in one batch; each new digest is charged to the key's customer.
|
||||
func (c *Client) StampBatch(ctx context.Context, digests []string, bearer string) (*BatchResult, error) {
|
||||
items := make([]map[string]any, 0, len(digests))
|
||||
for i, d := range digests {
|
||||
h, err := digest.NormalizeSHA256(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sha256s[%d]: %w", i, err)
|
||||
}
|
||||
items = append(items, map[string]any{"data": h, "digest": h, "hashAlg": "SHA256", "restamp": false})
|
||||
}
|
||||
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/batch/timestamp", map[string]any{"items": items}, bearer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
return nil, authErr(status, body)
|
||||
}
|
||||
if status >= 300 {
|
||||
return nil, httpErr("batch timestamp", status, body)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
var out BatchResult
|
||||
if err := json.Unmarshal(raw, &out); err != nil || len(out.Results) != len(digests) {
|
||||
return nil, fmt.Errorf("veraetime: batch answer has %d results for %d digests", len(out.Results), len(digests))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue