tripwire-server 0.1.1 → 0.3.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +48 -13
  3. data/lib/tripwire/server/client.rb +171 -16
  4. data/lib/tripwire/server/crypto_support.rb +49 -0
  5. data/lib/tripwire/server/gate_delivery.rb +298 -0
  6. data/lib/tripwire/server/sealed_token.rb +2 -0
  7. data/lib/tripwire/server/version.rb +1 -1
  8. data/lib/tripwire/server.rb +12 -0
  9. data/spec/README.md +37 -6
  10. data/spec/fixtures/api/fingerprints/detail.json +70 -0
  11. data/spec/fixtures/api/fingerprints/list.json +37 -0
  12. data/spec/fixtures/api/gate/agent-token-verify.json +12 -0
  13. data/spec/fixtures/api/gate/login-session-consume.json +10 -0
  14. data/spec/fixtures/api/gate/login-session-create.json +12 -0
  15. data/spec/fixtures/api/gate/registry-detail.json +45 -0
  16. data/spec/fixtures/api/gate/registry-list.json +47 -0
  17. data/spec/fixtures/api/gate/service-create.json +49 -0
  18. data/spec/fixtures/api/gate/service-detail.json +49 -0
  19. data/spec/fixtures/api/gate/service-disable.json +49 -0
  20. data/spec/fixtures/api/gate/service-update.json +49 -0
  21. data/spec/fixtures/api/gate/services-list.json +51 -0
  22. data/spec/fixtures/api/gate/session-ack.json +10 -0
  23. data/spec/fixtures/api/gate/session-create.json +13 -0
  24. data/spec/fixtures/api/gate/session-poll.json +36 -0
  25. data/spec/fixtures/api/sessions/detail.json +405 -0
  26. data/spec/fixtures/api/sessions/list.json +36 -0
  27. data/spec/fixtures/api/teams/api-key-create.json +21 -0
  28. data/spec/fixtures/api/teams/api-key-list.json +26 -0
  29. data/spec/fixtures/api/teams/api-key-revoke.json +20 -0
  30. data/spec/fixtures/api/teams/api-key-rotate.json +21 -0
  31. data/spec/fixtures/api/teams/team-create.json +14 -0
  32. data/spec/fixtures/api/teams/team-update.json +14 -0
  33. data/spec/fixtures/api/teams/team.json +14 -0
  34. data/spec/fixtures/errors/invalid-api-key.json +3 -3
  35. data/spec/fixtures/errors/missing-api-key.json +2 -2
  36. data/spec/fixtures/errors/not-found.json +4 -4
  37. data/spec/fixtures/errors/validation-error.json +6 -7
  38. data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +20 -0
  39. data/spec/fixtures/gate-delivery/delivery-request.json +9 -0
  40. data/spec/fixtures/gate-delivery/env-policy.json +40 -0
  41. data/spec/fixtures/gate-delivery/vector.v1.json +28 -0
  42. data/spec/fixtures/gate-delivery/webhook-signature.json +9 -0
  43. data/spec/fixtures/manifest.json +179 -0
  44. data/spec/fixtures/sealed-token/vector.v1.json +37 -24
  45. data/spec/openapi.json +4905 -779
  46. data/spec/sealed-token.md +36 -17
  47. metadata +36 -14
  48. data/spec/fixtures/public-api/fingerprints/detail.json +0 -40
  49. data/spec/fixtures/public-api/fingerprints/list.json +0 -31
  50. data/spec/fixtures/public-api/sessions/detail.json +0 -47
  51. data/spec/fixtures/public-api/sessions/list.json +0 -33
  52. data/spec/fixtures/public-api/teams/api-key-create.json +0 -18
  53. data/spec/fixtures/public-api/teams/api-key-list.json +0 -23
  54. data/spec/fixtures/public-api/teams/api-key-rotate.json +0 -18
  55. data/spec/fixtures/public-api/teams/team-create.json +0 -11
  56. data/spec/fixtures/public-api/teams/team-update.json +0 -11
  57. data/spec/fixtures/public-api/teams/team.json +0 -11
  58. /data/spec/fixtures/{public-api/teams/api-key-revoke.json → api/gate/agent-token-revoke.json} +0 -0
