attago 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 90446f07e1eb80c83339dfe22a11073a29d8407d8dd1339b602fa9aeb6cd53c3
4
+ data.tar.gz: aa45cf4c90dca3fbeb0be2816c5d6fd733f40c9d25ce428800c29fa9cb728d09
5
+ SHA512:
6
+ metadata.gz: 5383151b402e020df0f63e70334e7e40d1e5e689edfbfbed9c245794d394d839bb9fdf9f8472795432943fcf5e5c2a6f9fdcc1adcc3258011f4be9d6bd29d901
7
+ data.tar.gz: 839aee72e606fdc9547eb040b111b027ace186636407b765f5d43656d8b31d1a716856da2cfab31ff5c47c09a1e4943fead6874e3ad069ecb9cebe65127b2906
data/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # attago
2
+
3
+ [![CI](https://github.com/AttaGo/attago-rb-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/AttaGo/attago-rb-sdk/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/attago.svg)](https://badge.fury.io/rb/attago)
5
+
6
+ Ruby SDK for the [AttaGo](https://attago.bid) crypto trading dashboard API.
7
+
8
+ Go/No-Go crypto trading signals, alert subscriptions, x402 payments, webhook
9
+ HMAC verification, and MCP JSON-RPC 2.0 -- **zero runtime dependencies**
10
+ (stdlib only).
11
+
12
+ ## Install
13
+
14
+ ```ruby
15
+ # Gemfile
16
+ gem "attago"
17
+ ```
18
+
19
+ Or:
20
+
21
+ ```bash
22
+ gem install attago
23
+ ```
24
+
25
+ Requires **Ruby 3.1+**.
26
+
27
+ ## Quick Start
28
+
29
+ ### API Key (agent endpoints)
30
+
31
+ ```ruby
32
+ require "attago"
33
+
34
+ client = Attago::Client.new(api_key: "ak_live_...")
35
+
36
+ score = client.agent.get_score("BTC")
37
+ puts score.composite.signal # "GO", "NO-GO", or "NEUTRAL"
38
+ puts score.composite.score # 0-100
39
+ puts score.composite.confidence # 0.0-1.0
40
+
41
+ client.close
42
+ ```
43
+
44
+ ### x402 Signer (pay-per-request)
45
+
46
+ ```ruby
47
+ client = Attago::Client.new(signer: my_signer)
48
+
49
+ # x402 payment handled transparently
50
+ score = client.agent.get_score("ETH")
51
+ data = client.agent.get_data("BTC", "ETH", "SOL")
52
+ ```
53
+
54
+ ### Cognito (account management)
55
+
56
+ ```ruby
57
+ client = Attago::Client.new(
58
+ email: "user@example.com",
59
+ password: "...",
60
+ cognito_client_id: "abc123"
61
+ )
62
+
63
+ subs = client.subscriptions.list
64
+ status = client.payments.status
65
+
66
+ client.close
67
+ ```
68
+
69
+ ## Auth Modes
70
+
71
+ Exactly one auth mode per client. Mix-and-match raises `ConfigError`.
72
+
73
+ | Mode | Use Case | Endpoints |
74
+ |------|----------|-----------|
75
+ | `api_key:` | Scripts, bots, CI | Agent (score, data) |
76
+ | `signer:` | Pay-per-request (x402) | Agent (score, data) |
77
+ | `email: + password:` | Account management | Subscriptions, wallets, payments, webhooks, API keys, bundles, push, redeem |
78
+
79
+ No auth is also valid -- public data endpoints only.
80
+
81
+ ## API Reference
82
+
83
+ | Service | Methods |
84
+ |---------|---------|
85
+ | `client.agent` | `get_score(symbol)`, `get_data(*symbols)` |
86
+ | `client.data` | `get_latest`, `get_token_data(token)`, `get_data_push(id)` |
87
+ | `client.subscriptions` | `catalog`, `list`, `create(input)`, `update(id, input)`, `delete(id)` |
88
+ | `client.payments` | `subscribe(input)`, `status`, `upgrade_quote(tier, cycle)` |
89
+ | `client.wallets` | `register(input)`, `list`, `remove(address)` |
90
+ | `client.webhooks` | `create(url)`, `list`, `delete(id)`, `send_test(opts)`, `send_server_test(id)` |
91
+ | `client.mcp` | `initialize_session`, `list_tools`, `call_tool(name, args)`, `ping` |
92
+ | `client.api_keys` | `create(name)`, `list`, `revoke(id)` |
93
+ | `client.bundles` | `list`, `purchase(input)` |
94
+ | `client.push` | `list`, `create(input)`, `delete(id)` |
95
+ | `client.redeem` | `redeem(code)` |
96
+
97
+ ## Typed Inputs
98
+
99
+ Service methods that accept structured input use typed structs:
100
+
101
+ ```ruby
102
+ # Create a subscription
103
+ input = Attago::SubscriptionCreateInput.new(
104
+ token_id: "BTC",
105
+ label: "BTC Price Alert",
106
+ groups: [
107
+ [Attago::Condition.new(
108
+ metric_name: "spotPrice",
109
+ threshold_op: "gte",
110
+ threshold_val: 100_000
111
+ )]
112
+ ],
113
+ cooldown_minutes: 60
114
+ )
115
+ sub = client.subscriptions.create(input)
116
+ ```
117
+
118
+ ```ruby
119
+ # Register a wallet
120
+ input = Attago::WalletRegisterInput.new(
121
+ wallet_address: "0x...",
122
+ chain: "base",
123
+ signature: "0x...",
124
+ timestamp: Time.now.utc.iso8601
125
+ )
126
+ wallet = client.wallets.register(input)
127
+ ```
128
+
129
+ ## Webhook Verification
130
+
131
+ Verify incoming webhook signatures without a full client:
132
+
133
+ ```ruby
134
+ body = request.body.read
135
+ signature = request.headers["X-AttaGo-Signature"]
136
+ secret = "whsec_..."
137
+
138
+ if Attago::Webhooks.verify_signature(body, secret, signature)
139
+ payload = JSON.parse(body)
140
+ # Handle webhook
141
+ else
142
+ # Invalid signature -- reject
143
+ end
144
+ ```
145
+
146
+ ## Webhook Listener
147
+
148
+ Standalone HTTP server for receiving webhooks in development or background
149
+ workers:
150
+
151
+ ```ruby
152
+ require "attago"
153
+
154
+ listener = Attago::WebhookListener.new(secret: "whsec_...", port: 4000)
155
+
156
+ listener.on_alert do |payload|
157
+ puts "#{payload.alert.token}: #{payload.alert.state}"
158
+ end
159
+
160
+ listener.on_test do |payload|
161
+ puts "Test webhook received"
162
+ end
163
+
164
+ listener.on_error do |err|
165
+ warn "Webhook error: #{err.message}"
166
+ end
167
+
168
+ listener.start # Runs in a background thread
169
+ # ... do other work ...
170
+ listener.stop
171
+ ```
172
+
173
+ ## MCP (Model Context Protocol)
174
+
175
+ JSON-RPC 2.0 over HTTP for AI agent integration:
176
+
177
+ ```ruby
178
+ client = Attago::Client.new(api_key: "ak_live_...")
179
+
180
+ info = client.mcp.initialize_session
181
+ tools = client.mcp.list_tools
182
+
183
+ result = client.mcp.call_tool("get_score", { "symbol" => "BTC" })
184
+ puts result.content.first.text
185
+
186
+ client.mcp.ping
187
+
188
+ client.close
189
+ ```
190
+
191
+ ## Error Handling
192
+
193
+ ```ruby
194
+ begin
195
+ score = client.agent.get_score("BTC")
196
+ rescue Attago::PaymentRequiredError => e
197
+ puts "Payment required: #{e.message}"
198
+ puts "Requirements: #{e.payment_requirements}"
199
+ rescue Attago::RateLimitError => e
200
+ puts "Rate limited, retry after: #{e.retry_after}s"
201
+ rescue Attago::ApiError => e
202
+ puts "API error #{e.status_code}: #{e.message}"
203
+ rescue Attago::McpError => e
204
+ puts "MCP error #{e.mcp_code}: #{e.mcp_message}"
205
+ rescue Attago::AuthError => e
206
+ puts "Auth error: #{e.message}"
207
+ rescue Attago::MfaRequiredError => e
208
+ puts "MFA required: #{e.challenge_name}"
209
+ end
210
+ ```
211
+
212
+ ## Configuration
213
+
214
+ ```ruby
215
+ client = Attago::Client.new(
216
+ api_key: "ak_live_...",
217
+ base_url: "https://staging.attago.io", # Default: https://api.attago.bid
218
+ )
219
+ ```
220
+
221
+ ## Testing
222
+
223
+ The SDK ships with a `MockTransport` for unit testing your integrations:
224
+
225
+ ```ruby
226
+ require "attago"
227
+
228
+ transport = Attago::Testing::MockTransport.new do |req|
229
+ Attago::Testing::MockResponse.new(
230
+ code: 200,
231
+ body: { "token" => "BTC", "composite" => { "score" => 75, "signal" => "GO" } }
232
+ )
233
+ end
234
+
235
+ client = Attago::Client.new(api_key: "test", transport: transport)
236
+ score = client.agent.get_score("BTC")
237
+ assert_equal "GO", score.composite.signal
238
+ ```
239
+
240
+ ## Development
241
+
242
+ ```bash
243
+ git clone git@github.com:AttaGo/attago-rb-sdk.git
244
+ cd attago-rb-sdk
245
+ bundle install
246
+
247
+ # Run unit tests
248
+ bundle exec rake test
249
+
250
+ # Run conformance tests (requires live API)
251
+ ATTAGO_BASE_URL=https://api.attago.bid ATTAGO_API_KEY=ak_... bundle exec rake conformance
252
+ ```
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attago
4
+ class AgentService
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ # GET /agent/score?symbol=SYM
10
+ def get_score(symbol)
11
+ data = @client.request("GET", "/agent/score", params: { "symbol" => symbol })
12
+ AgentScoreResponse.from_hash(data)
13
+ end
14
+
15
+ # GET /agent/data?symbols=SYM1,SYM2 (omit for all)
16
+ def get_data(*symbols)
17
+ params = symbols.empty? ? {} : { "symbols" => symbols.join(",") }
18
+ data = @client.request("GET", "/agent/data", params: params)
19
+ AgentDataResponse.from_hash(data)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Attago
6
+ class ApiKeyService
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # POST /api-keys
12
+ def create(name)
13
+ data = @client.request("POST", "/user/api-keys", body: { "name" => name })
14
+ ApiKeyCreateResponse.from_hash(data)
15
+ end
16
+
17
+ # GET /api-keys
18
+ def list
19
+ data = @client.request("GET", "/user/api-keys")
20
+ (data["keys"] || []).map { |k| ApiKeyListItem.from_hash(k) }
21
+ end
22
+
23
+ # DELETE /api-keys/{key_id}
24
+ def revoke(key_id)
25
+ @client.request("DELETE", "/user/api-keys/#{URI.encode_www_form_component(key_id)}")
26
+ nil
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Attago
8
+ class CognitoAuth
9
+ # Allow injection of an HTTP client for testing.
10
+ attr_writer :http_client
11
+
12
+ def initialize(client_id:, region: DEFAULT_COGNITO_REGION, email: nil, password: nil)
13
+ @client_id = client_id
14
+ @region = region
15
+ @email = email
16
+ @password = password
17
+ @tokens = nil
18
+ @mu = Mutex.new # Thread safety (GO-C1 audit lesson)
19
+ @http_client = nil
20
+ end
21
+
22
+ # Sign in and return tokens. Raises MfaRequiredError if MFA challenge.
23
+ def sign_in
24
+ resp = cognito_request("AWSCognitoIdentityProviderService.InitiateAuth", {
25
+ "AuthFlow" => "USER_PASSWORD_AUTH",
26
+ "ClientId" => @client_id,
27
+ "AuthParameters" => {
28
+ "USERNAME" => @email,
29
+ "PASSWORD" => @password
30
+ }
31
+ })
32
+
33
+ if resp["ChallengeName"]
34
+ raise MfaRequiredError.new(
35
+ session: resp["Session"],
36
+ challenge_name: resp["ChallengeName"]
37
+ )
38
+ end
39
+
40
+ tokens = parse_auth_result(resp["AuthenticationResult"])
41
+ @mu.synchronize { @tokens = tokens }
42
+ tokens
43
+ end
44
+
45
+ def sign_out
46
+ @mu.synchronize { @tokens = nil }
47
+ end
48
+
49
+ # Returns ID token, auto sign-in if needed.
50
+ def get_id_token
51
+ t = @mu.synchronize { @tokens }
52
+ if t.nil?
53
+ sign_in
54
+ t = @mu.synchronize { @tokens }
55
+ end
56
+ t.id_token
57
+ end
58
+
59
+ def set_tokens(tokens)
60
+ @mu.synchronize { @tokens = tokens }
61
+ end
62
+
63
+ def get_tokens
64
+ @mu.synchronize { @tokens }
65
+ end
66
+
67
+ # Respond to MFA challenge.
68
+ def respond_to_mfa(session, totp_code)
69
+ resp = cognito_request("AWSCognitoIdentityProviderService.RespondToAuthChallenge", {
70
+ "ChallengeName" => "SOFTWARE_TOKEN_MFA",
71
+ "ClientId" => @client_id,
72
+ "Session" => session,
73
+ "ChallengeResponses" => {
74
+ "USERNAME" => @email,
75
+ "SOFTWARE_TOKEN_MFA_CODE" => totp_code
76
+ }
77
+ })
78
+
79
+ tokens = parse_auth_result(resp["AuthenticationResult"])
80
+ @mu.synchronize { @tokens = tokens }
81
+ tokens
82
+ end
83
+
84
+ # Class-level methods (no instance needed)
85
+ def self.sign_up(email:, password:, client_id:, region: DEFAULT_COGNITO_REGION, http_client: nil)
86
+ auth = new(client_id: client_id, region: region)
87
+ auth.http_client = http_client if http_client
88
+ auth.send(:cognito_request, "AWSCognitoIdentityProviderService.SignUp", {
89
+ "ClientId" => client_id,
90
+ "Username" => email,
91
+ "Password" => password
92
+ })
93
+ end
94
+
95
+ def self.confirm_sign_up(email:, code:, client_id:, region: DEFAULT_COGNITO_REGION, http_client: nil)
96
+ auth = new(client_id: client_id, region: region)
97
+ auth.http_client = http_client if http_client
98
+ auth.send(:cognito_request, "AWSCognitoIdentityProviderService.ConfirmSignUp", {
99
+ "ClientId" => client_id,
100
+ "Username" => email,
101
+ "ConfirmationCode" => code
102
+ })
103
+ end
104
+
105
+ def self.forgot_password(email:, client_id:, region: DEFAULT_COGNITO_REGION, http_client: nil)
106
+ auth = new(client_id: client_id, region: region)
107
+ auth.http_client = http_client if http_client
108
+ auth.send(:cognito_request, "AWSCognitoIdentityProviderService.ForgotPassword", {
109
+ "ClientId" => client_id,
110
+ "Username" => email
111
+ })
112
+ end
113
+
114
+ def self.confirm_forgot_password(email:, code:, new_password:, client_id:, region: DEFAULT_COGNITO_REGION, http_client: nil)
115
+ auth = new(client_id: client_id, region: region)
116
+ auth.http_client = http_client if http_client
117
+ auth.send(:cognito_request, "AWSCognitoIdentityProviderService.ConfirmForgotPassword", {
118
+ "ClientId" => client_id,
119
+ "Username" => email,
120
+ "ConfirmationCode" => code,
121
+ "Password" => new_password
122
+ })
123
+ end
124
+
125
+ private
126
+
127
+ def parse_auth_result(result)
128
+ CognitoTokens.new(
129
+ id_token: result["IdToken"],
130
+ access_token: result["AccessToken"],
131
+ refresh_token: result["RefreshToken"]
132
+ )
133
+ end
134
+
135
+ def cognito_request(target, body)
136
+ if @http_client
137
+ # Use injected HTTP client for testing
138
+ resp = @http_client.call(target, body)
139
+ else
140
+ uri = URI.parse("https://cognito-idp.#{@region}.amazonaws.com/")
141
+ http = Net::HTTP.new(uri.host, uri.port)
142
+ http.use_ssl = true
143
+ http.open_timeout = 10
144
+ http.read_timeout = 15
145
+
146
+ req = Net::HTTP::Post.new("/")
147
+ req["Content-Type"] = "application/x-amz-json-1.1"
148
+ req["X-Amz-Target"] = target
149
+ req.body = JSON.generate(body)
150
+
151
+ resp = http.request(req)
152
+ end
153
+
154
+ # Check HTTP status BEFORE JSON decode (GO audit I3 lesson)
155
+ unless resp.code.to_i == 200
156
+ begin
157
+ err_body = JSON.parse(resp.body || "")
158
+ err_type = err_body["__type"]&.split("#")&.last || "UnknownError"
159
+ err_msg = err_body["message"] || err_body["Message"] || "Authentication failed"
160
+ rescue JSON::ParserError
161
+ err_type = "UnknownError"
162
+ err_msg = "HTTP #{resp.code}"
163
+ end
164
+ raise AuthError.new(err_msg, code: err_type)
165
+ end
166
+
167
+ JSON.parse(resp.body)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attago
4
+ class BundleService
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ # GET /api/bundles
10
+ def list
11
+ data = @client.request("GET", "/api/bundles")
12
+ BundleListResponse.from_hash(data)
13
+ end
14
+
15
+ # POST /api/bundles
16
+ def purchase(input)
17
+ body = { "bundleIndex" => input.bundle_index, "walletAddress" => input.wallet_address }
18
+ data = @client.request("POST", "/api/bundles", body: body)
19
+ BundlePurchaseResponse.from_hash(data)
20
+ end
21
+ end
22
+ end