Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
2eb9ec72c6 feat(mcp): oauth_start / oauth_complete for social login
Some checks failed
test-and-deploy / test (push) Failing after 10m46s
test-and-deploy / deploy-138 (push) Has been cancelled
Browser OAuth via customer.verae.ai portal (authentik at auth.verae.ai).
oauth_start returns authorize_url+state; oauth_complete polls until xVerae-
access_token. Open without bearer on public MCP; welcome/register copy updated.
2026-10-20 16:40:50 -04:00
3ec1f0516b feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Some checks failed
test-and-deploy / test (push) Failing after 12m55s
test-and-deploy / deploy-138 (push) Has been cancelled
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.
2026-10-20 16:02:20 -04:00
13d1c5851b fix(mcp): require xVerae- commercial key when EDGE_URL set; batch via edge 2026-10-17 00:26:50 -04:00
93c2548450 docs: Standard Readme update for production board pack
Some checks failed
test-and-deploy / test (push) Failing after 12m6s
test-and-deploy / deploy-138 (push) Has been cancelled
2026-10-12 19:24:32 -04:00
d49c51c349 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
2026-10-12 14:59:31 -04:00
369f9210bd fix(auth): clear public write errors and Grok client path
Some checks failed
test-and-deploy / test (push) Failing after 12m11s
test-and-deploy / deploy-138 (push) Has been cancelled
Public DEC-654 still requires a caller bearer; anonymous session now
returns that guidance as a tool error instead of silent valid:false.
Error text names JWT and API key, Grok bearer_token_file, and local
stdio. BearerFromRequest also accepts X-Api-Key / X-Verae-Key.
Document Grok stdio vs public URL; add scripts/sync-mcp-bearer.sh.
2026-10-12 14:09:22 -04:00
Cursor Agent
dc39095694
Add an ns1 test-and-deploy workflow for the Go MCP server.
All checks were successful
test-and-deploy / test (push) Successful in 13s
test-and-deploy / deploy-138 (push) Successful in 13s
scripts/deploy-138.sh tests with Go 1.26.9, swaps the live binary, and rolls back if port 8000 stops answering. Port 0 in the embedded NATS listener now picks an ephemeral port so the test can run while 4222 is already taken.
2026-10-11 05:52:17 +00:00
Cursor Agent
deb0e251de
Send global stamps as a verae-meta-git/1 history.
Optional metadata or restamp builds the same commit history as node-red. A call that omits both stays digest-only with restamp false. Local and shared stamps reject metadata.
2026-10-11 05:37:24 +00:00
35 changed files with 3490 additions and 205 deletions

View file

@ -0,0 +1,24 @@
name: test-and-deploy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: 138
steps:
- uses: actions/checkout@v4
- name: Unit tests
run: bash scripts/test-138.sh
deploy-138:
needs: test
if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: 138
steps:
- uses: actions/checkout@v4
- name: Deploy with automatic health rollback
run: bash scripts/deploy-138.sh

1
.gitignore vendored
View file

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

View file