@@ -0,0 +1,298 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Tripwire
7
+ module Server
8
+ module GateDelivery
9
+ GATE_DELIVERY_VERSION = 1
10
+ GATE_DELIVERY_ALGORITHM = "x25519-hkdf-sha256/aes-256-gcm"
11
+ GATE_AGENT_TOKEN_ENV_SUFFIX = "_GATE_AGENT_TOKEN"
12
+ BLOCKED_GATE_ENV_VAR_KEYS = %w[
13
+ BASH_ENV
14
+ BROWSER
15
+ CDPATH
16
+ DYLD_INSERT_LIBRARIES
17
+ DYLD_LIBRARY_PATH
18
+ EDITOR
19
+ ENV
20
+ GIT_ASKPASS
21
+ GIT_SSH_COMMAND
22
+ HOME
23
+ LD_LIBRARY_PATH
24
+ LD_PRELOAD
25
+ NODE_OPTIONS
26
+ NODE_PATH
27
+ PATH
28
+ PERL5OPT
29
+ PERLLIB
30
+ PROMPT_COMMAND
31
+ PYTHONHOME
32
+ PYTHONPATH
33
+ PYTHONSTARTUP
34
+ RUBYLIB
35
+ RUBYOPT
36
+ SHELLOPTS
37
+ SSH_ASKPASS
38
+ VISUAL
39
+ XDG_CONFIG_HOME
40
+ ].freeze
41
+ BLOCKED_GATE_ENV_VAR_PREFIXES = %w[NPM_CONFIG_ BUN_CONFIG_ GIT_CONFIG_].freeze
42
+ GATE_DELIVERY_HKDF_INFO = "tripwire-gate-delivery:v1".b.freeze
43
+ X25519_SPKI_PREFIX = ["302a300506032b656e032100"].pack("H*").freeze
44
+
45
+ module_function
46
+
47
+ def derive_gate_agent_token_env_key(service_id)
48
+ normalized = service_id.to_s.strip.gsub(/[^A-Za-z0-9]+/, "_").gsub(/^_+|_+$/, "").gsub(/_+/, "_").upcase
49
+ raise ArgumentError, "service_id is required to derive a Gate agent token env key" if normalized.empty?
50
+
51
+ "#{normalized}#{GATE_AGENT_TOKEN_ENV_SUFFIX}"
52
+ end
53
+
54
+ def is_gate_managed_env_var_key(key)
55
+ key == "TRIPWIRE_AGENT_TOKEN" || key.to_s.end_with?(GATE_AGENT_TOKEN_ENV_SUFFIX)
56
+ end
57
+
58
+ def is_blocked_gate_env_var_key(key)
59
+ normalized = key.to_s.strip.upcase
60
+ BLOCKED_GATE_ENV_VAR_KEYS.include?(normalized) || BLOCKED_GATE_ENV_VAR_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
61
+ end
62
+
63
+ def raw_x25519_public_key_from_key_object(public_key)
64
+ der = public_key.public_to_der
65
+ raise ArgumentError, "Unexpected X25519 public key encoding" unless der.bytesize == X25519_SPKI_PREFIX.bytesize + 32
66
+ raise ArgumentError, "Unexpected X25519 public key prefix" unless der.byteslice(0, X25519_SPKI_PREFIX.bytesize) == X25519_SPKI_PREFIX
67
+
68
+ der.byteslice(X25519_SPKI_PREFIX.bytesize, 32)
69
+ end
70
+
71
+ def key_id_for_raw_x25519_public_key(raw_public_key)
72
+ raise ArgumentError, "X25519 public key must be 32 bytes" unless raw_public_key.bytesize == 32
73
+
74
+ Base64.urlsafe_encode64(Digest::SHA256.digest(raw_public_key), padding: false)
75
+ end
76
+
77
+ def create_delivery_key_pair
78
+ CryptoSupport.ensure_supported_runtime!
79
+ private_key = OpenSSL::PKey.generate_key("X25519")
80
+ raw_public_key = raw_x25519_public_key_from_key_object(private_key.public_key)
81
+ {
82
+ delivery: {
83
+ version: GATE_DELIVERY_VERSION,
84
+ algorithm: GATE_DELIVERY_ALGORITHM,
85
+ key_id: key_id_for_raw_x25519_public_key(raw_public_key),
86
+ public_key: Base64.urlsafe_encode64(raw_public_key, padding: false)
87
+ },
88
+ private_key: private_key
89
+ }
90
+ end
91
+
92
+ def export_delivery_private_key_pkcs8(private_key)
93
+ CryptoSupport.ensure_supported_runtime!
94
+ Base64.urlsafe_encode64(private_key.private_to_der, padding: false)
95
+ end
96
+
97
+ def import_delivery_private_key_pkcs8(value)
98
+ CryptoSupport.ensure_supported_runtime!
99
+ OpenSSL::PKey.read(b64url_decode(value, "delivery.private_key_pkcs8"))
100
+ end
101
+
102
+ def validate_gate_delivery_request(value)
103
+ candidate = symbolize(value)
104
+ raise ArgumentError, "delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
105
+ raise ArgumentError, "delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
106
+ raise ArgumentError, "delivery.public_key is required" if candidate[:public_key].to_s.empty?
107
+ raise ArgumentError, "delivery.key_id is required" if candidate[:key_id].to_s.empty?
108
+
109
+ raw_public_key = b64url_decode(candidate[:public_key], "delivery.public_key")
110
+ raise ArgumentError, "delivery.public_key must be a raw X25519 public key" unless raw_public_key.bytesize == 32
111
+ raise ArgumentError, "delivery.key_id does not match delivery.public_key" unless key_id_for_raw_x25519_public_key(raw_public_key) == candidate[:key_id]
112
+
113
+ {
114
+ version: GATE_DELIVERY_VERSION,
115
+ algorithm: GATE_DELIVERY_ALGORITHM,
116
+ key_id: candidate[:key_id],
117
+ public_key: candidate[:public_key]
118
+ }
119
+ end
120
+
121
+ def create_encrypted_delivery_response(input)
122
+ {
123
+ encrypted_delivery: encrypt_gate_delivery_payload(
124
+ input.fetch(:delivery),
125
+ {
126
+ version: GATE_DELIVERY_VERSION,
127
+ outputs: input.fetch(:outputs)
128
+ }
129
+ )
130
+ }
131
+ end
132
+
133
+ def create_gate_approved_webhook_response(input)
134
+ create_encrypted_delivery_response(input)
135
+ end
136
+
137
+ def validate_encrypted_gate_delivery_envelope(value)
138
+ candidate = symbolize(value)
139
+ raise ArgumentError, "encrypted_delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
140
+ raise ArgumentError, "encrypted_delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
141
+ %i[key_id ephemeral_public_key salt iv ciphertext tag].each do |field|
142
+ raise ArgumentError, "encrypted_delivery.#{field} is required" if candidate[field].to_s.empty?
143
+ end
144
+ raise ArgumentError, "encrypted_delivery.ephemeral_public_key must be 32 bytes" unless b64url_decode(candidate[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key").bytesize == 32
145
+ raise ArgumentError, "encrypted_delivery.salt must be 32 bytes" unless b64url_decode(candidate[:salt], "encrypted_delivery.salt").bytesize == 32
146
+ raise ArgumentError, "encrypted_delivery.iv must be 12 bytes" unless b64url_decode(candidate[:iv], "encrypted_delivery.iv").bytesize == 12
147
+ raise ArgumentError, "encrypted_delivery.tag must be 16 bytes" unless b64url_decode(candidate[:tag], "encrypted_delivery.tag").bytesize == 16
148
+
149
+ candidate
150
+ end
151
+
152
+ def encrypt_gate_delivery_payload(delivery, payload)
153
+ CryptoSupport.ensure_supported_runtime!
154
+ validated_delivery = validate_gate_delivery_request(delivery)
155
+ payload = symbolize(payload)
156
+ raise ArgumentError, "Gate delivery payload version must be 1" unless payload[:version] == GATE_DELIVERY_VERSION
157
+
158
+ recipient_public_key = OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated_delivery[:public_key], "delivery.public_key"))
159
+ ephemeral_private_key = OpenSSL::PKey.generate_key("X25519")
160
+ shared_secret = ephemeral_private_key.derive(recipient_public_key)
161
+ salt = OpenSSL::Random.random_bytes(32)
162
+ iv = OpenSSL::Random.random_bytes(12)
163
+ key = OpenSSL::KDF.hkdf(shared_secret, salt: salt, info: GATE_DELIVERY_HKDF_INFO, length: 32, hash: "SHA256")
164
+
165
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
166
+ cipher.encrypt
167
+ cipher.key = key
168
+ cipher.iv = iv
169
+ ciphertext = cipher.update(JSON.generate(compact_payload(payload))) + cipher.final
170
+ tag = cipher.auth_tag
171
+
172
+ {
173
+ version: GATE_DELIVERY_VERSION,
174
+ algorithm: GATE_DELIVERY_ALGORITHM,
175
+ key_id: validated_delivery[:key_id],
176
+ ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key.public_key), padding: false),
177
+ salt: Base64.urlsafe_encode64(salt, padding: false),
178
+ iv: Base64.urlsafe_encode64(iv, padding: false),
179
+ ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
180
+ tag: Base64.urlsafe_encode64(tag, padding: false)
181
+ }
182
+ end
183
+
184
+ def decrypt_gate_delivery_envelope(private_key, envelope)
185
+ CryptoSupport.ensure_supported_runtime!
186
+ validated = validate_encrypted_gate_delivery_envelope(envelope)
187
+ shared_secret = private_key.derive(
188
+ OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key"))
189
+ )
190
+ key = OpenSSL::KDF.hkdf(
191
+ shared_secret,
192
+ salt: b64url_decode(validated[:salt], "encrypted_delivery.salt"),
193
+ info: GATE_DELIVERY_HKDF_INFO,
194
+ length: 32,
195
+ hash: "SHA256"
196
+ )
197
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
198
+ cipher.decrypt
199
+ cipher.key = key
200
+ cipher.iv = b64url_decode(validated[:iv], "encrypted_delivery.iv")
201
+ cipher.auth_tag = b64url_decode(validated[:tag], "encrypted_delivery.tag")
202
+ cipher.auth_data = ""
203
+ plaintext = cipher.update(b64url_decode(validated[:ciphertext], "encrypted_delivery.ciphertext")) + cipher.final
204
+ payload = JSON.parse(plaintext)
205
+ raise ArgumentError, "encrypted_delivery payload must be an object" unless payload.is_a?(Hash)
206
+
207
+ symbolize(payload).tap do |candidate|
208
+ raise ArgumentError, "encrypted_delivery payload version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
209
+ raise ArgumentError, "encrypted_delivery payload outputs must be an object" unless candidate[:outputs].is_a?(Hash)
210
+ candidate[:outputs].each do |key_name, item|
211
+ raise ArgumentError, "encrypted_delivery output #{key_name} must be a string" unless item.is_a?(String)
212
+ end
213
+ end
214
+ rescue JSON::ParserError
215
+ raise ArgumentError, "encrypted_delivery decrypted to invalid JSON"
216
+ end
217
+
218
+ def validate_gate_approved_webhook_payload(value)
219
+ candidate = symbolize(value)
220
+ raise ArgumentError, "event must be gate.session.approved" unless candidate[:event] == "gate.session.approved"
221
+ raise ArgumentError, "service_id is required" if candidate[:service_id].to_s.empty?
222
+ raise ArgumentError, "gate_session_id is required" if candidate[:gate_session_id].to_s.empty?
223
+ raise ArgumentError, "gate_account_id is required" if candidate[:gate_account_id].to_s.empty?
224
+ raise ArgumentError, "account_name is required" if candidate[:account_name].to_s.empty?
225
+ raise ArgumentError, "metadata must be an object or null" unless candidate[:metadata].nil? || candidate[:metadata].is_a?(Hash)
226
+ raise ArgumentError, "tripwire must be an object" unless candidate[:tripwire].is_a?(Hash)
227
+ verdict = candidate[:tripwire][:verdict]
228
+ raise ArgumentError, "tripwire.verdict is invalid" unless %w[bot human inconclusive].include?(verdict)
229
+ score = candidate[:tripwire][:score]
230
+ raise ArgumentError, "tripwire.score must be a number or null" unless score.nil? || score.is_a?(Numeric)
231
+
232
+ {
233
+ event: "gate.session.approved",
234
+ service_id: candidate[:service_id],
235
+ gate_session_id: candidate[:gate_session_id],
236
+ gate_account_id: candidate[:gate_account_id],
237
+ account_name: candidate[:account_name],
238
+ metadata: candidate[:metadata]&.dup,
239
+ tripwire: {
240
+ verdict: verdict,
241
+ score: score
242
+ },
243
+ delivery: validate_gate_delivery_request(candidate[:delivery])
244
+ }
245
+ end
246
+
247
+ def verify_gate_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
248
+ parsed_timestamp = Integer(timestamp)
249
+ current = now_seconds || Time.now.to_i
250
+ return false if (current - parsed_timestamp).abs > max_age_seconds
251
+
252
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
253
+ secure_compare(expected, signature)
254
+ rescue ArgumentError
255
+ false
256
+ end
257
+
258
+ def symbolize(value)
259
+ case value
260
+ when Array
261
+ value.map { |item| symbolize(item) }
262
+ when Hash
263
+ value.each_with_object({}) do |(key, item), memo|
264
+ memo[key.to_sym] = symbolize(item)
265
+ end
266
+ else
267
+ value
268
+ end
269
+ end
270
+ private_class_method :symbolize
271
+
272
+ def compact_payload(payload)
273
+ {
274
+ version: payload[:version],
275
+ outputs: payload[:outputs],
276
+ **(payload[:ack_token] ? { ack_token: payload[:ack_token] } : {})
277
+ }
278
+ end
279
+ private_class_method :compact_payload
280
+
281
+ def b64url_decode(value, label)
282
+ Base64.urlsafe_decode64(value.to_s)
283
+ rescue ArgumentError => error
284
+ raise ArgumentError, "Invalid #{label}: #{error.message}"
285
+ end
286
+ private_class_method :b64url_decode
287
+
288
+ def secure_compare(left, right)
289
+ return false unless left.bytesize == right.bytesize
290
+
291
+ result = 0
292
+ left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
293
+ result.zero?
294
+ end
295
+ private_class_method :secure_compare
296
+ end
297
+ end
298
+ end
@@ -12,6 +12,7 @@ module Tripwire
12
12
  module_function
