foil-server 0.3.5 → 0.5.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -30
  3. data/lib/foil/server/client.rb +31 -159
  4. data/lib/foil/server/sealed_token.rb +18 -1
  5. data/lib/foil/server/version.rb +1 -1
  6. data/lib/foil/server/webhooks.rb +71 -0
  7. data/lib/foil/server.rb +8 -6
  8. data/spec/README.md +14 -28
  9. data/spec/fixtures/api/fingerprints/detail.json +1 -1
  10. data/spec/fixtures/api/fingerprints/list.json +1 -1
  11. data/spec/fixtures/api/organizations/api-key-create.json +1 -0
  12. data/spec/fixtures/api/organizations/api-key-list.json +1 -0
  13. data/spec/fixtures/api/organizations/api-key-revoke.json +1 -0
  14. data/spec/fixtures/api/organizations/api-key-rotate.json +1 -0
  15. data/spec/fixtures/api/organizations/api-key-update.json +1 -0
  16. data/spec/fixtures/api/sessions/detail.json +18 -8
  17. data/spec/fixtures/api/sessions/list.json +1 -0
  18. data/spec/fixtures/manifest.json +0 -85
  19. data/spec/fixtures/webhooks/signature.json +9 -0
  20. data/spec/openapi.json +2335 -6503
  21. metadata +4 -22
  22. data/lib/foil/server/gate_delivery.rb +0 -332
  23. data/spec/fixtures/api/gate/agent-token-revoke.json +0 -3
  24. data/spec/fixtures/api/gate/agent-token-verify.json +0 -12
  25. data/spec/fixtures/api/gate/login-session-consume.json +0 -10
  26. data/spec/fixtures/api/gate/login-session-create.json +0 -12
  27. data/spec/fixtures/api/gate/registry-detail.json +0 -45
  28. data/spec/fixtures/api/gate/registry-list.json +0 -47
  29. data/spec/fixtures/api/gate/service-create.json +0 -49
  30. data/spec/fixtures/api/gate/service-detail.json +0 -49
  31. data/spec/fixtures/api/gate/service-disable.json +0 -49
  32. data/spec/fixtures/api/gate/service-update.json +0 -49
  33. data/spec/fixtures/api/gate/services-list.json +0 -51
  34. data/spec/fixtures/api/gate/session-ack.json +0 -10
  35. data/spec/fixtures/api/gate/session-create.json +0 -13
  36. data/spec/fixtures/api/gate/session-poll.json +0 -36
  37. data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +0 -19
  38. data/spec/fixtures/gate-delivery/delivery-request.json +0 -9
  39. data/spec/fixtures/gate-delivery/env-policy.json +0 -40
  40. data/spec/fixtures/gate-delivery/vector.v1.json +0 -28
  41. data/spec/fixtures/gate-delivery/webhook-signature.json +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d41b78e23e89f77b68f871a2454fb7d3ee87828a66e76fc95c6baca12127ff2
4
- data.tar.gz: ba257476579665aa554305f16a98793b5a9b98726d90784c5d5ae4b643ba04e2
3
+ metadata.gz: e998be2281e9d8fbacd45e49596b2424d46509066ff8f97e1ddfa5763531ba10
4
+ data.tar.gz: be9cae7a65e5d4e13c0ef726d56f1ca9281237b50e79345a0dc5a5904987d9f6
5
5
  SHA512:
