Compare commits
4
Commits
v2.0.0
...
b085947547
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b085947547 | ||
|
|
5e08ab20f5 | ||
|
|
27b720ca69 | ||
|
|
f5341ed557 |
No files matched your search
@@ -31,3 +31,4 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
/elydora
|
||||||
+1
-2
@@ -2,8 +2,7 @@ package elydora
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// ListAdminEvents retrieves recent administrative events for the organization.
|
// ListAdminEvents retrieves recent administrative events; limit 0 uses the server default.
|
||||||
// An optional limit (1–100) may be provided; pass 0 to use the server default (20).
|
|
||||||
func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) {
|
func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) {
|
||||||
path := "/v1/admin/events"
|
path := "/v1/admin/events"
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
|
|||||||
@@ -1,51 +1,30 @@
|
|||||||
package elydora
|
package elydora
|
||||||
|
|
||||||
import (
|
import "net/http"
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegisterOption is a functional option for the Register function.
|
// RegisterOption configures a registration request.
|
||||||
type RegisterOption func(*AuthRegisterRequest)
|
type RegisterOption func(*AuthRegisterRequest)
|
||||||
|
|
||||||
// WithDisplayName sets the display_name on a registration request.
|
// WithDisplayName sets the display_name on a registration request.
|
||||||
func WithDisplayName(name string) RegisterOption {
|
func WithDisplayName(name string) RegisterOption {
|
||||||
return func(req *AuthRegisterRequest) {
|
return func(req *AuthRegisterRequest) { req.DisplayName = name }
|
||||||
req.DisplayName = name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOrgName sets the org_name on a registration request.
|
// WithOrgName sets the org_name on a registration request.
|
||||||
func WithOrgName(name string) RegisterOption {
|
func WithOrgName(name string) RegisterOption {
|
||||||
return func(req *AuthRegisterRequest) {
|
return func(req *AuthRegisterRequest) { req.OrgName = name }
|
||||||
req.OrgName = name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a new user and organization.
|
// 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) {
|
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
|
||||||
if baseURL == "" {
|
req := &AuthRegisterRequest{Email: email, Password: password}
|
||||||
baseURL = defaultBaseURL
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
|
||||||
|
|
||||||
req := &AuthRegisterRequest{
|
|
||||||
Email: email,
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(req)
|
opt(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result AuthRegisterResponse
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
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.
|
// 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) {
|
func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
|
||||||
if baseURL == "" {
|
req := &AuthLoginRequest{Email: email, Password: password}
|
||||||
baseURL = defaultBaseURL
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
|
||||||
|
|
||||||
req := &AuthLoginRequest{
|
|
||||||
Email: email,
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
|
|
||||||
var result AuthLoginResponse
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
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.
|
// GetMe retrieves the current authenticated user's profile.
|
||||||
func (c *Client) GetMe() (*GetMeResponse, error) {
|
func (c *Client) GetMe() (*GetMeResponse, error) {
|
||||||
var result GetMeResponse
|
var result GetMeResponse
|
||||||
@@ -145,8 +60,7 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotateApiToken rotates the current API token and leaves the old token
|
// RotateApiToken rotates the current API token; the old token stays valid for 24h.
|
||||||
// valid for a 24h grace window.
|
|
||||||
func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) {
|
func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) {
|
||||||
var result RotateApiTokenResponse
|
var result RotateApiTokenResponse
|
||||||
if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil {
|
if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const (
|
|||||||
defaultBaseURL = "https://api.elydora.com"
|
defaultBaseURL = "https://api.elydora.com"
|
||||||
defaultTTLMs = 30000
|
defaultTTLMs = 30000
|
||||||
defaultMaxRetries = 3
|
defaultMaxRetries = 3
|
||||||
|
maxResponseBytes = 10 << 20
|
||||||
|
requestTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the configuration for creating a new Client.
|
// Config holds the configuration for creating a new Client.
|
||||||
@@ -44,135 +46,180 @@ func NewClient(cfg *Config) (*Client, error) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, fmt.Errorf("elydora: config must not be 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
|
ttlMs := cfg.TTLMs
|
||||||
if ttlMs <= 0 {
|
if ttlMs <= 0 {
|
||||||
ttlMs = defaultTTLMs
|
ttlMs = defaultTTLMs
|
||||||
}
|
}
|
||||||
|
|
||||||
maxRetries := cfg.MaxRetries
|
maxRetries := cfg.MaxRetries
|
||||||
if maxRetries <= 0 {
|
if maxRetries <= 0 {
|
||||||
maxRetries = defaultMaxRetries
|
maxRetries = defaultMaxRetries
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
orgID: cfg.OrgID,
|
orgID: cfg.OrgID,
|
||||||
agentID: cfg.AgentID,
|
agentID: cfg.AgentID,
|
||||||
privateKey: cfg.PrivateKey,
|
privateKey: cfg.PrivateKey,
|
||||||
baseURL: baseURL,
|
baseURL: normalizeBaseURL(cfg.BaseURL),
|
||||||
ttlMs: ttlMs,
|
ttlMs: ttlMs,
|
||||||
maxRetries: maxRetries,
|
maxRetries: maxRetries,
|
||||||
token: cfg.Token,
|
token: cfg.Token,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{Timeout: requestTimeout},
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
}, nil
|
}, 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.
|
// SetToken sets the API token used for authenticated API requests.
|
||||||
func (c *Client) SetToken(token string) {
|
func (c *Client) SetToken(token string) {
|
||||||
c.token = token
|
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)
|
||||||
|
}
|
||||||
@@ -119,9 +119,6 @@ func readSecretFileWithOpen(
|
|||||||
if err := validateSecretFile(before, path, label); err != nil {
|
if err := validateSecretFile(before, path, label); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !os.SameFile(before, before) {
|
|
||||||
return "", fmt.Errorf("establish %s file identity at %s", label, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := openFile(path)
|
file, err := openFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-26
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "2.0.0"
|
const version = "2.0.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@@ -57,10 +57,6 @@ Run "elydora <command> -h" for details on each command.
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// install
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdInstall(args []string) {
|
func cmdInstall(args []string) {
|
||||||
if err := rejectLegacySecretArguments(args); err != nil {
|
if err := rejectLegacySecretArguments(args); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -160,10 +156,6 @@ func cmdInstall(args []string) {
|
|||||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// uninstall
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdUninstall(args []string) {
|
func cmdUninstall(args []string) {
|
||||||
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||||
agent := fs.String("agent", "", "Agent name (required)")
|
agent := fs.String("agent", "", "Agent name (required)")
|
||||||
@@ -209,10 +201,6 @@ func cmdUninstall(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// status
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdStatus(args []string) {
|
func cmdStatus(args []string) {
|
||||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
|
agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
|
||||||
@@ -256,10 +244,6 @@ func showAgentStatus(name string) {
|
|||||||
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
|
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// agents
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdAgents() {
|
func cmdAgents() {
|
||||||
fmt.Println("Supported agents:")
|
fmt.Println("Supported agents:")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
|
|||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardScriptPathForAgent returns the path to ~/.elydora/<agentId>/guard.js.
|
|
||||||
func guardScriptPathForAgent(agentId string) (string, error) {
|
|
||||||
agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(agentDirectory, "guard.js"), nil
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setMainTestHome(t *testing.T) string {
|
func setMainTestHome(t *testing.T) string {
|
||||||
@@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string {
|
|||||||
|
|
||||||
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
|
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
|
||||||
setMainTestHome(t)
|
setMainTestHome(t)
|
||||||
_, err := guardScriptPathForAgent("../escape")
|
_, err := plugins.ResolveAgentRuntimeDirectory("../escape")
|
||||||
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
|
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
|
||||||
t.Fatalf("guardScriptPathForAgent() error = %v", err)
|
t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mismatchAPI struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rejections int
|
||||||
|
delay time.Duration
|
||||||
|
staleHead bool
|
||||||
|
staleLock bool
|
||||||
|
liveLock bool
|
||||||
|
statePath string
|
||||||
|
operations []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatchExpected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
|
||||||
|
|
||||||
|
func (api *mismatchAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||||
|
api.mu.Lock()
|
||||||
|
defer api.mu.Unlock()
|
||||||
|
response.Header().Set("Content-Type", "application/json")
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/v1/operations" {
|
||||||
|
_ = json.NewEncoder(response).Encode(map[string]any{"agent": map[string]any{"status": "active"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var operation map[string]any
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
|
||||||
|
http.Error(response, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.operations = append(api.operations, operation)
|
||||||
|
count := len(api.operations)
|
||||||
|
if api.delay > 0 {
|
||||||
|
api.mu.Unlock()
|
||||||
|
time.Sleep(api.delay)
|
||||||
|
api.mu.Lock()
|
||||||
|
}
|
||||||
|
if api.statePath != "" {
|
||||||
|
_ = os.WriteFile(api.statePath, []byte(`{"prev_chain_hash":"`+strings.Repeat("B", 43)+`"}`), 0o600)
|
||||||
|
}
|
||||||
|
if count > api.rejections {
|
||||||
|
response.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = fmt.Fprintf(response, `{"receipt":{"seq_no":%d}}`, count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := mismatchExpected
|
||||||
|
if api.rejections != 1 {
|
||||||
|
expected = strings.Repeat("A", 42) + fmt.Sprint(count)
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
response,
|
||||||
|
`{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"%s\", got \"x\"."}}`,
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClaudeAuditAgainst(t *testing.T, api *mismatchAPI) (*claudeFixture, []map[string]any, time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(api)
|
||||||
|
defer server.Close()
|
||||||
|
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||||
|
fixture.config.BaseURL = server.URL
|
||||||
|
fixture.config.Token = "ely_test_token"
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Claude hooks: %v", err)
|
||||||
|
}
|
||||||
|
settings := readClaudeTestObject(t, fixture.configPath)
|
||||||
|
audit := claudeTestManagedHandler(t, settings, "PostToolUse", claudeAuditScript, claudeAuditStatusMessage)
|
||||||
|
if api.staleHead {
|
||||||
|
api.mu.Lock()
|
||||||
|
api.statePath = filepath.Join(fixture.agentDir, "chain-state.json")
|
||||||
|
api.mu.Unlock()
|
||||||
|
}
|
||||||
|
if api.staleLock || api.liveLock {
|
||||||
|
lockPath := filepath.Join(fixture.agentDir, "chain-state.json.lock")
|
||||||
|
var owner []byte
|
||||||
|
if api.liveLock {
|
||||||
|
owner = []byte(fmt.Sprint(os.Getpid()))
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(lockPath, owner, 0o600); err != nil {
|
||||||
|
t.Fatalf("write stale lock: %v", err)
|
||||||
|
}
|
||||||
|
stale := time.Now().Add(-10 * time.Second)
|
||||||
|
if err := os.Chtimes(lockPath, stale, stale); err != nil {
|
||||||
|
t.Fatalf("age stale lock: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
exit, stdout, stderr := runClaudeHandler(t, audit, fixture, claudeOfficialPayload("PostToolUse"))
|
||||||
|
elapsed := time.Since(started)
|
||||||
|
if exit != 0 || stdout != "" || stderr != "" {
|
||||||
|
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
|
||||||
|
}
|
||||||
|
api.mu.Lock()
|
||||||
|
defer api.mu.Unlock()
|
||||||
|
return fixture, api.operations, elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditRetriesRejectedChainHash(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 1})
|
||||||
|
if len(operations) != 2 || operations[1]["prev_chain_hash"] != mismatchExpected {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if operations[0]["operation_id"] == operations[1]["operation_id"] || operations[0]["nonce"] == operations[1]["nonce"] {
|
||||||
|
t.Fatalf("retry reused identifiers: %#v", operations)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read chain state: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(state), fmt.Sprint(operations[1]["chain_hash"])) {
|
||||||
|
t.Fatalf("chain state %s does not hold %v", state, operations[1]["chain_hash"])
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "resynced to server: Rxlf") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditStopsAfterFiveRejections(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99})
|
||||||
|
if len(operations) != 5 || operations[4]["prev_chain_hash"] != strings.Repeat("A", 42)+"4" {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "rejected prev_chain_hash 5 times") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditStopsRetryingWhenBudgetSpent(t *testing.T) {
|
||||||
|
fixture, operations, elapsed := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99, delay: 2600 * time.Millisecond})
|
||||||
|
if elapsed > 7500*time.Millisecond {
|
||||||
|
t.Fatalf("hook ran %s", elapsed)
|
||||||
|
}
|
||||||
|
if len(operations) < 2 || len(operations) > 3 {
|
||||||
|
t.Fatalf("submissions = %d", len(operations))
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !(strings.Contains(string(log), "aborted") || strings.Contains(string(log), "retry budget exhausted")) {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditDoesNotRegressAConcurrentlyAdvancedChainHead(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleHead: true})
|
||||||
|
if len(operations) != 1 || operations[0]["prev_chain_hash"] != strings.Repeat("A", 43) {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil || !strings.Contains(string(state), strings.Repeat("B", 43)) {
|
||||||
|
t.Fatalf("chain state = %s, %v", state, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditClearsAStaleChainStateLock(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleLock: true})
|
||||||
|
if len(operations) != 1 {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale lock still present: %v", err)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil || !strings.Contains(string(state), fmt.Sprint(operations[0]["chain_hash"])) {
|
||||||
|
t.Fatalf("chain state = %s, %v", state, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditDoesNotReclaimALiveOwnersLock(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{liveLock: true})
|
||||||
|
if len(operations) != 1 {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); err != nil {
|
||||||
|
t.Fatalf("live lock was removed: %v", err)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "lock timed out") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,3 +344,12 @@ func requireNoDroidStagingFiles(t *testing.T, root string) {
|
|||||||
t.Fatalf("walk staging files: %v", err)
|
t.Fatalf("walk staging files: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) {
|
||||||
|
value, err := parseJSONC(source, label, allowTrailingCommas)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value.Standardize()
|
||||||
|
return value.Pack(), nil
|
||||||
|
}
|
||||||
@@ -146,30 +146,8 @@ function writeStatusCache(status) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBaseURL(config) {
|
|
||||||
const value = config.base_url || 'https://api.elydora.com';
|
|
||||||
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
|
|
||||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
|
||||||
}
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = new URL(value);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
|
||||||
}
|
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
|
||||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
|
||||||
}
|
|
||||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
||||||
throw new Error(
|
|
||||||
'Agent config base_url must exclude credentials, query parameters, and fragments',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value.replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveRemoteStatus(config) {
|
async function resolveRemoteStatus(config) {
|
||||||
const baseURL = resolveBaseURL(config);
|
const baseURL = resolveConfigBaseURL(config);
|
||||||
const token = config.token || '';
|
const token = config.token || '';
|
||||||
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ func buildHookScriptWithOutput(
|
|||||||
return fmt.Sprintf(`#!/usr/bin/env node
|
return fmt.Sprintf(`#!/usr/bin/env node
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Elydora audit hook for %s. Generated by Elydora SDK.
|
||||||
// Self-contained Elydora audit hook for: %s
|
|
||||||
// Generated by @elydora/sdk — DO NOT EDIT
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
@@ -53,10 +50,6 @@ function parsePrivateKey(raw) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function base64urlEncode(data) {
|
function base64urlEncode(data) {
|
||||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
return buf.toString('base64url');
|
return buf.toString('base64url');
|
||||||
@@ -113,6 +106,8 @@ function signEd25519(privateKeyBase64url, data) {
|
|||||||
return base64urlEncode(signature);
|
return base64urlEncode(signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_CHAIN_ATTEMPTS = 5;
|
||||||
|
const SUBMIT_BUDGET_MS = 4000;
|
||||||
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||||
|
|
||||||
function validateChainHash(value, source) {
|
function validateChainHash(value, source) {
|
||||||
@@ -156,10 +151,6 @@ function generateNonce() {
|
|||||||
return base64urlEncode(crypto.randomBytes(16));
|
return base64urlEncode(crypto.randomBytes(16));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chain state persistence (atomic writes)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function readChainState() {
|
function readChainState() {
|
||||||
let raw;
|
let raw;
|
||||||
try {
|
try {
|
||||||
@@ -188,9 +179,80 @@ function writeChainState(chainHash) {
|
|||||||
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function lockOwnerAlive(lockPath) {
|
||||||
// Error logging
|
let owner;
|
||||||
// ---------------------------------------------------------------------------
|
try {
|
||||||
|
owner = Number.parseInt(fs.readFileSync(lockPath, 'utf-8'), 10);
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(owner) || owner <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(owner, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (hasRuntimeErrorCode(error, 'ESRCH')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withChainLock(update) {
|
||||||
|
const lockPath = CHAIN_STATE_PATH + '.lock';
|
||||||
|
const deadline = performance.now() + 1000;
|
||||||
|
for (;;) {
|
||||||
|
let descriptor;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
||||||
|
fs.writeSync(descriptor, String(process.pid));
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasRuntimeErrorCode(error, 'EEXIST')) throw error;
|
||||||
|
if (lockOwnerAlive(lockPath)) {
|
||||||
|
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let age = 0;
|
||||||
|
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (statError) {
|
||||||
|
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||||
|
}
|
||||||
|
if (age > 5000) {
|
||||||
|
const reclaimed = lockPath + '.' + process.pid + '.stale';
|
||||||
|
try {
|
||||||
|
fs.renameSync(lockPath, reclaimed);
|
||||||
|
fs.unlinkSync(reclaimed);
|
||||||
|
} catch (reclaimError) {
|
||||||
|
if (!hasRuntimeErrorCode(reclaimError, 'ENOENT')) throw reclaimError;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (performance.now() > deadline) throw new Error('Chain state lock timed out: ' + lockPath);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return update();
|
||||||
|
} finally {
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
current = fs.lstatSync(lockPath);
|
||||||
|
} catch (statError) {
|
||||||
|
if (!hasRuntimeErrorCode(statError, 'ENOENT')) throw statError;
|
||||||
|
}
|
||||||
|
const own = fs.fstatSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
if (current && current.ino === own.ino && current.dev === own.dev) fs.unlinkSync(lockPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceChainState(fromHash, toHash) {
|
||||||
|
return withChainLock(() => {
|
||||||
|
if (readChainState() !== fromHash) return false;
|
||||||
|
writeChainState(toHash);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function logError(err) {
|
function logError(err) {
|
||||||
try {
|
try {
|
||||||
@@ -204,35 +266,43 @@ function logError(err) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAuditBaseURL(config) {
|
async function postOperation(baseUrl, token, eor, deadline) {
|
||||||
const value = config.base_url || 'https://api.elydora.com';
|
const remaining = deadline - performance.now();
|
||||||
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
|
if (remaining <= 0) throw new Error('Audit API retry budget exhausted');
|
||||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
const controller = new AbortController();
|
||||||
}
|
const timeout = setTimeout(() => controller.abort(), Math.min(5000, remaining));
|
||||||
let parsed;
|
|
||||||
try {
|
try {
|
||||||
parsed = new URL(value);
|
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
||||||
} catch (error) {
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
const res = await fetch(baseUrl + '/v1/operations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(eor),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (res.ok) return { accepted: true };
|
||||||
|
if (res.status === 400) {
|
||||||
|
let errBody;
|
||||||
|
try {
|
||||||
|
errBody = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('Audit API returned invalid error JSON: ' + err.message);
|
||||||
}
|
}
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
||||||
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
return { accepted: false, expected: validateChainHash(match[1], 'Audit API mismatch response') };
|
||||||
}
|
}
|
||||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
||||||
throw new Error(
|
|
||||||
'Agent config base_url must exclude credentials, query parameters, and fragments',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return value.replace(/\/+$/, '');
|
}
|
||||||
|
throw new Error('Audit API returned HTTP ' + res.status);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main hook logic
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
// Read JSON from stdin
|
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for await (const chunk of process.stdin) {
|
for await (const chunk of process.stdin) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
@@ -268,7 +338,6 @@ async function main() {
|
|||||||
hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
||||||
hookData.session || hookData.taskId || 'unknown';
|
hookData.session || hookData.taskId || 'unknown';
|
||||||
|
|
||||||
// Read agent config and private key
|
|
||||||
let config;
|
let config;
|
||||||
let privateKey;
|
let privateKey;
|
||||||
try {
|
try {
|
||||||
@@ -296,7 +365,7 @@ async function main() {
|
|||||||
const orgId = config.org_id;
|
const orgId = config.org_id;
|
||||||
const agentId = config.agent_id;
|
const agentId = config.agent_id;
|
||||||
const kid = config.kid;
|
const kid = config.kid;
|
||||||
const baseUrl = resolveAuditBaseURL(config);
|
const baseUrl = resolveConfigBaseURL(config);
|
||||||
const token = config.token || '';
|
const token = config.token || '';
|
||||||
if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' ||
|
if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' ||
|
||||||
!agentId || typeof kid !== 'string' || !kid) {
|
!agentId || typeof kid !== 'string' || !kid) {
|
||||||
@@ -304,23 +373,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
|
||||||
|
|
||||||
// Read chain state
|
// Retry from the server chain head on PREV_HASH_MISMATCH.
|
||||||
const prevChainHash = readChainState();
|
|
||||||
|
|
||||||
// Build EOR
|
|
||||||
const operationId = uuidv7();
|
|
||||||
const issuedAt = Date.now();
|
|
||||||
const nonce = generateNonce();
|
|
||||||
|
|
||||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||||
tool_name: toolName,
|
tool_name: toolName,
|
||||||
tool_input: toolInput,
|
tool_input: toolInput,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const payloadHash = computePayloadHash(payload);
|
const payloadHash = computePayloadHash(payload);
|
||||||
|
const deadline = performance.now() + SUBMIT_BUDGET_MS;
|
||||||
|
let prevChainHash = readChainState();
|
||||||
|
for (let attempt = 1; ; attempt += 1) {
|
||||||
|
const operationId = uuidv7();
|
||||||
|
const issuedAt = Date.now();
|
||||||
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||||
|
|
||||||
const eorWithoutSig = {
|
const eorWithoutSig = {
|
||||||
op_version: '1.0',
|
op_version: '1.0',
|
||||||
operation_id: operationId,
|
operation_id: operationId,
|
||||||
@@ -328,7 +393,7 @@ async function main() {
|
|||||||
agent_id: agentId,
|
agent_id: agentId,
|
||||||
issued_at: issuedAt,
|
issued_at: issuedAt,
|
||||||
ttl_ms: 30000,
|
ttl_ms: 30000,
|
||||||
nonce: nonce,
|
nonce: generateNonce(),
|
||||||
operation_type: 'ai.tool_use',
|
operation_type: 'ai.tool_use',
|
||||||
subject: { session_id: sessionId },
|
subject: { session_id: sessionId },
|
||||||
action: { tool: toolName },
|
action: { tool: toolName },
|
||||||
@@ -337,58 +402,19 @@ async function main() {
|
|||||||
prev_chain_hash: prevChainHash,
|
prev_chain_hash: prevChainHash,
|
||||||
agent_pubkey_kid: kid,
|
agent_pubkey_kid: kid,
|
||||||
};
|
};
|
||||||
|
const signature = signEd25519(privateKey, Buffer.from(jcsCanonicalise(eorWithoutSig), 'utf-8'));
|
||||||
const canonical = jcsCanonicalise(eorWithoutSig);
|
|
||||||
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
|
|
||||||
|
|
||||||
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
||||||
|
const result = await postOperation(baseUrl, token, eor, deadline);
|
||||||
// POST to /v1/operations (5s timeout)
|
if (result.accepted) {
|
||||||
// Only update local chain state on success to prevent desync
|
advanceChainState(prevChainHash, chainHash);
|
||||||
const url = baseUrl + '/v1/operations';
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
};
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = 'Bearer ' + token;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify(eor),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
writeChainState(chainHash);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.status === 400) {
|
advanceChainState(prevChainHash, result.expected);
|
||||||
// Possible chain hash mismatch — try to resync from server response
|
logError(new Error('Chain hash resynced to server: ' + result.expected));
|
||||||
try {
|
if (attempt >= MAX_CHAIN_ATTEMPTS) {
|
||||||
const errBody = await res.json();
|
throw new Error('Audit API rejected prev_chain_hash ' + attempt + ' times');
|
||||||
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
|
||||||
// Extract the expected hash from the error message
|
|
||||||
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
|
||||||
if (match) {
|
|
||||||
const expectedHash = validateChainHash(match[1], 'Audit API mismatch response');
|
|
||||||
writeChainState(expectedHash);
|
|
||||||
logError(new Error('Chain hash resynced to server: ' + expectedHash));
|
|
||||||
}
|
}
|
||||||
}
|
prevChainHash = readChainState();
|
||||||
} catch (err) {
|
|
||||||
logError(new Error('Failed to parse audit API error: ' + err.message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Audit API returned HTTP ' + res.status);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err);
|
logError(err);
|
||||||
|
|||||||
@@ -17,15 +17,6 @@ type jsoncEditor struct {
|
|||||||
label string
|
label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) {
|
|
||||||
value, err := parseJSONC(source, label, allowTrailingCommas)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
value.Standardize()
|
|
||||||
return value.Pack(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) {
|
func parseJSONC(source []byte, label string, allowTrailingCommas bool) (hujson.Value, error) {
|
||||||
value, err := hujson.Parse(source)
|
value, err := hujson.Parse(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,22 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// expandHome replaces a leading ~ with the user's home directory.
|
// readJSONFile reads a JSON object; a missing file yields an empty map.
|
||||||
func expandHome(p string) (string, error) {
|
|
||||||
if !strings.HasPrefix(p, "~") {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, p[1:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readJSONFile reads a JSON file into a map. Returns an empty map if the file does not exist.
|
|
||||||
func readJSONFile(path string) (map[string]interface{}, error) {
|
func readJSONFile(path string) (map[string]interface{}, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,7 +40,7 @@ func writeJSONFile(path string, data map[string]interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hookScriptPath returns the default path for the hook script inside ~/.elydora/<agentId>/.
|
// hookScriptPath returns ~/.elydora/<agentId>/hook.js.
|
||||||
func hookScriptPath(agentId string) (string, error) {
|
func hookScriptPath(agentId string) (string, error) {
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
|
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,7 +49,7 @@ func hookScriptPath(agentId string) (string, error) {
|
|||||||
return filepath.Join(agentDirectory, "hook.js"), nil
|
return filepath.Join(agentDirectory, "hook.js"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardScriptPath returns the default path for the guard script inside ~/.elydora/<agentId>/.
|
// guardScriptPath returns ~/.elydora/<agentId>/guard.js.
|
||||||
func guardScriptPath(agentId string) (string, error) {
|
func guardScriptPath(agentId string) (string, error) {
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
|
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ package plugins
|
|||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// extractElydoraScriptPath supports adapters that still use the legacy shared
|
// extractElydoraScriptPath reads the legacy shared command-hook shape.
|
||||||
// command-hook shape. Provider-specific adapters should define exact ownership.
|
|
||||||
func extractElydoraScriptPath(hookArray any) string {
|
func extractElydoraScriptPath(hookArray any) string {
|
||||||
entries, _ := hookArray.([]any)
|
entries, _ := hookArray.([]any)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"qwen": {Name: "Qwen Code", ConfigDir: "~/.qwen", ConfigFile: "settings.json"},
|
"qwen": {Name: "Qwen Code", ConfigDir: "~/.qwen", ConfigFile: "settings.json"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPlugin creates a plugin instance for the given agent name.
|
// NewPlugin returns the plugin for an agent name, or nil when unsupported.
|
||||||
// Returns nil if the agent is not supported.
|
|
||||||
func NewPlugin(agentName string) AgentPlugin {
|
func NewPlugin(agentName string) AgentPlugin {
|
||||||
switch agentName {
|
switch agentName {
|
||||||
case "augment":
|
case "augment":
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ type agentRuntimeConfig struct {
|
|||||||
AgentName string `json:"agent_name"`
|
AgentName string `json:"agent_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteRuntimeFileAtomic replaces one managed runtime file through a flushed,
|
// WriteRuntimeFileAtomic replaces one managed runtime file atomically.
|
||||||
// same-directory temporary file.
|
|
||||||
func WriteRuntimeFileAtomic(
|
func WriteRuntimeFileAtomic(
|
||||||
path string,
|
path string,
|
||||||
label string,
|
label string,
|
||||||
@@ -40,8 +39,7 @@ func WriteRuntimeFileAtomic(
|
|||||||
return writeChanges([]*fileChange{change}, "write "+label, nil)
|
return writeChanges([]*fileChange{change}, "write "+label, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateHookScript atomically commits the agent config, private key, and
|
// GenerateHookScript commits the config, private key, and audit runtime together.
|
||||||
// self-contained Node.js audit runtime.
|
|
||||||
func GenerateHookScript(destPath string, config InstallConfig) error {
|
func GenerateHookScript(destPath string, config InstallConfig) error {
|
||||||
return generateHookScriptWithRename(destPath, config, nil)
|
return generateHookScriptWithRename(destPath, config, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,4 +142,26 @@ function writeProtectedJson(filePath, label, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveConfigBaseURL(config) {
|
||||||
|
const value = config.base_url || 'https://api.elydora.com';
|
||||||
|
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
|
||||||
|
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
||||||
|
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||||
|
throw new Error(
|
||||||
|
'Agent config base_url must exclude credentials, query parameters, and fragments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
`
|
`
|
||||||
@@ -11,12 +11,6 @@ import (
|
|||||||
"strings"
|
"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.
|
// sha256BytesBase64url computes SHA-256 of raw bytes and returns base64url-encoded result.
|
||||||
func sha256BytesBase64url(data []byte) string {
|
func sha256BytesBase64url(data []byte) string {
|
||||||
h := sha256.Sum256(data)
|
h := sha256.Sum256(data)
|
||||||
@@ -32,8 +26,7 @@ func computePayloadHash(payload interface{}) (string, error) {
|
|||||||
return sha256BytesBase64url([]byte(canonical)), nil
|
return sha256BytesBase64url([]byte(canonical)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// signEd25519 signs data with an Ed25519 private key seed (base64url-encoded 32 bytes).
|
// signEd25519 signs data with a base64url Ed25519 seed and returns a base64url signature.
|
||||||
// Returns the signature as base64url.
|
|
||||||
func signEd25519(privateKeyBase64url string, data []byte) (string, error) {
|
func signEd25519(privateKeyBase64url string, data []byte) (string, error) {
|
||||||
seed, err := base64urlDecode(privateKeyBase64url)
|
seed, err := base64urlDecode(privateKeyBase64url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,13 +40,7 @@ func signEd25519(privateKeyBase64url string, data []byte) (string, error) {
|
|||||||
return base64urlEncode(sig), nil
|
return base64urlEncode(sig), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// jcsCanonicalise renders a value as RFC 8785 canonical JSON.
|
||||||
// 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.
|
|
||||||
func jcsCanonicalise(value interface{}) (string, error) {
|
func jcsCanonicalise(value interface{}) (string, error) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if err := jcsWrite(&b, value); err != nil {
|
if err := jcsWrite(&b, value); err != nil {
|
||||||
@@ -113,10 +100,6 @@ func jcsWrite(b *strings.Builder, value interface{}) error {
|
|||||||
first := true
|
first := true
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
val := v[k]
|
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 {
|
if !first {
|
||||||
b.WriteByte(',')
|
b.WriteByte(',')
|
||||||
}
|
}
|
||||||
@@ -145,7 +128,7 @@ func jcsWrite(b *strings.Builder, value interface{}) error {
|
|||||||
return nil
|
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) {
|
func jcsWriteString(b *strings.Builder, s string) {
|
||||||
b.WriteByte('"')
|
b.WriteByte('"')
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -175,7 +158,7 @@ func jcsWriteString(b *strings.Builder, s string) {
|
|||||||
b.WriteByte('"')
|
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 {
|
func jcsFormatNumber(f float64) string {
|
||||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
return "null"
|
return "null"
|
||||||
@@ -183,18 +166,14 @@ func jcsFormatNumber(f float64) string {
|
|||||||
if f == 0 {
|
if f == 0 {
|
||||||
return "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 {
|
if f == math.Trunc(f) && !math.IsInf(f, 0) && math.Abs(f) < 1e20 {
|
||||||
return strconv.FormatInt(int64(f), 10)
|
return strconv.FormatInt(int64(f), 10)
|
||||||
}
|
}
|
||||||
// Use the shortest representation that round-trips
|
|
||||||
return strconv.FormatFloat(f, 'G', -1, 64)
|
return strconv.FormatFloat(f, 'G', -1, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// signEOR signs an EOR struct. It computes the canonical representation (all fields except signature),
|
// signEOR signs the canonical EOR fields except the signature.
|
||||||
// hashes it, and produces an Ed25519 signature.
|
|
||||||
func signEOR(eor *EOR, privateKeyBase64url string) (string, error) {
|
func signEOR(eor *EOR, privateKeyBase64url string) (string, error) {
|
||||||
// Build canonical EOR without signature field
|
|
||||||
canonical := map[string]interface{}{
|
canonical := map[string]interface{}{
|
||||||
"op_version": eor.OpVersion,
|
"op_version": eor.OpVersion,
|
||||||
"operation_id": eor.OperationID,
|
"operation_id": eor.OperationID,
|
||||||
|
|||||||
+5
-31
@@ -1,9 +1,7 @@
|
|||||||
package elydora
|
package elydora
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,44 +34,20 @@ func (c *Client) GetExport(exportID string) (*GetExportResponse, error) {
|
|||||||
|
|
||||||
// DownloadExport downloads the raw file data for a completed export.
|
// DownloadExport downloads the raw file data for a completed export.
|
||||||
func (c *Client) DownloadExport(exportID string) ([]byte, error) {
|
func (c *Client) DownloadExport(exportID string) ([]byte, error) {
|
||||||
u := c.baseURL + fmt.Sprintf("/v1/exports/%s/download", exportID)
|
req, err := newRequest(http.MethodGet, c.baseURL+fmt.Sprintf("/v1/exports/%s/download", exportID), nil, c.token, "")
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("elydora: http request: %w", err)
|
return nil, fmt.Errorf("elydora: http request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
body, err := readBody(resp, 0)
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("elydora: read response body: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
var errResp errorResponse
|
return nil, apiError(resp.StatusCode, body)
|
||||||
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 body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
@@ -1,116 +1,29 @@
|
|||||||
package elydora
|
package elydora
|
||||||
|
|
||||||
import (
|
import "net/http"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Health checks the health of the Elydora API. This is a public endpoint
|
// Health checks the public health endpoint.
|
||||||
// that does not require authentication.
|
|
||||||
func Health(baseURL string) (*HealthResponse, error) {
|
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
|
var result HealthResponse
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
if err := publicRequest(http.MethodGet, normalizeBaseURL(baseURL)+"/v1/health", nil, &result); err != nil {
|
||||||
return nil, fmt.Errorf("elydora: unmarshal response: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepHealth checks the deep health of the Elydora API, including its backend
|
// DeepHealth checks backend dependencies; 503 reports a degraded API.
|
||||||
// dependencies (D1, R2, KV). This is a public endpoint that does not require
|
|
||||||
// authentication.
|
|
||||||
func DeepHealth(baseURL string) (*DeepHealthResponse, error) {
|
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)
|
|
||||||
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
|
var result DeepHealthResponse
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
err := publicRequest(
|
||||||
return nil, fmt.Errorf("elydora: unmarshal response: %w", err)
|
http.MethodGet,
|
||||||
|
normalizeBaseURL(baseURL)+"/v1/health/deep",
|
||||||
|
nil,
|
||||||
|
&result,
|
||||||
|
http.StatusOK,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+2
-5
@@ -2,13 +2,10 @@ package elydora
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// GenesisChainHash is the initial chain hash for an agent's first operation.
|
// GenesisChainHash is the base64url encoding of 32 zero bytes.
|
||||||
// It is the base64url encoding of 32 zero bytes, matching the backend.
|
|
||||||
const GenesisChainHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
const GenesisChainHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
|
||||||
// CreateOperation builds a signed EOR from the given parameters.
|
// CreateOperation builds and signs an EOR locally.
|
||||||
// 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.
|
|
||||||
func (c *Client) CreateOperation(params *CreateOperationParams) (*EOR, error) {
|
func (c *Client) CreateOperation(params *CreateOperationParams) (*EOR, error) {
|
||||||
if params == nil {
|
if params == nil {
|
||||||
return nil, fmt.Errorf("elydora: params must not be nil")
|
return nil, fmt.Errorf("elydora: params must not be nil")
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func TestSDKCompatibilityAgainstLatestAPI(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("api token auth failed: %v", err)
|
t.Fatalf("api token auth failed: %v", err)
|
||||||
}
|
}
|
||||||
if meWithAPI.User.OrgID != login.User.OrgID {
|
if orgID(meWithAPI.User.OrgID) != login.User.OrgID {
|
||||||
t.Fatalf("api token org mismatch: got %s want %s", 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()
|
rotateResp, err := client.RotateApiToken()
|
||||||
@@ -98,8 +98,8 @@ func TestSDKCompatibilityAgainstLatestAPI(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("rotated token auth failed: %v", err)
|
t.Fatalf("rotated token auth failed: %v", err)
|
||||||
}
|
}
|
||||||
if meWithRotated.User.OrgID != login.User.OrgID {
|
if orgID(meWithRotated.User.OrgID) != login.User.OrgID {
|
||||||
t.Fatalf("rotated token org mismatch: got %s want %s", 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{
|
_, err = client.RegisterAgent(&elydora.RegisterAgentRequest{
|
||||||
@@ -154,3 +154,10 @@ func TestSDKCompatibilityAgainstLatestAPI(t *testing.T) {
|
|||||||
t.Fatalf("verify operation invalid: %+v", verify)
|
t.Fatalf("verify operation invalid: %+v", verify)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func orgID(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
package elydora
|
package elydora
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Enums
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type AgentStatus string
|
type AgentStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -69,7 +65,9 @@ const (
|
|||||||
ErrorCodeInvalidSignature ErrorCode = "INVALID_SIGNATURE"
|
ErrorCodeInvalidSignature ErrorCode = "INVALID_SIGNATURE"
|
||||||
ErrorCodeUnknownAgent ErrorCode = "UNKNOWN_AGENT"
|
ErrorCodeUnknownAgent ErrorCode = "UNKNOWN_AGENT"
|
||||||
ErrorCodeKeyRevoked ErrorCode = "KEY_REVOKED"
|
ErrorCodeKeyRevoked ErrorCode = "KEY_REVOKED"
|
||||||
|
ErrorCodeKeyRetired ErrorCode = "KEY_RETIRED"
|
||||||
ErrorCodeAgentFrozen ErrorCode = "AGENT_FROZEN"
|
ErrorCodeAgentFrozen ErrorCode = "AGENT_FROZEN"
|
||||||
|
ErrorCodeAgentRevoked ErrorCode = "AGENT_REVOKED"
|
||||||
ErrorCodeTTLExpired ErrorCode = "TTL_EXPIRED"
|
ErrorCodeTTLExpired ErrorCode = "TTL_EXPIRED"
|
||||||
ErrorCodeReplayDetected ErrorCode = "REPLAY_DETECTED"
|
ErrorCodeReplayDetected ErrorCode = "REPLAY_DETECTED"
|
||||||
ErrorCodePrevHashMismatch ErrorCode = "PREV_HASH_MISMATCH"
|
ErrorCodePrevHashMismatch ErrorCode = "PREV_HASH_MISMATCH"
|
||||||
@@ -82,6 +80,36 @@ const (
|
|||||||
ErrorCodeValidationError ErrorCode = "VALIDATION_ERROR"
|
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
|
type ExportFormat string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -89,10 +117,6 @@ const (
|
|||||||
ExportFormatPDF ExportFormat = "pdf"
|
ExportFormatPDF ExportFormat = "pdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Entity types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
OrgID string `json:"org_id"`
|
OrgID string `json:"org_id"`
|
||||||
@@ -155,7 +179,7 @@ type Epoch struct {
|
|||||||
type Organization struct {
|
type Organization struct {
|
||||||
OrgID string `json:"org_id"`
|
OrgID string `json:"org_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description"`
|
||||||
BAOrgID *string `json:"ba_org_id"`
|
BAOrgID *string `json:"ba_org_id"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
@@ -182,11 +206,7 @@ type Export struct {
|
|||||||
CompletedAt *int64 `json:"completed_at"`
|
CompletedAt *int64 `json:"completed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// EOR is the Elydora Operation Record.
|
||||||
// Protocol types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// EOR is the Elydora Operation Record — the fundamental unit of auditable activity.
|
|
||||||
type EOR struct {
|
type EOR struct {
|
||||||
OpVersion string `json:"op_version"`
|
OpVersion string `json:"op_version"`
|
||||||
OperationID string `json:"operation_id"`
|
OperationID string `json:"operation_id"`
|
||||||
@@ -205,7 +225,7 @@ type EOR struct {
|
|||||||
Signature string `json:"signature"`
|
Signature string `json:"signature"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EAR is the Elydora Acknowledgment Receipt — server-issued receipt confirming acceptance.
|
// EAR is the Elydora Acknowledgment Receipt.
|
||||||
type EAR struct {
|
type EAR struct {
|
||||||
ReceiptVersion string `json:"receipt_version"`
|
ReceiptVersion string `json:"receipt_version"`
|
||||||
ReceiptID string `json:"receipt_id"`
|
ReceiptID string `json:"receipt_id"`
|
||||||
@@ -221,210 +241,6 @@ type EAR struct {
|
|||||||
ElydoraSignature string `json:"elydora_signature"`
|
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.
|
// AdminEvent represents an administrative event log entry.
|
||||||
type AdminEvent struct {
|
type AdminEvent struct {
|
||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
@@ -453,25 +269,11 @@ type Webhook struct {
|
|||||||
OrgID string `json:"org_id"`
|
OrgID string `json:"org_id"`
|
||||||
EndpointURL string `json:"endpoint_url"`
|
EndpointURL string `json:"endpoint_url"`
|
||||||
Events []string `json:"events"`
|
Events []string `json:"events"`
|
||||||
Status string `json:"status"`
|
Status WebhookStatus `json:"status"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Member represents a console user returned from the members list endpoint.
|
// Member represents a console user returned from the members list endpoint.
|
||||||
type Member struct {
|
type Member struct {
|
||||||
MemberID string `json:"member_id"`
|
MemberID string `json:"member_id"`
|
||||||
@@ -482,40 +284,42 @@ type Member struct {
|
|||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListMembersResponse struct {
|
// AuthMeUser is the profile returned by GET /v1/auth/me.
|
||||||
Members []Member `json:"members"`
|
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 {
|
// CurrentOrganization is the active organization of the authenticated user.
|
||||||
Events []AdminEvent `json:"events"`
|
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 {
|
type DependencyHealth struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
LatencyMs int64 `json:"latency_ms"`
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
Error *string `json:"error,omitempty"`
|
ContractPhase string `json:"contract_phase,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepHealthDependencies lists the health of all backend dependencies.
|
// DeepHealthDependencies lists every backend dependency.
|
||||||
type DeepHealthDependencies struct {
|
type DeepHealthDependencies struct {
|
||||||
D1 DependencyHealth `json:"d1"`
|
D1 DependencyHealth `json:"d1"`
|
||||||
|
AuthStorage DependencyHealth `json:"auth_storage"`
|
||||||
R2 DependencyHealth `json:"r2"`
|
R2 DependencyHealth `json:"r2"`
|
||||||
KV DependencyHealth `json:"kv"`
|
KV DependencyHealth `json:"kv"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepHealthResponse is returned by GET /v1/health/deep.
|
// CreateOperationParams are the inputs for CreateOperation.
|
||||||
type DeepHealthResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
Dependencies DeepHealthDependencies `json:"dependencies"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CreateOperation params (SDK-specific)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type CreateOperationParams struct {
|
type CreateOperationParams struct {
|
||||||
OperationType string
|
OperationType string
|
||||||
Subject map[string]interface{}
|
Subject map[string]interface{}
|
||||||
|
|||||||
+235
@@ -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"`
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// base64urlEncode encodes bytes to base64url without padding (RFC 4648 section 5).
|
// base64urlEncode encodes bytes as unpadded base64url.
|
||||||
func base64urlEncode(data []byte) string {
|
func base64urlEncode(data []byte) string {
|
||||||
return base64.RawURLEncoding.EncodeToString(data)
|
return base64.RawURLEncoding.EncodeToString(data)
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ func base64urlDecode(s string) ([]byte, error) {
|
|||||||
return base64.RawURLEncoding.DecodeString(s)
|
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) {
|
func generateNonce() (string, error) {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||||
@@ -34,19 +34,15 @@ func generateUUIDv7() (string, error) {
|
|||||||
|
|
||||||
var uuid [16]byte
|
var uuid [16]byte
|
||||||
|
|
||||||
// Timestamp: 48 bits in big-endian
|
|
||||||
binary.BigEndian.PutUint16(uuid[0:2], uint16(ms>>32))
|
binary.BigEndian.PutUint16(uuid[0:2], uint16(ms>>32))
|
||||||
binary.BigEndian.PutUint16(uuid[2:4], uint16(ms>>16))
|
binary.BigEndian.PutUint16(uuid[2:4], uint16(ms>>16))
|
||||||
binary.BigEndian.PutUint16(uuid[4:6], uint16(ms))
|
binary.BigEndian.PutUint16(uuid[4:6], uint16(ms))
|
||||||
|
|
||||||
// Fill the rest with random data
|
|
||||||
if _, err := io.ReadFull(rand.Reader, uuid[6:]); err != nil {
|
if _, err := io.ReadFull(rand.Reader, uuid[6:]); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set version 7 (bits 48-51)
|
|
||||||
uuid[6] = (uuid[6] & 0x0F) | 0x70
|
uuid[6] = (uuid[6] & 0x0F) | 0x70
|
||||||
// Set variant 10 (bits 64-65)
|
|
||||||
uuid[8] = (uuid[8] & 0x3F) | 0x80
|
uuid[8] = (uuid[8] & 0x3F) | 0x80
|
||||||
|
|
||||||
return formatUUID(uuid), nil
|
return formatUUID(uuid), nil
|
||||||
|
|||||||
Reference in new issue
Block a user