13
13
 
14
14
  def verify_tripwire_token(sealed_token, secret_key = nil)
15
+ CryptoSupport.ensure_supported_runtime!
15
16
  resolved_secret = secret_key || ENV["TRIPWIRE_SECRET_KEY"]
16
17
  raise ConfigurationError, "Missing Tripwire secret key. Pass secret_key explicitly or set TRIPWIRE_SECRET_KEY." if resolved_secret.nil? || resolved_secret.empty?
17
18
 
@@ -30,6 +31,7 @@ module Tripwire
30
31
  cipher.key = derive_key(resolved_secret)
31
32
  cipher.iv = nonce
32
33
  cipher.auth_tag = tag
34
+ cipher.auth_data = ""
33
35
 
34
36
  compressed = cipher.update(ciphertext) + cipher.final
35
37
  payload = JSON.parse(Zlib::Inflate.inflate(compressed))
@@ -1,5 +1,5 @@
1
1
  module Tripwire
2
2
  module Server
3
- VERSION = "0.1.1".freeze
3
+ VERSION = "0.3.0".freeze
4
4
  end
5
5
  end
@@ -1,7 +1,9 @@
1
1
  require_relative "server/version"
2
2
  require_relative "server/errors"
3
+ require_relative "server/crypto_support"
3
4
  require_relative "server/types"
