feat: single-billing edge path for MCP + open public signup
Some checks failed
test-and-deploy / test (push) Successful in 14s
test-and-deploy / deploy-138 (push) Failing after 15m9s

- Public signup no longer requires a caller bearer (POST /auth/signup)
- create_user remains admin-only (POST /auth/users)
- Optional VERAE_MCP_EDGE_URL bills global stamps via zap.verae.ai
- Clearer public auth errors and instructions for Grok/clients
This commit is contained in:
George Lambert 2026-10-12 14:59:31 -04:00
commit d49c51c349
8 changed files with 236 additions and 25 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
bin/ bin/
*.exe *.exe
.venv/ .venv/
Verae-Secret-Credentials.MD

View file

@ -10,6 +10,7 @@ import (
"os/signal" "os/signal"
"syscall" "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/embednats"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server" "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)) global := veraetime.NewClient(cfg.GlobalURL, veraetime.WithAuthPath(cfg.AuthPath))
keeper := veraetime.NewKeeper(global, 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{ emb, err := embednats.Start(embednats.Options{
Listen: cfg.NATSListen, Listen: cfg.NATSListen,
@ -54,6 +59,7 @@ func main() {
Ledger: led, Ledger: led,
Global: global, Global: global,
Keeper: keeper, Keeper: keeper,
Edge: edge,
AuthPath: cfg.AuthPath, AuthPath: cfg.AuthPath,
GlobalURL: cfg.GlobalURL, GlobalURL: cfg.GlobalURL,
RequireBearer: cfg.RequireBearer, RequireBearer: cfg.RequireBearer,
@ -82,6 +88,7 @@ func main() {
Ledger: led, Ledger: led,
Global: global, Global: global,
Keeper: keeper, Keeper: keeper,
Edge: edge,
RequireBearer: cfg.RequireBearer, RequireBearer: cfg.RequireBearer,
}) })
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

158
internal/edgebill/client.go Normal file
View file

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

View file

@ -37,6 +37,10 @@ func newFakeVT(t *testing.T) *fakeVT {
switch { switch {
case strings.HasPrefix(r.URL.Path, "/auth/login"): case strings.HasPrefix(r.URL.Path, "/auth/login"):
json.NewEncoder(w).Encode(map[string]any{"token": "host-jwt", "username": "svc", "role": "admin", "expires_at": "2099-01-01T00:00:00Z"}) json.NewEncoder(w).Encode(map[string]any{"token": "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: case !ok:
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"}) 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{}}) json.NewEncoder(w).Encode(map[string]any{"receipts": []any{}})
case r.URL.Path == "/auth/validate": case r.URL.Path == "/auth/validate":
json.NewEncoder(w).Encode(map[string]any{"valid": true, "username": "who", "role": "user"}) 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) f.users.Add(1)
json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"}) json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"})
default: default:
@ -139,20 +143,26 @@ func TestPublicAnonymousWritesRefusedNoHostFallback(t *testing.T) {
{"stamp", map[string]any{"sha256": dig, "scope": "global"}}, {"stamp", map[string]any{"sha256": dig, "scope": "global"}},
{"stamp", map[string]any{"sha256": dig}}, // local ledger on the server is a write too {"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"}}, {"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"}}, {"login", map[string]any{"username": "admin", "password": "x"}},
} { } {
if ok, out := call(t, s, c.tool, c.args); ok { if ok, out := call(t, s, c.tool, c.args); ok {
t.Fatalf("%s anonymous succeeded: %s", c.tool, out) 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{}) 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. // Anonymous session is a tool error (not a silent valid:false) so agents see how to attach Bearer.
if ok || strings.Contains(out, `"valid":true`) || !strings.Contains(out, "needs your own Verae credential") { if ok || strings.Contains(out, `"valid":true`) || !strings.Contains(out, "needs your own Verae credential") {
t.Fatalf("anonymous session must error with credential guidance: %v %s", ok, out) 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 { if vt.stamps.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()) t.Fatalf("upstream stamps or host-token use: stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load())
} }
} }

View file

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"strings" "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/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/tools" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/tools"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
@ -18,6 +19,7 @@ type Options struct {
Ledger *ledger.FileLedger Ledger *ledger.FileLedger
Global *veraetime.Client Global *veraetime.Client
Keeper *veraetime.Keeper Keeper *veraetime.Keeper
Edge *edgebill.Client
AuthPath string AuthPath string
GlobalURL string GlobalURL string
RequireBearer bool RequireBearer bool
@ -62,10 +64,15 @@ func New(opts Options) (*Server, error) {
} }
} }
edge := opts.Edge
if edge == nil {
edge = edgebill.FromEnv()
}
deps := &tools.Deps{ deps := &tools.Deps{
Ledger: led, Ledger: led,
Global: global, Global: global,
Keeper: keeper, Keeper: keeper,
Edge: edge,
RequireBearer: opts.RequireBearer, RequireBearer: opts.RequireBearer,
} }
if opts.Public { if opts.Public {

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx" "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/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
@ -21,6 +22,8 @@ type Deps struct {
// never limited (DEC-660). // never limited (DEC-660).
Public bool Public bool
Checker CredentialChecker 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) { 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 { switch scope {
case "global": case "global":
if d.Global == nil {
return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured")
}
tok, err := d.writeBearer(ctx, req) tok, err := d.writeBearer(ctx, req)
if err != nil { if err != nil {
return nil, ReceiptsOut{}, err 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{ rec, err := d.Global.StampWithMeta(ctx, in.SHA256, tok, veraetime.StampMeta{
Metadata: in.Metadata, Metadata: in.Metadata,
Restamp: in.Restamp, Restamp: in.Restamp,
@ -265,9 +288,9 @@ func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUser
if d.Global == nil { if d.Global == nil {
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured") return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
} }
if _, err := d.writeBearer(ctx, req); d.Public && err != nil { // Public self-serve: POST /auth/signup needs no caller bearer (DEC-654 write lockdown
return nil, CreateUserOut{}, err // 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) res, err := d.Global.Signup(ctx, in.Username, in.Password)
if err != nil { if err != nil {
return nil, CreateUserOut{}, err return nil, CreateUserOut{}, err

View file

@ -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 // 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 // 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, // host's stored service login. Writes that need a caller bearer: stamp (any scope), create_user, session,
// create_user, session, stamp_batch; login (it would replace the host credential) is refused. Read-only verify and // stamp_batch. Public signup is open (POST /auth/signup, no bearer) so new customers can register, then stamp
// hash_report stay anonymous for the public pages (receipt links, explorer); only those use the host login, and they // with their own JWT. login is refused on the public server (it would replace the host credential).
// are never rate limited (DEC-660, George 2026-10-10: "never limit"). // 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: // 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 // bearer_token_file = "~/.verae/mcp-bearer" # raw JWT only, not auth.properties

View file

@ -3,11 +3,12 @@ package tools
import "github.com/modelcontextprotocol/go-sdk/mcp" import "github.com/modelcontextprotocol/go-sdk/mcp"
const instructions = "Digests-only timestamping. " + const instructions = "Digests-only timestamping. " +
"Public HTTP (mcp.verae.ai / mcp.veraetime.net): every write needs Authorization: Bearer " + "Public HTTP (mcp.verae.ai / mcp.veraetime.net): stamp writes need Authorization: Bearer " +
"<VeraeTime JWT or Verae API key> on each tools/call — no host-login fallback. " + "<xVerae- key or VeraeTime JWT> — 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), " + "When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai (same billing as PrintIt/Zapier). " +
"or run this binary with -transport stdio so the laptop ~/.verae/auth.properties host login is used. " + "New accounts: tool signup (POST /auth/signup) or portal createUser; admin create_user for operators. " +
"Do not paste tokens into chat. Stdio/trusted-local HTTP may use the host login when no caller bearer is sent." "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. // NewServer builds an MCP server with the full Verae timestamping tool set.
func NewServer(d *Deps) *mcp.Server { func NewServer(d *Deps) *mcp.Server {
@ -44,18 +45,22 @@ func Register(s *mcp.Server, d *Deps) {
}, d.hashReport) }, d.hashReport)
mcp.AddTool(s, &mcp.Tool{ mcp.AddTool(s, &mcp.Tool{
Name: "signup", Name: "signup",
Description: "Create a VeraeTime user account. Role is user. Does not replace the stored JWT.", 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) }, d.signup)
mcp.AddTool(s, &mcp.Tool{ mcp.AddTool(s, &mcp.Tool{
Name: "login", 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) }, d.login)
mcp.AddTool(s, &mcp.Tool{ mcp.AddTool(s, &mcp.Tool{
Name: "session", 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) }, d.session)
mcp.AddTool(s, &mcp.Tool{ mcp.AddTool(s, &mcp.Tool{
Name: "create_user", Name: "create_user",
Description: "Create a VeraeTime user. The stored JWT must belong to an admin.", 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) }, d.createUser)
} }