Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eb9ec72c6 | |||
| 3ec1f0516b | |||
| 13d1c5851b | |||
| 93c2548450 | |||
| d49c51c349 | |||
| 369f9210bd | |||
|
|
dc39095694 |
||
|
|
deb0e251de |
35 changed files with 3490 additions and 205 deletions
24
.forgejo/workflows/test-and-deploy.yml
Normal file
24
.forgejo/workflows/test-and-deploy.yml
Normal 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
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@
|
|||
bin/
|
||||
*.exe
|
||||
.venv/
|
||||
Verae-Secret-Credentials.MD
|
||||
|
|
|
|||
96
README.md
96
README.md
|
|
@ -2,106 +2,46 @@
|
|||
|
||||
[](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)
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -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
15
go.sum
|
|
@ -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
158
internal/edgebill/client.go
Normal 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]) + "…"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
341
internal/metadatahist/history.go
Normal file
341
internal/metadatahist/history.go
Normal 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
|
||||
}
|
||||
52
internal/metadatahist/history_test.go
Normal file
52
internal/metadatahist/history_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
312
internal/pdfcert/fallback.go
Normal file
312
internal/pdfcert/fallback.go
Normal 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
BIN
internal/pdfcert/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
330
internal/pdfcert/pdfcert.go
Normal file
330
internal/pdfcert/pdfcert.go
Normal 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
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
278
internal/statuslang/status.go
Normal file
278
internal/statuslang/status.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
internal/tools/email_username.go
Normal file
20
internal/tools/email_username.go
Normal 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
Loading…
Reference in a new issue