Add SDK parity and Go CLI release readiness

This commit is contained in:
Codex
2026-06-07 22:35:23 +02:00
commit 61f3a217e6
11 changed files with 2797 additions and 0 deletions

561
client.go Normal file
View File

@@ -0,0 +1,561 @@
package attesto
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
type Client struct {
baseURL string
bearer string
httpClient *http.Client
maxRetries int
userAgent string
validateKey bool
}
type Option func(*Client) error
var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`)
func NewClient(apiKey string, opts ...Option) (*Client, error) {
if !apiKeyPattern.MatchString(apiKey) {
return nil, errors.New("api key must match atto_live_<32 lowercase hex chars> or atto_test_<32 lowercase hex chars>")
}
return newClient(apiKey, true, opts...)
}
func NewBearerClient(token string, opts ...Option) (*Client, error) {
if strings.TrimSpace(token) == "" {
return nil, errors.New("bearer token must not be blank")
}
return newClient(token, false, opts...)
}
func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error) {
client := &Client{
baseURL: DefaultBaseURL,
bearer: bearer,
httpClient: &http.Client{Timeout: 10 * time.Second},
maxRetries: 3,
userAgent: "attesto-go/" + SDKVersion,
validateKey: validateKey,
}
for _, opt := range opts {
if err := opt(client); err != nil {
return nil, err
}
}
if _, err := url.ParseRequestURI(client.baseURL); err != nil {
return nil, fmt.Errorf("base url must be an absolute URL: %w", err)
}
client.baseURL = strings.TrimRight(client.baseURL, "/")
return client, nil
}
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
baseURL = strings.TrimSpace(strings.TrimRight(baseURL, "/"))
if baseURL == "" {
return errors.New("base url must not be blank")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return errors.New("base url must be an absolute URL")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return errors.New("base url must use http or https")
}
c.baseURL = baseURL
return nil
}
}
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) error {
if httpClient == nil {
return errors.New("http client must not be nil")
}
c.httpClient = httpClient
return nil
}
}
func WithMaxRetries(maxRetries int) Option {
return func(c *Client) error {
if maxRetries < 1 {
return errors.New("max retries must be at least 1")
}
c.maxRetries = maxRetries
return nil
}
}
func WithUserAgent(userAgent string) Option {
return func(c *Client) error {
if strings.TrimSpace(userAgent) == "" {
return errors.New("user agent must not be blank")
}
c.userAgent = userAgent
return nil
}
}
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
var out Stream
if input.Metadata == nil {
input.Metadata = M{}
}
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) GetStreamHead(ctx context.Context, streamID string) (*StreamHead, error) {
var out StreamHead
err := c.requestJSON(ctx, http.MethodGet, "/v2/streams/"+url.PathEscape(streamID)+"/head", nil, nil, "", &out)
return &out, err
}
func (c *Client) ListTenantStreams(ctx context.Context, systemID string, limit, offset int) ([]TenantStream, error) {
values := url.Values{}
if systemID != "" {
values.Set("systemId", systemID)
}
setPaging(values, limit, offset)
var out []TenantStream
err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/streams", values, nil, "", &out)
return out, err
}
func (c *Client) GetTenantStream(ctx context.Context, streamID string) (*TenantStream, error) {
offset := 0
for {
streams, err := c.ListTenantStreams(ctx, "", 500, offset)
if err != nil {
return nil, err
}
for _, stream := range streams {
if stream.StreamID == streamID {
return &stream, nil
}
}
if len(streams) < 500 {
return nil, fmt.Errorf("stream not found: %s", streamID)
}
offset += 500
}
}
func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput, options ...RequestOptions) (*EventReceipt, error) {
if input.EventType == "" {
input.EventType = "inference"
}
if input.SourceKind == "" {
input.SourceKind = "sdk"
}
if input.Payload == nil {
input.Payload = M{}
}
if input.Metadata == nil {
input.Metadata = M{}
}
var out EventReceipt
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
if len(events) > 1000 {
return nil, errors.New("max 1000 events per batch")
}
for i := range events {
if events[i].EventType == "" {
events[i].EventType = "inference"
}
if events[i].SourceKind == "" {
events[i].SourceKind = "sdk"
}
if events[i].Payload == nil {
events[i].Payload = M{}
}
if events[i].Metadata == nil {
events[i].Metadata = M{}
}
}
body := M{"events": events}
var out EventBatchResponse
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
var out EventReceipt
err := c.requestJSON(ctx, http.MethodGet, "/v2/receipts/"+url.PathEscape(streamEventID), nil, nil, "", &out)
return &out, err
}
func (c *Client) GetWindow(ctx context.Context, windowID string) (M, error) {
return c.getObject(ctx, "/v2/windows/"+url.PathEscape(windowID), nil)
}
func (c *Client) GetCheckpoint(ctx context.Context, checkpointID string) (M, error) {
return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID), nil)
}
func (c *Client) GetCheckpointConsistency(ctx context.Context, checkpointID, fromCheckpointID string) (M, error) {
values := url.Values{"from": []string{fromCheckpointID}}
return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID)+"/consistency", values)
}
func (c *Client) GetWitnessPolicy(ctx context.Context, policyID string) (*WitnessPolicy, error) {
var out WitnessPolicy
err := c.requestJSON(ctx, http.MethodGet, "/v2/witness/policies/"+url.PathEscape(policyID), nil, nil, "", &out)
return &out, err
}
func (c *Client) GetAnchorEpoch(ctx context.Context, anchorEpochID string) (M, error) {
return c.getObject(ctx, "/v2/anchors/"+url.PathEscape(anchorEpochID), nil)
}
func (c *Client) GetIVCEpoch(ctx context.Context, ivcEpochID string) (M, error) {
return c.getObject(ctx, "/v2/ivc/epochs/"+url.PathEscape(ivcEpochID), nil)
}
func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
var out VerifierBundle
err := c.requestJSON(ctx, http.MethodPost, "/v2/audit/packs", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
var out VerifyReport
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
var out VerifyReport
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) ListTenantStreamEvents(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/events", limit, offset)
}
func (c *Client) ListTenantWindows(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/windows", limit, offset)
}
func (c *Client) ListTenantCheckpoints(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/checkpoints", limit, offset)
}
func (c *Client) ListForkEvidence(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/forks", limit, offset)
}
func (c *Client) GetTenantProofState(ctx context.Context, streamID string) (M, error) {
return c.getObject(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/proof-state", nil)
}
func (c *Client) ListTenantIVCEpochs(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/ivc/epochs", limit, offset)
}
func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
var out VerifierBundle
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) ListSignedWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
return c.listConnectors(ctx, "/v2/tenant/connectors/signed-webhooks", limit, offset)
}
func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeSignedWebhookConnector(ctx context.Context, connectorID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/signed-webhooks/"+url.PathEscape(connectorID))
}
func (c *Client) ListS3ObjectConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
return c.listConnectors(ctx, "/v2/tenant/connectors/s3-objects", limit, offset)
}
func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) {
var out EventReceipt
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeS3ObjectConnector(ctx context.Context, connectorID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID))
}
func (c *Client) ListRepositoryWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
return c.listConnectors(ctx, "/v2/tenant/connectors/repository-webhooks", limit, offset)
}
func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeRepositoryWebhookConnector(ctx context.Context, connectorID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/repository-webhooks/"+url.PathEscape(connectorID))
}
func (c *Client) IngestSignedWebhookEvent(ctx context.Context, connectorID string, event EventInput, secret string) (*EventReceipt, error) {
raw, err := json.Marshal(event)
if err != nil {
return nil, err
}
headers := SignedConnectorWebhookHeaders(secret, raw, 0)
var out EventReceipt
err = c.requestRaw(ctx, http.MethodPost, "/v2/connectors/signed-webhooks/"+url.PathEscape(connectorID)+"/events", nil, raw, headers, "", &out)
return &out, err
}
func (c *Client) IngestRepositoryWebhookEvent(ctx context.Context, connectorID string, rawBody []byte, headers map[string]string) (*EventReceipt, error) {
var out EventReceipt
err := c.requestRaw(ctx, http.MethodPost, "/v2/connectors/repository-webhooks/"+url.PathEscape(connectorID)+"/events", nil, rawBody, headers, "", &out)
return &out, err
}
func (c *Client) ListLocalVaultInstallations(ctx context.Context, limit, offset int) ([]LocalVaultInstallation, error) {
values := url.Values{}
setPaging(values, limit, offset)
var out []LocalVaultInstallation
err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/local-vault/installations", values, nil, "", &out)
return out, err
}
func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) {
var out LocalVaultInstallation
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeLocalVaultInstallation(ctx context.Context, installationID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/local-vault/installations/"+url.PathEscape(installationID))
}
func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) {
body := M{"envelope": envelope, "payload": payload}
headers := map[string]string{
"X-Attesto-Local-Vault-Envelope-Hash": envelopeHash,
"X-Attesto-Local-Vault-Signature": signatureHex,
"X-Attesto-Local-Vault-Public-Key": publicKeyHex,
}
var out EventReceipt
err := c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, mustJSON(body), headers, "", &out)
return &out, err
}
func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, idempotency(options))
}
func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options))
}
func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) {
var out M
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
return out, err
}
func (c *Client) postObject(ctx context.Context, path string, body M, idempotencyKey string) (M, error) {
var out M
err := c.requestJSON(ctx, http.MethodPost, path, nil, body, idempotencyKey, &out)
return out, err
}
func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) {
values := url.Values{}
setPaging(values, limit, offset)
var out []M
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
return out, err
}
func (c *Client) listConnectors(ctx context.Context, path string, limit, offset int) ([]Connector, error) {
values := url.Values{}
setPaging(values, limit, offset)
var out []Connector
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
return out, err
}
func (c *Client) requestNoBody(ctx context.Context, method, path string) error {
return c.requestRaw(ctx, method, path, nil, nil, nil, "", nil)
}
func (c *Client) requestJSON(ctx context.Context, method, path string, values url.Values, body any, idempotencyKey string, out any) error {
var raw []byte
var err error
if body != nil {
raw, err = json.Marshal(body)
if err != nil {
return err
}
}
return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out)
}
func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error {
if values != nil && len(values) > 0 {
path += "?" + values.Encode()
}
var lastErr error
for attempt := 1; attempt <= c.maxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearer)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("X-Attesto-SDK", c.userAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
for key, value := range extraHeaders {
req.Header.Set(key, value)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
if attempt == c.maxRetries {
break
}
sleep(attempt)
continue
}
err = decodeResponse(resp, out)
if err == nil {
return nil
}
lastErr = err
var apiErr *APIError
if errors.As(err, &apiErr) && !retryable(apiErr.StatusCode) {
return err
}
if attempt < c.maxRetries {
sleep(attempt)
}
}
return lastErr
}
type APIError struct {
StatusCode int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message)
}
func decodeResponse(resp *http.Response, out any) error {
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return nil
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if out == nil || len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
message := http.StatusText(resp.StatusCode)
var parsed M
if json.Unmarshal(raw, &parsed) == nil {
if detail, ok := parsed["detail"].(string); ok && detail != "" {
message = detail
}
}
return &APIError{StatusCode: resp.StatusCode, Message: sanitizeMessage(message)}
}
func retryable(status int) bool {
switch status {
case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
}
func idempotency(options []RequestOptions) string {
if len(options) > 0 && options[0].IdempotencyKey != "" {
return options[0].IdempotencyKey
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(raw[:])
}
func setPaging(values url.Values, limit, offset int) {
if limit > 0 {
values.Set("limit", fmt.Sprintf("%d", limit))
}
if offset > 0 {
values.Set("offset", fmt.Sprintf("%d", offset))
}
}
func sleep(attempt int) {
time.Sleep(time.Duration(100*attempt) * time.Millisecond)
}
func mustJSON(value any) []byte {
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func sanitizeMessage(message string) string {
for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} {
if strings.Contains(message, marker) {
return "redacted"
}
}
return message
}