@ -2,106 +2,46 @@
[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
Go MCP timestamping server for Verae. Digests only; sibling to the Python server.
Implements the same tools as https://mcp.verae.ai/mcp (`stamp`, `verify`, `hash_report`, auth tools) using the official MCP Go SDK. Designed for shadow deploy beside Python, then cutover.
AI assistants use MCP. Bulk app workers can import `pkg/tsnats` for core NATS request/reply on `verae.ts.*` (job-complete stays ephemeral; no JetStream for those events). Cutover steps: [`docs/cutover.md`](docs/cutover.md).
MCP server for digests-only timestamping (public HTTP and local stdio).
## Table of Contents
- [Background](#background)
- [Install](#install)
- [Usage](#usage)
- [Contributing](#contributing)
- [License](#license)
## Background
Public writes require caller Bearer (DEC-654). Optional VERAE_MCP_EDGE_URL bills via zap.verae.ai.
Account and auth are required for use. On connect, instructions tell the AI to welcome the user and call tool `welcome`, then `signup` / `login`.
Only `welcome`, `signup`, and `login` work without a bearer. `signup` / `login` return `access_token` (public HTTP never writes the host credential).
Grok: bearer_token_file or local stdio. Job-complete is core NATS only.
**Co-contributors:** Grok (xAI), George Lambert, Scott "R00tPiG Security" Lindsey — Verae Inc. ([verae.com](https://verae.com)).
## Install
```sh
cd verae-mcp-timestamping-server-go
go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-server
```
Requires Go 1.26+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`. Shadow systemd unit (not installed by this build): `deploy/verae-mcp-timestamping-go.service`.
## Usage
Shadow beside Python (`:8000` stays live):
```sh
VERAE_MCP_TRANSPORT=streamable-http \
VERAE_MCP_HOST=127.0.0.1 \
VERAE_MCP_PORT=8001 \
VERAE_NATS_LISTEN=127.0.0.1:4223 \
VERAE_MCP_REQUIRE_BEARER=0 \
./bin/verae-mcp-timestamping-server
./bin/verae-mcp-timestamping-server -transport http -addr :8000
# or -transport stdio for laptop host auth
```
Point MCP clients at `http://127.0.0.1:8001/mcp`. For stdio: `-transport stdio` or `VERAE_MCP_TRANSPORT=stdio`.
| Variable | Default | Notes |
| --- | --- | --- |
| `VERAE_MCP_TRANSPORT` | `streamable-http` | or `stdio` |
| `VERAE_MCP_HOST` | `127.0.0.1` | HTTP bind |
| `VERAE_MCP_PORT` | `8000` | shadow examples use `8001` |
| `VERAE_NATS_LISTEN` | `127.0.0.1:4223` | embedded NATS; set empty to disable |
| `VERAE_MCP_REQUIRE_BEARER` | `0` | `0` = host-token keeper when no caller bearer; `1` = require caller bearer |
| `VERAE_GLOBAL_URL` | `https://api.veraetime.net` | |
| `VERAE_AUTH_PROPERTIES` | `$HOME/.verae/auth.properties` | |
| `VERAE_GLOBAL_TOKEN` | empty | optional static host bearer |
| `VERAE_LEDGER_PATH` | `$HOME/.verae/mcp-timestamp-local.json` | |
| `VERAE_MCP_CORS` | off | set `1` for browser CORS |
Global stamps use VeraeTime with auth from `auth.properties` (or caller Bearer). Embedded NATS serves `verae.ts.*`; job-complete is core `verae.ts.completed` only.
Parity smoke when Python `:8000` and Go `:8001` are both up:
```sh
./scripts/parity_smoke.sh
```
### pkg/tsnats
Bulk workers talk to the embedded (or any) NATS URL. Digests only; `SubscribeCompleted` is core NATS, not JetStream.
```go
package main
import (
"context"
"log"
"os"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/tsnats"
)
func main() {
url := os.Getenv("NATS_URL")
if url == "" {
url = "nats://127.0.0.1:4223"
}
client, err := tsnats.Connect(url)
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
digest := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
receipts, err := client.Stamp(ctx, digest, tsnats.StampOptions{Scope: "global"})
if err != nil {
log.Fatal(err)
}
log.Printf("receipts: %+v", receipts)
}
```
Runnable sketch: `examples/tsnats-bulk/main.go`.
Optional `receipt_format` on `stamp` / `verify`: omit → text; `receipt` / `PDF-Receipt` → PDF; `JSON-Receipt` → JSON; `MD` / `MD-Receipt`; `CSV` / `CSV-Receipt`; `TSV` / `TSV-Receipt`. Stdio writes under `~/.verae/receipts`; public MCP returns `content_text` / `content_base64` only.
## Contributing
Questions: this research workspace / marchon@gmail.com. PRs coordinated in-workspace first. Protocol questions: https://github.com/modelcontextprotocol.
Questions: open an issue on the project Forgejo/GitHub remote, or contact Verae engineering.
PRs are accepted when they include tests and do not leak secrets.
## License
UNLICENSED © George Lambert
UNLICENSED © Verae Inc. (verae.com)

View file

@ -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)

2
go.mod
View file

@ -6,6 +6,8 @@ require (
github.com/modelcontextprotocol/go-sdk v1.8.0
github.com/nats-io/nats-server/v2 v2.15.0
github.com/nats-io/nats.go v1.51.0
github.com/phpdave11/gofpdf v1.4.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
)
require (

15
go.sum
View file

@ -1,5 +1,7 @@
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -8,6 +10,7 @@ github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
@ -24,14 +27,25 @@ github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/phpdave11/gofpdf v1.4.2 h1:KPKiIbfwbvC/wOncwhrpRdXVj2CZTCFlw4wnoyjtHfQ=
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@ -39,6 +53,7 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=

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 commercial edge (non-Zapier).
const DefaultBaseURL = "https://services.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

@ -119,5 +119,15 @@ func splitHostPort(listen string) (string, int, error) {
if host == "" {
host = "127.0.0.1"
}
// nats-server treats port 0 as its default (4222), which is already taken
// on ns1. Bind an ephemeral port and hand that number to the server.
if port == 0 {
ln, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
if err != nil {
return "", 0, fmt.Errorf("embednats: ephemeral listen: %w", err)
}
port = ln.Addr().(*net.TCPAddr).Port
ln.Close()
}
return host, port, nil
}

View file

@ -0,0 +1,341 @@
// Package metadatahist builds verae-meta-git/1 documents the same way as
// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete
// fields. A document that already uses the schema is stored as sent.
package metadatahist
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"time"
)
const Schema = "verae-meta-git/1"
func IsBlank(value any) bool {
if value == nil {
return true
}
switch v := value.(type) {
case string:
for _, r := range v {
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
return false
}
}
return true
case []any:
return len(v) == 0
case map[string]any:
return len(v) == 0
default:
return false
}
}
func isObject(value any) (map[string]any, bool) {
m, ok := value.(map[string]any)
return m, ok
}
func clone(value any) any {
switch v := value.(type) {
case []any:
out := make([]any, len(v))
for i := range v {
out[i] = clone(v[i])
}
return out
case map[string]any:
out := make(map[string]any, len(v))
for k, item := range v {
out[k] = clone(item)
}
return out
default:
return v
}
}
func jsonScalar(value any) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
_ = enc.Encode(value)
b := bytes.TrimRight(buf.Bytes(), "\n")
return string(b)
}
func canonical(value any) string {
switch v := value.(type) {
case []any:
parts := make([]string, len(v))
for i := range v {
parts[i] = canonical(v[i])
}
return "[" + join(parts) + "]"
case map[string]any:
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, len(keys))
for i, k := range keys {
parts[i] = jsonScalar(k) + ":" + canonical(v[k])
}
return "{" + join(parts) + "}"
default:
return jsonScalar(value)
}
}
func join(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += ","
}
out += p
}
return out
}
func same(a, b any) bool { return canonical(a) == canonical(b) }
func ExtendSnapshot(base, patch map[string]any) map[string]any {
out := map[string]any{}
if base != nil {
if cloned, ok := clone(base).(map[string]any); ok {
out = cloned
}
}
if patch == nil {
return out
}
for key, next := range patch {
if IsBlank(next) {
continue
}
prev, has := out[key]
prevObj, prevIsObj := isObject(prev)
nextObj, nextIsObj := isObject(next)
prevArr, prevIsArr := prev.([]any)
nextArr, nextIsArr := next.([]any)
switch {
case has && prevIsObj && nextIsObj:
out[key] = ExtendSnapshot(prevObj, nextObj)
case prevIsArr && nextIsArr:
extra := nonBlank(nextArr)
if len(extra) > 0 {
out[key] = append(append([]any{}, prevArr...), extra...)
}
case nextIsArr:
extra := nonBlank(nextArr)
if len(extra) == 0 {
continue
}
if prevIsArr {
out[key] = append(append([]any{}, prevArr...), extra...)
} else {
out[key] = extra
}
case nextIsObj:
nestedBase := map[string]any{}
if prevIsObj {
nestedBase = prevObj
}
nested := ExtendSnapshot(nestedBase, nextObj)
if !IsBlank(nested) {
out[key] = nested
}
default:
out[key] = clone(next)
}
}
return out
}
func nonBlank(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
if !IsBlank(item) {
out = append(out, clone(item))
}
}
return out
}
func ChangesBetween(before, after map[string]any) map[string]any {
added := map[string]any{}
extended := map[string]any{}
updated := map[string]any{}
if before == nil {
before = map[string]any{}
}
if after == nil {
after = map[string]any{}
}
keys := map[string]struct{}{}
for k := range before {
keys[k] = struct{}{}
}
for k := range after {
keys[k] = struct{}{}
}
ordered := make([]string, 0, len(keys))
for k := range keys {
ordered = append(ordered, k)
}
sort.Strings(ordered)
for _, key := range ordered {
next, hasNext := after[key]
if !hasNext {
continue
}
prev, hasPrev := before[key]
if !hasPrev {
added[key] = clone(next)
continue
}
if same(prev, next) {
continue
}
prevObj, prevIsObj := isObject(prev)
nextObj, nextIsObj := isObject(next)
prevArr, prevIsArr := prev.([]any)
nextArr, nextIsArr := next.([]any)
switch {
case prevIsObj && nextIsObj:
nested := ChangesBetween(prevObj, nextObj)
if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) {
extended[key] = nested
}
case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr):
appended := nextArr[len(prevArr):]
if len(appended) > 0 {
extended[key] = map[string]any{"appended": clone(appended)}
}
default:
updated[key] = map[string]any{"from": clone(prev), "to": clone(next)}
}
}
return map[string]any{"added": added, "extended": extended, "updated": updated}
}
func commitID(commit map[string]any) string {
material := canonical(map[string]any{
"parent": commit["parent"],
"at": commit["at"],
"message": commit["message"],
"snapshot": commit["snapshot"],
})
sum := sha256.Sum256([]byte(material))
return hex.EncodeToString(sum[:])
}
func IsHistory(value any) bool {
m, ok := isObject(value)
if !ok || m["schema"] != Schema {
return false
}
if _, ok := m["commits"].([]any); !ok {
return false
}
_, ok = isObject(m["snapshot"])
return ok
}
func historyFromSnapshot(snapshot map[string]any, at string) map[string]any {
stored, _ := clone(snapshot).(map[string]any)
commit := map[string]any{
"parent": nil,
"at": at,
"message": "stamp",
"snapshot": stored,
"changes": ChangesBetween(map[string]any{}, stored),
}
commit["id"] = commitID(commit)
return map[string]any{
"schema": Schema,
"head": commit["id"],
"snapshot": clone(stored),
"commits": []any{commit},
}
}
func coerceHistory(existing any, at string) map[string]any {
if IsHistory(existing) {
cloned, _ := clone(existing).(map[string]any)
return cloned
}
if m, ok := isObject(existing); ok && !IsBlank(m) {
return historyFromSnapshot(m, at)
}
return map[string]any{
"schema": Schema,
"head": nil,
"snapshot": map[string]any{},
"commits": []any{},
}
}
func hasChanges(changes map[string]any) bool {
return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"])
}
// Append builds the next history document. A nil result means the caller
// should omit metadata. restamp with a blank patch still appends one commit.
func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any {
if at == "" {
at = time.Now().UTC().Format(time.RFC3339Nano)
}
blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch))
if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) {
return nil
}
if priorAt == "" {
priorAt = at
}
doc := coerceHistory(existing, priorAt)
head, _ := doc["head"].(string)
if !restamp && blankPatch {
if head != "" {
return doc
}
return nil
}
base, _ := doc["snapshot"].(map[string]any)
snapshot := ExtendSnapshot(base, patch)
changes := ChangesBetween(base, snapshot)
if !restamp && !hasChanges(changes) && head != "" {
return doc
}
msg := message
if msg == "" {
if head != "" {
msg = "restamp"
} else {
msg = "stamp"
}
}
var parent any
if head != "" {
parent = head
}
commit := map[string]any{
"parent": parent,
"at": at,
"message": msg,
"snapshot": snapshot,
"changes": changes,
}
commit["id"] = commitID(commit)
commits, _ := doc["commits"].([]any)
doc["commits"] = append(commits, commit)
doc["head"] = commit["id"]
doc["snapshot"] = snapshot
return doc
}

