foil-server 0.3.3

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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +154 -0
  4. data/lib/foil/server/client.rb +472 -0
  5. data/lib/foil/server/crypto_support.rb +49 -0
  6. data/lib/foil/server/errors.rb +21 -0
  7. data/lib/foil/server/gate_delivery.rb +325 -0
  8. data/lib/foil/server/sealed_token.rb +78 -0
  9. data/lib/foil/server/types.rb +5 -0
  10. data/lib/foil/server/version.rb +5 -0
  11. data/lib/foil/server.rb +31 -0
  12. data/spec/LICENSE +21 -0
  13. data/spec/README.md +160 -0
  14. data/spec/fixtures/api/fingerprints/detail.json +70 -0
  15. data/spec/fixtures/api/fingerprints/list.json +37 -0
  16. data/spec/fixtures/api/gate/agent-token-revoke.json +3 -0
  17. data/spec/fixtures/api/gate/agent-token-verify.json +12 -0
  18. data/spec/fixtures/api/gate/login-session-consume.json +10 -0
  19. data/spec/fixtures/api/gate/login-session-create.json +12 -0
  20. data/spec/fixtures/api/gate/registry-detail.json +45 -0
  21. data/spec/fixtures/api/gate/registry-list.json +47 -0
  22. data/spec/fixtures/api/gate/service-create.json +49 -0
  23. data/spec/fixtures/api/gate/service-detail.json +49 -0
  24. data/spec/fixtures/api/gate/service-disable.json +49 -0
  25. data/spec/fixtures/api/gate/service-update.json +49 -0
  26. data/spec/fixtures/api/gate/services-list.json +51 -0
  27. data/spec/fixtures/api/gate/session-ack.json +10 -0
  28. data/spec/fixtures/api/gate/session-create.json +13 -0
  29. data/spec/fixtures/api/gate/session-poll.json +36 -0
  30. data/spec/fixtures/api/organizations/api-key-create.json +27 -0
  31. data/spec/fixtures/api/organizations/api-key-list.json +31 -0
  32. data/spec/fixtures/api/organizations/api-key-revoke.json +25 -0
  33. data/spec/fixtures/api/organizations/api-key-rotate.json +27 -0
  34. data/spec/fixtures/api/organizations/api-key-update.json +29 -0
  35. data/spec/fixtures/api/organizations/organization-create.json +14 -0
  36. data/spec/fixtures/api/organizations/organization-update.json +14 -0
  37. data/spec/fixtures/api/organizations/organization.json +14 -0
  38. data/spec/fixtures/api/sessions/detail.json +434 -0
  39. data/spec/fixtures/api/sessions/list.json +36 -0
  40. data/spec/fixtures/errors/invalid-api-key.json +10 -0
  41. data/spec/fixtures/errors/missing-api-key.json +10 -0
  42. data/spec/fixtures/errors/not-found.json +10 -0
  43. data/spec/fixtures/errors/validation-error.json +20 -0
  44. data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +19 -0
  45. data/spec/fixtures/gate-delivery/delivery-request.json +9 -0
  46. data/spec/fixtures/gate-delivery/env-policy.json +40 -0
  47. data/spec/fixtures/gate-delivery/vector.v1.json +28 -0
  48. data/spec/fixtures/gate-delivery/webhook-signature.json +9 -0
  49. data/spec/fixtures/manifest.json +185 -0
  50. data/spec/fixtures/sealed-token/invalid.json +4 -0
  51. data/spec/fixtures/sealed-token/vector.v1.json +54 -0
  52. data/spec/openapi.json +20482 -0
  53. data/spec/sealed-token.md +114 -0
  54. metadata +96 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cb419b8f30f0937555fa2b515c66f6bca1e8dc042ab72eee05396b53fe02d3a6