6
- metadata.gz: 5a979d1b334c0c7331fcd3f174b3ddece6f371d200ee7cd4ffe4287b8e651cadada1ae15cc495b14c849e290de9384ab53cc826c80e5d5e49b4ae56fde1659d3
7
- data.tar.gz: 70a38a2b6466c9e184b67629e9d45cdce7d620e032737d8c5cad9d6c96eba590c7a58b80ed22573b976ae1fb2ebad215e70f9edfea8cee6f0f9bf0eb65784f85
6
+ metadata.gz: f9bfc32b352566af63551b720bba2f6dc790040073cb793ffa3c31017d126d960ae2cdacc0a1e4cb6db89785a4eb074456e34945274578d01744ba849eefc76b
7
+ data.tar.gz: 21141586684db218908995affc1fd2ac5271dca1ae273a2208682c77a1936b4427c08d376d45c337f73e1d1ec712f906770b660a3b7f049beb30873d36647f25
data/README.md CHANGED
@@ -4,16 +4,14 @@
4
4
  ![Ruby 3.3+](https://img.shields.io/badge/ruby-3.3%2B-CC342D?logo=ruby&logoColor=white)
5
5
  ![License: MIT](https://img.shields.io/badge/license-MIT-0f766e.svg)
6
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.
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, webhook endpoints, and sealed token verification.
8
8
 
9
9
  The library also provides:
10
10
 
11
11
  - a fast configuration path using `FOIL_SECRET_KEY`
12
12
  - lazy helpers for cursor-based pagination
13
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
14
+ - webhook endpoint management, test sends, event delivery history, and webhook signature verification
17
15
 
18
16
  ## Documentation
19
17
 
@@ -33,17 +31,19 @@ bundle add foil-server
33
31
 
34
32
  ## Usage
35
33
 
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:
34
+ Use `FOIL_SECRET_KEY` or `secret_key:`:
37
35
 
38
36
  ```ruby
39
37
  require "foil/server"
40
38
 
41
39
  client = Foil::Server::Client.new(secret_key: "sk_live_...")
42
40
 
43
- page = client.sessions.list(verdict: "bot", limit: 25)
41
+ page = client.sessions.list(verdict: "bot", limit: 25, client_user_id: "user_123", client_application_id: "app_123")
44
42
  session = client.sessions.get("sid_0123456789abcdefghjkmnpqrs")
45
43
  client.sessions.attach_client_user("sid_0123456789abcdefghjkmnpqrs", "user_123")
46
44
  client.sessions.clear_client_user("sid_0123456789abcdefghjkmnpqrs")
45
+ client.sessions.attach_client_application("sid_0123456789abcdefghjkmnpqrs", "app_123")
46
+ client.sessions.clear_client_application("sid_0123456789abcdefghjkmnpqrs")
47
47
 
48
48
  puts "#{session[:decision][:automation_status]} #{session[:highlights].first&.fetch(:summary, nil)}"
49
49
  ```
@@ -98,7 +98,7 @@ endpoint = client.webhooks.create_endpoint(
98
98
  "org_0123456789abcdefghjkmnpqrs",
99
99
  name: "Production alerts",
100
100
  url: "https://example.com/foil/webhook",
101
- event_types: ["session.result.persisted", "gate.session.approved"]
101
+ event_types: ["session.result.persisted"]
102
102
  )
103
103
 
104
104
  events = client.webhooks.list_events(
@@ -110,37 +110,34 @@ events = client.webhooks.list_events(
110
110
  puts events.items.first[:webhook_deliveries].first[:status]
111
111
  ```
112
112
 
113
- ### Gate APIs
113
+ #### Verifying webhook deliveries
114
114
 
115
- ```ruby
116
- delivery_key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
115
+ Every webhook delivery is signed with your endpoint's signing secret. Verify the `X-Foil-Timestamp` and `X-Foil-Signature` headers against the raw request body before trusting the payload:
117
116
 
118
- services = client.gate.registry.list
119
- session = client.gate.sessions.create(
120
- service_id: "foil",
121
- account_name: "my-project",
122
- delivery: delivery_key_pair[:delivery]
117
+ ```ruby
118
+ valid = Foil::Server.verify_webhook_signature(
119
+ secret: ENV.fetch("FOIL_WEBHOOK_SECRET"),
120
+ timestamp: request.headers["X-Foil-Timestamp"],
121
+ raw_body: raw_body,
122
+ signature: request.headers["X-Foil-Signature"]
123
123
  )
124
124
 
125
- puts "#{services.first[:id]} #{session[:consent_url]}"
126
- ```
127
-
128
- ### Gate delivery and webhook helpers
129
-
130
- ```ruby
131
- key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
132
- response = Foil::Server::GateDelivery.create_gate_approved_webhook_response(
133
- delivery: key_pair[:delivery],
134
- outputs: {
135
- "FOIL_PUBLISHABLE_KEY" => "pk_live_...",
136
- "FOIL_SECRET_KEY" => "sk_live_..."
137
- }
125
+ # Verify and parse in one step. Raises ArgumentError if the signature is invalid or expired.
126
+ event = Foil::Server.verify_and_parse_webhook_event(
127
+ secret: ENV.fetch("FOIL_WEBHOOK_SECRET"),
128
+ timestamp: request.headers["X-Foil-Timestamp"],
129
+ raw_body: raw_body,
130
+ signature: request.headers["X-Foil-Signature"]
138
131
  )
139
- payload = Foil::Server::GateDelivery.decrypt_gate_delivery_envelope(key_pair[:private_key], response[:encrypted_delivery])
140
132
 
141
- puts payload[:outputs]["FOIL_SECRET_KEY"]
133
+ puts event[:data] if event[:type] == "session.result.persisted"
134
+
135
+ # Parse a payload you have already verified.
136
+ parsed = Foil::Server.parse_webhook_event(raw_body)
142
137
  ```
143
138
 
139
+ Signatures older than five minutes are rejected by default. Pass `max_age_seconds:` to change the tolerance.
140
+
144
141
  ### Error handling
145
142
 
146
143
  ```ruby
@@ -10,7 +10,7 @@ module Foil
10
10
  DEFAULT_TIMEOUT = 30
11
11
  SDK_CLIENT_HEADER = "foil-server-ruby/0.1.0".freeze
12
12
 
13
- attr_reader :sessions, :fingerprints, :organizations, :gate, :webhooks, :timeout
13
+ attr_reader :sessions, :fingerprints, :organizations, :webhooks, :timeout
14
14
 
15
15
  def initialize(secret_key: ENV["FOIL_SECRET_KEY"], base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil, transport: nil)
16
16
  @secret_key = secret_key
@@ -22,11 +22,10 @@ module Foil
22
22
  @sessions = SessionsResource.new(self)
23
23
  @fingerprints = FingerprintsResource.new(self)
24
24
  @organizations = OrganizationsResource.new(self)
25
- @gate = GateResource.new(self)
26
25
  @webhooks = WebhooksResource.new(self)
27
26
  end
28
27
 
29
- def request_json(method, path, query: {}, body: nil, expect_content: true, auth: { kind: :secret })
28
+ def request_json(method, path, query: {}, body: nil, expect_content: true)
30
29
  url = build_url(path, query)
31
30
  headers = {
32
31
  "Accept" => "application/json",
@@ -34,7 +33,7 @@ module Foil
34
33
  }
35
34
  headers["User-Agent"] = @user_agent if @user_agent
36
35
  headers["Content-Type"] = "application/json" if body
37
- apply_auth_headers(headers, auth)
36
+ apply_auth_headers(headers)
38
37
 
39
38
  status, response_headers, response_body =
40
39
  if @transport
@@ -126,21 +125,10 @@ module Foil
126
125
  end
127
126
  private :deep_symbolize
128
127
 
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?
128
+ def apply_auth_headers(headers)
129
+ raise ConfigurationError, "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY." if @secret_key.nil? || @secret_key.empty?
137
130
 
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
131
+ headers["Authorization"] = "Bearer #{@secret_key}"
144
132
  end
145
133
  private :apply_auth_headers
146
134
  end
@@ -163,12 +151,14 @@ module Foil
163
151
  end
164
152
 
165
153
  class SessionsResource < BaseResource
166
- def list(limit: nil, cursor: nil, verdict: nil, search: nil)
154
+ def list(limit: nil, cursor: nil, verdict: nil, search: nil, client_user_id: nil, client_application_id: nil)
167
155
  payload = @client.request_json("GET", "/v1/sessions", query: {
168
156
  limit: limit,
169
157
  cursor: cursor,
170
158
  verdict: verdict,
171
- search: search
159
+ search: search,
160
+ client_user_id: client_user_id,
161
+ client_application_id: client_application_id
172
162
  })
173
163
  list_result(payload)
174
164
  end
@@ -189,11 +179,30 @@ module Foil
189
179
  })[:data]
190
180
  end
191
181
 
192
- def iter(limit: nil, verdict: nil, search: nil)
182
+ def attach_client_application(session_id, client_application_id)
183
+ @client.request_json("PATCH", "/v1/sessions/#{CGI.escape(session_id)}", body: {
184
+ client_application_id: client_application_id
185
+ })[:data]
186
+ end
187
+
188
+ def clear_client_application(session_id)
189
+ @client.request_json("PATCH", "/v1/sessions/#{CGI.escape(session_id)}", body: {
190
+ client_application_id: nil
191
+ })[:data]
192
+ end
193
+
194
+ def iter(limit: nil, verdict: nil, search: nil, client_user_id: nil, client_application_id: nil)
193
195
  Enumerator.new do |yielder|
194
196
  cursor = nil
195
197
  loop do
196
- page = list(limit: limit, cursor: cursor, verdict: verdict, search: search)
198
+ page = list(
199
+ limit: limit,
200
+ cursor: cursor,
201
+ verdict: verdict,
202
+ search: search,
203
+ client_user_id: client_user_id,
204
+ client_application_id: client_application_id
205
+ )
197
206
  page.items.each { |item| yielder << item }
198
207
  break unless page.has_more && page.next_cursor
199
208
 
@@ -300,82 +309,6 @@ module Foil
300
309
  end
301
310
  end
302
311
 
303
- class GateResource < BaseResource
304
- attr_reader :registry, :services, :sessions, :login_sessions, :agent_tokens
305
-
306
- def initialize(client)
307
- super(client)
308
- @registry = GateRegistryResource.new(client)
309
- @services = GateServicesResource.new(client)
310
- @sessions = GateSessionsResource.new(client)
311
- @login_sessions = GateLoginSessionsResource.new(client)
312
- @agent_tokens = GateAgentTokensResource.new(client)
313
- end
314
- end
315
-
316
- class GateRegistryResource < BaseResource
317
- def list
318
- @client.request_json("GET", "/v1/gate/registry", auth: { kind: :none })[:data]
319
- end
320
-
321
- def get(service_id)
322
- @client.request_json("GET", "/v1/gate/registry/#{CGI.escape(service_id)}", auth: { kind: :none })[:data]
323
- end
324
- end
325
-
326
- class GateServicesResource < BaseResource
327
- def list
328
- @client.request_json("GET", "/v1/gate/services")[:data]
329
- end
330
-
331
- def get(service_id)
332
- @client.request_json("GET", "/v1/gate/services/#{CGI.escape(service_id)}")[:data]
333
- end
334
-
335
- 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)
336
- @client.request_json("POST", "/v1/gate/services", body: compact({
337
- id: id,
338
- discoverable: discoverable,
339
- name: name,
340
- description: description,
341
- website: website,
342
- dashboard_login_url: dashboard_login_url,
343
- webhook_endpoint_id: webhook_endpoint_id,
344
- env_vars: env_vars,
345
- docs_url: docs_url,
346
- sdks: sdks,
347
- branding: branding,
348
- consent: consent
349
- }))[:data]
350
- end
351
-
352
- 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)
353
- @client.request_json("PATCH", "/v1/gate/services/#{CGI.escape(service_id)}", body: compact({
354
- discoverable: discoverable,
355
- name: name,
356
- description: description,
357
- website: website,
358
- dashboard_login_url: dashboard_login_url,
359
- webhook_endpoint_id: webhook_endpoint_id,
360
- env_vars: env_vars,
361
- docs_url: docs_url,
362
- sdks: sdks,
363
- branding: branding,
364
- consent: consent
365
- }))[:data]
366
- end
367
-
368
- def disable(service_id)
369
- @client.request_json("DELETE", "/v1/gate/services/#{CGI.escape(service_id)}")[:data]
370
- end
371
-
372
- private
373
-
374
- def compact(hash)
375
- hash.reject { |_key, value| value.nil? }
376
- end
377
- end
378
-
379
312
  class WebhooksResource < BaseResource
380
313
  def list_endpoints(organization_id)
381
314
  payload = @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints")
@@ -419,66 +352,5 @@ module Foil
419
352
  @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/events/#{CGI.escape(event_id)}")[:data]
420
353
  end
421
354
  end
422
-
423
- class GateSessionsResource < BaseResource
424
- def create(service_id:, account_name:, delivery:, metadata: nil)
425
- body = {
426
- service_id: service_id,
427
- account_name: account_name,
428
- delivery: delivery
429
- }
430
- body[:metadata] = metadata unless metadata.nil?
431
-
432
- @client.request_json("POST", "/v1/gate/sessions", body: body, auth: { kind: :none })[:data]
433
- end
434
-
435
- def poll(gate_session_id, poll_token:)
436
- @client.request_json(
437
- "GET",
438
- "/v1/gate/sessions/#{CGI.escape(gate_session_id)}",
439
- auth: { kind: :bearer, token: poll_token }
440
- )[:data]
441
- end
442
-
443
- def acknowledge(gate_session_id, poll_token:, ack_token:)
444
- @client.request_json(
445
- "POST",
446
- "/v1/gate/sessions/#{CGI.escape(gate_session_id)}/ack",
447
- body: { ack_token: ack_token },
448
- auth: { kind: :bearer, token: poll_token }
449
- )[:data]
450
- end
451
- end
452
-
453
- class GateLoginSessionsResource < BaseResource
454
- def create(service_id:, agent_token:)
455
- @client.request_json(
456
- "POST",
457
- "/v1/gate/login-sessions",
458
- body: { service_id: service_id },
459
- auth: { kind: :bearer, token: agent_token }
460
- )[:data]
461
- end
462
-
463
- def consume(code:)
464
- @client.request_json("POST", "/v1/gate/login-sessions/consume", body: { code: code })[:data]
465
- end
466
- end
467
-
468
- class GateAgentTokensResource < BaseResource
469
- def verify(agent_token:)
470
- @client.request_json("POST", "/v1/gate/agent-tokens/verify", body: { agent_token: agent_token })[:data]
471
- end
472
-
473
- def revoke(agent_token:)
474
- @client.request_json(
475
- "POST",
476
- "/v1/gate/agent-tokens/revoke",
477
- body: { agent_token: agent_token },
478
- expect_content: false
479
- )
480
- nil
481
- end
482
- end
483
355
  end
484
356
  end
@@ -18,6 +18,8 @@ module Foil
18
18
  V2_RECIPIENT_BYTES = RECIPIENT_ID_BYTES + NONCE_BYTES + CONTENT_KEY_BYTES + TAG_BYTES
19
19
  V2_PAYLOAD_AAD_PREFIX = "foil-sealed-results-v2\0payload\0".b
20
20
  V2_WRAP_AAD_PREFIX = "foil-sealed-results-v2\0recipient\0".b
21
+ # Decompressed payloads are a few kilobytes; the cap bounds memory if a token decompresses to far more.
22
+ MAX_PAYLOAD_BYTES = 1024 * 1024
21
23
 
22
24
  module_function
23
25
 
@@ -30,7 +32,7 @@ module Foil
30
32
  raise TokenVerificationError, "Foil token is too short." if raw.bytesize < 29
31
33
 
32
34
  compressed = decrypt_payload(raw, resolved_secret)
33
- payload = JSON.parse(Zlib::Inflate.inflate(compressed))
35
+ payload = JSON.parse(inflate_payload(compressed))
34
36
  deep_symbolize(payload)
35
37
  rescue ConfigurationError, TokenVerificationError
36
38
  raise
@@ -49,6 +51,21 @@ module Foil
49
51
  end
50
52
  private_class_method :derive_key
51
53
 
54
+ def inflate_payload(compressed)
55
+ inflater = Zlib::Inflate.new
56
+ payload = "".b
57
+ inflater.inflate(compressed) do |chunk|
58
+ payload << chunk
59
+ raise TokenVerificationError, "Foil token payload is too large." if payload.bytesize > MAX_PAYLOAD_BYTES
60
+ end
61
+ raise Zlib::BufError, "Foil token payload is truncated." unless inflater.finished?
62
+
63
+ payload
64
+ ensure
65
+ inflater&.close
66
+ end
67
+ private_class_method :inflate_payload
68
+
52
69
  def decrypt_gcm(ciphertext, key, nonce, tag, aad = "".b)
53
70
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
54
71
  cipher.decrypt
@@ -1,5 +1,5 @@
1
1
  module Foil
2
2
  module Server
3
- VERSION = "0.3.5".freeze
3
+ VERSION = "0.5.0".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,71 @@
1
+ require "json"
2
+ require "openssl"
3
+
4
+ module Foil
5
+ module Server
6
+ module Webhooks
7
+ WEBHOOK_EVENT_TYPES = %w[
8
+ session.result.persisted
9
+ webhook.test
10
+ ].freeze
11
+
12
+ module_function
13
+
14
+ def verify_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
15
+ return false if secret.to_s.empty?
16
+
17
+ parsed_timestamp = Integer(timestamp)
18
+ current = now_seconds || Time.now.to_i
19
+ return false if (current - parsed_timestamp).abs > max_age_seconds
20
+
21
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
22
+ secure_compare(expected, signature.to_s)
23
+ rescue ArgumentError, TypeError
24
+ false
25
+ end
26
+
27
+ def parse_webhook_event(raw_body)
28
+ envelope = symbolize(JSON.parse(raw_body))
29
+ raise ArgumentError, "webhook event envelope must be an object" unless envelope.is_a?(Hash)
30
+ raise ArgumentError, "webhook event object must be webhook_event" unless envelope[:object] == "webhook_event"
31
+ raise ArgumentError, "webhook event id is required" if envelope[:id].to_s.empty?
32
+ raise ArgumentError, "webhook event type is required" if envelope[:type].to_s.empty?
33
+ raise ArgumentError, "unsupported webhook event type: #{envelope[:type]}" unless WEBHOOK_EVENT_TYPES.include?(envelope[:type])
34
+ raise ArgumentError, "webhook event created timestamp is required" if envelope[:created].to_s.empty?
35
+ raise ArgumentError, "webhook event data must be an object" unless envelope[:data].is_a?(Hash)
36
+
37
+ envelope
38
+ end
39
+
40
+ def verify_and_parse_webhook_event(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
41
+ unless verify_webhook_signature(secret: secret, timestamp: timestamp, raw_body: raw_body, signature: signature, max_age_seconds: max_age_seconds, now_seconds: now_seconds)
42
+ raise ArgumentError, "Invalid Foil webhook signature"
43
+ end
44
+ parse_webhook_event(raw_body)
45
+ end
46
+
47
+ def symbolize(value)
48
+ case value
49
+ when Array
50
+ value.map { |item| symbolize(item) }
51
+ when Hash
52
+ value.each_with_object({}) do |(key, item), memo|
53
+ memo[key.to_sym] = symbolize(item)
54
+ end
55
+ else
56
+ value
57
+ end
58
+ end
59
+ private_class_method :symbolize
60
+
61
+ def secure_compare(left, right)
62
+ return false unless left.bytesize == right.bytesize
63
+
64
+ result = 0
65
+ left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
66
+ result.zero?
67
+ end
68
+ private_class_method :secure_compare
69
+ end
70
+ end
71
+ end
data/lib/foil/server.rb CHANGED
@@ -3,7 +3,7 @@ require_relative "server/errors"
3
3
  require_relative "server/crypto_support"
4
4
  require_relative "server/types"
5
5
  require_relative "server/sealed_token"
6
- require_relative "server/gate_delivery"
6
+ require_relative "server/webhooks"
7
7
  require_relative "server/client"
8
8
 
9
9
  module Foil
@@ -18,14 +18,16 @@ module Foil
18
18
  SealedToken.safe_verify_foil_token(sealed_token, secret_key)
19
19
  end
20
20
 
21
- def method_missing(name, *args, **kwargs, &block)
22
- return GateDelivery.public_send(name, *args, **kwargs, &block) if GateDelivery.respond_to?(name)
21
+ def verify_webhook_signature(**options)
22
+ Webhooks.verify_webhook_signature(**options)
23
+ end
23
24
 
24
- super
25
+ def parse_webhook_event(raw_body)
26
+ Webhooks.parse_webhook_event(raw_body)
25
27
  end
26
28
 
27
- def respond_to_missing?(name, include_private = false)
28
- GateDelivery.respond_to?(name) || super
29
+ def verify_and_parse_webhook_event(**options)
30
+ Webhooks.verify_and_parse_webhook_event(**options)
29
31
  end
30
32
  end
31
33
  end
data/spec/README.md CHANGED
@@ -6,7 +6,7 @@ It defines:
6
6
 
7
7
  - the supported server API surface
8
8
  - the shared sealed token verification behavior
9
- - the shared Gate delivery/webhook helper behavior
9
+ - the shared webhook signature verification behavior
10
10
  - golden fixtures for success, error, pagination, and helper flows
11
11
 
12
12
  ## Scope
@@ -17,11 +17,6 @@ Server SDKs include only customer-facing APIs:
17
17
  - `/v1/fingerprints`
18
18
  - `/v1/organizations`
19
19
  - `/v1/organizations/:organizationId/api-keys`
20
- - `/v1/gate/registry`
21
- - `/v1/gate/services`
22
- - `/v1/gate/sessions`
23
- - `/v1/gate/login-sessions`
24
- - `/v1/gate/agent-tokens/*`
25
20
 
26
21
  Server SDKs do **not** include:
27
22
 
@@ -53,24 +48,13 @@ Every server SDK should expose these top-level capabilities:
53
48
  - list
54
49
  - revoke
55
50
  - rotate
56
- - Gate
57
- - registry list/get
58
- - services list/get/create/update/disable
59
- - sessions create/poll/acknowledge
60
- - login sessions create/consume
61
- - agent tokens verify/revoke
62
51
  - sealed token helpers
63
52
  - strict verify
64
53
  - safe verify
65
- - Gate delivery/webhook helpers
66
- - generate delivery keypair
67
- - import/export delivery private key
68
- - validate delivery request
69
- - encrypt/decrypt delivery envelopes
70
- - validate approved webhook payload
54
+ - webhook helpers
71
55
  - verify webhook signature
72
- - derive agent-token env keys
73
- - check blocked/managed Gate env vars
56
+ - parse webhook event
57
+ - verify and parse webhook event
74
58
 
75
59
  ## Shared Defaults
76
60
 
@@ -78,7 +62,6 @@ Every SDK should default to:
78
62
 
79
63
  - `base_url = https://api.usefoil.com`
80
64
  - `secret_key = env(FOIL_SECRET_KEY)`
81
- - support for public, bearer-token, and secret-key auth as required by the Gate surface
82
65
  - request timeout support
83
66
  - no automatic retries
84
67
 
@@ -120,15 +103,17 @@ Use both:
120
103
 
121
104
  to validate correctness and failure behavior.
122
105
 
123
- ## Gate Delivery Helper Coverage
106
+ ## Webhook Signature Verification
124
107
 
125
- Use the shared fixtures in `fixtures/gate-delivery/` to validate:
108
+ Every Foil webhook delivery carries `X-Foil-Timestamp` and `X-Foil-Signature`. The signature is the lowercase hex HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with the endpoint's signing secret. SDK helpers must:
126
109
 
127
- - delivery request validation and key-id derivation
128
- - envelope encrypt/decrypt roundtrips
129
- - approved webhook payload validation
130
- - webhook signature verification
131
- - Gate env-var policy helpers
110
+ - compute the HMAC over the raw request body bytes
111
+ - compare signatures in constant time
112
+ - reject a timestamp more than 300 seconds from the current time by default
113
+ - reject an empty signing secret
114
+ - parse only the event types in the spec's `WebhookDeliveryEventType` schema and reject any other type
115
+
116
+ Use `fixtures/webhooks/signature.json` to validate the valid, tampered, expired and malformed cases.
132
117
 
133
118
  ## Sync Model
134
119
 
@@ -156,5 +141,6 @@ When changing any server SDK:
156
141
  - `next_cursor`
157
142
  - preserve structured API errors
158
143
  - keep sealed token golden-vector coverage
144
+ - keep webhook signature fixture coverage
159
145
  - keep one live smoke suite per SDK
160
146
  - only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
@@ -6,7 +6,7 @@
6
6
  "first_seen_at": "2026-03-24T19:58:00.000Z",
7
7
  "last_seen_at": "2026-03-24T20:00:05.000Z",
8
8
  "seen_count": 3,
9
- "expires_at": "2026-06-22T20:00:05.000Z"
9
+ "expires_at": "9999-12-31T00:00:00.000Z"
10
10
  },
11
11
  "latest_request": {
12
12
  "user_agent": "Mozilla/5.0",
@@ -7,7 +7,7 @@
7
7
  "first_seen_at": "2026-03-24T19:58:00.000Z",
8
8
  "last_seen_at": "2026-03-24T20:00:05.000Z",
9
9
  "seen_count": 3,
10
- "expires_at": "2026-06-22T20:00:05.000Z"
10
+ "expires_at": "9999-12-31T00:00:00.000Z"
11
11
  },
12
12
  "latest_request": {
13
13
  "user_agent": "Mozilla/5.0",
@@ -7,6 +7,7 @@
7
7
  "environment": "live",
8
8
  "status": "active",
9
9
  "allowed_origins": null,
10
+ "client_target": null,
10
11
  "scopes": [
11
12
  "sessions:list",
12
13
  "sessions:read",
@@ -10,6 +10,7 @@
10
10
  "allowed_origins": [
11
11
  "https://example.com"
12
12
  ],
13
+ "client_target": "browser",
13
14
  "scopes": null,
14
15
  "rate_limit": null,
15
16
  "key_preview": "pk_live_[example]...",
@@ -9,6 +9,7 @@
9
9
  "allowed_origins": [
10
10
  "https://example.com"
11
11
  ],
12
+ "client_target": "browser",
12
13
  "scopes": null,
13
14
  "rate_limit": null,
14
15
  "key_preview": "pk_live_[example]...",
@@ -7,6 +7,7 @@
7
7
  "environment": "live",
8
8
  "status": "active",
9
9
  "allowed_origins": null,
10
+ "client_target": null,
10
11
  "scopes": [
11
12
  "sessions:list",
12
13
  "sessions:read",