4
5
  require_relative "server/sealed_token"
6
+ require_relative "server/gate_delivery"
5
7
  require_relative "server/client"
6
8
 
7
9
  module Tripwire
@@ -15,5 +17,15 @@ module Tripwire
15
17
  def safe_verify_tripwire_token(sealed_token, secret_key = nil)
16
18
  SealedToken.safe_verify_tripwire_token(sealed_token, secret_key)
17
19
  end
20
+
21
+ def method_missing(name, *args, **kwargs, &block)
22
+ return GateDelivery.public_send(name, *args, **kwargs, &block) if GateDelivery.respond_to?(name)
23
+
24
+ super
25
+ end
26
+
27
+ def respond_to_missing?(name, include_private = false)
28
+ GateDelivery.respond_to?(name) || super
29
+ end
18
30
  end
19
31
  end
data/spec/README.md CHANGED
@@ -4,18 +4,24 @@ This directory is the authoritative cross-language contract for Tripwire server
4
4
 
5
5
  It defines:
6
6
 
7
- - the supported public server API surface
7
+ - the supported server API surface
8
8
  - the shared sealed token verification behavior
9
- - golden fixtures for success, error, and pagination flows
9
+ - the shared Gate delivery/webhook helper behavior
10
+ - golden fixtures for success, error, pagination, and helper flows
10
11
 
