melaya 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,17 +8,41 @@ require "openssl"
8
8
  require_relative "errors"
9
9
 
10
10
  module Melaya
11
- # Internal HTTP client. Injects the API key on every call as both
12
- # a query-param (?apiKey=) and Authorization: Bearer header.
11
+ # Internal HTTP client. Supports Bearer JWT *and* mk_* platform API key.
12
+ # The credential is sent ONLY via the Authorization header — never in the URL
13
+ # query string, so it cannot leak into access logs or proxies. TLS is
14
+ # enforced by default; never log secrets.
15
+ #
16
+ # Retry policy: bounded exponential back-off with jitter on network errors,
17
+ # 429, and 5xx — but ONLY for idempotent GET requests (max 2 retries).
18
+ # POST/PUT/PATCH/DELETE are never retried. Retry-After header is honoured
19
+ # on 429. Per-request timeout default: 30 seconds (configurable).
13
20
  class HttpClient
14
- DEFAULT_BASE_URL = "https://api.melaya.org"
15
-
16
- def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true)
17
- @api_key = api_key
21
+ DEFAULT_BASE_URL = "https://api.melaya.org"
22
+ DEFAULT_TIMEOUT_MS = 30_000 # milliseconds
23
+
24
+ # Maximum additional retries after the first attempt (2 retries = 3 total
25
+ # attempts) for idempotent GET requests only.
26
+ MAX_GET_RETRIES = 2
27
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
28
+
29
+ # @param api_key [String] mk_* platform key or Bearer JWT
30
+ # @param base_url [String]
31
+ # @param verify_ssl [Boolean]
32
+ # @param timeout_ms [Integer] per-request timeout in milliseconds (default 30 000)
33
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, verify_ssl: true,
34
+ timeout_ms: DEFAULT_TIMEOUT_MS)
35
+ raise ArgumentError, "Melaya: TLS certificate verification cannot be disabled." unless verify_ssl
36
+ # Never store in a way that could leak to logs accidentally — keep as
37
+ # an opaque token string only accessible through the private accessor.
38
+ @_tok = api_key.freeze
18
39
  @base_uri = URI.parse(base_url.chomp("/"))
19
- @verify_ssl = verify_ssl
40
+ @verify_ssl = true
41
+ @timeout_s = (timeout_ms / 1000.0).ceil
20
42
  end
21
43
 
44
+ # ── Public verb helpers ────────────────────────────────────────────────────
45
+
22
46
  def get(path, params = {})
23
47
  request(:get, path, params: params)
24
48
  end
@@ -27,6 +51,14 @@ module Melaya
27
51
  request(:post, path, body: body)
28
52
  end
29
53
 
54
+ def put(path, body = nil)
55
+ request(:put, path, body: body)
56
+ end
57
+
58
+ def patch(path, body = nil)
59
+ request(:patch, path, body: body)
60
+ end
61
+
30
62
  def delete(path, params = {})
31
63
  request(:delete, path, params: params)
32
64
  end
@@ -35,38 +67,120 @@ module Melaya
35
67
 
36
68
  def build_uri(path, params = {})
37
69
  uri = URI.parse("#{@base_uri}#{path}")
38
- query = { "apiKey" => @api_key }
70
+ # SECURITY: the credential is never placed in the query string; it is
71
+ # sent only via the Authorization header (see make_request).
72
+ query = {}
39
73
  params.each { |k, v| query[k.to_s] = v.to_s unless v.nil? }
40
- uri.query = URI.encode_www_form(query)
74
+ uri.query = URI.encode_www_form(query) unless query.empty?
41
75
  uri
42
76
  end
43
77
 
44
- def request(method, path, params: {}, body: nil)
45
- uri = build_uri(path, params)
46
-
47
- http = Net::HTTP.new(uri.host, uri.port)
48
- http.use_ssl = uri.scheme == "https"
49
- http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
50
- http.open_timeout = 15
51
- http.read_timeout = 60
52
-
78
+ def make_request(method, uri, body)
53
79
  req = case method
54
80
  when :get then Net::HTTP::Get.new(uri)
55
81
  when :post then Net::HTTP::Post.new(uri)
