foil-server 0.3.5 → 0.4.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -29
  3. data/lib/foil/server/client.rb +6 -155
  4. data/lib/foil/server/version.rb +1 -1
  5. data/lib/foil/server/webhooks.rb +71 -0
  6. data/lib/foil/server.rb +8 -6
  7. data/spec/README.md +14 -28
  8. data/spec/fixtures/api/organizations/api-key-create.json +1 -0
  9. data/spec/fixtures/api/organizations/api-key-list.json +1 -0
  10. data/spec/fixtures/api/organizations/api-key-revoke.json +1 -0
  11. data/spec/fixtures/api/organizations/api-key-rotate.json +1 -0
  12. data/spec/fixtures/api/organizations/api-key-update.json +1 -0
  13. data/spec/fixtures/api/sessions/detail.json +8 -1
  14. data/spec/fixtures/manifest.json +0 -85
  15. data/spec/fixtures/webhooks/signature.json +9 -0
  16. data/spec/openapi.json +1683 -6333
  17. metadata +4 -22
  18. data/lib/foil/server/gate_delivery.rb +0 -332
  19. data/spec/fixtures/api/gate/agent-token-revoke.json +0 -3
  20. data/spec/fixtures/api/gate/agent-token-verify.json +0 -12
  21. data/spec/fixtures/api/gate/login-session-consume.json +0 -10
  22. data/spec/fixtures/api/gate/login-session-create.json +0 -12
  23. data/spec/fixtures/api/gate/registry-detail.json +0 -45
  24. data/spec/fixtures/api/gate/registry-list.json +0 -47
  25. data/spec/fixtures/api/gate/service-create.json +0 -49
  26. data/spec/fixtures/api/gate/service-detail.json +0 -49
  27. data/spec/fixtures/api/gate/service-disable.json +0 -49
  28. data/spec/fixtures/api/gate/service-update.json +0 -49
  29. data/spec/fixtures/api/gate/services-list.json +0 -51
  30. data/spec/fixtures/api/gate/session-ack.json +0 -10
  31. data/spec/fixtures/api/gate/session-create.json +0 -13
  32. data/spec/fixtures/api/gate/session-poll.json +0 -36
  33. data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +0 -19
  34. data/spec/fixtures/gate-delivery/delivery-request.json +0 -9
  35. data/spec/fixtures/gate-delivery/env-policy.json +0 -40
  36. data/spec/fixtures/gate-delivery/vector.v1.json +0 -28
  37. 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: 142ee8a11ee7b38b933aab55b22188e9f2a6ef73186097a5ea708d93fa0be2b8
4
+ data.tar.gz: cbcf119648d0de7699f30ff12894ed6303e7a6c1a874178db73e6a706a828b6a
5
5
  SHA512:
6
- metadata.gz: 5a979d1b334c0c7331fcd3f174b3ddece6f371d200ee7cd4ffe4287b8e651cadada1ae15cc495b14c849e290de9384ab53cc826c80e5d5e49b4ae56fde1659d3
7
- data.tar.gz: 70a38a2b6466c9e184b67629e9d45cdce7d620e032737d8c5cad9d6c96eba590c7a58b80ed22573b976ae1fb2ebad215e70f9edfea8cee6f0f9bf0eb65784f85
6
+ metadata.gz: 899d02338303288f518178bb8c1a72e815dd2372ef428534fe3c36a2574a09eecb021164ebda90f90439d74ce0bdd112d5d2301ddf393dd837fe4bf18e91103a
7
+ data.tar.gz: c88c199301d6c99dc1e612824b3d96caddaec9fd70b1dee9abc31f29ac071e75fcfba2d4bc55055ee7c2f75b8ca394b2bd8e01d0d7cc3eeacb5b779b2dc4465c
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,7 +31,7 @@ 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"
@@ -98,7 +96,7 @@ endpoint = client.webhooks.create_endpoint(
98
96
  "org_0123456789abcdefghjkmnpqrs",
99
97
  name: "Production alerts",
100
98
  url: "https://example.com/foil/webhook",
101
- event_types: ["session.result.persisted", "gate.session.approved"]
99
+ event_types: ["session.result.persisted"]
102
100
  )
103
101
 
