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 err := normalizeEventInput(&input); err != nil { return nil, err } 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 err := normalizeEventInput(&events[i]); err != nil { return nil, fmt.Errorf("event %d: %w", i, err) } } 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) { if err := normalizeEventInput(&event); err != nil { return nil, err } 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 normalizeEventInput(input *EventInput) error { if input.SourceRef == "" { return errors.New("source ref must not be blank") } if input.EventType == "" { input.EventType = "inference" } if input.SourceKind == "" { input.SourceKind = "sdk" } if input.OccurredAt == "" { input.OccurredAt = time.Now().UTC().Format(time.RFC3339Nano) } else if _, err := time.Parse(time.RFC3339, input.OccurredAt); err != nil { if _, nanoErr := time.Parse(time.RFC3339Nano, input.OccurredAt); nanoErr != nil { return errors.New("occurredAt must include a valid RFC3339 timezone offset") } } if input.Payload == nil { input.Payload = M{} } if input.Metadata == nil { input.Metadata = M{} } return nil } 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) GetMarketplaceItem(ctx context.Context, slug string) (M, error) { return c.getObject(ctx, "/v1/marketplace/items/"+url.PathEscape(slug), nil) } func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) { return c.postObject(ctx, "/v1/marketplace/publisher/assets", M{ "manifest": input.Manifest, "sourceRef": input.SourceRef, "visibility": input.Visibility, "pricingModel": input.PricingModel, "priceCents": input.PriceCents, }, idempotency(options)) } func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) { values := url.Values{} if state != "" { values.Set("state", state) } var out []M err := c.requestJSON(ctx, http.MethodGet, "/v1/platform/marketplace/assets", values, nil, "", &out) return out, err } func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) { return c.postObject(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, 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 }