82
+ when :put then Net::HTTP::Put.new(uri)
83
+ when :patch then Net::HTTP::Patch.new(uri)
56
84
  when :delete then Net::HTTP::Delete.new(uri)
57
85
  else raise ArgumentError, "Unknown HTTP method: #{method}"
58
86
  end
59
87
 
60
- req["Authorization"] = "Bearer #{@api_key}"
88
+ # Authorization: never expose token in error output below
89
+ req["Authorization"] = "Bearer #{@_tok}"
61
90
  req["Accept"] = "application/json"
91
+ req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
62
92
 
63
93
  if body
64
94
  req["Content-Type"] = "application/json"
65
95
  req.body = JSON.generate(body)
66
96
  end
67
97
 
68
- resp = http.request(req)
69
- parse(resp)
98
+ req
99
+ end
100
+
101
+ def request(method, path, params: {}, body: nil)
102
+ uri = build_uri(path, params)
103
+
104
+ http = Net::HTTP.new(uri.host, uri.port)
105
+ http.use_ssl = uri.scheme == "https"
106
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
107
+ http.open_timeout = @timeout_s
108
+ http.read_timeout = @timeout_s
109
+
110
+ # Only GET requests are retried (idempotent); all mutating verbs fail fast.
111
+ retryable = (method == :get)
112
+ attempt = 0
113
+
114
+ retry_after_hdr = nil
115
+ begin
116
+ attempt += 1
117
+ retry_after_hdr = nil # reset on each attempt
118
+ req = make_request(method, uri, body)
119
+ resp = http.request(req)
120
+ # Snapshot Retry-After before parse() consumes the response object,
121
+ # so we can honour the header even after the MelayaError is raised.
122
+ retry_after_hdr = resp["retry-after"] || resp["Retry-After"]
123
+ parse(resp)
124
+ rescue MelayaError => e
125
+ if retryable && RETRY_STATUSES.include?(e.status) && attempt <= MAX_GET_RETRIES
126
+ # Build a minimal resp-like object carrying only the header we need,
127
+ # so _backoff_delay can honour Retry-After without holding the socket.
128
+ hdr_carrier = { "retry-after" => retry_after_hdr }
129
+ delay = _backoff_delay(attempt, e, hdr_carrier)
130
+ sleep(delay)
131
+ retry
132
+ end
133
+ raise
134
+ rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout
135
+ raise unless retryable && attempt <= MAX_GET_RETRIES
136
+ sleep(_backoff_delay(attempt, nil))
137
+ retry
138
+ end
139
+ end
140
+
141
+ # Exponential backoff with ±25 % jitter; honours Retry-After on 429.
142
+ # Base: 2^(attempt-1) seconds, capped at 16 s before jitter.
143
+ #
144
+ # Retry-After resolution order (first match wins):
145
+ # 1. HTTP `Retry-After` response header — seconds integer or HTTP-date
146
+ # 2. JSON body `retryAfter` / `retry_after` field (legacy fallback)
147
+ # 3. Exponential back-off
148
+ def _backoff_delay(attempt, err, resp = nil)
149
+ if err.is_a?(MelayaError) && err.status == 429
150
+ # 1. HTTP Retry-After header (preferred, RFC 7231)
151
+ if resp.respond_to?(:[]) && (ra_hdr = resp["retry-after"] || resp["Retry-After"])
152
+ secs = _parse_retry_after_header(ra_hdr)
153
+ return [secs, 0.5].max if secs
154
+ end
155
+
156
+ # 2. JSON body fallback ("retryAfter" or "retry_after")
157
+ if err.respond_to?(:body) && err.body.is_a?(Hash)
158
+ ra = err.body["retryAfter"] || err.body["retry_after"]
159
+ return [ra.to_f, 0.5].max if ra
160
+ end
161
+ end
162
+ base = [2**(attempt - 1), 16].min.to_f
163
+ jitter = base * 0.25 * (rand - 0.5) * 2 # ±25 %
164
+ [base + jitter, 0.1].max
165
+ end
166
+
167
+ # Parse an RFC 7231 Retry-After value: either a delay-seconds integer
168
+ # or an HTTP-date string. Returns seconds as Float, or nil if unparseable.
169
+ def _parse_retry_after_header(value)
170
+ str = value.to_s.strip
171
+ # Delay-seconds: plain non-negative integer
172
+ if str =~ /\A\d+\z/
173
+ return str.to_f
174
+ end
175
+ # HTTP-date (e.g. "Wed, 21 Oct 2099 07:28:00 GMT")
176
+ begin
177
+ require "time"
178
+ target = Time.httpdate(str)
179
+ delay = target - Time.now
180
+ return [delay, 0.0].max
181
+ rescue ArgumentError, TypeError
182
+ nil
183
+ end
70
184
  end