View file

@ -0,0 +1,52 @@
package metadatahist
import "testing"
func TestAppendMatchesNodeReference(t *testing.T) {
first := Append(nil, map[string]any{"title": "deed", "tags": []any{"a"}}, false, "2026-10-11T01:00:00.000Z", "", "stamp")
if first["head"] != "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7" {
t.Fatalf("head1=%v", first["head"])
}
second := Append(first, map[string]any{"title": "deed-2", "tags": []any{"b"}, "place": "office"}, true, "2026-10-11T02:00:00.000Z", "", "correct the title")
if second["head"] != "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918" {
t.Fatalf("head2=%v", second["head"])
}
snap := second["snapshot"].(map[string]any)
if snap["title"] != "deed-2" || snap["place"] != "office" {
t.Fatalf("snapshot=%v", snap)
}
tags := snap["tags"].([]any)
if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
t.Fatalf("tags=%v", tags)
}
commits := second["commits"].([]any)
tip := commits[1].(map[string]any)
if tip["parent"] != first["head"] {
t.Fatalf("parent=%v", tip["parent"])
}
if _, ok := tip["changes"].(map[string]any)["removed"]; ok {
t.Fatal("removed key")
}
}
func TestBlankRestampKeepsSnapshot(t *testing.T) {
base := map[string]any{"title": "deed", "note": "keep", "tags": []any{"a"}}
next := Append(base, map[string]any{"title": "", "note": nil, "tags": []any{}}, true, "2026-10-11T01:47:00.000Z", "2026-10-11T01:00:00.000Z", "")
snap := next["snapshot"].(map[string]any)
if snap["title"] != "deed" || snap["note"] != "keep" {
t.Fatalf("snapshot=%v", snap)
}
commits := next["commits"].([]any)
if len(commits) != 2 {
t.Fatalf("commits=%d", len(commits))
}
}
func TestOmitWhenEmpty(t *testing.T) {
if Append(nil, nil, false, "2026-10-11T01:00:00.000Z", "", "") != nil {
t.Fatal("expected omit")
}
if Append(nil, map[string]any{}, false, "2026-10-11T01:00:00.000Z", "", "") != nil {
t.Fatal("expected omit")
}
}

View file