11
12
  ## Scope
12
13
 
13
- Server SDKs include only customer-facing public APIs:
14
+ Server SDKs include only customer-facing APIs:
14
15
 
15
16
  - `/v1/sessions`
16
17
  - `/v1/fingerprints`
17
18
  - `/v1/teams`
18
19
  - `/v1/teams/:teamId/api-keys`
20
+ - `/v1/gate/registry`
21
+ - `/v1/gate/services`
22
+ - `/v1/gate/sessions`
23
+ - `/v1/gate/login-sessions`
24
+ - `/v1/gate/agent-tokens/*`
19
25
 
20
26
  Server SDKs do **not** include:
21
27
 
@@ -47,9 +53,24 @@ Every server SDK should expose these top-level capabilities:
47
53
  - list
48
54
  - revoke
49
55
  - 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
50
62
  - sealed token helpers
51
63
  - strict verify
52
64
  - 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
71
+ - verify webhook signature
72
+ - derive agent-token env keys
73
+ - check blocked/managed Gate env vars
53
74
 
54
75
  ## Shared Defaults
55
76
 
@@ -57,7 +78,7 @@ Every SDK should default to:
57
78
 
58
79
  - `base_url = https://api.tripwirejs.com`
59
80
  - `secret_key = env(TRIPWIRE_SECRET_KEY)`