71
185
 
72
186
  def parse(resp)
@@ -77,18 +191,33 @@ module Melaya
77
191
  text
78
192
  end
79
193
 
80
- if resp.code.to_i >= 400
81
- code = data.is_a?(Hash) ? data["error"] : nil
82
- msg = "Melaya API #{resp.code}" + (code ? " (#{code})" : "")
83
- raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
194
+ status = resp.code.to_i
195
+ if status >= 400
196
+ # Two error envelope shapes:
197
+ # 1. { error: 'tier_insufficient', tier: '...' } -> 403
198
+ # 2. { error: '...', message: '...', code: '...' }
199
+ # Extract error code safely — never echo raw body in message
200
+ err_code = data.is_a?(Hash) ? data["error"] : nil
201
+
202
+ if status == 403 && err_code == "tier_insufficient"
203
+ raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
204
+ end
205
+ if status == 429
206
+ # Raised here; the GET retry loop above may swallow-and-retry it —
207
+ # callers only see it once retries are exhausted.
208
+ ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
209
+ raise RateLimitError.new(retry_after: ra, body: data)
210
+ end
211
+
212
+ msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
213
+ raise MelayaError.new(msg, status: status, code: err_code, body: data)
84
214
  end
85
215
 
86
- # The API wraps every payload in { "ok": true/false, ... }.
87
- # ok:false is a request-level failure — raise instead of returning silently.
216
+ # The API may wrap payload in { "ok": false, ... } for request-level failures.
88
217
  if data.is_a?(Hash) && data["ok"] == false
89
- code = data["error"]
90
- msg = "Melaya API request failed" + (code ? ": #{code}" : "")
91
- raise MelayaError.new(msg, status: resp.code.to_i, code: code, body: data)
218
+ err_code = data["error"]
219
+ msg = "Melaya API request failed" + (err_code ? ": #{err_code}" : "")
220
+ raise MelayaError.new(msg, status: resp.code.to_i, code: err_code, body: data)
92
221
  end
93
222
 
94
223
  data
data/lib/melaya/market.rb CHANGED
@@ -147,6 +147,48 @@ module Melaya
147
147
  @http.get("/api/v1/public/catalog-counts")
148
148
  end
149
149
 
150
+ # ── restRoutes.ts market endpoints ──────────────────────────────────────────
151
+
152
+ # POST /api/v1/private/market/liquidations
153
+ # Get aggregated CEX liquidation data. (requireAuth)
154
+ # @param params [Hash] e.g. exchange, symbol, since_ms
155
+ def cex_liquidations(params = {})
156
+ @http.post("/api/v1/private/market/liquidations", params)
157
+ end
158
+
159
+ # GET /api/v1/market/mdd-pairs (public)
160
+ # Get max-drawdown pairs list (public screener data).
161
+ def mdd_pairs
162
+ @http.get("/api/v1/market/mdd-pairs")
163
+ end
164
+
165
+ # GET /api/v1/private/market/onchain-yields (Forge+ tier)
166
+ # Get on-chain yield data.
167
+ # @param params [Hash]
168
+ def onchain_yields(params = {})
169
+ @http.get("/api/v1/private/market/onchain-yields", params)
170
+ end
171
+
172
+ # GET /api/v1/private/market/onchain-liquidity (Forge+ tier)
173
+ # Get on-chain liquidity data.
174
+ # @param params [Hash]
175
+ def onchain_liquidity(params = {})
176
+ @http.get("/api/v1/private/market/onchain-liquidity", params)
177
+ end
178
+
179
+ # GET /api/v1/market/banner (public)
180
+ # Get marketing/notification banner content.
181
+ def banner
182
+ @http.get("/api/v1/market/banner")
183
+ end
184
+
185
+ # GET /api/v1/market/price-history (public)
186
+ # Get price history for chart display.
187
+ # @param params [Hash] e.g. exchange, symbol, timeframe
188
+ def price_history(params = {})
189
+ @http.get("/api/v1/market/price-history", params)
190
+ end
191
+
150
192
  private
