tripwire-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ea227f476d80187c6545059912b6a32a0adaa4f34bdc1bb47ee21e238c796515
4
+ data.tar.gz: c6790aad09a6b96e6fb25e8fe72e18797bd282e08207559967a817d6734d7a0f
5
+ SHA512:
6
+ metadata.gz: 4c7e6cbb34ca5c959e712ee227d74027a988a4805e5046c47a59f8241c023144787448890df131d370d5fe76d4d2dba50579ec53e5e5c1c9de504e8d97621ad2
7
+ data.tar.gz: afe4dd7afb7436c186a4c3da2cf261403e1b160a40e6a12eea55f4a9d3d8af5b070f9eb17b9821d8a031bd5e14daa5c8d27a3cd25858dcbd89a73935e7a132bd
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,99 @@
1
+ # Tripwire Ruby Library
2
+
3
+ ![Preview](https://img.shields.io/badge/status-preview-111827)
4
+ ![Ruby 2.6+](https://img.shields.io/badge/ruby-2.6%2B-CC342D?logo=ruby&logoColor=white)
5
+ ![License: MIT](https://img.shields.io/badge/license-MIT-0f766e.svg)
6
+
7
+ The Tripwire Ruby library provides convenient access to the Tripwire API from applications written in Ruby. It includes a client for Sessions, Fingerprints, Teams, Team API key management, and sealed token verification.
8
+
9
+ The library also provides:
10
+
11
+ - a fast configuration path using `TRIPWIRE_SECRET_KEY`
12
+ - lazy helpers for cursor-based pagination
13
+ - structured API errors and built-in sealed token verification
14
+
15
+ ## Documentation
16
+
17
+ See the [Tripwire docs](https://tripwirejs.com/docs) and [API reference](https://tripwirejs.com/docs/api-reference/introduction).
18
+
19
+ ## Installation
20
+
21
+ You don't need this source code unless you want to modify the gem. If you just want to use the package, run:
22
+
23
+ ```bash
24
+ bundle add tripwire-server
25
+ ```
26
+
27
+ ## Requirements
28
+
29
+ - Ruby 2.6+
30
+
31
+ ## Usage
32
+
33
+ The library needs to be configured with your account's secret key. Set `TRIPWIRE_SECRET_KEY` in your environment or pass `secret_key` directly:
34
+
35
+ ```ruby
36
+ require "tripwire/server"
37
+
38
+ client = Tripwire::Server::Client.new(secret_key: "sk_live_...")
39
+
40
+ page = client.sessions.list(verdict: "bot", limit: 25)
41
+ session = client.sessions.get("sid_123")
42
+ ```
43
+
44
+ ### Sealed token verification
45
+
46
+ ```ruby
47
+ result = Tripwire::Server.safe_verify_tripwire_token(sealed_token, "sk_live_...")
48
+
49
+ if result[:ok]
50
+ puts "#{result[:data][:verdict]} #{result[:data][:score]}"
51
+ else
52
+ warn result[:error].message
53
+ end
54
+ ```
55
+
56
+ ### Pagination
57
+
58
+ ```ruby
59
+ client.sessions.iter(search: "signup").each do |session|
60
+ puts "#{session[:id]} #{session[:latestResult][:verdict]}"
61
+ end
62
+ ```
63
+
64
+ ### Fingerprints
65
+
66
+ ```ruby
67
+ fingerprint = client.fingerprints.get("vis_123")
68
+ puts fingerprint[:id]
69
+ ```
70
+
71
+ ### Teams
72
+
73
+ ```ruby
74
+ team = client.teams.get("team_123")
75
+ updated = client.teams.update("team_123", name: "New Name")
76
+
77
+ puts updated[:name]
78
+ ```
79
+
80
+ ### Team API keys
81
+
82
+ ```ruby
83
+ created = client.teams.api_keys.create("team_123", name: "Production")
84
+ client.teams.api_keys.revoke("team_123", created[:id])
85
+ ```
86
+
87
+ ### Error handling
88
+
89
+ ```ruby
90
+ begin
91
+ client.sessions.list(limit: 999)
92
+ rescue Tripwire::Server::ApiError => error
93
+ warn "#{error.status} #{error.code} #{error.message}"
94
+ end
95
+ ```
96
+
97
+ ## Support
98
+
99
+ If you need help integrating Tripwire, start with [tripwirejs.com/docs](https://tripwirejs.com/docs).
@@ -0,0 +1,265 @@
1
+ require "cgi"
2
+ require "json"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Tripwire
7
+ module Server
8
+ class Client
9
+ DEFAULT_BASE_URL = "https://api.tripwirejs.com".freeze
10
+ DEFAULT_TIMEOUT = 30
11
+ SDK_CLIENT_HEADER = "tripwire-server-ruby/0.1.0".freeze
12
+
13
+ attr_reader :sessions, :fingerprints, :teams, :timeout
14
+
15
+ def initialize(secret_key: ENV["TRIPWIRE_SECRET_KEY"], base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil, transport: nil)
16
+ raise ConfigurationError, "Missing Tripwire secret key. Pass secret_key explicitly or set TRIPWIRE_SECRET_KEY." if secret_key.nil? || secret_key.empty?
17
+
18
+ @secret_key = secret_key
19
+ @base_url = base_url
20
+ @timeout = timeout
21
+ @user_agent = user_agent
22
+ @transport = transport
23
+
24
+ @sessions = SessionsResource.new(self)
25
+ @fingerprints = FingerprintsResource.new(self)
26
+ @teams = TeamsResource.new(self)
27
+ end
28
+
29
+ def request_json(method, path, query: {}, body: nil, expect_content: true)
30
+ url = build_url(path, query)
31
+ headers = {
32
+ "Authorization" => "Bearer #{@secret_key}",
33
+ "Accept" => "application/json",
34
+ "X-Tripwire-Client" => SDK_CLIENT_HEADER
35
+ }
36
+ headers["User-Agent"] = @user_agent if @user_agent
37
+ headers["Content-Type"] = "application/json" if body
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[:requestId],
58
+ field_errors: details[:fieldErrors] || [],
59
+ docs_url: error[:docsUrl],
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
+ end
129
+
130
+ class BaseResource
131
+ def initialize(client)
132
+ @client = client
133
+ end
134
+
135
+ private
136
+
137
+ def list_result(payload)
138
+ ListResult.new(
139
+ items: payload[:data],
140
+ limit: payload.fetch(:pagination).fetch(:limit),
141
+ has_more: payload.fetch(:pagination).fetch(:hasMore),
142
+ next_cursor: payload.fetch(:pagination)[:nextCursor]
143
+ )
144
+ end
145
+ end
146
+
147
+ class SessionsResource < BaseResource
148
+ def list(limit: nil, cursor: nil, verdict: nil, search: nil)
149
+ payload = @client.request_json("GET", "/v1/sessions", query: {
150
+ limit: limit,
151
+ cursor: cursor,
152
+ verdict: verdict,
153
+ search: search
154
+ })
155
+ list_result(payload)
156
+ end
157
+
158
+ def get(session_id)
159
+ @client.request_json("GET", "/v1/sessions/#{CGI.escape(session_id)}")[:data]
160
+ end
161
+
162
+ def iter(limit: nil, verdict: nil, search: nil)
163
+ Enumerator.new do |yielder|
164
+ cursor = nil
165
+ loop do
166
+ page = list(limit: limit, cursor: cursor, verdict: verdict, search: search)
167
+ page.items.each { |item| yielder << item }
168
+ break unless page.has_more && page.next_cursor
169
+
170
+ cursor = page.next_cursor
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ class FingerprintsResource < BaseResource
177
+ def list(limit: nil, cursor: nil, search: nil, sort: nil)
178
+ payload = @client.request_json("GET", "/v1/fingerprints", query: {
179
+ limit: limit,
180
+ cursor: cursor,
181
+ search: search,
182
+ sort: sort
183
+ })
184
+ list_result(payload)
185
+ end
186
+
187
+ def get(visitor_id)
188
+ @client.request_json("GET", "/v1/fingerprints/#{CGI.escape(visitor_id)}")[:data]
189
+ end
190
+
191
+ def iter(limit: nil, search: nil, sort: nil)
192
+ Enumerator.new do |yielder|
193
+ cursor = nil
194
+ loop do
195
+ page = list(limit: limit, cursor: cursor, search: search, sort: sort)
196
+ page.items.each { |item| yielder << item }
197
+ break unless page.has_more && page.next_cursor
198
+
199
+ cursor = page.next_cursor
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ class ApiKeysResource < BaseResource
206
+ def create(team_id, name: nil, is_test: nil, allowed_origins: nil, rate_limit: nil)
207
+ payload = @client.request_json("POST", "/v1/teams/#{CGI.escape(team_id)}/api-keys", body: compact({
208
+ name: name,
209
+ isTest: is_test,
210
+ allowedOrigins: allowed_origins,
211
+ rateLimit: rate_limit
212
+ }))
213
+ payload[:data]
214
+ end
215
+
216
+ def list(team_id, limit: nil, cursor: nil)
217
+ payload = @client.request_json("GET", "/v1/teams/#{CGI.escape(team_id)}/api-keys", query: {
218
+ limit: limit,
219
+ cursor: cursor
220
+ })
221
+ list_result(payload)
222
+ end
223
+
224
+ def revoke(team_id, key_id)
225
+ @client.request_json("DELETE", "/v1/teams/#{CGI.escape(team_id)}/api-keys/#{CGI.escape(key_id)}", expect_content: false)
226
+ nil
227
+ end
228
+
229
+ def rotate(team_id, key_id)
230
+ payload = @client.request_json("POST", "/v1/teams/#{CGI.escape(team_id)}/api-keys/#{CGI.escape(key_id)}/rotations")
231
+ payload[:data]
232
+ end
233
+
234
+ private
235
+
236
+ def compact(hash)
237
+ hash.reject { |_key, value| value.nil? }
238
+ end
239
+ end
240
+
241
+ class TeamsResource < BaseResource
242
+ attr_reader :api_keys
243
+
244
+ def initialize(client)
245
+ super(client)
246
+ @api_keys = ApiKeysResource.new(client)
247
+ end
248
+
249
+ def create(name:, slug:)
250
+ @client.request_json("POST", "/v1/teams", body: { name: name, slug: slug })[:data]
251
+ end
252
+
253
+ def get(team_id)
254
+ @client.request_json("GET", "/v1/teams/#{CGI.escape(team_id)}")[:data]
255
+ end
256
+
257
+ def update(team_id, name: nil, status: nil)
258
+ @client.request_json("PATCH", "/v1/teams/#{CGI.escape(team_id)}", body: {
259
+ name: name,
260
+ status: status
261
+ }.reject { |_key, value| value.nil? })[:data]
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,21 @@
1
+ module Tripwire
2
+ module Server
3
+ class ConfigurationError < StandardError; end
4
+
5
+ class TokenVerificationError < StandardError; end
6
+
7
+ class ApiError < StandardError
8
+ attr_reader :status, :code, :request_id, :field_errors, :docs_url, :body
9
+
10
+ def initialize(status:, code:, message:, request_id: nil, field_errors: [], docs_url: nil, body: nil)
11
+ super(message)
12
+ @status = status
13
+ @code = code
14
+ @request_id = request_id
15
+ @field_errors = field_errors
16
+ @docs_url = docs_url
17
+ @body = body
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,76 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "openssl"
5
+ require "zlib"
6
+
7
+ module Tripwire
8
+ module Server
9
+ module SealedToken
10
+ VERSION = 0x01
11
+
12
+ module_function
13
+
14
+ def verify_tripwire_token(sealed_token, secret_key = nil)
15
+ resolved_secret = secret_key || ENV["TRIPWIRE_SECRET_KEY"]
16
+ raise ConfigurationError, "Missing Tripwire secret key. Pass secret_key explicitly or set TRIPWIRE_SECRET_KEY." if resolved_secret.nil? || resolved_secret.empty?
17
+
18
+ raw = Base64.decode64(sealed_token)
19
+ raise TokenVerificationError, "Tripwire token is too short." if raw.bytesize < 29
20
+
21
+ version = raw.getbyte(0)
22
+ raise TokenVerificationError, "Unsupported Tripwire token version: #{version}" if version != VERSION
23
+
24
+ nonce = raw.byteslice(1, 12)
25
+ ciphertext = raw.byteslice(13, raw.bytesize - 29)
26
+ tag = raw.byteslice(raw.bytesize - 16, 16)
27
+
28
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
29
+ cipher.decrypt
30
+ cipher.key = derive_key(resolved_secret)
31
+ cipher.iv = nonce
32
+ cipher.auth_tag = tag
33
+
34
+ compressed = cipher.update(ciphertext) + cipher.final
35
+ payload = JSON.parse(Zlib::Inflate.inflate(compressed))
36
+ deep_symbolize(payload)
37
+ rescue ConfigurationError, TokenVerificationError
38
+ raise
39
+ rescue StandardError => error
40
+ raise TokenVerificationError, "Failed to verify Tripwire token: #{error.message}"
41
+ end
42
+
43
+ def safe_verify_tripwire_token(sealed_token, secret_key = nil)
44
+ { ok: true, data: verify_tripwire_token(sealed_token, secret_key) }
45
+ rescue ConfigurationError, TokenVerificationError => error
46
+ { ok: false, error: error }
47
+ end
48
+
49
+ def derive_key(secret_key_or_hash)
50
+ Digest::SHA256.digest("#{normalize_secret(secret_key_or_hash)}\0sealed-results")
51
+ end
52
+ private_class_method :derive_key
53
+
54
+ def normalize_secret(secret_key_or_hash)
55
+ return secret_key_or_hash.downcase if /\A[0-9a-fA-F]{64}\z/.match?(secret_key_or_hash)
56
+
57
+ Digest::SHA256.hexdigest(secret_key_or_hash)
58
+ end
59
+ private_class_method :normalize_secret
60
+
61
+ def deep_symbolize(value)
62
+ case value
63
+ when Array
64
+ value.map { |item| deep_symbolize(item) }
65
+ when Hash
66
+ value.each_with_object({}) do |(key, item), memo|
67
+ memo[key.to_sym] = deep_symbolize(item)
68
+ end
69
+ else
70
+ value
71
+ end
72
+ end
73
+ private_class_method :deep_symbolize
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,5 @@
1
+ module Tripwire
2
+ module Server
3
+ ListResult = Struct.new(:items, :limit, :has_more, :next_cursor, keyword_init: true)
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Tripwire
2
+ module Server
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "server/version"
2
+ require_relative "server/errors"
3
+ require_relative "server/types"
4
+ require_relative "server/sealed_token"
5
+ require_relative "server/client"
6
+
7
+ module Tripwire
8
+ module Server
9
+ module_function
10
+
11
+ def verify_tripwire_token(sealed_token, secret_key = nil)
12
+ SealedToken.verify_tripwire_token(sealed_token, secret_key)
13
+ end
14
+
15
+ def safe_verify_tripwire_token(sealed_token, secret_key = nil)
16
+ SealedToken.safe_verify_tripwire_token(sealed_token, secret_key)
17
+ end
18
+ end
19
+ end
data/spec/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/spec/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Server SDK Spec
2
+
3
+ This directory is the authoritative cross-language contract for Tripwire server SDKs.
4
+
5
+ It defines:
6
+
7
+ - the supported public server API surface
8
+ - the shared sealed token verification behavior
9
+ - golden fixtures for success, error, and pagination flows
10
+
11
+ ## Scope
12
+
13
+ Server SDKs include only customer-facing public APIs:
14
+
15
+ - `/v1/sessions`
16
+ - `/v1/fingerprints`
17
+ - `/v1/teams`
18
+ - `/v1/teams/:teamId/api-keys`
19
+
20
+ Server SDKs do **not** include:
21
+
22
+ - collect endpoints
23
+ - internal scoring APIs
24
+ - dashboard/internal APIs
25
+ - framework adapters
26
+ - retry middleware
27
+ - policy helpers like `shouldBlock`
28
+
29
+ ## Required Namespaces
30
+
31
+ Every server SDK should expose these top-level capabilities:
32
+
33
+ - Sessions
34
+ - list
35
+ - get
36
+ - iterator / auto-pagination helper
37
+ - Fingerprints
38
+ - list
39
+ - get
40
+ - iterator / auto-pagination helper
41
+ - Teams
42
+ - create
43
+ - get
44
+ - update
45
+ - Team API keys
46
+ - create
47
+ - list
48
+ - revoke
49
+ - rotate
50
+ - sealed token helpers
51
+ - strict verify
52
+ - safe verify
53
+
54
+ ## Shared Defaults
55
+
56
+ Every SDK should default to:
57
+
58
+ - `base_url = https://api.tripwirejs.com`
59
+ - `secret_key = env(TRIPWIRE_SECRET_KEY)`
60
+ - secret-key-only auth via `Authorization: Bearer <secret>`
61
+ - request timeout support
62
+ - no automatic retries
63
+
64
+ ## Pagination Normalization
65
+
66
+ List APIs should normalize cursor pagination into these fields using each language's native style:
67
+
68
+ - `items`
69
+ - `limit`
70
+ - `has_more`
71
+ - `next_cursor`
72
+
73
+ The underlying API responses remain cursor-based. SDKs may expose helper iterators/enumerators/page walkers on top.
74
+
75
+ ## Error Model
76
+
77
+ SDKs should parse public API failures into structured errors with, at minimum:
78
+
79
+ - `status`
80
+ - `code`
81
+ - `message`
82
+ - `request_id`
83
+ - `field_errors`
84
+ - `docs_url`
85
+ - parsed raw body
86
+
87
+ Use the fixtures in `fixtures/errors/` as the source of truth for error-shape behavior.
88
+
89
+ ## Sealed Token Verification
90
+
91
+ `sealed-token.md` is the cross-language source of truth for verification behavior.
92
+
93
+ SDKs must implement verification natively in their own language runtime. They should not depend on another SDK implementation.
94
+
95
+ Use both:
96
+
97
+ - `fixtures/sealed-token/vector.v1.json`
98
+ - `fixtures/sealed-token/invalid.json`
99
+
100
+ to validate correctness and failure behavior.
101
+
102
+ ## Sync Model
103
+
104
+ This repo is the source of truth for the shared server SDK contract.
105
+
106
+ Each language SDK repo carries a synced copy of this repository in `spec/`, and the Tripwire monorepo vendors this repository as a submodule at `sdk-spec/server`.
107
+
108
+ Keep the synced copies and the monorepo submodule pointer current before advancing them.
109
+
110
+ ## SDK Authoring Checklist
111
+
112
+ When changing any server SDK:
113
+
114
+ - sync `spec/` before changing SDK code or tests
115
+ - do not expose collect or internal-only endpoints
116
+ - preserve the shared defaults:
117
+ - env-based secret key fallback
118
+ - `https://api.tripwirejs.com`
119
+ - request timeout support
120
+ - no automatic retries
121
+ - preserve pagination normalization:
122
+ - `items`
123
+ - `limit`
124
+ - `has_more`
125
+ - `next_cursor`
126
+ - preserve structured public API errors
127
+ - keep sealed token golden-vector coverage
128
+ - keep one live smoke suite per SDK
129
+ - only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
@@ -0,0 +1,10 @@
1
+ {
2
+ "error": {
3
+ "code": "auth.invalid_api_key",
4
+ "message": "The provided API key is invalid. Check the key and retry.",
5
+ "status": 401,
6
+ "retryable": false,
7
+ "requestId": "req_invalid_api_key",
8
+ "docsUrl": "https://tripwire.com/docs/api-reference/introduction"
9
+ }
10
+ }