60
- - secret-key-only auth via `Authorization: Bearer <secret>`
81
+ - support for public, bearer-token, and secret-key auth as required by the Gate surface
61
82
  - request timeout support
62
83
  - no automatic retries
63
84
 
@@ -74,7 +95,7 @@ The underlying API responses remain cursor-based. SDKs may expose helper iterato
74
95
 
75
96
  ## Error Model
76
97
 
77
- SDKs should parse public API failures into structured errors with, at minimum:
98
+ SDKs should parse API failures into structured errors with, at minimum:
78
99
 
79
100
  - `status`
80
101
  - `code`
@@ -99,6 +120,16 @@ Use both:
99
120
 
100
121
  to validate correctness and failure behavior.
101
122
 
123
+ ## Gate Delivery Helper Coverage
124
+
125
+ Use the shared fixtures in `fixtures/gate-delivery/` to validate:
126
+
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
132
+
102
133
  ## Sync Model
103
134
 
104
135
  This repo is the source of truth for the shared server SDK contract.
@@ -123,7 +154,7 @@ When changing any server SDK:
123
154
  - `limit`
124
155
  - `has_more`
125
156
  - `next_cursor`
126
- - preserve structured public API errors
157
+ - preserve structured API errors
127
158
  - keep sealed token golden-vector coverage