@ -0,0 +1,312 @@
package pdfcert
import (
"bytes"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/statuslang"
"github.com/phpdave11/gofpdf"
qrcode "github.com/skip2/go-qrcode"
)
// brandTitleFamily is the gofpdf family name when a brand TTF/OTF registers.
const brandTitleFamily = "VeraeBrand"
// Brand tokens (CERT-01 / BrandingBook): plum, gold, ink, muted, ok.
const (
plumR, plumG, plumB = 83, 27, 89 // #531B59
goldR, goldG, goldB = 196, 168, 90 // #C4A85A
goldDR, goldDG, goldDB = 110, 87, 23 // #6E5717
inkR, inkG, inkB = 29, 21, 52 // #1D1534
mutedR, mutedG, mutedB = 94, 84, 112 // #5E5470
okR, okG, okB = 14, 107, 58 // #0E6B3A
okBgR, okBgG, okBgB = 227, 245, 234 // #E3F5EA
okBdR, okBdG, okBdB = 185, 226, 200 // #B9E2C8
linkR, linkG, linkB = 59, 42, 140 // #3B2A8C info
)
// Minimal fallback when Node/jsPDF is unavailable.
//
// Fonts: PDF core faces by default (Times title, Helvetica body).
// When EmbedBrandFonts is on and BrandFontsDir has a .ttf/.otf, attempt
// gofpdf AddUTF8FontFromBytes for the title; on failure log and keep Times.
func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup string) ([]byte, error) {
pdf := gofpdf.New("P", "pt", "Letter", "")
pdf.SetCompression(false)
pdf.SetMargins(48, 48, 48)
pdf.SetAutoPageBreak(false, 0)
pdf.AddPage()
pageW, pageH := pdf.GetPageSize()
margin := 48.0
titleFamily := tryRegisterBrandTitleFont(pdf, in)
// CERT-01 double frame: plum outer, gold inner
pdf.SetDrawColor(plumR, plumG, plumB)
pdf.SetLineWidth(1.5)
pdf.Rect(28, 28, pageW-56, pageH-56, "D")
pdf.SetDrawColor(goldR, goldG, goldB)
pdf.SetLineWidth(0.5)
pdf.Rect(34, 34, pageW-68, pageH-68, "D")
if p, cleanup, err := materializeLogo(); err == nil {
pdf.ImageOptions(p, margin, 48, 120, 36, false, gofpdf.ImageOptions{ImageType: "PNG", ReadDpi: true}, 0, "")
if cleanup != nil {
defer cleanup()
}
}
// Gold-d eyebrow (BrandingBook §12) — Helvetica core font (not Fraunces)
pdf.SetTextColor(goldDR, goldDG, goldDB)
pdf.SetFont("Helvetica", "B", 9)
pdf.SetXY(margin, 92)
pdf.CellFormat(pageW-margin*2, 11, "TIMESTAMP CERTIFICATE", "", 0, "C", false, 0, "")
// Title — brand face when registered; else Times core serif stand-in
pdf.SetTextColor(inkR, inkG, inkB)
if titleFamily != "" {
pdf.SetFont(titleFamily, "B", 22)
} else {
pdf.SetFont("Times", "B", 22)
}
pdf.SetXY(margin, 106)
pdf.CellFormat(pageW-margin*2, 24, "Verae Timestamp Certificate", "", 0, "C", false, 0, "")
pdf.SetTextColor(mutedR, mutedG, mutedB)
pdf.SetFont("Helvetica", "", 10)
pdf.SetXY(margin, 132)
pdf.CellFormat(pageW-margin*2, 12, "Proof that this exact document existed at the time below,", "", 0, "C", false, 0, "")
pdf.SetXY(margin, 144)
pdf.CellFormat(pageW-margin*2, 12, "recorded on the "+statuslang.ChainName+" (digests only)", "", 0, "C", false, 0, "")
// Short centered gold rule (CERT-01)
ruleW := 120.0
pdf.SetDrawColor(goldR, goldG, goldB)
pdf.SetLineWidth(2)
pdf.Line(pageW/2-ruleW/2, 162, pageW/2+ruleW/2, 162)
// FINAL-only status banner (receipt-final / statuslang wording)
bannerY := 174.0
bannerH := 40.0
pdf.SetFillColor(okBgR, okBgG, okBgB)
pdf.SetDrawColor(okBdR, okBdG, okBdB)
pdf.SetLineWidth(1.5)
pdf.Rect(margin, bannerY, pageW-margin*2, bannerH, "FD")
pdf.SetTextColor(okR, okG, okB)
pdf.SetFont("Helvetica", "B", 12)
pdf.SetXY(margin+12, bannerY+8)
pdf.Cell(pageW-margin*2-24, 14, finalBannerTitle(merkle))
pdf.SetFont("Helvetica", "", 9)
pdf.SetXY(margin+12, bannerY+24)
pdf.Cell(pageW-margin*2-24, 12, statuslang.EvidenceNote)
if created == "" {
created = "—"
}
mh, b3, b3mh := ResolveDigests(in)
fields := [][2]string{
{"Document", docName},
}
if in.ByteLength > 0 {
fields = append(fields, [2]string{"Document size", fmt.Sprintf("%d bytes", in.ByteLength)})
}
// CERT-01: digest lines only when data is available (never invent).
if mh != "" {
fields = append(fields, [2]string{"Content multihash", mh})
}
if strings.TrimSpace(in.SHA256) != "" {
fields = append(fields, [2]string{"SHA-256 (hex)", in.SHA256})
}
if b3mh != "" {
fields = append(fields, [2]string{"BLAKE3 multihash", b3mh})
}
if b3 != "" {
fields = append(fields, [2]string{"BLAKE3 (hex)", b3})
}
fields = append(fields,
[2]string{"Registration scope", scope},
[2]string{"Original timestamp (UTC)", created},
[2]string{"VeraeTime receipt (CID)", orDash(in.ReceiptID)},
[2]string{"Merkle / block", merkle},
[2]string{"Network", statuslang.ChainName + " (" + statuslang.NetworkName + ")"},
[2]string{"Blockchain explorer", statuslang.ExplorerURL},
[2]string{"Public lookup URL", lookup},
)
isHTTP := func(s string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "http://") ||
strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "https://")
}
y := bannerY + bannerH + 18
for _, fv := range fields {
pdf.SetFont("Helvetica", "B", 9)
pdf.SetTextColor(mutedR, mutedG, mutedB)
pdf.SetXY(margin, y)
pdf.Cell(pageW-margin*2, 11, fv[0])
y += 12
pdf.SetFont("Helvetica", "", 10)
linkURL := ""
if isHTTP(fv[1]) {
linkURL = strings.TrimSpace(fv[1])
pdf.SetTextColor(linkR, linkG, linkB)
} else {
pdf.SetTextColor(inkR, inkG, inkB)
}
for _, ln := range pdf.SplitLines([]byte(fv[1]), pageW-margin*2-10) {
pdf.SetXY(margin, y)
if linkURL != "" {
pdf.CellFormat(pageW-margin*2-10, 12, string(ln), "", 0, "L", false, 0, linkURL)
} else {
pdf.Cell(pageW-margin*2-10, 12, string(ln))
}
y += 12
}
y += 8
if y > pageH-220 {
break
}
}
if qrPNG, err := qrcode.Encode(lookup, qrcode.Medium, 280); err == nil {
if qrFile, qclean, qerr := writeTempPNG(qrPNG); qerr == nil {
qrSize := 110.0
qrX := pageW - margin - qrSize
qrY := pageH - margin - qrSize - 48
pdf.ImageOptions(qrFile, qrX, qrY, qrSize, qrSize, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "")
pdf.LinkString(qrX, qrY, qrSize, qrSize, lookup)
pdf.SetFont("Helvetica", "B", 8)
pdf.SetTextColor(linkR, linkG, linkB)
pdf.SetXY(qrX, qrY+qrSize+4)
pdf.CellFormat(qrSize, 10, "Scan to verify", "", 0, "C", false, 0, lookup)
if qclean != nil {
defer qclean()
}
}
}
// Final-only legal + digests-only (CERT-01 footer / BrandingBook)
pdf.SetFont("Helvetica", "", 8)
pdf.SetTextColor(mutedR, mutedG, mutedB)
pdf.SetXY(margin, pageH-72)
pdf.MultiCell(pageW-margin*2-130, 10, "Only the document's SHA-256 digest was submitted; the file was not uploaded. This certificate is final receipt evidence for the digest above. A jobId or pending hold is not proof. Anyone can verify this receipt for free — no account needed.", "", "L", false)
pdf.SetFont("Helvetica", "B", 9)
pdf.SetTextColor(plumR, plumG, plumB)
pdf.SetXY(margin, pageH-36)
pdf.Cell(55, 12, "Verae LLC · ")
pdf.SetTextColor(linkR, linkG, linkB)
pdf.CellFormat(40, 12, "verae.ai", "", 0, "L", false, 0, "https://verae.ai")
pdf.SetTextColor(plumR, plumG, plumB)
pdf.Cell(12, 12, " · ")
pdf.SetTextColor(linkR, linkG, linkB)
pdf.CellFormat(90, 12, "explorer.verae.ai", "", 0, "L", false, 0, "https://explorer.verae.ai/")
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// tryRegisterBrandTitleFont attempts gofpdf UTF-8 embed of a licensed brand
// serif when EmbedBrandFonts is on and BrandFontsDir has .ttf/.otf.
// Returns brandTitleFamily on success, "" on skip/failure (caller keeps Times).
// Never errors the PDF build — registration failure is a logged no-op.
func tryRegisterBrandTitleFont(pdf *gofpdf.Fpdf, in Input) string {
if pdf == nil || !EmbedBrandFontsEnabled(in) {
return ""
}
dir := BrandFontsDir()
face := findBrandFaceFile(dir)
if face == "" {
log.Printf("pdfcert: EmbedBrandFonts on but no .ttf/.otf in %q — keeping Times", dir)
return ""
}
ext := strings.ToLower(filepath.Ext(face))
// gofpdf AddUTF8FontFromBytes parses TrueType tables; OTF-CFF often fails.
b, err := os.ReadFile(face)
if err != nil {
log.Printf("pdfcert: brand font read %s: %v — keeping Times", face, err)
return ""
}
pdf.AddUTF8FontFromBytes(brandTitleFamily, "B", b)
if err := pdf.Error(); err != nil {
log.Printf("pdfcert: gofpdf could not register brand font %s (%s): %v — keeping Times", face, ext, err)
pdf.ClearError()
return ""
}
// Regular style alias so SetFont(family, "", …) would also work if needed.
pdf.AddUTF8FontFromBytes(brandTitleFamily, "", b)
if err := pdf.Error(); err != nil {
pdf.ClearError()
// Bold alone is enough for the title.
}
log.Printf("pdfcert: registered brand title font from %s", face)
return brandTitleFamily
}
// finalBannerTitle uses statuslang.Label("final", block) — BrandingBook chip text.
// Certificates are issued only for evidence states (IsEvidence); hold ≠ evidence.
func finalBannerTitle(merkle string) string {
s := strings.TrimSpace(merkle)
if n, err := strconv.Atoi(s); err == nil && n > 0 {
return statuslang.Label("final", n)
}
// Block unknown: BrandingBook #N placeholder (still final / evidence wording).
return statuslang.Label("final")
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
func materializeLogo() (path string, cleanup func(), err error) {
// Prefer tree-local assets, then walk for go.mod (packaging / tests).
var candidates []string
candidates = append(candidates, filepath.Join("assets", "VeraeFullLogo.png"))
if wd, e := os.Getwd(); e == nil {
dir := wd
for i := 0; i < 8; i++ {
candidates = append(candidates, filepath.Join(dir, "assets", "VeraeFullLogo.png"))
if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
for _, c := range candidates {
if st, e := os.Stat(c); e == nil && st.Size() > 0 {
return c, nil, nil
}
}
if len(embeddedLogo) == 0 {
return "", nil, fmt.Errorf("logo not found")
}
return writeTempPNG(embeddedLogo)
}
func writeTempPNG(b []byte) (path string, cleanup func(), err error) {
f, err := os.CreateTemp("", "verae-png-*.png")
if err != nil {
return "", nil, err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(f.Name())
return "", nil, err
}
f.Close()
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}

BIN
internal/pdfcert/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

330
internal/pdfcert/pdfcert.go Normal file
View file

@ -0,0 +1,330 @@
package pdfcert
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
//go:embed logo.png
var embeddedLogo []byte
// Input for the official Verae Timestamp Certificate (CERT-01).
type Input struct {
DocumentName string
SHA256 string
// Multihash is the content multihash (f1220 + sha2-256 hex) when known.
// Empty → derive from SHA256 if valid; else omit (never invent).
Multihash string
// Blake3 is the optional BLAKE3-256 hex digest when provided upstream.
// Empty → omit (never invent / never compute from file bytes here).
Blake3 string
// Blake3Multihash is the blake3 multihash (f1e20 + hex) when known.
// Empty → derive from Blake3 if valid 64-hex; else omit.
Blake3Multihash string
ReceiptID string
CreatedAt string
Merkle string
Scope string
VerifyURL string
Channel string
ByteLength int64
// EmbedBrandFonts requests PDF embed of a licensed brand serif when a
// .ttf/.otf is present under BrandFontsDir(). Default false: core
// Times/Helvetica only. Also via VERAE_EMBED_BRAND_FONTS=1|true|yes.
// gofpdf path attempts AddUTF8FontFromBytes; failure is a logged no-op.
EmbedBrandFonts bool
}
// EmbedBrandFontsEnabled reports whether brand-font PDF embed is requested.
// Default false. True only when Input.EmbedBrandFonts is set or env
// VERAE_EMBED_BRAND_FONTS is 1/true/yes. When enabled, gofpdf may register a
// .ttf/.otf from BrandFontsDir(); failure is a logged no-op (core fonts).
func EmbedBrandFontsEnabled(in Input) bool {
if in.EmbedBrandFonts {
return true
}
v := strings.TrimSpace(os.Getenv("VERAE_EMBED_BRAND_FONTS"))
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// BrandFontsDir is the directory scanned for .ttf/.otf when EmbedBrandFonts is on.
// Override with VERAE_BRAND_FONTS_DIR; else site/printit.verae.ai/fonts from the
// module tree. Empty when neither is available (embed no-ops with a log).
func BrandFontsDir() string {
if d := strings.TrimSpace(os.Getenv("VERAE_BRAND_FONTS_DIR")); d != "" {
if st, err := os.Stat(d); err == nil && st.IsDir() {
return d
}
}
var candidates []string
if wd, err := os.Getwd(); err == nil {
dir := wd
for i := 0; i < 8; i++ {
candidates = append(candidates, filepath.Join(dir, "site", "printit.verae.ai", "fonts"))
if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
candidates = append(candidates, filepath.Join("site", "printit.verae.ai", "fonts"))
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && st.IsDir() {
abs, _ := filepath.Abs(c)
return abs
}
}
return ""
}
// findBrandFaceFile returns the preferred .ttf/.otf under dir (Fraunces-named
// first, then any ttf, then otf). Empty when none present.
func findBrandFaceFile(dir string) string {
if dir == "" {
return ""
}
ents, err := os.ReadDir(dir)
if err != nil {
return ""
}
var frauncesTTF, anyTTF, frauncesOTF, anyOTF string
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
low := strings.ToLower(name)
path := filepath.Join(dir, name)
switch {
case strings.HasSuffix(low, ".ttf"):
if strings.Contains(low, "fraunces") && frauncesTTF == "" {
frauncesTTF = path
} else if anyTTF == "" {
anyTTF = path
}
case strings.HasSuffix(low, ".otf"):
if strings.Contains(low, "fraunces") && frauncesOTF == "" {
frauncesOTF = path
} else if anyOTF == "" {
anyOTF = path
}
}
}
for _, p := range []string{frauncesTTF, anyTTF, frauncesOTF, anyOTF} {
if p != "" {
return p
}
}
return ""
}
// ContentMultihash returns sha2-256 multihash hex (code 0x12, len 0x20 → f1220…)
// when sha256 is a 64-char hex digest; otherwise "" (caller omits the field).
func ContentMultihash(sha256 string) string {
return contentMultihashPrefixed("1220", sha256)
}
// ContentBlake3Multihash returns blake3 multihash hex (code 0x1e, len 0x20 → f1e20…)
// when blake3 is a 64-char hex digest; otherwise "" (caller omits the field).
func ContentBlake3Multihash(blake3 string) string {
return contentMultihashPrefixed("1e20", blake3)
}
// contentMultihashPrefixed wraps a 64-char hex digest as multibase-base16 multihash
// ("f" + code/len prefix + digest). Invalid/empty → "" (never invent).
func contentMultihashPrefixed(codeLen, hexDigest string) string {
s := strings.TrimSpace(hexDigest)
s = strings.TrimPrefix(strings.ToLower(s), "0x")
if len(s) != 64 {
return ""
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return ""
}
}
return "f" + codeLen + s
}
// ResolveDigests returns CERT multihash / blake3 display values from Input.
// Empty strings mean omit that line (never invent digests).
func ResolveDigests(in Input) (mh, blake3Hex, blake3MH string) {
mh = strings.TrimSpace(in.Multihash)
if mh == "" {
mh = ContentMultihash(in.SHA256)
}
blake3Hex = strings.TrimSpace(in.Blake3)
blake3MH = strings.TrimSpace(in.Blake3Multihash)
if blake3MH == "" {
blake3MH = ContentBlake3Multihash(in.Blake3)
}
return mh, blake3Hex, blake3MH
}
// LookupURL builds a public VeraeTime verify deep link (same as explorer
// scan-bar → /time, which auto-fills receipt/sha256 and runs Lookup).
// https://explorer.verae.ai/time?receipt=…&sha256=…
func LookupURL(sha256, receiptID string) string {
sha256 = strings.TrimSpace(sha256)
sha256 = strings.TrimPrefix(strings.ToLower(sha256), "0x")
receiptID = strings.TrimSpace(receiptID)
u := "https://explorer.verae.ai/time"
sep := "?"
if receiptID != "" {
u += sep + "receipt=" + receiptID
sep = "&"
}
if sha256 != "" {
u += sep + "sha256=" + sha256
}
return u
}
// Build returns the branded CERT-01 certificate PDF (plum/gold, final-only
// evidence copy). Prefers platform/registration-pdf/build.mjs when Node is
// available; otherwise gofpdf fallback.
//
// Typography: default PDF standard fonts (Times title, Helvetica body).
// EmbedBrandFonts defaults false (env VERAE_EMBED_BRAND_FONTS off). When on,
// gofpdf fallback may register a licensed .ttf/.otf from BrandFontsDir();
// failure is a logged no-op. Node path stays core faces (OPEN-ISSUES #7).
func Build(in Input) ([]byte, error) {
created := in.CreatedAt
if created == "" {
// Do not invent a ledger time — leave blank for the PDF field.
created = ""
}
scope := strings.ToUpper(strings.TrimSpace(in.Scope))
if scope == "" {
scope = "GLOBAL"
}
lookup := in.VerifyURL
if lookup == "" || strings.Contains(strings.ToLower(lookup), "mcp.verae.ai") {
lookup = LookupURL(in.SHA256, in.ReceiptID)
}
merkle := in.Merkle
if merkle == "" {
merkle = "See ledger lookup via receipt CID"
}
docName := in.DocumentName
if docName == "" {
docName = "(digest only)"
}
mh, b3, b3mh := ResolveDigests(in)
row := map[string]any{
"file": docName,
"sha256": in.SHA256,
"scope": scope,
"receipt_id": in.ReceiptID,
"created_at": created,
"merkle": merkle,
"lookup_url": lookup,
"size_bytes": in.ByteLength,
}
// Omit digest fields when empty — never invent.
if mh != "" {
row["multihash"] = mh
}
if b3 != "" {
row["blake3"] = b3
}
if b3mh != "" {
row["blake3_multihash"] = b3mh
}
if b, err := buildWithNode(row); err == nil && len(b) > 1000 {
return b, nil
}
return buildFallbackGofpdf(in, docName, created, scope, merkle, lookup)
}
func buildWithNode(row map[string]any) ([]byte, error) {
script := findBuildScript()
if script == "" {
return nil, fmt.Errorf("registration-pdf build.mjs not found")
}
tmp, err := os.MkdirTemp("", "verae-regpdf-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
inPath := filepath.Join(tmp, "in.json")
outPath := filepath.Join(tmp, "out.pdf")
jb, err := json.Marshal(row)
if err != nil {
return nil, err
}
if err := os.WriteFile(inPath, jb, 0o600); err != nil {
return nil, err
}
cmd := exec.Command("node", script, inPath, outPath)
cmd.Dir = filepath.Dir(script)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("node build.mjs: %w (%s)", err, stderr.String())
}
return os.ReadFile(outPath)
}
func findBuildScript() string {
// Prefer tree-local build.mjs (repo / package) so CERT-01 edits win over
// a stale install under Application Support during tests and dev runs.
var candidates []string
if wd, err := os.Getwd(); err == nil {
dir := wd
for i := 0; i < 8; i++ {
candidates = append(candidates, filepath.Join(dir, "platform", "registration-pdf", "build.mjs"))
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
candidates = append(candidates, filepath.Join("platform", "registration-pdf", "build.mjs"))
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "registration-pdf", "build.mjs"),
filepath.Join(dir, "..", "registration-pdf", "build.mjs"),
filepath.Join(dir, "platform", "registration-pdf", "build.mjs"),
filepath.Join(dir, "..", "platform", "registration-pdf", "build.mjs"),
filepath.Join(dir, "..", "..", "platform", "registration-pdf", "build.mjs"),
)
}
// Installed helper copy last (production path when no tree is present).
// No machine-specific absolute research paths — packaging resolves via go.mod walk above.
candidates = append(candidates,
"/Library/Application Support/Verae/PrintStamp/registration-pdf/build.mjs",
)
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
abs, _ := filepath.Abs(c)
return abs
}
}
return ""
}
// Ensure created stays empty when unknown — used by tests.
var _ = time.Now

