diff --git a/admin_events.go b/admin_events.go index adcb1a3..bb5d221 100644 --- a/admin_events.go +++ b/admin_events.go @@ -2,8 +2,7 @@ package elydora import "fmt" -// ListAdminEvents retrieves recent administrative events for the organization. -// An optional limit (1–100) may be provided; pass 0 to use the server default (20). +// ListAdminEvents retrieves recent administrative events; limit 0 uses the server default. func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) { path := "/v1/admin/events" if limit > 0 { diff --git a/auth.go b/auth.go index 44921ab..01526a6 100644 --- a/auth.go +++ b/auth.go @@ -1,51 +1,30 @@ package elydora -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) +import "net/http" -// RegisterOption is a functional option for the Register function. +// RegisterOption configures a registration request. type RegisterOption func(*AuthRegisterRequest) // WithDisplayName sets the display_name on a registration request. func WithDisplayName(name string) RegisterOption { - return func(req *AuthRegisterRequest) { - req.DisplayName = name - } + return func(req *AuthRegisterRequest) { req.DisplayName = name } } // WithOrgName sets the org_name on a registration request. func WithOrgName(name string) RegisterOption { - return func(req *AuthRegisterRequest) { - req.OrgName = name - } + return func(req *AuthRegisterRequest) { req.OrgName = name } } // Register creates a new user and organization. // -// Deprecated: Use Better Auth endpoints directly. See docs. +// Deprecated: use the Console sign-up flow; password registration is being phased out. func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) { - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - - req := &AuthRegisterRequest{ - Email: email, - Password: password, - } + req := &AuthRegisterRequest{Email: email, Password: password} for _, opt := range opts { opt(req) } - var result AuthRegisterResponse - if err := authPost(baseURL, "/v1/auth/register", req, &result); err != nil { + if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/register", req, &result); err != nil { return nil, err } return &result, nil @@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg // Login authenticates a user and returns a session token. // -// Deprecated: Use Better Auth endpoints directly. See docs. +// Deprecated: use the Console sign-in flow; password login is being phased out. func Login(baseURL, email, password string) (*AuthLoginResponse, error) { - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - - req := &AuthLoginRequest{ - Email: email, - Password: password, - } - + req := &AuthLoginRequest{Email: email, Password: password} var result AuthLoginResponse - if err := authPost(baseURL, "/v1/auth/login", req, &result); err != nil { + if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/login", req, &result); err != nil { return nil, err } return &result, nil } -// authPost is a helper for unauthenticated POST requests used by Register and Login. -func authPost(baseURL, path string, body interface{}, result interface{}) error { - u := baseURL + path - - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("elydora: marshal request body: %w", err) - } - - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("elydora: create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("elydora: http request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) - if err != nil { - return fmt.Errorf("elydora: read response body: %w", err) - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - if result != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, result); err != nil { - return fmt.Errorf("elydora: unmarshal response: %w", err) - } - } - return nil - } - - var errResp errorResponse - if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" { - return &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - } - - return &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(respBody), - } -} - // GetMe retrieves the current authenticated user's profile. func (c *Client) GetMe() (*GetMeResponse, error) { var result GetMeResponse @@ -145,8 +60,7 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons return &result, nil } -// RotateApiToken rotates the current API token and leaves the old token -// valid for a 24h grace window. +// RotateApiToken rotates the current API token; the old token stays valid for 24h. func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) { var result RotateApiTokenResponse if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil { diff --git a/client.go b/client.go index da15b37..e40af34 100644 --- a/client.go +++ b/client.go @@ -14,6 +14,8 @@ const ( defaultBaseURL = "https://api.elydora.com" defaultTTLMs = 30000 defaultMaxRetries = 3 + maxResponseBytes = 10 << 20 + requestTimeout = 30 * time.Second ) // Config holds the configuration for creating a new Client. @@ -44,135 +46,180 @@ func NewClient(cfg *Config) (*Client, error) { if cfg == nil { return nil, fmt.Errorf("elydora: config must not be nil") } - - baseURL := cfg.BaseURL - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - ttlMs := cfg.TTLMs if ttlMs <= 0 { ttlMs = defaultTTLMs } - maxRetries := cfg.MaxRetries if maxRetries <= 0 { maxRetries = defaultMaxRetries } - return &Client{ orgID: cfg.OrgID, agentID: cfg.AgentID, privateKey: cfg.PrivateKey, - baseURL: baseURL, + baseURL: normalizeBaseURL(cfg.BaseURL), ttlMs: ttlMs, maxRetries: maxRetries, token: cfg.Token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, + httpClient: &http.Client{Timeout: requestTimeout}, }, nil } -// doRequest executes an HTTP request with retries and error handling. -func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error { - u := c.baseURL + path - - var bodyReader io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("elydora: marshal request body: %w", err) - } - bodyReader = bytes.NewReader(data) - } - - var lastErr error - for attempt := 0; attempt <= c.maxRetries; attempt++ { - if attempt > 0 { - // Reset body reader for retry - if body != nil { - data, _ := json.Marshal(body) - bodyReader = bytes.NewReader(data) - } - time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond) - } - - req, err := http.NewRequest(method, u, bodyReader) - if err != nil { - return fmt.Errorf("elydora: create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - lastErr = fmt.Errorf("elydora: http request: %w", err) - continue - } - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) - resp.Body.Close() - if err != nil { - lastErr = fmt.Errorf("elydora: read response body: %w", err) - continue - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - if result != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, result); err != nil { - return fmt.Errorf("elydora: unmarshal response: %w", err) - } - } - return nil - } - - // Parse error response - var errResp errorResponse - if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" { - apiErr := &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - // Don't retry 4xx errors (except 429) - if resp.StatusCode < 500 && resp.StatusCode != 429 { - return apiErr - } - lastErr = apiErr - continue - } - - lastErr = &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(respBody), - } - if resp.StatusCode < 500 && resp.StatusCode != 429 { - return lastErr - } - } - return lastErr -} - -// doGet performs a GET request. -func (c *Client) doGet(path string, result interface{}) error { - return c.doRequest(http.MethodGet, path, nil, result) -} - -// doPost performs a POST request. -func (c *Client) doPost(path string, body interface{}, result interface{}) error { - return c.doRequest(http.MethodPost, path, body, result) -} - // SetToken sets the API token used for authenticated API requests. func (c *Client) SetToken(token string) { c.token = token } + +func normalizeBaseURL(baseURL string) string { + if baseURL == "" { + baseURL = defaultBaseURL + } + return strings.TrimRight(baseURL, "/") +} + +func newRequest(method, url string, body []byte, token, accept string) (*http.Request, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + return nil, fmt.Errorf("elydora: create request: %w", err) + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, nil +} + +func marshalBody(body interface{}) ([]byte, error) { + if body == nil { + return nil, nil + } + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("elydora: marshal request body: %w", err) + } + return data, nil +} + +func readBody(resp *http.Response, limit int64) ([]byte, error) { + defer resp.Body.Close() + var reader io.Reader = resp.Body + if limit > 0 { + reader = io.LimitReader(resp.Body, limit) + } + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("elydora: read response body: %w", err) + } + return body, nil +} + +func decodeSuccess(body []byte, result interface{}) error { + if result == nil { + return nil + } + if err := json.Unmarshal(body, result); err != nil { + return fmt.Errorf("elydora: unmarshal response: %w", err) + } + return nil +} + +func apiError(status int, body []byte) *ElydoraError { + var errResp errorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Code != "" { + return &ElydoraError{ + StatusCode: status, + Code: errResp.Error.Code, + Message: errResp.Error.Message, + RequestID: errResp.Error.RequestID, + Details: errResp.Error.Details, + } + } + return &ElydoraError{StatusCode: status, Code: ErrorCodeInternalError, Message: string(body)} +} + +func retryable(status int) bool { + return status >= 500 || status == http.StatusTooManyRequests +} + +// doRequest executes an authenticated request, retrying transport and 5xx failures. +func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error { + data, err := marshalBody(body) + if err != nil { + return err + } + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond) + } + req, err := newRequest(method, c.baseURL+path, data, c.token, "application/json") + if err != nil { + return err + } + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("elydora: http request: %w", err) + continue + } + respBody, err := readBody(resp, maxResponseBytes) + if err != nil { + lastErr = err + continue + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return decodeSuccess(respBody, result) + } + lastErr = apiError(resp.StatusCode, respBody) + if !retryable(resp.StatusCode) { + return lastErr + } + } + return lastErr +} + +func (c *Client) doGet(path string, result interface{}) error { + return c.doRequest(http.MethodGet, path, nil, result) +} + +func (c *Client) doPost(path string, body interface{}, result interface{}) error { + return c.doRequest(http.MethodPost, path, body, result) +} + +// publicRequest sends one unauthenticated request and decodes accepted statuses. +func publicRequest(method, url string, body interface{}, result interface{}, accepted ...int) error { + data, err := marshalBody(body) + if err != nil { + return err + } + req, err := newRequest(method, url, data, "", "application/json") + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: requestTimeout}).Do(req) + if err != nil { + return fmt.Errorf("elydora: http request: %w", err) + } + respBody, err := readBody(resp, maxResponseBytes) + if err != nil { + return err + } + for _, status := range accepted { + if resp.StatusCode == status { + return decodeSuccess(respBody, result) + } + } + if len(accepted) == 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 { + return decodeSuccess(respBody, result) + } + return apiError(resp.StatusCode, respBody) +} diff --git a/crypto.go b/crypto.go index f7ae7aa..5af9d17 100644 --- a/crypto.go +++ b/crypto.go @@ -11,12 +11,6 @@ import ( "strings" ) -// sha256Base64url computes SHA-256 of the input string and returns base64url-encoded result. -func sha256Base64url(data string) string { - h := sha256.Sum256([]byte(data)) - return base64urlEncode(h[:]) -} - // sha256BytesBase64url computes SHA-256 of raw bytes and returns base64url-encoded result. func sha256BytesBase64url(data []byte) string { h := sha256.Sum256(data) @@ -32,8 +26,7 @@ func computePayloadHash(payload interface{}) (string, error) { return sha256BytesBase64url([]byte(canonical)), nil } -// signEd25519 signs data with an Ed25519 private key seed (base64url-encoded 32 bytes). -// Returns the signature as base64url. +// signEd25519 signs data with a base64url Ed25519 seed and returns a base64url signature. func signEd25519(privateKeyBase64url string, data []byte) (string, error) { seed, err := base64urlDecode(privateKeyBase64url) if err != nil { @@ -47,13 +40,7 @@ func signEd25519(privateKeyBase64url string, data []byte) (string, error) { return base64urlEncode(sig), nil } -// --------------------------------------------------------------------------- -// JCS Canonicalization (RFC 8785) -// --------------------------------------------------------------------------- - -// jcsCanonicalise produces a JCS-canonicalized JSON string from a Go value. -// Object keys are sorted lexicographically, no whitespace is used, and numbers -// follow ES2015 serialization rules. +// jcsCanonicalise renders a value as RFC 8785 canonical JSON. func jcsCanonicalise(value interface{}) (string, error) { var b strings.Builder if err := jcsWrite(&b, value); err != nil { @@ -113,10 +100,6 @@ func jcsWrite(b *strings.Builder, value interface{}) error { first := true for _, k := range keys { val := v[k] - if val == nil { - // JCS includes null values but skips undefined; in Go there is no undefined - // so we include null. - } if !first { b.WriteByte(',') } @@ -145,7 +128,7 @@ func jcsWrite(b *strings.Builder, value interface{}) error { return nil } -// jcsWriteString writes a JSON string with minimal escaping per the JSON spec. +// jcsWriteString writes a JSON string with minimal escaping. func jcsWriteString(b *strings.Builder, s string) { b.WriteByte('"') for _, r := range s { @@ -175,7 +158,7 @@ func jcsWriteString(b *strings.Builder, s string) { b.WriteByte('"') } -// jcsFormatNumber formats a float64 according to ES2015 Number serialization (JCS/RFC 8785). +// jcsFormatNumber formats a float64 as ES2015 Number serialization. func jcsFormatNumber(f float64) string { if math.IsNaN(f) || math.IsInf(f, 0) { return "null" @@ -183,32 +166,28 @@ func jcsFormatNumber(f float64) string { if f == 0 { return "0" } - // If the number is an integer that fits in int64, format without decimal point if f == math.Trunc(f) && !math.IsInf(f, 0) && math.Abs(f) < 1e20 { return strconv.FormatInt(int64(f), 10) } - // Use the shortest representation that round-trips return strconv.FormatFloat(f, 'G', -1, 64) } -// signEOR signs an EOR struct. It computes the canonical representation (all fields except signature), -// hashes it, and produces an Ed25519 signature. +// signEOR signs the canonical EOR fields except the signature. func signEOR(eor *EOR, privateKeyBase64url string) (string, error) { - // Build canonical EOR without signature field canonical := map[string]interface{}{ - "op_version": eor.OpVersion, - "operation_id": eor.OperationID, - "org_id": eor.OrgID, - "agent_id": eor.AgentID, - "issued_at": eor.IssuedAt, - "ttl_ms": eor.TTLMs, - "nonce": eor.Nonce, - "operation_type": eor.OperationType, - "subject": eor.Subject, - "action": eor.Action, - "payload": eor.Payload, - "payload_hash": eor.PayloadHash, - "prev_chain_hash": eor.PrevChainHash, + "op_version": eor.OpVersion, + "operation_id": eor.OperationID, + "org_id": eor.OrgID, + "agent_id": eor.AgentID, + "issued_at": eor.IssuedAt, + "ttl_ms": eor.TTLMs, + "nonce": eor.Nonce, + "operation_type": eor.OperationType, + "subject": eor.Subject, + "action": eor.Action, + "payload": eor.Payload, + "payload_hash": eor.PayloadHash, + "prev_chain_hash": eor.PrevChainHash, "agent_pubkey_kid": eor.AgentPubkeyKID, } canonicalStr, err := jcsCanonicalise(canonical) diff --git a/exports.go b/exports.go index 5985db4..3541994 100644 --- a/exports.go +++ b/exports.go @@ -1,9 +1,7 @@ package elydora import ( - "encoding/json" "fmt" - "io" "net/http" ) @@ -36,44 +34,20 @@ func (c *Client) GetExport(exportID string) (*GetExportResponse, error) { // DownloadExport downloads the raw file data for a completed export. func (c *Client) DownloadExport(exportID string) ([]byte, error) { - u := c.baseURL + fmt.Sprintf("/v1/exports/%s/download", exportID) - - req, err := http.NewRequest(http.MethodGet, u, nil) + req, err := newRequest(http.MethodGet, c.baseURL+fmt.Sprintf("/v1/exports/%s/download", exportID), nil, c.token, "") if err != nil { - return nil, fmt.Errorf("elydora: create request: %w", err) + return nil, err } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("elydora: http request: %w", err) } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) + body, err := readBody(resp, 0) if err != nil { - return nil, fmt.Errorf("elydora: read response body: %w", err) + return nil, err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var errResp errorResponse - if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil && errResp.Error.Code != "" { - return nil, &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - } - return nil, &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(body), - } + return nil, apiError(resp.StatusCode, body) } - return body, nil } diff --git a/health.go b/health.go index cf78961..9664809 100644 --- a/health.go +++ b/health.go @@ -1,116 +1,29 @@ package elydora -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) +import "net/http" -// Health checks the health of the Elydora API. This is a public endpoint -// that does not require authentication. +// Health checks the public health endpoint. func Health(baseURL string) (*HealthResponse, error) { - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/health", nil) - if err != nil { - return nil, fmt.Errorf("elydora: create request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("elydora: http request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) - if err != nil { - return nil, fmt.Errorf("elydora: read response body: %w", err) - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - var result HealthResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("elydora: unmarshal response: %w", err) - } - return &result, nil - } - - var errResp errorResponse - if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" { - return nil, &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - } - - return nil, &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(respBody), + var result HealthResponse + if err := publicRequest(http.MethodGet, normalizeBaseURL(baseURL)+"/v1/health", nil, &result); err != nil { + return nil, err } + return &result, nil } -// DeepHealth checks the deep health of the Elydora API, including its backend -// dependencies (D1, R2, KV). This is a public endpoint that does not require -// authentication. +// DeepHealth checks backend dependencies; 503 reports a degraded API. func DeepHealth(baseURL string) (*DeepHealthResponse, error) { - if baseURL == "" { - baseURL = defaultBaseURL - } - baseURL = strings.TrimRight(baseURL, "/") - - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/health/deep", nil) + var result DeepHealthResponse + err := publicRequest( + http.MethodGet, + normalizeBaseURL(baseURL)+"/v1/health/deep", + nil, + &result, + http.StatusOK, + http.StatusServiceUnavailable, + ) if err != nil { - return nil, fmt.Errorf("elydora: create request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("elydora: http request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) - if err != nil { - return nil, fmt.Errorf("elydora: read response body: %w", err) - } - - // Accept both 200 (healthy) and 503 (degraded) as valid deep-health responses. - if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusServiceUnavailable { - var result DeepHealthResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("elydora: unmarshal response: %w", err) - } - return &result, nil - } - - var errResp errorResponse - if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" { - return nil, &ElydoraError{ - StatusCode: resp.StatusCode, - Code: errResp.Error.Code, - Message: errResp.Error.Message, - RequestID: errResp.Error.RequestID, - Details: errResp.Error.Details, - } - } - - return nil, &ElydoraError{ - StatusCode: resp.StatusCode, - Code: ErrorCodeInternalError, - Message: string(respBody), + return nil, err } + return &result, nil } diff --git a/operations.go b/operations.go index 9eec08d..656f1c7 100644 --- a/operations.go +++ b/operations.go @@ -2,13 +2,10 @@ package elydora import "fmt" -// GenesisChainHash is the initial chain hash for an agent's first operation. -// It is the base64url encoding of 32 zero bytes, matching the backend. +// GenesisChainHash is the base64url encoding of 32 zero bytes. const GenesisChainHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" -// CreateOperation builds a signed EOR from the given parameters. -// It generates a UUIDv7 operation ID, a nonce, computes the payload hash and chain hash, -// and signs the entire record with the client's private key. +// CreateOperation builds and signs an EOR locally. func (c *Client) CreateOperation(params *CreateOperationParams) (*EOR, error) { if params == nil { return nil, fmt.Errorf("elydora: params must not be nil") diff --git a/sdk_compat_integration_test.go b/sdk_compat_integration_test.go index 5d9bb9c..4f1c3be 100644 --- a/sdk_compat_integration_test.go +++ b/sdk_compat_integration_test.go @@ -81,8 +81,8 @@ func TestSDKCompatibilityAgainstLatestAPI(t *testing.T) { if err != nil { t.Fatalf("api token auth failed: %v", err) } - if meWithAPI.User.OrgID != login.User.OrgID { - t.Fatalf("api token org mismatch: got %s want %s", meWithAPI.User.OrgID, login.User.OrgID) + if orgID(meWithAPI.User.OrgID) != login.User.OrgID { + t.Fatalf("api token org mismatch: got %s want %s", orgID(meWithAPI.User.OrgID), login.User.OrgID) } rotateResp, err := client.RotateApiToken() @@ -98,8 +98,8 @@ func TestSDKCompatibilityAgainstLatestAPI(t *testing.T) { if err != nil { t.Fatalf("rotated token auth failed: %v", err) } - if meWithRotated.User.OrgID != login.User.OrgID { - t.Fatalf("rotated token org mismatch: got %s want %s", meWithRotated.User.OrgID, login.User.OrgID) + if orgID(meWithRotated.User.OrgID) != login.User.OrgID { + t.Fatalf("rotated token org mismatch: got %s want %s", orgID(meWithRotated.User.OrgID), login.User.OrgID) } _, err = client.RegisterAgent(&elydora.RegisterAgentRequest{ @@ -154,3 +154,10 @@ func TestSDKCompatibilityAgainstLatestAPI(t *testing.T) { t.Fatalf("verify operation invalid: %+v", verify) } } + +func orgID(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/types.go b/types.go index 99d7db0..481196a 100644 --- a/types.go +++ b/types.go @@ -1,9 +1,5 @@ package elydora -// --------------------------------------------------------------------------- -// Enums -// --------------------------------------------------------------------------- - type AgentStatus string const ( @@ -69,7 +65,9 @@ const ( ErrorCodeInvalidSignature ErrorCode = "INVALID_SIGNATURE" ErrorCodeUnknownAgent ErrorCode = "UNKNOWN_AGENT" ErrorCodeKeyRevoked ErrorCode = "KEY_REVOKED" + ErrorCodeKeyRetired ErrorCode = "KEY_RETIRED" ErrorCodeAgentFrozen ErrorCode = "AGENT_FROZEN" + ErrorCodeAgentRevoked ErrorCode = "AGENT_REVOKED" ErrorCodeTTLExpired ErrorCode = "TTL_EXPIRED" ErrorCodeReplayDetected ErrorCode = "REPLAY_DETECTED" ErrorCodePrevHashMismatch ErrorCode = "PREV_HASH_MISMATCH" @@ -82,6 +80,36 @@ const ( ErrorCodeValidationError ErrorCode = "VALIDATION_ERROR" ) +type AdminAction string + +const ( + AdminActionAgentRegister AdminAction = "agent.register" + AdminActionAgentUpdate AdminAction = "agent.update" + AdminActionAgentFreeze AdminAction = "agent.freeze" + AdminActionAgentUnfreeze AdminAction = "agent.unfreeze" + AdminActionAgentRevoke AdminAction = "agent.revoke" + AdminActionAgentDelete AdminAction = "agent.delete" + AdminActionKeyRevoke AdminAction = "key.revoke" + AdminActionExportCreate AdminAction = "export.create" + AdminActionOrgCreate AdminAction = "org.create" + AdminActionOrgUpdate AdminAction = "org.update" + AdminActionMemberInvite AdminAction = "member.invite" + AdminActionInvitationCancel AdminAction = "invitation.cancel" + AdminActionMemberRemove AdminAction = "member.remove" + AdminActionMemberRoleChange AdminAction = "member.role_change" + AdminActionAgentAssign AdminAction = "agent.assign" + AdminActionAgentUnassign AdminAction = "agent.unassign" + AdminActionWebhookRegister AdminAction = "webhook.register" + AdminActionWebhookDelete AdminAction = "webhook.delete" +) + +type WebhookStatus string + +const ( + WebhookStatusActive WebhookStatus = "active" + WebhookStatusDisabled WebhookStatus = "disabled" +) + type ExportFormat string const ( @@ -89,10 +117,6 @@ const ( ExportFormatPDF ExportFormat = "pdf" ) -// --------------------------------------------------------------------------- -// Entity types -// --------------------------------------------------------------------------- - type Agent struct { AgentID string `json:"agent_id"` OrgID string `json:"org_id"` @@ -155,7 +179,7 @@ type Epoch struct { type Organization struct { OrgID string `json:"org_id"` Name string `json:"name"` - Description string `json:"description"` + Description *string `json:"description"` BAOrgID *string `json:"ba_org_id"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` @@ -182,11 +206,7 @@ type Export struct { CompletedAt *int64 `json:"completed_at"` } -// --------------------------------------------------------------------------- -// Protocol types -// --------------------------------------------------------------------------- - -// EOR is the Elydora Operation Record — the fundamental unit of auditable activity. +// EOR is the Elydora Operation Record. type EOR struct { OpVersion string `json:"op_version"` OperationID string `json:"operation_id"` @@ -205,7 +225,7 @@ type EOR struct { Signature string `json:"signature"` } -// EAR is the Elydora Acknowledgment Receipt — server-issued receipt confirming acceptance. +// EAR is the Elydora Acknowledgment Receipt. type EAR struct { ReceiptVersion string `json:"receipt_version"` ReceiptID string `json:"receipt_id"` @@ -221,210 +241,6 @@ type EAR struct { ElydoraSignature string `json:"elydora_signature"` } -// --------------------------------------------------------------------------- -// API request/response types -// --------------------------------------------------------------------------- - -type RegisterAgentKeyInput struct { - KID string `json:"kid"` - PublicKey string `json:"public_key"` - Algorithm string `json:"algorithm"` -} - -type RegisterAgentRequest struct { - AgentID string `json:"agent_id"` - DisplayName string `json:"display_name,omitempty"` - ResponsibleEntity string `json:"responsible_entity,omitempty"` - IntegrationType IntegrationType `json:"integration_type"` - Keys []RegisterAgentKeyInput `json:"keys"` -} - -type RegisterAgentResponse struct { - Agent Agent `json:"agent"` - Keys []AgentKey `json:"keys"` -} - -type GetAgentResponse struct { - Agent Agent `json:"agent"` - Keys []AgentKey `json:"keys"` -} - -type FreezeAgentRequest struct { - Reason string `json:"reason"` -} - -type FreezeAgentResponse struct { - Agent Agent `json:"agent"` - PreviousStatus AgentStatus `json:"previous_status"` -} - -type RevokeAgentRequest struct { - KID string `json:"kid"` - Reason string `json:"reason"` -} - -type UnfreezeAgentRequest struct { - Reason string `json:"reason"` -} - -type UnfreezeAgentResponse struct { - Agent Agent `json:"agent"` - PreviousStatus AgentStatus `json:"previous_status"` -} - -type UpdateAgentRequest struct { - IntegrationType IntegrationType `json:"integration_type"` -} - -type UpdateAgentResponse struct { - Agent Agent `json:"agent"` -} - -type ListAgentsResponse struct { - Agents []Agent `json:"agents"` -} - -type DeleteAgentResponse struct { - Deleted bool `json:"deleted"` -} - -type GetMeResponse struct { - User User `json:"user"` -} - -type IssueApiTokenRequest struct { - TTLSeconds *int `json:"ttl_seconds"` -} - -type IssueApiTokenResponse struct { - Token string `json:"token"` - ExpiresAt *int64 `json:"expires_at"` - TokenID string `json:"token_id"` -} - -type RotateApiTokenResponse struct { - Token string `json:"token"` - ExpiresAt *int64 `json:"expires_at"` - TokenID string `json:"token_id"` -} - -type HealthResponse struct { - Status string `json:"status"` - Version string `json:"version"` - ProtocolVersion string `json:"protocol_version"` - Timestamp int64 `json:"timestamp"` -} - -type SubmitOperationResponse struct { - Receipt EAR `json:"receipt"` -} - -type GetOperationResponse struct { - Operation Operation `json:"operation"` - Receipt *Receipt `json:"receipt,omitempty"` -} - -type VerifyOperationChecks struct { - Signature bool `json:"signature"` - Chain bool `json:"chain"` - Receipt bool `json:"receipt"` - Merkle *bool `json:"merkle,omitempty"` -} - -type VerifyOperationResponse struct { - Valid bool `json:"valid"` - Checks VerifyOperationChecks `json:"checks"` - Errors []string `json:"errors,omitempty"` -} - -type AuditQueryRequest struct { - OrgID string `json:"org_id,omitempty"` - AgentID string `json:"agent_id,omitempty"` - OperationType string `json:"operation_type,omitempty"` - StartTime *int64 `json:"start_time,omitempty"` - EndTime *int64 `json:"end_time,omitempty"` - Cursor string `json:"cursor,omitempty"` - Limit *int `json:"limit,omitempty"` -} - -type AuditQueryResponse struct { - Operations []Operation `json:"operations"` - Cursor string `json:"cursor,omitempty"` - TotalCount int64 `json:"total_count"` -} - -type GetEpochResponse struct { - Epoch Epoch `json:"epoch"` - Anchor *EpochAnchor `json:"anchor,omitempty"` -} - -type EpochAnchor struct { - TSAToken string `json:"tsa_token,omitempty"` - TSAUrl string `json:"tsa_url,omitempty"` - AnchoredAt *int64 `json:"anchored_at,omitempty"` -} - -type ListEpochsResponse struct { - Epochs []Epoch `json:"epochs"` -} - -type CreateExportRequest struct { - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - AgentID string `json:"agent_id,omitempty"` - OperationType string `json:"operation_type,omitempty"` - Format ExportFormat `json:"format"` -} - -type CreateExportResponse struct { - Export Export `json:"export"` -} - -type GetExportResponse struct { - Export Export `json:"export"` - DownloadURL string `json:"download_url,omitempty"` -} - -type ListExportsResponse struct { - Exports []Export `json:"exports"` -} - -type JWK struct { - KTY string `json:"kty"` - CRV string `json:"crv,omitempty"` - X string `json:"x,omitempty"` - KID string `json:"kid"` - Use string `json:"use"` - Alg string `json:"alg"` -} - -type JWKSResponse struct { - Keys []JWK `json:"keys"` -} - -type AuthRegisterRequest struct { - Email string `json:"email"` - Password string `json:"password"` - DisplayName string `json:"display_name,omitempty"` - OrgName string `json:"org_name,omitempty"` -} - -type AuthRegisterResponse struct { - User User `json:"user"` - Organization Organization `json:"organization"` - Token string `json:"token"` -} - -type AuthLoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type AuthLoginResponse struct { - User User `json:"user"` - Token string `json:"token"` -} - // AdminEvent represents an administrative event log entry. type AdminEvent struct { EventID string `json:"event_id"` @@ -449,27 +265,13 @@ type AgentAssignment struct { // Webhook represents a registered webhook endpoint. type Webhook struct { - WebhookID string `json:"webhook_id"` - OrgID string `json:"org_id"` - EndpointURL string `json:"endpoint_url"` - Events []string `json:"events"` - Status string `json:"status"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - -type ListWebhooksResponse struct { - Webhooks []Webhook `json:"webhooks"` -} - -type RegisterWebhookRequest struct { - EndpointURL string `json:"endpoint_url"` - Events []string `json:"events"` - Secret string `json:"secret"` -} - -type RegisterWebhookResponse struct { - Webhook Webhook `json:"webhook"` + WebhookID string `json:"webhook_id"` + OrgID string `json:"org_id"` + EndpointURL string `json:"endpoint_url"` + Events []string `json:"events"` + Status WebhookStatus `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } // Member represents a console user returned from the members list endpoint. @@ -482,40 +284,42 @@ type Member struct { DisplayName string `json:"display_name"` } -type ListMembersResponse struct { - Members []Member `json:"members"` +// AuthMeUser is the profile returned by GET /v1/auth/me. +type AuthMeUser struct { + UserID string `json:"user_id"` + OrgID *string `json:"org_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role RbacRole `json:"role"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + OnboardingCompleted bool `json:"onboarding_completed"` } -type ListAdminEventsResponse struct { - Events []AdminEvent `json:"events"` +// CurrentOrganization is the active organization of the authenticated user. +type CurrentOrganization struct { + OrgID *string `json:"org_id"` + BAOrgID *string `json:"ba_org_id"` + Role RbacRole `json:"role"` } -// DependencyHealth holds the health status of a single backend dependency. +// DependencyHealth is the health of one backend dependency. type DependencyHealth struct { - Status string `json:"status"` - LatencyMs int64 `json:"latency_ms"` - Error *string `json:"error,omitempty"` + Status string `json:"status"` + LatencyMs int64 `json:"latency_ms"` + ContractPhase string `json:"contract_phase,omitempty"` } -// DeepHealthDependencies lists the health of all backend dependencies. +// DeepHealthDependencies lists every backend dependency. type DeepHealthDependencies struct { - D1 DependencyHealth `json:"d1"` - R2 DependencyHealth `json:"r2"` - KV DependencyHealth `json:"kv"` + D1 DependencyHealth `json:"d1"` + AuthStorage DependencyHealth `json:"auth_storage"` + R2 DependencyHealth `json:"r2"` + KV DependencyHealth `json:"kv"` } -// DeepHealthResponse is returned by GET /v1/health/deep. -type DeepHealthResponse struct { - Status string `json:"status"` - Version string `json:"version"` - Timestamp int64 `json:"timestamp"` - Dependencies DeepHealthDependencies `json:"dependencies"` -} - -// --------------------------------------------------------------------------- -// CreateOperation params (SDK-specific) -// --------------------------------------------------------------------------- - +// CreateOperationParams are the inputs for CreateOperation. type CreateOperationParams struct { OperationType string Subject map[string]interface{} diff --git a/types_api.go b/types_api.go new file mode 100644 index 0000000..3a2a807 --- /dev/null +++ b/types_api.go @@ -0,0 +1,235 @@ +package elydora + +type RegisterAgentKeyInput struct { + KID string `json:"kid"` + PublicKey string `json:"public_key"` + Algorithm string `json:"algorithm"` +} + +type RegisterAgentRequest struct { + AgentID string `json:"agent_id"` + DisplayName string `json:"display_name,omitempty"` + ResponsibleEntity string `json:"responsible_entity,omitempty"` + IntegrationType IntegrationType `json:"integration_type"` + Keys []RegisterAgentKeyInput `json:"keys"` +} + +type RegisterAgentResponse struct { + Agent Agent `json:"agent"` + Keys []AgentKey `json:"keys"` +} + +type GetAgentResponse struct { + Agent Agent `json:"agent"` + Keys []AgentKey `json:"keys"` +} + +type FreezeAgentRequest struct { + Reason string `json:"reason"` +} + +type FreezeAgentResponse struct { + Agent Agent `json:"agent"` + PreviousStatus AgentStatus `json:"previous_status"` +} + +type RevokeAgentRequest struct { + KID string `json:"kid"` + Reason string `json:"reason"` +} + +type UnfreezeAgentRequest struct { + Reason string `json:"reason"` +} + +type UnfreezeAgentResponse struct { + Agent Agent `json:"agent"` + PreviousStatus AgentStatus `json:"previous_status"` +} + +type UpdateAgentRequest struct { + IntegrationType IntegrationType `json:"integration_type"` +} + +type UpdateAgentResponse struct { + Agent Agent `json:"agent"` +} + +type ListAgentsResponse struct { + Agents []Agent `json:"agents"` +} + +type DeleteAgentResponse struct { + Deleted bool `json:"deleted"` +} + +type GetMeResponse struct { + User AuthMeUser `json:"user"` + CurrentOrganization CurrentOrganization `json:"current_organization"` +} + +type IssueApiTokenRequest struct { + TTLSeconds *int `json:"ttl_seconds"` +} + +type IssueApiTokenResponse struct { + Token string `json:"token"` + ExpiresAt *int64 `json:"expires_at"` + TokenID string `json:"token_id"` +} + +type RotateApiTokenResponse struct { + Token string `json:"token"` + ExpiresAt *int64 `json:"expires_at"` + TokenID string `json:"token_id"` +} + +type HealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + ProtocolVersion string `json:"protocol_version"` + Capabilities map[string]string `json:"capabilities"` + Timestamp int64 `json:"timestamp"` +} + +type SubmitOperationResponse struct { + Receipt EAR `json:"receipt"` +} + +type GetOperationResponse struct { + Operation Operation `json:"operation"` + Receipt *Receipt `json:"receipt,omitempty"` + Payload interface{} `json:"payload,omitempty"` +} + +type VerifyOperationChecks struct { + Signature bool `json:"signature"` + Chain bool `json:"chain"` + Receipt bool `json:"receipt"` + Merkle *bool `json:"merkle,omitempty"` +} + +type VerifyOperationResponse struct { + Valid bool `json:"valid"` + Checks VerifyOperationChecks `json:"checks"` + Errors []string `json:"errors,omitempty"` +} + +type AuditQueryRequest struct { + OrgID string `json:"org_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + OperationType string `json:"operation_type,omitempty"` + StartTime *int64 `json:"start_time,omitempty"` + EndTime *int64 `json:"end_time,omitempty"` + Cursor string `json:"cursor,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +type AuditQueryResponse struct { + Operations []Operation `json:"operations"` + Cursor string `json:"cursor,omitempty"` + TotalCount int64 `json:"total_count"` +} + +type GetEpochResponse struct { + Epoch Epoch `json:"epoch"` + Anchor *EpochAnchor `json:"anchor,omitempty"` +} + +type EpochAnchor struct { + TSAToken string `json:"tsa_token,omitempty"` + TSAUrl string `json:"tsa_url,omitempty"` + AnchoredAt *int64 `json:"anchored_at,omitempty"` +} + +type ListEpochsResponse struct { + Epochs []Epoch `json:"epochs"` +} + +type CreateExportRequest struct { + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + AgentID string `json:"agent_id,omitempty"` + OperationType string `json:"operation_type,omitempty"` + Format ExportFormat `json:"format"` +} + +type CreateExportResponse struct { + Export Export `json:"export"` +} + +type GetExportResponse struct { + Export Export `json:"export"` + DownloadURL string `json:"download_url,omitempty"` +} + +type ListExportsResponse struct { + Exports []Export `json:"exports"` +} + +type JWK struct { + KTY string `json:"kty"` + CRV string `json:"crv,omitempty"` + X string `json:"x,omitempty"` + KID string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` +} + +type JWKSResponse struct { + Keys []JWK `json:"keys"` +} + +type AuthRegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name,omitempty"` + OrgName string `json:"org_name,omitempty"` +} + +type AuthRegisterResponse struct { + User User `json:"user"` + Organization Organization `json:"organization"` + Token string `json:"token"` +} + +type AuthLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AuthLoginResponse struct { + User User `json:"user"` + Token string `json:"token"` +} + +type ListWebhooksResponse struct { + Webhooks []Webhook `json:"webhooks"` +} + +type RegisterWebhookRequest struct { + EndpointURL string `json:"endpoint_url"` + Events []string `json:"events"` + Secret string `json:"secret"` +} + +type RegisterWebhookResponse struct { + Webhook Webhook `json:"webhook"` +} + +type ListMembersResponse struct { + Members []Member `json:"members"` +} + +type ListAdminEventsResponse struct { + Events []AdminEvent `json:"events"` +} + +type DeepHealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + ProtocolVersion string `json:"protocol_version"` + Capabilities map[string]string `json:"capabilities"` + Timestamp int64 `json:"timestamp"` + Dependencies DeepHealthDependencies `json:"dependencies"` +} diff --git a/utils.go b/utils.go index bfc16ee..67c67bf 100644 --- a/utils.go +++ b/utils.go @@ -8,7 +8,7 @@ import ( "time" ) -// base64urlEncode encodes bytes to base64url without padding (RFC 4648 section 5). +// base64urlEncode encodes bytes as unpadded base64url. func base64urlEncode(data []byte) string { return base64.RawURLEncoding.EncodeToString(data) } @@ -18,7 +18,7 @@ func base64urlDecode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } -// generateNonce generates a 16-byte cryptographically random nonce, base64url encoded. +// generateNonce returns 16 random bytes as base64url. func generateNonce() (string, error) { b := make([]byte, 16) if _, err := io.ReadFull(rand.Reader, b); err != nil { @@ -34,19 +34,15 @@ func generateUUIDv7() (string, error) { var uuid [16]byte - // Timestamp: 48 bits in big-endian binary.BigEndian.PutUint16(uuid[0:2], uint16(ms>>32)) binary.BigEndian.PutUint16(uuid[2:4], uint16(ms>>16)) binary.BigEndian.PutUint16(uuid[4:6], uint16(ms)) - // Fill the rest with random data if _, err := io.ReadFull(rand.Reader, uuid[6:]); err != nil { return "", err } - // Set version 7 (bits 48-51) uuid[6] = (uuid[6] & 0x0F) | 0x70 - // Set variant 10 (bits 64-65) uuid[8] = (uuid[8] & 0x3F) | 0x80 return formatUUID(uuid), nil