128
159
  - keep one live smoke suite per SDK
129
160
  - only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
@@ -0,0 +1,70 @@
1
+ {
2
+ "data": {
3
+ "object": "visitor_fingerprint",
4
+ "id": "vid_456789abcdefghjkmnpqrstvwx",
5
+ "lifecycle": {
6
+ "first_seen_at": "2026-03-24T19:58:00.000Z",
7
+ "last_seen_at": "2026-03-24T20:00:05.000Z",
8
+ "seen_count": 3,
9
+ "expires_at": "2026-06-22T20:00:05.000Z"
10
+ },
11
+ "latest_request": {
12
+ "user_agent": "Mozilla/5.0",
13
+ "ip_address": "203.0.113.9"
14
+ },
15
+ "storage": {
16
+ "cookies": true,
17
+ "local_storage": true,
18
+ "indexed_db": true,
19
+ "service_worker": false,
20
+ "window_name": false
21
+ },
22
+ "anchors": {
23
+ "webgl_hash": null,
24
+ "parameters_hash": null,
25
+ "audio_hash": null
26
+ },
27
+ "components": {
28
+ "vector": [
29
+ 1,
30
+ 0,
31
+ 1
32
+ ]
33
+ },
34
+ "activity": {
35
+ "sessions": [
36
+ {
37
+ "session_id": "sid_0123456789abcdefghjkmnpqrs",
38
+ "decision": {
39
+ "event_id": "evt_23456789abcdefghjkmnpqrstv",
40
+ "verdict": "human",
41
+ "risk_score": 9,
42
+ "phase": "behavioral",
43
+ "is_provisional": false,
44
+ "manipulation": {
45
+ "score": 0,
46
+ "verdict": "none"
47
+ },
48
+ "evaluation_duration_ms": 142,
49
+ "evaluated_at": "2026-03-24T20:00:05.000Z"
50
+ },
51
+ "request": {
52
+ "url": "https://example.com/signup",
53
+ "user_agent": "Mozilla/5.0",
54
+ "ip_address": "203.0.113.9",
55
+ "screen_size": "1440x900",
56
+ "is_touch_capable": false
57
+ },
58
+ "score_breakdown": {
59
+ "categories": {
60
+ "behavioral": 9
61
+ }
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ },
67
+ "meta": {
68
+ "request_id": "req_0123456789abcdef0123456789abcdef"
69
+ }
70
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "object": "visitor_fingerprint",
5
+ "id": "vid_456789abcdefghjkmnpqrstvwx",
6
+ "lifecycle": {
7
+ "first_seen_at": "2026-03-24T19:58:00.000Z",
8
+ "last_seen_at": "2026-03-24T20:00:05.000Z",
9
+ "seen_count": 3,
10
+ "expires_at": "2026-06-22T20:00:05.000Z"
11
+ },
12
+ "latest_request": {
13
+ "user_agent": "Mozilla/5.0",
14
+ "ip_address": "203.0.113.9"
15
+ },
16
+ "storage": {
17
+ "cookies": true,
18
+ "local_storage": true,
19
+ "indexed_db": true,
20
+ "service_worker": false,
21
+ "window_name": false
22
+ },
23
+ "anchors": {
24
+ "webgl_hash": null,
25
+ "parameters_hash": null,
26
+ "audio_hash": null
27
+ }
28
+ }
29
+ ],
30
+ "pagination": {
31
+ "limit": 50,
32
+ "has_more": false
33
+ },
34
+ "meta": {
35
+ "request_id": "req_0123456789abcdef0123456789abcdef"
36
+ }
37
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "data": {
3
+ "valid": true,
4
+ "gate_account_id": "gacct_0123456789abcdefghjkmnpqrs",
5
+ "status": "active",
6
+ "created_at": "2026-04-01T12:00:00.000Z",
7
+ "expires_at": "2026-06-30T12:00:00.000Z"
8
+ },
9
+ "meta": {
10
+ "request_id": "req_0123456789abcdef0123456789abcdef"
11
+ }
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "data": {
3
+ "object": "gate_dashboard_login",
4
+ "gate_account_id": "gacct_0123456789abcdefghjkmnpqrs",
5
+ "account_name": "my-project"
6
+ },
7
+ "meta": {
8
+ "request_id": "req_0123456789abcdef0123456789abcdef"
9
+ }
10
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "data": {
3
+ "object": "gate_login_session",
4
+ "id": "gate_1123456789abcdefghjkmnpqrs",
5
+ "status": "pending",
6
+ "consent_url": "https://tripwirejs.com/gate?session=gate_1123456789abcdefghjkmnpqrs",
7
+ "expires_at": "2026-04-04T20:20:00.000Z"
8
+ },
9
+ "meta": {
10
+ "request_id": "req_0123456789abcdef0123456789abcdef"
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "data": {
3
+ "id": "tripwire",
4
+ "status": "active",
5
+ "discoverable": true,
6
+ "name": "Tripwire",
7
+ "description": "Detect AI agents and malicious users",
8
+ "website": "https://tripwirejs.com",
9
+ "dashboard_login_url": "https://dashboard.tripwirejs.com/auth/gate",
10
+ "env_vars": [
11
+ {
12
+ "name": "Publishable key",
13
+ "key": "TRIPWIRE_PUBLISHABLE_KEY",
14
+ "secret": false
15
+ },
16
+ {
17
+ "name": "Secret key",
18
+ "key": "TRIPWIRE_SECRET_KEY",
19
+ "secret": true
20
+ }
21
+ ],
22
+ "docs_url": "https://tripwirejs.com/docs",
23
+ "sdks": [
24
+ {
25
+ "label": "Node",
26
+ "install": "npm install @abxy/tripwire-server",
27
+ "url": "https://www.npmjs.com/package/@abxy/tripwire-server"
28
+ }
29
+ ],
30
+ "branding": {
31
+ "logo_url": "https://tripwirejs.com/logo.png",
32
+ "primary_color": "#117BE7",
33
+ "secondary_color": "#0B5CAD",
34
+ "ascii_art": "TRIPWIRE",
35
+ "verified": true
36
+ },
37
+ "consent": {
38
+ "terms_url": "https://tripwirejs.com/terms",
39
+ "privacy_url": "https://tripwirejs.com/privacy"
40
+ }
41
+ },
42
+ "meta": {
43
+ "request_id": "req_0123456789abcdef0123456789abcdef"
44
+ }
45
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "data": [
3
+ {
4
+ "id": "tripwire",
5
+ "status": "active",
6
+ "discoverable": true,
7
+ "name": "Tripwire",
8
+ "description": "Detect AI agents and malicious users",
9
+ "website": "https://tripwirejs.com",
10
+ "dashboard_login_url": "https://dashboard.tripwirejs.com/auth/gate",
11
+ "env_vars": [
12
+ {
13
+ "name": "Publishable key",
14
+ "key": "TRIPWIRE_PUBLISHABLE_KEY",
15
+ "secret": false
16
+ },
17
+ {
18
+ "name": "Secret key",
19
+ "key": "TRIPWIRE_SECRET_KEY",
20
+ "secret": true
21
+ }
22
+ ],
23
+ "docs_url": "https://tripwirejs.com/docs",
24
+ "sdks": [
25
+ {
26
+ "label": "Node",
27
+ "install": "npm install @abxy/tripwire-server",
28
+ "url": "https://www.npmjs.com/package/@abxy/tripwire-server"
29
+ }
30
+ ],
31
+ "branding": {
32
+ "logo_url": "https://tripwirejs.com/logo.png",
33
+ "primary_color": "#117BE7",
34
+ "secondary_color": "#0B5CAD",
35
+ "ascii_art": "TRIPWIRE",
36
+ "verified": true
37
+ },
38
+ "consent": {
39
+ "terms_url": "https://tripwirejs.com/terms",
40
+ "privacy_url": "https://tripwirejs.com/privacy"
41
+ }
42
+ }
43
+ ],
44
+ "meta": {
45
+ "request_id": "req_0123456789abcdef0123456789abcdef"
46
+ }
47
+ }