View file

@ -36,7 +36,15 @@ func newFakeVT(t *testing.T) *fakeVT {
w.Header().Set("Content-Type", "application/json")
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"})
json.NewEncoder(w).Encode(map[string]any{
"token": "user-jwt",
"expiresAt": "2099-01-01T00:00:00Z",
"user": map[string]any{"username": "spam", "role": "user"},
})
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": "spam", "role": "user"})
case !ok:
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"})
@ -70,7 +78,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,19 +147,34 @@ 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)
}
}
ok, out := call(t, s, "session", map[string]any{})
if !ok || strings.Contains(out, `"valid":true`) {
t.Fatalf("anonymous session must not report the host login: %v %s", ok, out)
// Public self-serve signup creates the account and returns access_token (no host persist).
okSignup, outSignup := call(t, s, "signup", map[string]any{"username": "spam@example.com", "password": "pw123456"})
if !okSignup {
t.Fatalf("anonymous signup should succeed: %s", outSignup)
}
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 !strings.Contains(outSignup, `"access_token":"user-jwt"`) {
t.Fatalf("signup must return access_token: %s", outSignup)
}
if vt.users.Load() != 1 {
t.Fatalf("signup should hit /auth/signup once, users=%d", vt.users.Load())
}
// Public login returns a token and must not touch host auth.
okLogin, outLogin := call(t, s, "login", map[string]any{"username": "spam@example.com", "password": "pw123456"})
if !okLogin || !strings.Contains(outLogin, `"access_token":"user-jwt"`) {
t.Fatalf("anonymous login should return access_token: %v %s", okLogin, outLogin)
}
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, "account and auth are required") {
t.Fatalf("anonymous session must error with credential guidance: %v %s", ok, out)
}
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())
}
}
@ -181,36 +204,38 @@ func TestPublicKeyedWritesUseCallerCredential(t *testing.T) {
if ok, out := call(t, s, "session", map[string]any{}); !ok || !strings.Contains(out, `"valid":true`) {
t.Fatalf("keyed session: %s", out)
}
if ok, _ := call(t, s, "login", map[string]any{"username": "a", "password": "b"}); ok {
t.Fatal("login must stay disabled on the public server")
if ok, out := call(t, s, "login", map[string]any{"username": "spam", "password": "pw"}); !ok || !strings.Contains(out, `"access_token":"user-jwt"`) {
t.Fatalf("public login should return access_token without host persist: %v %s", ok, out)
}
if vt.stamps.Load() != 1 || vt.hostUse.Load() != 0 {
t.Fatalf("stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load())
}
}
func TestPublicAnonymousReadsNeverLimited(t *testing.T) {
func TestPublicAnonymousReadsRequireAccount(t *testing.T) {
vt := newFakeVT(t)
s := publicSession(t, vt, "", "198.51.100.9")
// 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, tool, args); !ok {
t.Fatalf("anonymous %s #%d refused: %s", tool, i, out)
// Account/auth required: anonymous verify and hash_report are refused.
for _, c := range []struct {
tool string
args map[string]any
}{
{"verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}},
{"hash_report", map[string]any{"sha256": dig, "want_scope": "global"}},
{"stamp", map[string]any{"sha256": dig, "scope": "global"}},
{"stamp_batch", map[string]any{"sha256s": []string{dig}, "scope": "global"}},
} {
ok, out := call(t, s, c.tool, c.args)
if ok || !strings.Contains(out, "account and auth are required") {
t.Fatalf("anonymous %s: %v %s", c.tool, ok, out)
}
}
// 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)
// welcome / signup / login remain open
if ok, out := call(t, s, "welcome", map[string]any{}); !ok || !strings.Contains(out, "Welcome to Verae") {
t.Fatalf("anonymous welcome: %v %s", ok, 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")
if vt.stamps.Load() != 0 || vt.batches.Load() != 0 || vt.hostUse.Load() != 0 {
t.Fatal("anonymous use reached VeraeTime or host token")
}
}

View file

@ -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 {

View file

@ -55,7 +55,7 @@ func TestStreamableHTTP_InitializeListStampLocal(t *testing.T) {
if err != nil {
t.Fatalf("tools/list: %v", err)
}
wantTools := []string{"stamp", "verify", "hash_report", "signup", "login", "session", "create_user"}
wantTools := []string{"welcome", "stamp", "verify", "hash_report", "signup", "login", "oauth_start", "oauth_complete", "session", "create_user"}
got := map[string]bool{}
for _, tool := range tools.Tools {
got[tool.Name] = true

View file

@ -0,0 +1,278 @@
// Package statuslang holds exact Verae 2026 receipt status strings (BrandingBook).
// Only "final" (and "existing") is evidence — never present a jobId as proof.
package statuslang
import (
"fmt"
"strings"
)
// Exact BrandingBook receipt status labels (brand-tokens status.*).
const (
Queued = "Queued offline"
Hashing = "Hashing on device…"
Existing = "Already registered"
Pending = "Pending receipt"
Provisional = "Stamped, awaiting blockchain"
Failed = "Failed – retrying"
Action = "Failed – action needed"
// Notify / tray short titles (not chip labels; still brand-aligned).
FinalReceiptReady = "Final receipt ready"
SignInToFinish = "Sign in to finish timestamp"
OutOfCredits = "Out of credits — add funds"
PrinterReady = "Printer ready"
// EvidenceNote is the standing disclaimer for queue / tray UI.
EvidenceNote = "Only a final receipt is evidence — hold ids are local queue handles, not proof."
// BrandingBook proof vocabulary (name the chain fully once per view).
ChainName = "Verae Substrate blockchain"
NetworkName = "VeraeNet"
VerifyBase = "https://verify.verae.ai"
ExplorerURL = "https://explorer.verae.ai/time"
)
// BrandingBook status.desc strings (tooltips / secondary copy).
const (
DescQueued = "Not yet sent. Will submit when online."
DescHashing = "SHA-256 computed locally; file never leaves the device."
DescExisting = "Hash was known: existing receipt returned instantly."
DescPending = "Submitted. Waiting for VeraeTime + Substrate."
DescProvisional = "VeraeTime receipt issued; not yet in a Substrate block."
DescIncluded = "In a Substrate block; waiting for GRANDPA finality."
DescFinal = "GRANDPA-finalized. Receipt + proof available. This is evidence."
DescFailed = "Submission failed; retrying with backoff (never resubmits blindly)."
DescAction = "Needs the user (e.g. key expired, 401)."
)
func Included(block int) string {
return fmt.Sprintf("Included in block #%s (not final)", comma(block))
}
func Finalized(block int) string {
return fmt.Sprintf("Final: Substrate block #%s, finalized", comma(block))
}
// IncludedN / FinalizedN use literal "N" when the block number is unknown
// (matches BrandingBook / Swift ReceiptState when block is nil).
func IncludedN() string {
return "Included in block #N (not final)"
}
func FinalizedN() string {
return "Final: Substrate block #N, finalized"
}
// Label returns the exact BrandingBook chip string for a receipt state key.
// block is required for included/final when known; pass 0 (or omit) to use #N.
func Label(state string, block ...int) string {
var n int
have := false
if len(block) > 0 {
n = block[0]
have = n > 0
}
switch strings.ToLower(strings.TrimSpace(state)) {
case "queued":
return Queued
case "hashing":
return Hashing
case "existing":
return Existing
case "pending":
return Pending
case "provisional":
return Provisional
case "included":
if have {
return Included(n)
}
return IncludedN()
case "final", "completed", "ok":
if have {
return Finalized(n)
}
return FinalizedN()
case "failed":
return Failed
case "action":
return Action
default:
return ""
}
}
// Desc returns the BrandingBook status.desc for a state key.
func Desc(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "queued":
return DescQueued
case "hashing":
return DescHashing
case "existing":
return DescExisting
case "pending":
return DescPending
case "provisional":
return DescProvisional
case "included":
return DescIncluded
case "final", "completed", "ok":
return DescFinal
case "failed":
return DescFailed
case "action":
return DescAction
default:
return ""
}
}
// IsEvidence is true only for final and already-registered (BrandingBook rule 3).
// Certificates, Download proof, and legal claims are allowed only in these states.
func IsEvidence(state string) bool {
switch strings.ToLower(strings.TrimSpace(state)) {
case "final", "completed", "ok", "existing":
return true
default:
return false
}
}
// NotifyTitle is a short notification title for printit tray / OS banners.
func NotifyTitle(state string) string {
switch state {
case "queued":
return Queued
case "hashing":
return Hashing
case "existing":
return Existing
case "pending":
return Pending
case "provisional":
return Provisional
case "failed":
return Failed
case "action":
return Action
case "held", "signin":
return SignInToFinish
case "billing", "credits", "payment":
return OutOfCredits
case "final", "completed", "ok":
return FinalReceiptReady
default:
return "Verae Timestamp"
}
}
// NotifyBody formats a short tray/banner body.
// For held/pending, extra is a local hold id (never evidence).
// For final, extra is the final receipt id (evidence).
// For failed, extra may be a short error hint.
func NotifyBody(state, title, extra string) string {
t := strings.TrimSpace(title)
if t == "" {
t = "document"
}
switch state {
case "held", "signin", "pending":
if extra != "" {
// Local hold id only — not a receipt / not proof.
return fmt.Sprintf("%s · %s · hold %s — sign in to stamp", t, Pending, short(extra))
}
return fmt.Sprintf("%s · %s — sign in to stamp", t, Pending)
case "billing", "credits", "payment":
if extra != "" {
return fmt.Sprintf("%s · %s · hold %s — open Billing to add funds", t, OutOfCredits, short(extra))
}
return fmt.Sprintf("%s · %s — open Billing to add funds", t, OutOfCredits)
case "failed", "action":
if extra != "" {
return fmt.Sprintf("%s · %s · %s", t, Failed, short(extra))
}
return fmt.Sprintf("%s · %s", t, Failed)
case "final", "completed", "ok":
if extra != "" {
return fmt.Sprintf("%s · receipt %s", t, short(extra))
}
return t
default:
if extra != "" {
return fmt.Sprintf("%s · %s", t, short(extra))
}
return t
}
}
// NotifyHeldJobs is the cancel / still-waiting banner when n jobs remain held.
func NotifyHeldJobs(n int) (title, body string) {
title = NotifyTitle("held")
if n <= 0 {
return title, Pending
}
return title, fmt.Sprintf("%d job(s) · %s — will stamp after you sign in", n, Pending)
}
// TrayHeadline is the persistent status line for tray / menu / loopback health
// (PT-W3 / PT-06 substitute when a native NotifyIcon loop is not available).
// held is local pending count; keyOK means an xVerae- (or equivalent) key is stored;
// helperUp means the IPP loopback helper answered.
func TrayHeadline(held int, keyOK, helperUp bool) string {
switch {
case held > 0:
return NotifyTitle("held")
case !keyOK:
return SignInToFinish
case helperUp:
return PrinterReady
default:
return "Helper not running"
}
}
// TrayDetail is a one-line body under TrayHeadline (brand labels; hold ≠ evidence).
func TrayDetail(held int, keyOK, helperUp bool) string {
parts := make([]string, 0, 4)
if helperUp {
parts = append(parts, PrinterReady)
} else {
parts = append(parts, "helper down")
}
if keyOK {
parts = append(parts, "key saved")
} else {
parts = append(parts, "no signing key")
}
if held > 0 {
parts = append(parts, fmt.Sprintf("%d held · %s", held, Pending))
} else {
parts = append(parts, "queue empty")
}
return strings.Join(parts, " · ")
}
func short(s string) string {
s = strings.TrimSpace(s)
if len(s) <= 24 {
return s
}
return s[:20] + "…"
}
func comma(n int) string {
s := fmt.Sprintf("%d", n)
if n < 1000 {
return s
}
out := make([]byte, 0, len(s)+len(s)/3)
for i, c := range s {
if i != 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, byte(c))
}
return string(out)
}

View file

@ -8,12 +8,27 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// BearerFromRequest extracts Authorization: Bearer from the HTTP request headers.
// BearerFromRequest extracts the caller credential from the MCP HTTP request.
// Order: Authorization: Bearer, then X-Api-Key / X-Verae-Key (raw token).
func BearerFromRequest(req *mcp.CallToolRequest) string {
if req == nil || req.Extra == nil || req.Extra.Header == nil {
return ""
}
auth := strings.TrimSpace(req.Extra.Header.Get("Authorization"))
h := req.Extra.Header
if tok := bearerFromAuthHeader(h.Get("Authorization")); tok != "" {
return tok
}
// Some gateways forward the key under these names instead of Authorization.
for _, name := range []string{"X-Api-Key", "X-Verae-Key", "X-Verae-Token"} {
if tok := strings.TrimSpace(h.Get(name)); tok != "" {
return tok
}
}
return ""
}
func bearerFromAuthHeader(auth string) string {
auth = strings.TrimSpace(auth)
if auth == "" {
return ""
}

View file

@ -1,6 +1,12 @@
package tools
import "testing"
import (
"net/http"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestLooksLikeCID(t *testing.T) {
t.Parallel()
@ -20,3 +26,35 @@ func TestLooksLikeCID(t *testing.T) {
}
}
}
func TestBearerFromRequest(t *testing.T) {
t.Parallel()
req := &mcp.CallToolRequest{Extra: &mcp.RequestExtra{Header: http.Header{}}}
if got := BearerFromRequest(req); got != "" {
t.Fatalf("empty headers: %q", got)
}
req.Extra.Header.Set("Authorization", "Bearer jwt-one")
if got := BearerFromRequest(req); got != "jwt-one" {
t.Fatalf("Authorization: %q", got)
}
req.Extra.Header = http.Header{}
req.Extra.Header.Set("X-Api-Key", "key-two")
if got := BearerFromRequest(req); got != "key-two" {
t.Fatalf("X-Api-Key: %q", got)
}
req.Extra.Header = http.Header{}
req.Extra.Header.Set("Authorization", "bearer spaced ")
if got := BearerFromRequest(req); got != "spaced" {
t.Fatalf("case/space: %q", got)
}
}
func TestErrWriteNeedsKeyMentionsJWTAndGrok(t *testing.T) {
t.Parallel()
msg := ErrWriteNeedsKey.Error()
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)
}
}
}

View 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
}

Some files were not shown because too many files have changed in this diff Show more