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
|
stamps atomic.Int32
|
||||||
hostUse atomic.Int32
|
hostUse atomic.Int32
|
||||||
users atomic.Int32
|
users atomic.Int32
|
||||||
|
batches atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeVT(t *testing.T) *fakeVT {
|
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":
|
case r.URL.Path == "/api/status/mcp-credential-check":
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
json.NewEncoder(w).Encode(map[string]any{"error": "job not found"})
|
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":
|
case r.URL.Path == "/api/timestamp":
|
||||||
f.stamps.Add(1)
|
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"})
|
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)
|
vt := newFakeVT(t)
|
||||||
s := publicSession(t, vt, "", "198.51.100.9")
|
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 {
|
// DEC-660: well past the old 30/min (burst 15) per client and in one burst: every anonymous read answers
|
||||||
t.Fatalf("anonymous verify: %s", out)
|
for i := 0; i < 200; i++ {
|
||||||
}
|
tool, args := "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}
|
||||||
if ok, out := call(t, s, "hash_report", map[string]any{"sha256": dig, "want_scope": "global"}); !ok {
|
if i%2 == 1 {
|
||||||
t.Fatalf("anonymous hash_report: %s", out)
|
tool, args = "hash_report", map[string]any{"sha256": dig, "want_scope": "global"}
|
||||||
}
|
}
|
||||||
limited := false
|
if ok, out := call(t, s, tool, args); !ok {
|
||||||
for i := 0; i < 40; i++ {
|
t.Fatalf("anonymous %s #%d refused: %s", tool, i, out)
|
||||||
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 !limited {
|
// the write lockdown stays: an anonymous stamp or stamp_batch is still refused
|
||||||
t.Fatal("anonymous reads were not rate limited")
|
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
|
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") {
|
||||||
s2 := publicSession(t, vt, "", "198.51.100.10")
|
t.Fatalf("anonymous stamp_batch: %v %s", ok, out)
|
||||||
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 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,
|
RequireBearer: opts.RequireBearer,
|
||||||
}
|
}
|
||||||
if opts.Public {
|
if opts.Public {
|
||||||
deps.Public, deps.Checker, deps.Limiter = true, global, tools.NewLimiter()
|
deps.Public, deps.Checker = true, global
|
||||||
}
|
}
|
||||||
mcpServer := tools.NewServer(deps)
|
mcpServer := tools.NewServer(deps)
|
||||||
// Behind Caddy (or any reverse proxy) the bind is loopback while Host is
|
// Behind Caddy (or any reverse proxy) the bind is loopback while Host is
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ type Deps struct {
|
||||||
Global *veraetime.Client
|
Global *veraetime.Client
|
||||||
Keeper *veraetime.Keeper
|
Keeper *veraetime.Keeper
|
||||||
RequireBearer bool
|
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
|
Public bool
|
||||||
Checker CredentialChecker
|
Checker CredentialChecker
|
||||||
Limiter *Limiter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
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) {
|
func (d *Deps) verify(ctx context.Context, req *mcp.CallToolRequest, in VerifyArgs) (*mcp.CallToolResult, VerifyOut, error) {
|
||||||
if err := assertDigestArgs(req); err != nil {
|
if err := assertDigestArgs(req); err != nil {
|
||||||
return nil, VerifyOut{}, err
|
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
|
// 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
|
// 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,
|
// 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
|
// session, stamp_batch; login (it would replace the host credential) is refused. Read-only verify and hash_report
|
||||||
// anonymous for the public pages (receipt links, explorer); only those use the host login, rate limited per client.
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/ " +
|
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")
|
"(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).
|
// CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential).
|
||||||
type CredentialChecker interface {
|
type CredentialChecker interface {
|
||||||
CheckCredential(ctx context.Context, bearer string) error
|
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
|
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) {
|
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)
|
return d.resolveBearer(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,75 +91,3 @@ func (d *Deps) checkCred(ctx context.Context, tok string) error {
|
||||||
}
|
}
|
||||||
return nil
|
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
|
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) {
|
func Register(s *mcp.Server, d *Deps) {
|
||||||
if d == nil {
|
if d == nil {
|
||||||
d = &Deps{}
|
d = &Deps{}
|
||||||
|
|
@ -29,6 +29,10 @@ func Register(s *mcp.Server, d *Deps) {
|
||||||
Name: "stamp",
|
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.",
|
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)
|
}, 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{
|
mcp.AddTool(s, &mcp.Tool{
|
||||||
Name: "verify",
|
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).",
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,32 @@ type CreateUserOut struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
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 {
|
func httpErr(op string, status int, body map[string]any) error {
|
||||||
detail := str(body, "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 == "" {
|
if detail == "" {
|
||||||
return fmt.Errorf("veraetime: %s HTTP %d", op, status)
|
return fmt.Errorf("veraetime: %s HTTP %d", op, status)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("veraetime: %s HTTP %d: %s", op, status, detail)
|
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