diff --git a/.gitignore b/.gitignore index 8ae75ba..f1c21fe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ bin/ *.exe .venv/ +Verae-Secret-Credentials.MD diff --git a/cmd/verae-mcp-timestamping-server/main.go b/cmd/verae-mcp-timestamping-server/main.go index 642d6bc..5a31d28 100644 --- a/cmd/verae-mcp-timestamping-server/main.go +++ b/cmd/verae-mcp-timestamping-server/main.go @@ -10,6 +10,7 @@ import ( "os/signal" "syscall" + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/edgebill" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/embednats" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server" @@ -32,6 +33,10 @@ func main() { } global := veraetime.NewClient(cfg.GlobalURL, veraetime.WithAuthPath(cfg.AuthPath)) keeper := veraetime.NewKeeper(global, cfg.AuthPath) + edge := edgebill.FromEnv() + if edge != nil { + log.Printf("MCP global stamps bill via edge %s (channel=%s)", edge.BaseURL, edge.Channel) + } emb, err := embednats.Start(embednats.Options{ Listen: cfg.NATSListen, @@ -54,6 +59,7 @@ func main() { Ledger: led, Global: global, Keeper: keeper, + Edge: edge, AuthPath: cfg.AuthPath, GlobalURL: cfg.GlobalURL, RequireBearer: cfg.RequireBearer, @@ -82,6 +88,7 @@ func main() { Ledger: led, Global: global, Keeper: keeper, + Edge: edge, RequireBearer: cfg.RequireBearer, }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/edgebill/client.go b/internal/edgebill/client.go new file mode 100644 index 0000000..c9d9e71 --- /dev/null +++ b/internal/edgebill/client.go @@ -0,0 +1,158 @@ +// Package edgebill stamps digests through the commercial edge (zap.verae.ai) +// so MCP global stamps share one meter with PrintIt / Zapier / Node-RED. +package edgebill + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// Client posts digests to the edge /v1/timestamp API. +type Client struct { + BaseURL string + HTTPClient *http.Client + Channel string +} + +// DefaultBaseURL is the public edge. +const DefaultBaseURL = "https://zap.verae.ai" + +// FromEnv builds a client when VERAE_MCP_EDGE_URL is set (non-empty). +// Empty env disables edge billing (legacy direct VeraeTime only). +func FromEnv() *Client { + u := strings.TrimRight(strings.TrimSpace(os.Getenv("VERAE_MCP_EDGE_URL")), "/") + if u == "" { + return nil + } + ch := strings.TrimSpace(os.Getenv("VERAE_MCP_BILLING_CHANNEL")) + if ch == "" { + ch = "mcp" + } + return &Client{ + BaseURL: u, + HTTPClient: &http.Client{Timeout: 90 * time.Second}, + Channel: ch, + } +} + +// Receipt is a minimal stamp result compatible with tools.ReceiptDTO mapping. +type Receipt struct { + ReceiptID string + Hash string + Scope string + CreatedAt string + Merkle string +} + +// Stamp posts sha256 to edge /v1/timestamp with the caller credential. +// bearer may be an xVerae- / key- API key or a VeraeTime JWT (sent as Bearer). +func (c *Client) Stamp(ctx context.Context, sha256, bearer string) (*Receipt, error) { + if c == nil || c.BaseURL == "" { + return nil, fmt.Errorf("edgebill: not configured") + } + h := strings.ToLower(strings.TrimSpace(sha256)) + h = strings.TrimPrefix(h, "sha256:") + payload, _ := json.Marshal(map[string]any{ + "sha256": h, + "digest": h, + "data": h, + "hashAlg": "SHA256", + "wait": true, + "restamp": false, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/timestamp", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-billing-channel", c.Channel) + req.Header.Set("User-Agent", "verae-mcp-timestamping/edgebill") + tok := strings.TrimSpace(bearer) + if tok != "" { + if strings.HasPrefix(tok, "xVerae-") || strings.HasPrefix(tok, "key-") || strings.HasPrefix(tok, "zmw_") { + req.Header.Set("x-api-key", tok) + } else { + req.Header.Set("Authorization", "Bearer "+tok) + // Edge also accepts Bearer xVerae- in some paths; keep x-api-key empty. + } + } + hc := c.HTTPClient + if hc == nil { + hc = http.DefaultClient + } + res, err := hc.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if res.StatusCode >= 300 { + return nil, fmt.Errorf("edgebill: stamp HTTP %d: %s", res.StatusCode, truncate(raw, 300)) + } + var body map[string]any + _ = json.Unmarshal(raw, &body) + cid := str(body, "certificate") + if cid == "" { + cid = str(body, "receipt_id") + } + if cid == "" { + if recs, ok := body["receipts"].([]any); ok && len(recs) > 0 { + if m, ok := recs[0].(map[string]any); ok { + cid = str(m, "certificate") + if cid == "" { + cid = str(m, "receipt_id") + } + } + } + } + if cid == "" { + return nil, fmt.Errorf("edgebill: response missing certificate: %s", truncate(raw, 200)) + } + return &Receipt{ + ReceiptID: cid, + Hash: h, + Scope: "global", + CreatedAt: firstNonEmpty(str(body, "completedAt"), str(body, "created_at"), str(body, "timestamp")), + Merkle: firstNonEmpty(str(body, "blockHash"), str(body, "merkle")), + }, nil +} + +func str(m map[string]any, k string) string { + if m == nil { + return "" + } + v, ok := m[k] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + default: + return fmt.Sprint(t) + } +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/internal/server/public_test.go b/internal/server/public_test.go index 17c52c4..268f751 100644 --- a/internal/server/public_test.go +++ b/internal/server/public_test.go @@ -37,6 +37,10 @@ func newFakeVT(t *testing.T) *fakeVT { switch { case strings.HasPrefix(r.URL.Path, "/auth/login"): json.NewEncoder(w).Encode(map[string]any{"token": "host-jwt", "username": "svc", "role": "admin", "expires_at": "2099-01-01T00:00:00Z"}) + case r.URL.Path == "/auth/signup": + // Self-serve signup is unauthenticated on VeraeTime. + f.users.Add(1) + json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"}) case !ok: w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"}) @@ -70,7 +74,7 @@ func newFakeVT(t *testing.T) *fakeVT { json.NewEncoder(w).Encode(map[string]any{"receipts": []any{}}) case r.URL.Path == "/auth/validate": json.NewEncoder(w).Encode(map[string]any{"valid": true, "username": "who", "role": "user"}) - case r.URL.Path == "/auth/users", r.URL.Path == "/auth/signup": + case r.URL.Path == "/auth/users": f.users.Add(1) json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"}) default: @@ -139,20 +143,26 @@ func TestPublicAnonymousWritesRefusedNoHostFallback(t *testing.T) { {"stamp", map[string]any{"sha256": dig, "scope": "global"}}, {"stamp", map[string]any{"sha256": dig}}, // local ledger on the server is a write too {"create_user", map[string]any{"username": "evil", "password": "pw123456", "role": "admin"}}, - {"signup", map[string]any{"username": "spam", "password": "pw123456"}}, {"login", map[string]any{"username": "admin", "password": "x"}}, } { if ok, out := call(t, s, c.tool, c.args); ok { t.Fatalf("%s anonymous succeeded: %s", c.tool, out) } } + // Public self-serve signup is allowed without a caller bearer (creates the account only). + if ok, out := call(t, s, "signup", map[string]any{"username": "spam", "password": "pw123456"}); !ok { + t.Fatalf("anonymous signup should succeed: %s", out) + } + if vt.users.Load() != 1 { + t.Fatalf("signup should hit /auth/signup once, users=%d", vt.users.Load()) + } ok, out := call(t, s, "session", map[string]any{}) // Anonymous session is a tool error (not a silent valid:false) so agents see how to attach Bearer. if ok || strings.Contains(out, `"valid":true`) || !strings.Contains(out, "needs your own Verae credential") { t.Fatalf("anonymous session must error with credential guidance: %v %s", ok, out) } - if vt.stamps.Load() != 0 || vt.users.Load() != 0 || vt.hostUse.Load() != 0 { - t.Fatalf("upstream writes or host-token use: stamps=%d users=%d host=%d", vt.stamps.Load(), vt.users.Load(), vt.hostUse.Load()) + if vt.stamps.Load() != 0 || vt.hostUse.Load() != 0 { + t.Fatalf("upstream stamps or host-token use: stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load()) } } diff --git a/internal/server/server.go b/internal/server/server.go index d311c9b..47a5a67 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/edgebill" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/tools" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" @@ -18,6 +19,7 @@ type Options struct { Ledger *ledger.FileLedger Global *veraetime.Client Keeper *veraetime.Keeper + Edge *edgebill.Client AuthPath string GlobalURL string RequireBearer bool @@ -62,10 +64,15 @@ func New(opts Options) (*Server, error) { } } + edge := opts.Edge + if edge == nil { + edge = edgebill.FromEnv() + } deps := &tools.Deps{ Ledger: led, Global: global, Keeper: keeper, + Edge: edge, RequireBearer: opts.RequireBearer, } if opts.Public { diff --git a/internal/tools/handlers.go b/internal/tools/handlers.go index a567f70..16ef37d 100644 --- a/internal/tools/handlers.go +++ b/internal/tools/handlers.go @@ -6,6 +6,7 @@ import ( "strings" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx" + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/edgebill" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -21,6 +22,8 @@ type Deps struct { // never limited (DEC-660). Public bool Checker CredentialChecker + // Edge bills global stamps through zap.verae.ai when set (VERAE_MCP_EDGE_URL). + Edge *edgebill.Client } func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) { @@ -42,13 +45,33 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs } switch scope { case "global": - if d.Global == nil { - return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured") - } tok, err := d.writeBearer(ctx, req) if err != nil { return nil, ReceiptsOut{}, err } + // Single commercial meter: when Edge is configured, bill via zap.verae.ai. + if d.Edge != nil { + if len(in.Metadata) > 0 || in.Restamp { + return nil, ReceiptsOut{}, fmt.Errorf("metadata/restamp on edge-billed stamps is not supported yet; omit metadata or unset VERAE_MCP_EDGE_URL") + } + er, err := d.Edge.Stamp(ctx, in.SHA256, tok) + if err != nil { + return nil, ReceiptsOut{}, err + } + dto := ReceiptDTO{ + ReceiptID: er.ReceiptID, + Hash: er.Hash, + Scope: "global", + CreatedAt: er.CreatedAt, + Merkle: er.Merkle, + CubeID: in.CubeID, + OrgID: in.OrgID, + } + return nil, ReceiptsOut{Receipts: []ReceiptDTO{dto}}, nil + } + 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, @@ -265,9 +288,9 @@ func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUser if d.Global == nil { return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured") } - if _, err := d.writeBearer(ctx, req); d.Public && 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. res, err := d.Global.Signup(ctx, in.Username, in.Password) if err != nil { return nil, CreateUserOut{}, err diff --git a/internal/tools/public.go b/internal/tools/public.go index e0f23dd..1f0f6e4 100644 --- a/internal/tools/public.go +++ b/internal/tools/public.go @@ -2,10 +2,10 @@ 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: stamp (any scope; global stamps are billed to the caller's account), signup, -// create_user, 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"). +// 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). // // AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call. Grok Build: // bearer_token_file = "~/.verae/mcp-bearer" # raw JWT only, not auth.properties diff --git a/internal/tools/register.go b/internal/tools/register.go index b5b6439..16d4c4f 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -3,11 +3,12 @@ package tools import "github.com/modelcontextprotocol/go-sdk/mcp" const instructions = "Digests-only timestamping. " + - "Public HTTP (mcp.verae.ai / mcp.veraetime.net): every write needs Authorization: Bearer " + - " on each tools/call — no host-login fallback. " + - "Grok Build: set bearer_token_file to a file that holds only the raw token (e.g. ~/.verae/mcp-bearer from access_token), " + - "or run this binary with -transport stdio so the laptop ~/.verae/auth.properties host login is used. " + - "Do not paste tokens into chat. Stdio/trusted-local HTTP may use the host login when no caller bearer is sent." + "Public HTTP (mcp.verae.ai / mcp.veraetime.net): stamp writes need Authorization: Bearer " + + " — no host-login fallback. " + + "When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai (same billing as PrintIt/Zapier). " + + "New accounts: tool signup (POST /auth/signup) or portal createUser; admin create_user for operators. " + + "login is disabled on the public HTTP server. " + + "Grok Build: bearer_token_file with raw xVerae- or JWT, or -transport stdio. Do not paste tokens into chat." // NewServer builds an MCP server with the full Verae timestamping tool set. func NewServer(d *Deps) *mcp.Server { @@ -43,19 +44,23 @@ func Register(s *mcp.Server, d *Deps) { Description: "JSON-array history of receipts for one SHA-256 (Carfax).", }, d.hashReport) mcp.AddTool(s, &mcp.Tool{ - Name: "signup", - Description: "Create a VeraeTime user account. Role is user. Does not replace the stored JWT.", + 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.", }, 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.", + Description: "Log in to api.veraetime.net. Stores the JWT on this host and does not return it. Disabled on the public HTTP server.", }, d.login) mcp.AddTool(s, &mcp.Tool{ Name: "session", - Description: "Validate the stored VeraeTime JWT. Returns valid, username, role, and expires_at.", + Description: "Validate the caller's VeraeTime JWT (Authorization: Bearer). Returns valid, username, role, and expires_at.", }, d.session) mcp.AddTool(s, &mcp.Tool{ - Name: "create_user", - Description: "Create a VeraeTime user. The stored JWT must belong to an admin.", + Name: "create_user", + Description: "Admin create a VeraeTime user (POST /auth/users / Swagger Auth/createUser). " + + "Requires Authorization: Bearer of an admin. Used by operators and edge provision; " + + "self-serve MCP clients should use signup.", }, d.createUser) }