151
193
 
152
194
  def compact(hash)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Melaya
4
+ # ── Domain namespace objects ─────────────────────────────────────────────────
5
+ #
6
+ # Three read-only namespace objects hang off every +Melaya::Client+ instance,
7
+ # grouping the flat module accessors into logical planes:
8
+ #
9
+ # melaya.trading — market data, account, sim, strategies, backtest, stream, trade
10
+ # melaya.agents — pipelines/runs, hitl, assistant, phone, evals, models
11
+ # melaya.platform — projects, credentials, connectors, billing, team, templates,
12
+ # overview (via pipelines), runner, auth, mfa (via auth),
13
+ # accounts, bugs, events
14
+ #
15
+ # Every attribute on these objects is the *same* instance that is also reachable
16
+ # via the flat accessor on the client, so there is no duplication of state and
17
+ # no double HTTP calls.
18
+ #
19
+ # @example
20
+ # melaya = Melaya::Client.new(api_key: ENV["MELAYA_API_KEY"])
21
+ #
22
+ # # Namespaced (primary API)
23
+ # melaya.trading.market.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot")
24
+ # melaya.agents.pipelines.list(project: "my-project")
25
+ # melaya.platform.projects.list
26
+ #
27
+ # # Flat aliases still work (backward-compatible)
28
+ # melaya.market.ticker(exchange: "binance", symbol: "BTC/USDT", market: "spot")
29
+
30
+ # Namespace grouping all trading-plane modules.
31
+ #
32
+ # Modules:
33
+ # - +market+ — REST market-data + reference endpoints (public + authenticated).
34
+ # - +account+ — Authenticated account reads: connected keys, tier limits, usage.
35
+ # - +sim+ — Paper trading (sim broker): virtual balance, positions, and orders.
36
+ # - +strategies+ — Launch, control, and inspect trading strategies (paper + live).
37
+ # - +backtest+ — Historical backtests + parameter sweeps on the Rust engine.
38
+ # - +stream+ — WebSocket streaming endpoints (public market data + private feeds).
39
+ # - +trade+ — Live trading — credentialed order placement on a connected exchange.
40
+ TradingNamespace = Struct.new(
41
+ :market,
42
+ :account,
43
+ :sim,
44
+ :strategies,
45
+ :backtest,
46
+ :stream,
47
+ :trade,
48
+ keyword_init: true
49
+ )
50
+
51
+ # Namespace grouping all agent-plane modules.
52
+ #
53
+ # Modules:
54
+ # - +pipelines+ — Pipeline runs, traces, schedules, and overview dashboard.
55
+ # (also aliased as +runs+ for ergonomics)
56
+ # - +hitl+ — Human-in-the-loop approval queue: list pending, approve, reject.
57
+ # - +assistant+ — Assistant onboarding profile (get + set).
58
+ # - +phone+ — Phone device control: pair, list, screen-tree, apps.
59
+ # - +evals+ — Agent evaluation runs and benchmarks.
60
+ # - +models+ — AI model list (reached via credentials#list_models; this is
61
+ # the CredentialsAPI instance filtered by convention — call
62
+ # +models.list_models(provider: "anthropic")+ etc.)
63
+ AgentsNamespace = Struct.new(
64
+ :pipelines,
65
+ :hitl,
66
+ :assistant,
67
+ :phone,
68
+ :evals,
69
+ :models,
70
+ keyword_init: true
71
+ ) do
72
+ # +runs+ is an ergonomic alias for +pipelines+ (agents call them "runs").
73
+ def runs
74
+ pipelines
75
+ end
76
+ end
77
+
78
+ # Namespace grouping all platform-plane modules.
79
+ #
80
+ # Modules:
81
+ # - +projects+ — Create and list agent projects.
82
+ # - +credentials+ — User-scoped credential storage (services, OAuth, env handles, models).
83
+ # - +connectors+ — Project-scoped connector credentials.
84
+ # - +billing+ — Subscription, Stripe checkout/portal, pricing plans, credit balances.
85
+ # - +team+ — Project team management: members, roles, invite links.
86
+ # - +templates+ — Pipeline templates: create, share, assign, and manage visibility.
87
+ # - +overview+ — Pipeline overview dashboard (same object as +agents.pipelines+,
88
+ # exposed here for discoverability on the platform plane).
89
+ # - +runner+ — Runner tokens: mint, list, revoke +mel_run_+ tokens.
90
+ # - +auth+ — Login, MFA, registration, password management, session tokens.
91
+ # - +mfa+ — Alias for +auth+ (MFA operations live on the same AuthAPI object).
92
+ # - +accounts+ — Account management: GDPR export, CEX key removal, profile updates.
93
+ # - +bugs+ — Bug reports: submit, track, and comment.
94
+ # - +events+ — Platform real-time events over Socket.IO.
95
+ PlatformNamespace = Struct.new(
96
+ :projects,
97
+ :credentials,
98
+ :connectors,
99
+ :billing,
100
+ :team,
101
+ :templates,
102
+ :overview,
103
+ :runner,
104
+ :auth,
105
+ :accounts,
106
+ :bugs,
107
+ :events,
108
+ keyword_init: true
109
+ ) do
110
+ # +mfa+ is an ergonomic alias for +auth+ (MFA methods live on AuthAPI).
111
+ def mfa
112
+ auth
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Melaya
4
+ # Phone API — pair and control Android devices connected to the Melaya runner.
5
+ #
6
+ # Agents use these endpoints to drive a paired phone (tap, type, read
7
+ # screen state, launch apps) via the Melaya APK.
8
+ #
9
+ # Maps to /api/v1/private/phone/*.
10
+ #
11
+ # @example
12
+ # result = melaya.phone.pair
13
+ # puts "Pairing code: #{result["code"]}"
14
+ # # User enters code in the Melaya APK on the phone
15
+ #
16
+ # devices = melaya.phone.list_devices
17
+ # tree = melaya.phone.screen_tree
18
+ class PhoneAPI
19
+ def initialize(http)
20
+ @http = http
21
+ end
22
+
23
+ # POST /api/v1/private/phone/pair
24
+ # Start phone device pairing — generates a pairing code for the Melaya APK.
25
+ def pair
26
+ @http.post("/api/v1/private/phone/pair")
27
+ end
28
+
29
+ # GET /api/v1/private/phone/devices
30
+ # List all paired phone devices for the authenticated user.
31
+ def list_devices
32
+ @http.get("/api/v1/private/phone/devices").fetch("devices", [])
33
+ end
34
+
35
+ # DELETE /api/v1/private/phone/devices/:deviceId
36
+ # Revoke a paired phone device by ID.
37
+ # @param device_id [String]
38
+ def revoke_device(device_id)
39
+ @http.delete("/api/v1/private/phone/devices/#{enc(device_id)}")
40
+ end
41
+
42
+ # GET /api/v1/private/phone/screen-tree
43
+ # Get the current accessibility tree from the paired phone's screen.
44
+ def screen_tree
45
+ @http.get("/api/v1/private/phone/screen-tree")
46
+ end
47
+
48
+ # GET /api/v1/private/phone/apps
49
+ # List installed apps on the paired phone.
50
+ def list_apps
51
+ @http.get("/api/v1/private/phone/apps").dig("result", "apps") || []
52
+ end
53
+
54
+ # PUT /api/v1/private/phone/apps/allowed
55
+ # Set the allowlist of apps that agents are permitted to interact with.
56
+ # @param package_names [Array<String>]
57
+ def set_allowed_apps(package_names)
58
+ apps = package_names.map { |package| { "package" => package } }
59
+ @http.put("/api/v1/private/phone/apps/allowed", "apps" => apps)
60
+ end
61
+
62
+ # POST /api/v1/private/phone/active-run
63
+ # Register the currently active pipeline run on the phone (used by agents).
64
+ # @param run_id [String]
65
+ def register_active_run(run_id)
66
+ @http.post("/api/v1/private/phone/active-run", "runId" => run_id)
67
+ end
68
+
69
+ private
70
+
71
+ def enc(s)
72
+ URI.encode_www_form_component(s.to_s)
73
+ end
74
+ end
75
+ end