4
+ data.tar.gz: 1545b9bd453185c2656ca627adbe26f6e130d7d5da29491e30e780887e27415a
5
+ SHA512:
6
+ metadata.gz: 216b749d89ea86f9c80b01bf0186a7916dda90718f81bb52eb7c7699c323776a8a9e647e1c6486a8499e3488fab26c4b0d01f8f71849b0325b05514887bbb923
7
+ data.tar.gz: 31c99e14cd9c805e1af6b66719c0eecc1c3ad1c42a59b205e7d969234e1ff473b9169d6fb3d9aeda5aac9805458118dfa2e079f82599f51d34054b0f8a0dcb5b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ABXY Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # Foil Ruby Library
2
+
3
+ ![Preview](https://img.shields.io/badge/status-preview-111827)
4
+ ![Ruby 3.3+](https://img.shields.io/badge/ruby-3.3%2B-CC342D?logo=ruby&logoColor=white)
5
+ ![License: MIT](https://img.shields.io/badge/license-MIT-0f766e.svg)
6
+
7
+ The Foil Ruby library provides convenient access to the Foil API from applications written in Ruby. It includes a client for Sessions, visitor fingerprints, Organizations, Organization API key management, sealed token verification, Gate, and Gate delivery/webhook helpers.
8
+
9
+ The library also provides:
10
+
11
+ - a fast configuration path using `FOIL_SECRET_KEY`
12
+ - lazy helpers for cursor-based pagination
13
+ - structured API errors and built-in sealed token verification
14
+ - webhook endpoint management, test sends, and event delivery history
15
+ - public, bearer-token, and secret-key auth modes for Gate flows
16
+ - Gate delivery/webhook helpers
17
+
18
+ ## Documentation
19
+
20
+ See the [Foil docs](https://usefoil.com/docs) and [API reference](https://usefoil.com/docs/api-reference/introduction).
21
+
22
+ ## Installation
23
+
24
+ You don't need this source code unless you want to modify the gem. If you just want to use the package, run:
25
+
26
+ ```bash
27
+ bundle add foil-server
28
+ ```
29
+
30
+ ## Requirements
31
+
32
+ - Ruby 3.3+
33
+
34
+ ## Usage
35
+
36
+ Use `FOIL_SECRET_KEY` or `secret_key:` for core detect APIs. For public or bearer-auth Gate flows, the client can also be created without a secret key:
37
+
38
+ ```ruby
39
+ require "foil/server"
40
+
41
+ client = Foil::Server::Client.new(secret_key: "sk_live_...")
42
+
43
+ page = client.sessions.list(verdict: "bot", limit: 25)
44
+ session = client.sessions.get("sid_0123456789abcdefghjkmnpqrs")
45
+
46
+ puts "#{session[:decision][:automation_status]} #{session[:highlights].first&.fetch(:summary, nil)}"
47
+ ```
48
+
49
+ ### Sealed token verification
50
+
51
+ ```ruby
52
+ result = Foil::Server.safe_verify_foil_token(sealed_token, "sk_live_...")
53
+
54
+ if result[:ok]
55
+ puts "#{result[:data][:decision][:verdict]} #{result[:data][:decision][:risk_score]}"
56
+ else
57
+ warn result[:error].message
58
+ end
59
+ ```
60
+
61
+ ### Pagination
62
+
63
+ ```ruby
64
+ client.sessions.iter(search: "signup").each do |session|
65
+ puts "#{session[:id]} #{session[:latest_decision][:verdict]}"
66
+ end
67
+ ```
68
+
69
+ ### Visitor fingerprints
70
+
71
+ ```ruby
72
+ fingerprint = client.fingerprints.get("vid_0123456789abcdefghjkmnpqrs")
73
+ puts fingerprint[:id]
74
+ ```
75
+
76
+ ### Organizations
77
+
78
+ ```ruby
79
+ organization = client.organizations.get("org_0123456789abcdefghjkmnpqrs")
80
+ updated = client.organizations.update("org_0123456789abcdefghjkmnpqrs", name: "New Name")
81
+
82
+ puts updated[:name]
83
+ ```
84
+
85
+ ### Organization API keys
86
+
87
+ ```ruby
88
+ created = client.organizations.api_keys.create("org_0123456789abcdefghjkmnpqrs", name: "Production", type: "secret", environment: "live")
89
+ client.organizations.api_keys.revoke("org_0123456789abcdefghjkmnpqrs", created[:id])
90
+ ```
91
+
92
+ ### Webhooks
93
+
94
+ ```ruby
95
+ endpoint = client.webhooks.create_endpoint(
96
+ "org_0123456789abcdefghjkmnpqrs",
97
+ name: "Production alerts",
98
+ url: "https://example.com/foil/webhook",
99
+ event_types: ["session.result.persisted", "gate.session.approved"]
100
+ )
101
+
102
+ events = client.webhooks.list_events(
103
+ "org_0123456789abcdefghjkmnpqrs",
104
+ endpoint_id: endpoint[:id],
105
+ type: "session.result.persisted"
106
+ )
107
+
108
+ puts events.items.first[:webhook_deliveries].first[:status]
109
+ ```
110
+
111
+ ### Gate APIs
112
+
113
+ ```ruby
114
+ delivery_key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
115
+
116
+ services = client.gate.registry.list
117
+ session = client.gate.sessions.create(
118
+ service_id: "foil",
119
+ account_name: "my-project",
120
+ delivery: delivery_key_pair[:delivery]
121
+ )
122
+
123
+ puts "#{services.first[:id]} #{session[:consent_url]}"
124
+ ```
125
+
126
+ ### Gate delivery and webhook helpers
127
+
128
+ ```ruby
129
+ key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
130
+ response = Foil::Server::GateDelivery.create_gate_approved_webhook_response(
131
+ delivery: key_pair[:delivery],
132
+ outputs: {
133
+ "FOIL_PUBLISHABLE_KEY" => "pk_live_...",
134
+ "FOIL_SECRET_KEY" => "sk_live_..."
135
+ }
136
+ )
137
+ payload = Foil::Server::GateDelivery.decrypt_gate_delivery_envelope(key_pair[:private_key], response[:encrypted_delivery])
138
+
139
+ puts payload[:outputs]["FOIL_SECRET_KEY"]
140
+ ```
141
+
142
+ ### Error handling
143
+
144
+ ```ruby
145
+ begin
146
+ client.sessions.list(limit: 999)
147
+ rescue Foil::Server::ApiError => error
148
+ warn "#{error.status} #{error.code} #{error.message}"
149
+ end
150
+ ```
151
+
152
+ ## Support
153
+
154
+ If you need help integrating Foil, start with [usefoil.com/docs](https://usefoil.com/docs).
@@ -0,0 +1,472 @@
1
+ require "cgi"
2
+ require "json"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Foil
7
+ module Server
8
+ class Client
9
+ DEFAULT_BASE_URL = "https://api.usefoil.com".freeze
10
+ DEFAULT_TIMEOUT = 30
11
+ SDK_CLIENT_HEADER = "foil-server-ruby/0.1.0".freeze
12
+
13
+ attr_reader :sessions, :fingerprints, :organizations, :gate, :webhooks, :timeout
14
+
15
+ def initialize(secret_key: ENV["FOIL_SECRET_KEY"], base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil, transport: nil)
16
+ @secret_key = secret_key
17
+ @base_url = base_url
18
+ @timeout = timeout
19
+ @user_agent = user_agent
20
+ @transport = transport
21
+
22
+ @sessions = SessionsResource.new(self)
23
+ @fingerprints = FingerprintsResource.new(self)
24
+ @organizations = OrganizationsResource.new(self)
25
+ @gate = GateResource.new(self)
26
+ @webhooks = WebhooksResource.new(self)
27
+ end
28
+
29
+ def request_json(method, path, query: {}, body: nil, expect_content: true, auth: { kind: :secret })
30
+ url = build_url(path, query)
31
+ headers = {
32
+ "Accept" => "application/json",
33
+ "X-Foil-Client" => SDK_CLIENT_HEADER
34
+ }
35
+ headers["User-Agent"] = @user_agent if @user_agent
36
+ headers["Content-Type"] = "application/json" if body
37
+ apply_auth_headers(headers, auth)
38
+
39
+ status, response_headers, response_body =
40
+ if @transport
41
+ @transport.call(method: method, url: url.to_s, headers: headers, body: body.nil? ? nil : JSON.dump(body))
42
+ else
43
+ perform_http_request(method, url, headers, body)
44
+ end
45
+
46
+ request_id = response_headers["x-request-id"] || response_headers["X-Request-Id"]
47
+
48
+ if status >= 400
49
+ payload = parse_json(response_body)
50
+ if payload[:error].is_a?(Hash)
51
+ error = payload[:error]
52
+ details = error[:details].is_a?(Hash) ? error[:details] : {}
53
+ raise ApiError.new(
54
+ status: status,
55
+ code: error[:code] || "request.failed",
56
+ message: error[:message] || response_body.to_s,
57
+ request_id: request_id || error[:request_id],
58
+ field_errors: details[:fields] || [],
59
+ docs_url: error[:docs_url],
60
+ body: payload
61
+ )
62
+ end
63
+
64
+ raise ApiError.new(status: status, code: "request.failed", message: response_body.to_s, request_id: request_id, body: payload)
65
+ end
66
+
67
+ return {} unless expect_content
68
+ return {} if status == 204 || response_body.nil? || response_body.empty?
69
+
70
+ parse_json(response_body)
71
+ end
72
+
73
+ def perform_http_request(method, url, headers, body)
74
+ http = Net::HTTP.new(url.host, url.port)
75
+ http.use_ssl = (url.scheme == "https")
76
+ http.read_timeout = @timeout
77
+ http.open_timeout = @timeout
78
+
79
+ request_class = case method
80
+ when "GET" then Net::HTTP::Get
81
+ when "POST" then Net::HTTP::Post
82
+ when "PATCH" then Net::HTTP::Patch
83
+ when "DELETE" then Net::HTTP::Delete
84
+ else
85
+ raise ArgumentError, "Unsupported method #{method}"
86
+ end
87
+
88
+ request = request_class.new(url)
89
+ headers.each { |key, value| request[key] = value }
90
+ request.body = JSON.dump(body) if body
91
+
92
+ response = http.request(request)
93
+ [response.code.to_i, response.to_hash.transform_values { |value| Array(value).first }, response.body.to_s]
94
+ end
95
+ private :perform_http_request
96
+
97
+ def build_url(path, query)
98
+ url = URI.join(@base_url.end_with?("/") ? @base_url : "#{@base_url}/", path.sub(%r{\A/}, ""))
99
+ compact_query = query.each_with_object({}) do |(key, value), memo|
100
+ memo[key] = value unless value.nil? || value == ""
101
+ end
102
+ url.query = URI.encode_www_form(compact_query) unless compact_query.empty?
103
+ url
104
+ end
105
+ private :build_url
106
+
107
+ def parse_json(body)
108
+ data = JSON.parse(body)
109
+ deep_symbolize(data)
110
+ rescue JSON::ParserError
111
+ {}
112
+ end
113
+ private :parse_json
114
+
115
+ def deep_symbolize(value)
116
+ case value
117
+ when Array
118
+ value.map { |item| deep_symbolize(item) }
119
+ when Hash
120
+ value.each_with_object({}) do |(key, item), memo|
121
+ memo[key.to_sym] = deep_symbolize(item)
122
+ end
123
+ else
124
+ value
125
+ end
126
+ end
127
+ private :deep_symbolize
128
+
129
+ def apply_auth_headers(headers, auth)
130
+ kind = (auth[:kind] || :secret).to_sym
131
+ case kind
132
+ when :none
133
+ headers
134
+ when :bearer
135
+ token = auth[:token]
136
+ raise ConfigurationError, "Missing bearer token for this Foil request." if token.nil? || token.empty?
137
+
138
+ headers["Authorization"] = "Bearer #{token}"
139
+ else
140
+ raise ConfigurationError, "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY." if @secret_key.nil? || @secret_key.empty?
141
+
142
+ headers["Authorization"] = "Bearer #{@secret_key}"
143
+ end
144
+ end
145
+ private :apply_auth_headers
146
+ end
147
+
148
+ class BaseResource
149
+ def initialize(client)
150
+ @client = client
151
+ end
152
+
153
+ private
154
+
155
+ def list_result(payload)
156
+ ListResult.new(
157
+ items: payload[:data],
158
+ limit: payload.fetch(:pagination).fetch(:limit),
159
+ has_more: payload.fetch(:pagination).fetch(:has_more),
160
+ next_cursor: payload.fetch(:pagination)[:next_cursor]
161
+ )
162
+ end
163
+ end
164
+
165
+ class SessionsResource < BaseResource
166
+ def list(limit: nil, cursor: nil, verdict: nil, search: nil)
167
+ payload = @client.request_json("GET", "/v1/sessions", query: {
168
+ limit: limit,
169
+ cursor: cursor,
170
+ verdict: verdict,
171
+ search: search
172
+ })
173
+ list_result(payload)
174
+ end
175
+
176
+ def get(session_id)
177
+ @client.request_json("GET", "/v1/sessions/#{CGI.escape(session_id)}")[:data]
178
+ end
179
+
180
+ def iter(limit: nil, verdict: nil, search: nil)
181
+ Enumerator.new do |yielder|
182
+ cursor = nil
183
+ loop do
184
+ page = list(limit: limit, cursor: cursor, verdict: verdict, search: search)
185
+ page.items.each { |item| yielder << item }
186
+ break unless page.has_more && page.next_cursor
187
+
188
+ cursor = page.next_cursor
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ class FingerprintsResource < BaseResource
195
+ def list(limit: nil, cursor: nil, search: nil, sort: nil)
196
+ payload = @client.request_json("GET", "/v1/fingerprints", query: {
197
+ limit: limit,
198
+ cursor: cursor,
199
+ search: search,
200
+ sort: sort
201
+ })
202
+ list_result(payload)
203
+ end
204
+
205
+ def get(visitor_id)
206
+ @client.request_json("GET", "/v1/fingerprints/#{CGI.escape(visitor_id)}")[:data]
207
+ end
208
+
209
+ def iter(limit: nil, search: nil, sort: nil)
210
+ Enumerator.new do |yielder|
211
+ cursor = nil
212
+ loop do
213
+ page = list(limit: limit, cursor: cursor, search: search, sort: sort)
214
+ page.items.each { |item| yielder << item }
215
+ break unless page.has_more && page.next_cursor
216
+
217
+ cursor = page.next_cursor
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ class ApiKeysResource < BaseResource
224
+ def create(organization_id, name:, type: nil, environment: nil, allowed_origins: nil, scopes: nil)
225
+ payload = @client.request_json("POST", "/v1/organizations/#{CGI.escape(organization_id)}/api-keys", body: compact({
226
+ name: name,
227
+ type: type,
228
+ environment: environment,
229
+ allowed_origins: allowed_origins,
230
+ scopes: scopes
231
+ }))
232
+ payload[:data]
233
+ end
234
+
235
+ def list(organization_id, limit: nil, cursor: nil)
236
+ payload = @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/api-keys", query: {
237
+ limit: limit,
238
+ cursor: cursor
239
+ })
240
+ list_result(payload)
241
+ end
242
+
243
+ def update(organization_id, key_id, name: nil, allowed_origins: nil, scopes: nil)
244
+ @client.request_json("PATCH", "/v1/organizations/#{CGI.escape(organization_id)}/api-keys/#{CGI.escape(key_id)}", body: compact({
245
+ name: name,
246
+ allowed_origins: allowed_origins,
247
+ scopes: scopes
248
+ }))[:data]
249
+ end
250
+
251
+ def revoke(organization_id, key_id)
252
+ @client.request_json("DELETE", "/v1/organizations/#{CGI.escape(organization_id)}/api-keys/#{CGI.escape(key_id)}")[:data]
253
+ end
254
+
255
+ def rotate(organization_id, key_id)
256
+ payload = @client.request_json("POST", "/v1/organizations/#{CGI.escape(organization_id)}/api-keys/#{CGI.escape(key_id)}/rotations")
257
+ payload[:data]
258
+ end
259
+
260
+ private
261
+
262
+ def compact(hash)
263
+ hash.reject { |_key, value| value.nil? }
264
+ end
265
+ end
266
+
267
+ class OrganizationsResource < BaseResource
268
+ attr_reader :api_keys
269
+
270
+ def initialize(client)
271
+ super(client)
272
+ @api_keys = ApiKeysResource.new(client)
273
+ end
274
+
275
+ def create(name:, slug:)
276
+ @client.request_json("POST", "/v1/organizations", body: { name: name, slug: slug })[:data]
277
+ end
278
+
279
+ def get(organization_id)
280
+ @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}")[:data]
281
+ end
282
+
283
+ def update(organization_id, name: nil, status: nil)
284
+ @client.request_json("PATCH", "/v1/organizations/#{CGI.escape(organization_id)}", body: {
285
+ name: name,
286
+ status: status
287
+ }.reject { |_key, value| value.nil? })[:data]
288
+ end
289
+ end
290
+
291
+ class GateResource < BaseResource
292
+ attr_reader :registry, :services, :sessions, :login_sessions, :agent_tokens
293
+
294
+ def initialize(client)
295
+ super(client)
296
+ @registry = GateRegistryResource.new(client)
297
+ @services = GateServicesResource.new(client)
298
+ @sessions = GateSessionsResource.new(client)
299
+ @login_sessions = GateLoginSessionsResource.new(client)
300
+ @agent_tokens = GateAgentTokensResource.new(client)
301
+ end
302
+ end
303
+
304
+ class GateRegistryResource < BaseResource
305
+ def list
306
+ @client.request_json("GET", "/v1/gate/registry", auth: { kind: :none })[:data]
307
+ end
308
+
309
+ def get(service_id)
310
+ @client.request_json("GET", "/v1/gate/registry/#{CGI.escape(service_id)}", auth: { kind: :none })[:data]
311
+ end
312
+ end
313
+
314
+ class GateServicesResource < BaseResource
315
+ def list
316
+ @client.request_json("GET", "/v1/gate/services")[:data]
317
+ end
318
+
319
+ def get(service_id)
320
+ @client.request_json("GET", "/v1/gate/services/#{CGI.escape(service_id)}")[:data]
321
+ end
322
+
323
+ def create(id:, name:, description:, website:, webhook_endpoint_id:, discoverable: nil, dashboard_login_url: nil, env_vars: nil, docs_url: nil, sdks: nil, branding: nil, consent: nil)
324
+ @client.request_json("POST", "/v1/gate/services", body: compact({
325
+ id: id,
326
+ discoverable: discoverable,
327
+ name: name,
328
+ description: description,
329
+ website: website,
330
+ dashboard_login_url: dashboard_login_url,
331
+ webhook_endpoint_id: webhook_endpoint_id,
332
+ env_vars: env_vars,
333
+ docs_url: docs_url,
334
+ sdks: sdks,
335
+ branding: branding,
336
+ consent: consent
337
+ }))[:data]
338
+ end
339
+
340
+ def update(service_id, discoverable: nil, name: nil, description: nil, website: nil, dashboard_login_url: nil, webhook_endpoint_id: nil, env_vars: nil, docs_url: nil, sdks: nil, branding: nil, consent: nil)
341
+ @client.request_json("PATCH", "/v1/gate/services/#{CGI.escape(service_id)}", body: compact({
342
+ discoverable: discoverable,
343
+ name: name,
344
+ description: description,
345
+ website: website,
346
+ dashboard_login_url: dashboard_login_url,
347
+ webhook_endpoint_id: webhook_endpoint_id,
348
+ env_vars: env_vars,
349
+ docs_url: docs_url,
350
+ sdks: sdks,
351
+ branding: branding,
352
+ consent: consent
353
+ }))[:data]
354
+ end
355
+
356
+ def disable(service_id)
357
+ @client.request_json("DELETE", "/v1/gate/services/#{CGI.escape(service_id)}")[:data]
358
+ end
359
+
360
+ private
361
+
362
+ def compact(hash)
363
+ hash.reject { |_key, value| value.nil? }
364
+ end
365
+ end
366
+
367
+ class WebhooksResource < BaseResource
368
+ def list_endpoints(organization_id)
369
+ payload = @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints")
370
+ list_result(payload)
371
+ end
372
+
373
+ def create_endpoint(organization_id, name:, url:, event_types:)
374
+ @client.request_json("POST", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints", body: {
375
+ name: name,
376
+ url: url,
377
+ event_types: event_types
378
+ })[:data]
379
+ end
380
+
381
+ def update_endpoint(organization_id, endpoint_id, **updates)
382
+ @client.request_json("PATCH", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints/#{CGI.escape(endpoint_id)}", body: updates)[:data]
383
+ end
384
+
385
+ def disable_endpoint(organization_id, endpoint_id)
386
+ @client.request_json("DELETE", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints/#{CGI.escape(endpoint_id)}")[:data]
387
+ end
388
+
389
+ def rotate_secret(organization_id, endpoint_id)
390
+ @client.request_json("POST", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints/#{CGI.escape(endpoint_id)}/rotations")[:data]
391
+ end
392
+
393
+ def send_test(organization_id, endpoint_id)
394
+ @client.request_json("POST", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints/#{CGI.escape(endpoint_id)}/test")[:data]
395
+ end
396
+
397
+ def list_events(organization_id, endpoint_id: nil, type: nil, limit: nil)
398
+ payload = @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/events", query: {
399
+ endpoint_id: endpoint_id,
400
+ type: type,
401
+ limit: limit
402
+ })
403
+ list_result(payload)
404
+ end
405
+
406
+ def retrieve_event(organization_id, event_id)
407
+ @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/events/#{CGI.escape(event_id)}")[:data]
408
+ end
409
+ end
410
+
411
+ class GateSessionsResource < BaseResource
412
+ def create(service_id:, account_name:, delivery:, metadata: nil)
413
+ body = {
414
+ service_id: service_id,
415
+ account_name: account_name,
416
+ delivery: delivery
417
+ }
418
+ body[:metadata] = metadata unless metadata.nil?
419
+
420
+ @client.request_json("POST", "/v1/gate/sessions", body: body, auth: { kind: :none })[:data]
421
+ end
422
+
423
+ def poll(gate_session_id, poll_token:)
424
+ @client.request_json(
425
+ "GET",
426
+ "/v1/gate/sessions/#{CGI.escape(gate_session_id)}",
427
+ auth: { kind: :bearer, token: poll_token }
428
+ )[:data]
429
+ end
430
+
431
+ def acknowledge(gate_session_id, poll_token:, ack_token:)
432
+ @client.request_json(
433
+ "POST",
434
+ "/v1/gate/sessions/#{CGI.escape(gate_session_id)}/ack",
435
+ body: { ack_token: ack_token },
436
+ auth: { kind: :bearer, token: poll_token }
437
+ )[:data]
438
+ end
439
+ end
440
+
441
+ class GateLoginSessionsResource < BaseResource
442
+ def create(service_id:, agent_token:)
443
+ @client.request_json(
444
+ "POST",
445
+ "/v1/gate/login-sessions",
446
+ body: { service_id: service_id },
447
+ auth: { kind: :bearer, token: agent_token }
448
+ )[:data]
449
+ end
450
+
451
+ def consume(code:)
452
+ @client.request_json("POST", "/v1/gate/login-sessions/consume", body: { code: code })[:data]
453
+ end
454
+ end
455
+
456
+ class GateAgentTokensResource < BaseResource
457
+ def verify(agent_token:)
458
+ @client.request_json("POST", "/v1/gate/agent-tokens/verify", body: { agent_token: agent_token })[:data]
459
+ end
460
+
461
+ def revoke(agent_token:)
462
+ @client.request_json(
463
+ "POST",
464
+ "/v1/gate/agent-tokens/revoke",
465
+ body: { agent_token: agent_token },
466
+ expect_content: false
467
+ )
468
+ nil
469
+ end
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,49 @@
1
+ require "openssl"
2
+ require "rubygems"
3
+
4
+ module Foil
5
+ module Server
6
+ module CryptoSupport
7
+ MIN_SUPPORTED_RUBY_VERSION = Gem::Version.new("3.3.0")
8
+ UNSUPPORTED_RUNTIME_MESSAGE = "Foil Ruby cryptography helpers require Ruby 3.3+ with modern OpenSSL support.".freeze
9
+
10
+ module_function
11
+
12
+ def supported_runtime?
13
+ return @supported_runtime unless @supported_runtime.nil?
14
+
15
+ @supported_runtime = Gem::Version.new(RUBY_VERSION) >= MIN_SUPPORTED_RUBY_VERSION &&
16
+ OpenSSL::PKey.respond_to?(:generate_key) &&
17
+ defined?(OpenSSL::KDF) &&
18
+ OpenSSL::KDF.respond_to?(:hkdf) &&
19
+ aead_auth_data_supported?
20
+ end
21
+
22
+ def ensure_supported_runtime!
23
+ return if supported_runtime?
24
+
25
+ raise ConfigurationError, UNSUPPORTED_RUNTIME_MESSAGE
26
+ end
27
+
28
+ def minimum_supported_ruby_version
29
+ MIN_SUPPORTED_RUBY_VERSION
30
+ end
31
+
32
+ def unsupported_runtime_message
33
+ UNSUPPORTED_RUNTIME_MESSAGE
34
+ end
35
+
36
+ def aead_auth_data_supported?
37
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
38
+ cipher.encrypt
39
+ cipher.key = "\x00".b * 32
40
+ cipher.iv = "\x00".b * 12
41
+ cipher.auth_data = "".b
42
+ true
43
+ rescue StandardError
44
+ false
45
+ end
46
+ private_class_method :aead_auth_data_supported?
47
+ end
48
+ end
49
+ end