104
102
  events = client.webhooks.list_events(
@@ -110,37 +108,34 @@ events = client.webhooks.list_events(
110
108
  puts events.items.first[:webhook_deliveries].first[:status]
111
109
  ```
112
110
 
113
- ### Gate APIs
111
+ #### Verifying webhook deliveries
114
112
 
115
- ```ruby
116
- delivery_key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
113
+ 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
114
 
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]
115
+ ```ruby
116
+ valid = Foil::Server.verify_webhook_signature(
117
+ secret: ENV.fetch("FOIL_WEBHOOK_SECRET"),
118
+ timestamp: request.headers["X-Foil-Timestamp"],
119
+ raw_body: raw_body,
120
+ signature: request.headers["X-Foil-Signature"]
123
121
  )
124
122
 
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
- }
123
+ # Verify and parse in one step. Raises ArgumentError if the signature is invalid or expired.
124
+ event = Foil::Server.verify_and_parse_webhook_event(
125
+ secret: ENV.fetch("FOIL_WEBHOOK_SECRET"),
126
+ timestamp: request.headers["X-Foil-Timestamp"],
127
+ raw_body: raw_body,
128
+ signature: request.headers["X-Foil-Signature"]
138
129
  )
139
- payload = Foil::Server::GateDelivery.decrypt_gate_delivery_envelope(key_pair[:private_key], response[:encrypted_delivery])
140
130
 
141
- puts payload[:outputs]["FOIL_SECRET_KEY"]
131
+ puts event[:data] if event[:type] == "session.result.persisted"
132
+
133
+ # Parse a payload you have already verified.
134
+ parsed = Foil::Server.parse_webhook_event(raw_body)
142
135
  ```
143
136
 
137
+ Signatures older than five minutes are rejected by default. Pass `max_age_seconds:` to change the tolerance.
138
+
144
139
  ### Error handling
145
140
 
146
141
  ```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
@@ -300,82 +288,6 @@ module Foil
300
288
  end
301
289
  end
302
290
 
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
291
  class WebhooksResource < BaseResource
380
292
  def list_endpoints(organization_id)
381
293
  payload = @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints")
@@ -419,66 +331,5 @@ module Foil
419
331
  @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/events/#{CGI.escape(event_id)}")[:data]
420
332
  end
421
333
  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
334
  end
484
335
  end
@@ -1,5 +1,5 @@
1
1
  module Foil
2
2
  module Server
3
- VERSION = "0.3.5".freeze
3
+ VERSION = "0.4.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
@@ -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",
@@ -10,6 +10,7 @@
10
10
  "https://example.com",
11
11
  "https://app.example.com"
12
12
  ],
13
+ "client_target": "browser",
13
14
  "scopes": [
14
15
  "sessions:list",
15
16
  "sessions:read"
@@ -4,6 +4,7 @@
4
4
  "id": "sid_0123456789abcdefghjkmnpqrs",
5
5
  "created_at": "2026-03-24T20:00:00.000Z",
6
6
  "client_user_id": "user_123",
7
+ "behavior": null,
7
8
  "decision": {
8
9
  "event_id": "evt_23456789abcdefghjkmnpqrstv",
9
10
  "automation_status": "automated",
@@ -198,6 +199,10 @@
198
199
  "id": "vid_456789abcdefghjkmnpqrstvwx",
199
200
  "confidence": 93,
200
201
  "identified_at": "2026-03-24T20:00:01.000Z",
202
+ "resolution": {
203
+ "method": "continuity",
204
+ "basis": "deterministic"
205
+ },
201
206
  "lifecycle": {
202
207
  "first_seen_at": "2026-03-01T18:22:11.000Z",
203
208
  "last_seen_at": "2026-03-24T20:00:05.000Z",
@@ -257,7 +262,8 @@
257
262
  "name": "Chromium",
258
263
  "version": "123.0.0.0",
259
264
  "major_version": "123",
260
- "engine": "blink"
265
+ "engine": "blink",
266
+ "app_name": null
261
267
  },
262
268
  "device": {
263
269
  "form_factor": "desktop",
@@ -347,6 +353,7 @@
347
353
  ],
348
354
  "client_telemetry": {
349
355
  "navigator": {
356
+ "model": null,
350
357
  "platform": "macOS",
351
358
  "vendor": "Google Inc.",
352
359
  "hardware_concurrency": null,