foil-server 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +154 -0
  4. data/lib/foil/server/client.rb +472 -0
  5. data/lib/foil/server/crypto_support.rb +49 -0
  6. data/lib/foil/server/errors.rb +21 -0
  7. data/lib/foil/server/gate_delivery.rb +325 -0
  8. data/lib/foil/server/sealed_token.rb +78 -0
  9. data/lib/foil/server/types.rb +5 -0
  10. data/lib/foil/server/version.rb +5 -0
  11. data/lib/foil/server.rb +31 -0
  12. data/spec/LICENSE +21 -0
  13. data/spec/README.md +160 -0
  14. data/spec/fixtures/api/fingerprints/detail.json +70 -0
  15. data/spec/fixtures/api/fingerprints/list.json +37 -0
  16. data/spec/fixtures/api/gate/agent-token-revoke.json +3 -0
  17. data/spec/fixtures/api/gate/agent-token-verify.json +12 -0
  18. data/spec/fixtures/api/gate/login-session-consume.json +10 -0
  19. data/spec/fixtures/api/gate/login-session-create.json +12 -0
  20. data/spec/fixtures/api/gate/registry-detail.json +45 -0
  21. data/spec/fixtures/api/gate/registry-list.json +47 -0
  22. data/spec/fixtures/api/gate/service-create.json +49 -0
  23. data/spec/fixtures/api/gate/service-detail.json +49 -0
  24. data/spec/fixtures/api/gate/service-disable.json +49 -0
  25. data/spec/fixtures/api/gate/service-update.json +49 -0
  26. data/spec/fixtures/api/gate/services-list.json +51 -0
  27. data/spec/fixtures/api/gate/session-ack.json +10 -0
  28. data/spec/fixtures/api/gate/session-create.json +13 -0
  29. data/spec/fixtures/api/gate/session-poll.json +36 -0
  30. data/spec/fixtures/api/organizations/api-key-create.json +27 -0
  31. data/spec/fixtures/api/organizations/api-key-list.json +31 -0
  32. data/spec/fixtures/api/organizations/api-key-revoke.json +25 -0
  33. data/spec/fixtures/api/organizations/api-key-rotate.json +27 -0
  34. data/spec/fixtures/api/organizations/api-key-update.json +29 -0
  35. data/spec/fixtures/api/organizations/organization-create.json +14 -0
  36. data/spec/fixtures/api/organizations/organization-update.json +14 -0
  37. data/spec/fixtures/api/organizations/organization.json +14 -0
  38. data/spec/fixtures/api/sessions/detail.json +434 -0
  39. data/spec/fixtures/api/sessions/list.json +36 -0
  40. data/spec/fixtures/errors/invalid-api-key.json +10 -0
  41. data/spec/fixtures/errors/missing-api-key.json +10 -0
  42. data/spec/fixtures/errors/not-found.json +10 -0
  43. data/spec/fixtures/errors/validation-error.json +20 -0
  44. data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +19 -0
  45. data/spec/fixtures/gate-delivery/delivery-request.json +9 -0
  46. data/spec/fixtures/gate-delivery/env-policy.json +40 -0
  47. data/spec/fixtures/gate-delivery/vector.v1.json +28 -0
  48. data/spec/fixtures/gate-delivery/webhook-signature.json +9 -0
  49. data/spec/fixtures/manifest.json +185 -0
  50. data/spec/fixtures/sealed-token/invalid.json +4 -0
  51. data/spec/fixtures/sealed-token/vector.v1.json +54 -0
  52. data/spec/openapi.json +20482 -0
  53. data/spec/sealed-token.md +114 -0
  54. metadata +96 -0
@@ -0,0 +1,21 @@
1
+ module Foil
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,325 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Foil
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
+ WEBHOOK_EVENT_TYPES = %w[
43
+ session.fingerprint.calculated
44
+ session.result.persisted
45
+ gate.session.approved
46
+ webhook.test
47
+ ].freeze
48
+ GATE_DELIVERY_HKDF_INFO = "foil-gate-delivery:v1".b.freeze
49
+ X25519_SPKI_PREFIX = ["302a300506032b656e032100"].pack("H*").freeze
50
+
51
+ module_function
52
+
53
+ def derive_gate_agent_token_env_key(service_id)
54
+ normalized = service_id.to_s.strip.gsub(/[^A-Za-z0-9]+/, "_").gsub(/^_+|_+$/, "").gsub(/_+/, "_").upcase
55
+ raise ArgumentError, "service_id is required to derive a Gate agent token env key" if normalized.empty?
56
+
57
+ return "FOIL#{GATE_AGENT_TOKEN_ENV_SUFFIX}" if ["FOIL", "FOIL"].include?(normalized)
58
+
59
+ "#{normalized}#{GATE_AGENT_TOKEN_ENV_SUFFIX}"
60
+ end
61
+
62
+ def is_gate_managed_env_var_key(key)
63
+ key == "FOIL_AGENT_TOKEN" || key.to_s.end_with?(GATE_AGENT_TOKEN_ENV_SUFFIX)
64
+ end
65
+
66
+ def is_blocked_gate_env_var_key(key)
67
+ normalized = key.to_s.strip.upcase
68
+ BLOCKED_GATE_ENV_VAR_KEYS.include?(normalized) || BLOCKED_GATE_ENV_VAR_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
69
+ end
70
+
71
+ def raw_x25519_public_key_from_key_object(public_key)
72
+ der = public_key.public_to_der
73
+ raise ArgumentError, "Unexpected X25519 public key encoding" unless der.bytesize == X25519_SPKI_PREFIX.bytesize + 32
74
+ raise ArgumentError, "Unexpected X25519 public key prefix" unless der.byteslice(0, X25519_SPKI_PREFIX.bytesize) == X25519_SPKI_PREFIX
75
+
76
+ der.byteslice(X25519_SPKI_PREFIX.bytesize, 32)
77
+ end
78
+
79
+ def key_id_for_raw_x25519_public_key(raw_public_key)
80
+ raise ArgumentError, "X25519 public key must be 32 bytes" unless raw_public_key.bytesize == 32
81
+
82
+ Base64.urlsafe_encode64(Digest::SHA256.digest(raw_public_key), padding: false)
83
+ end
84
+
85
+ def create_delivery_key_pair
86
+ CryptoSupport.ensure_supported_runtime!
87
+ private_key = OpenSSL::PKey.generate_key("X25519")
88
+ raw_public_key = raw_x25519_public_key_from_key_object(private_key.public_key)
89
+ {
90
+ delivery: {
91
+ version: GATE_DELIVERY_VERSION,
92
+ algorithm: GATE_DELIVERY_ALGORITHM,
93
+ key_id: key_id_for_raw_x25519_public_key(raw_public_key),
94
+ public_key: Base64.urlsafe_encode64(raw_public_key, padding: false)
95
+ },
96
+ private_key: private_key
97
+ }
98
+ end
99
+
100
+ def export_delivery_private_key_pkcs8(private_key)
101
+ CryptoSupport.ensure_supported_runtime!
102
+ Base64.urlsafe_encode64(private_key.private_to_der, padding: false)
103
+ end
104
+
105
+ def import_delivery_private_key_pkcs8(value)
106
+ CryptoSupport.ensure_supported_runtime!
107
+ OpenSSL::PKey.read(b64url_decode(value, "delivery.private_key_pkcs8"))
108
+ end
109
+
110
+ def validate_gate_delivery_request(value)
111
+ candidate = symbolize(value)
112
+ raise ArgumentError, "delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
113
+ raise ArgumentError, "delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
114
+ raise ArgumentError, "delivery.public_key is required" if candidate[:public_key].to_s.empty?
115
+ raise ArgumentError, "delivery.key_id is required" if candidate[:key_id].to_s.empty?
116
+
117
+ raw_public_key = b64url_decode(candidate[:public_key], "delivery.public_key")
118
+ raise ArgumentError, "delivery.public_key must be a raw X25519 public key" unless raw_public_key.bytesize == 32
119
+ 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]
120
+
121
+ {
122
+ version: GATE_DELIVERY_VERSION,
123
+ algorithm: GATE_DELIVERY_ALGORITHM,
124
+ key_id: candidate[:key_id],
125
+ public_key: candidate[:public_key]
126
+ }
127
+ end
128
+
129
+ def create_encrypted_delivery_response(input)
130
+ {
131
+ encrypted_delivery: encrypt_gate_delivery_payload(
132
+ input.fetch(:delivery),
133
+ {
134
+ version: GATE_DELIVERY_VERSION,
135
+ outputs: input.fetch(:outputs)
136
+ }
137
+ )
138
+ }
139
+ end
140
+
141
+ def create_gate_approved_webhook_response(input)
142
+ create_encrypted_delivery_response(input)
143
+ end
144
+
145
+ def validate_encrypted_gate_delivery_envelope(value)
146
+ candidate = symbolize(value)
147
+ raise ArgumentError, "encrypted_delivery.version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
148
+ raise ArgumentError, "encrypted_delivery.algorithm must be #{GATE_DELIVERY_ALGORITHM}" unless candidate[:algorithm] == GATE_DELIVERY_ALGORITHM
149
+ %i[key_id ephemeral_public_key salt iv ciphertext tag].each do |field|
150
+ raise ArgumentError, "encrypted_delivery.#{field} is required" if candidate[field].to_s.empty?
151
+ end
152
+ 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
153
+ raise ArgumentError, "encrypted_delivery.salt must be 32 bytes" unless b64url_decode(candidate[:salt], "encrypted_delivery.salt").bytesize == 32
154
+ raise ArgumentError, "encrypted_delivery.iv must be 12 bytes" unless b64url_decode(candidate[:iv], "encrypted_delivery.iv").bytesize == 12
155
+ raise ArgumentError, "encrypted_delivery.tag must be 16 bytes" unless b64url_decode(candidate[:tag], "encrypted_delivery.tag").bytesize == 16
156
+
157
+ candidate
158
+ end
159
+
160
+ def encrypt_gate_delivery_payload(delivery, payload)
161
+ CryptoSupport.ensure_supported_runtime!
162
+ validated_delivery = validate_gate_delivery_request(delivery)
163
+ payload = symbolize(payload)
164
+ raise ArgumentError, "Gate delivery payload version must be 1" unless payload[:version] == GATE_DELIVERY_VERSION
165
+
166
+ recipient_public_key = OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated_delivery[:public_key], "delivery.public_key"))
167
+ ephemeral_private_key = OpenSSL::PKey.generate_key("X25519")
168
+ shared_secret = ephemeral_private_key.derive(recipient_public_key)
169
+ salt = OpenSSL::Random.random_bytes(32)
170
+ iv = OpenSSL::Random.random_bytes(12)
171
+ key = OpenSSL::KDF.hkdf(shared_secret, salt: salt, info: GATE_DELIVERY_HKDF_INFO, length: 32, hash: "SHA256")
172
+
173
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
174
+ cipher.encrypt
175
+ cipher.key = key
176
+ cipher.iv = iv
177
+ ciphertext = cipher.update(JSON.generate(compact_payload(payload))) + cipher.final
178
+ tag = cipher.auth_tag
179
+
180
+ {
181
+ version: GATE_DELIVERY_VERSION,
182
+ algorithm: GATE_DELIVERY_ALGORITHM,
183
+ key_id: validated_delivery[:key_id],
184
+ ephemeral_public_key: Base64.urlsafe_encode64(raw_x25519_public_key_from_key_object(ephemeral_private_key.public_key), padding: false),
185
+ salt: Base64.urlsafe_encode64(salt, padding: false),
186
+ iv: Base64.urlsafe_encode64(iv, padding: false),
187
+ ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
188
+ tag: Base64.urlsafe_encode64(tag, padding: false)
189
+ }
190
+ end
191
+
192
+ def decrypt_gate_delivery_envelope(private_key, envelope)
193
+ CryptoSupport.ensure_supported_runtime!
194
+ validated = validate_encrypted_gate_delivery_envelope(envelope)
195
+ shared_secret = private_key.derive(
196
+ OpenSSL::PKey.read(X25519_SPKI_PREFIX + b64url_decode(validated[:ephemeral_public_key], "encrypted_delivery.ephemeral_public_key"))
197
+ )
198
+ key = OpenSSL::KDF.hkdf(
199
+ shared_secret,
200
+ salt: b64url_decode(validated[:salt], "encrypted_delivery.salt"),
201
+ info: GATE_DELIVERY_HKDF_INFO,
202
+ length: 32,
203
+ hash: "SHA256"
204
+ )
205
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
206
+ cipher.decrypt
207
+ cipher.key = key
208
+ cipher.iv = b64url_decode(validated[:iv], "encrypted_delivery.iv")
209
+ cipher.auth_tag = b64url_decode(validated[:tag], "encrypted_delivery.tag")
210
+ cipher.auth_data = ""
211
+ plaintext = cipher.update(b64url_decode(validated[:ciphertext], "encrypted_delivery.ciphertext")) + cipher.final
212
+ payload = JSON.parse(plaintext)
213
+ raise ArgumentError, "encrypted_delivery payload must be an object" unless payload.is_a?(Hash)
214
+
215
+ symbolize(payload).tap do |candidate|
216
+ raise ArgumentError, "encrypted_delivery payload version must be 1" unless candidate[:version] == GATE_DELIVERY_VERSION
217
+ raise ArgumentError, "encrypted_delivery payload outputs must be an object" unless candidate[:outputs].is_a?(Hash)
218
+ candidate[:outputs].each do |key_name, item|
219
+ raise ArgumentError, "encrypted_delivery output #{key_name} must be a string" unless item.is_a?(String)
220
+ end
221
+ end
222
+ rescue JSON::ParserError
223
+ raise ArgumentError, "encrypted_delivery decrypted to invalid JSON"
224
+ end
225
+
226
+ def validate_gate_approved_webhook_payload(value)
227
+ candidate = symbolize(value)
228
+ raise ArgumentError, "webhook payload must not include event; use the webhook event envelope type" if candidate.key?(:event)
229
+ raise ArgumentError, "service_id is required" if candidate[:service_id].to_s.empty?
230
+ raise ArgumentError, "gate_session_id is required" if candidate[:gate_session_id].to_s.empty?
231
+ raise ArgumentError, "gate_account_id is required" if candidate[:gate_account_id].to_s.empty?
232
+ raise ArgumentError, "account_name is required" if candidate[:account_name].to_s.empty?
233
+ raise ArgumentError, "metadata must be an object or null" unless candidate[:metadata].nil? || candidate[:metadata].is_a?(Hash)
234
+ raise ArgumentError, "foil must be an object" unless candidate[:foil].is_a?(Hash)
235
+ verdict = candidate[:foil][:verdict]
236
+ raise ArgumentError, "foil.verdict is invalid" unless %w[bot human inconclusive].include?(verdict)
237
+ score = candidate[:foil][:score]
238
+ raise ArgumentError, "foil.score must be a number or null" unless score.nil? || score.is_a?(Numeric)
239
+
240
+ {
241
+ service_id: candidate[:service_id],
242
+ gate_session_id: candidate[:gate_session_id],
243
+ gate_account_id: candidate[:gate_account_id],
244
+ account_name: candidate[:account_name],
245
+ metadata: candidate[:metadata]&.dup,
246
+ foil: {
247
+ verdict: verdict,
248
+ score: score
249
+ },
250
+ delivery: validate_gate_delivery_request(candidate[:delivery])
251
+ }
252
+ end
253
+
254
+ def verify_gate_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
255
+ parsed_timestamp = Integer(timestamp)
256
+ current = now_seconds || Time.now.to_i
257
+ return false if (current - parsed_timestamp).abs > max_age_seconds
258
+
259
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
260
+ secure_compare(expected, signature)
261
+ rescue ArgumentError
262
+ false
263
+ end
264
+
265
+ def parse_webhook_event(raw_body)
266
+ envelope = symbolize(JSON.parse(raw_body))
267
+ raise ArgumentError, "webhook event object must be webhook_event" unless envelope[:object] == "webhook_event"
268
+ raise ArgumentError, "webhook event id is required" if envelope[:id].to_s.empty?
269
+ raise ArgumentError, "webhook event type is required" if envelope[:type].to_s.empty?
270
+ raise ArgumentError, "unsupported webhook event type: #{envelope[:type]}" unless WEBHOOK_EVENT_TYPES.include?(envelope[:type])
271
+ raise ArgumentError, "webhook event created timestamp is required" if envelope[:created].to_s.empty?
272
+ raise ArgumentError, "webhook event data must be an object" unless envelope[:data].is_a?(Hash)
273
+
274
+ envelope[:data] = validate_gate_approved_webhook_payload(envelope[:data]) if envelope[:type] == "gate.session.approved"
275
+ envelope
276
+ end
277
+
278
+ def verify_and_parse_webhook_event(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
279
+ unless verify_gate_webhook_signature(secret: secret, timestamp: timestamp, raw_body: raw_body, signature: signature, max_age_seconds: max_age_seconds, now_seconds: now_seconds)
280
+ raise ArgumentError, "Invalid Foil webhook signature"
281
+ end
282
+ parse_webhook_event(raw_body)
283
+ end
284
+
285
+ def symbolize(value)
286
+ case value
287
+ when Array
288
+ value.map { |item| symbolize(item) }
289
+ when Hash
290
+ value.each_with_object({}) do |(key, item), memo|
291
+ memo[key.to_sym] = symbolize(item)
292
+ end
293
+ else
294
+ value
295
+ end
296
+ end
297
+ private_class_method :symbolize
298
+
299
+ def compact_payload(payload)
300
+ {
301
+ version: payload[:version],
302
+ outputs: payload[:outputs],
303
+ **(payload[:ack_token] ? { ack_token: payload[:ack_token] } : {})
304
+ }
305
+ end
306
+ private_class_method :compact_payload
307
+
308
+ def b64url_decode(value, label)
309
+ Base64.urlsafe_decode64(value.to_s)
310
+ rescue ArgumentError => error
311
+ raise ArgumentError, "Invalid #{label}: #{error.message}"
312
+ end
313
+ private_class_method :b64url_decode
314
+
315
+ def secure_compare(left, right)
316
+ return false unless left.bytesize == right.bytesize
317
+
318
+ result = 0
319
+ left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
320
+ result.zero?
321
+ end
322
+ private_class_method :secure_compare
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,78 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "openssl"
5
+ require "zlib"
6
+
7
+ module Foil
8
+ module Server
9
+ module SealedToken
10
+ VERSION = 0x01
11
+
12
+ module_function
13
+
14
+ def verify_foil_token(sealed_token, secret_key = nil)
15
+ CryptoSupport.ensure_supported_runtime!
16
+ resolved_secret = secret_key || ENV["FOIL_SECRET_KEY"]
17
+ raise ConfigurationError, "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY." if resolved_secret.nil? || resolved_secret.empty?
18
+
19
+ raw = Base64.decode64(sealed_token)
20
+ raise TokenVerificationError, "Foil token is too short." if raw.bytesize < 29
21
+
22
+ version = raw.getbyte(0)
23
+ raise TokenVerificationError, "Unsupported Foil token version: #{version}" if version != VERSION
24
+
25
+ nonce = raw.byteslice(1, 12)
26
+ ciphertext = raw.byteslice(13, raw.bytesize - 29)
27
+ tag = raw.byteslice(raw.bytesize - 16, 16)
28
+
29
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
30
+ cipher.decrypt
31
+ cipher.key = derive_key(resolved_secret)
32
+ cipher.iv = nonce
33
+ cipher.auth_tag = tag
34
+ cipher.auth_data = ""
35
+
36
+ compressed = cipher.update(ciphertext) + cipher.final
37
+ payload = JSON.parse(Zlib::Inflate.inflate(compressed))
38
+ deep_symbolize(payload)
39
+ rescue ConfigurationError, TokenVerificationError
40
+ raise
41
+ rescue StandardError => error
42
+ raise TokenVerificationError, "Failed to verify Foil token: #{error.message}"
43
+ end
44
+
45
+ def safe_verify_foil_token(sealed_token, secret_key = nil)
46
+ { ok: true, data: verify_foil_token(sealed_token, secret_key) }
47
+ rescue ConfigurationError, TokenVerificationError => error
48
+ { ok: false, error: error }
49
+ end
50
+
51
+ def derive_key(secret_key_or_hash)
52
+ Digest::SHA256.digest("#{normalize_secret(secret_key_or_hash)}\0sealed-results")
53
+ end
54
+ private_class_method :derive_key
55
+
56
+ def normalize_secret(secret_key_or_hash)
57
+ return secret_key_or_hash.downcase if /\A[0-9a-fA-F]{64}\z/.match?(secret_key_or_hash)
58
+
59
+ Digest::SHA256.hexdigest(secret_key_or_hash)
60
+ end
61
+ private_class_method :normalize_secret
62
+
63
+ def deep_symbolize(value)
64
+ case value
65
+ when Array
66
+ value.map { |item| deep_symbolize(item) }
67
+ when Hash
68
+ value.each_with_object({}) do |(key, item), memo|
69
+ memo[key.to_sym] = deep_symbolize(item)
70
+ end
71
+ else
72
+ value
73
+ end
74
+ end
75
+ private_class_method :deep_symbolize
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ module Foil
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 Foil
2
+ module Server
3
+ VERSION = "0.3.3".freeze
4
+ end
5
+ end
@@ -0,0 +1,31 @@
1
+ require_relative "server/version"
2
+ require_relative "server/errors"
3
+ require_relative "server/crypto_support"
4
+ require_relative "server/types"
5
+ require_relative "server/sealed_token"
6
+ require_relative "server/gate_delivery"
7
+ require_relative "server/client"
8
+
9
+ module Foil
10
+ module Server
11
+ module_function
12
+
13
+ def verify_foil_token(sealed_token, secret_key = nil)
14
+ SealedToken.verify_foil_token(sealed_token, secret_key)
15
+ end
16
+
17
+ def safe_verify_foil_token(sealed_token, secret_key = nil)
18
+ SealedToken.safe_verify_foil_token(sealed_token, secret_key)
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
30
+ end
31
+ 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,160 @@
1
+ # Server SDK Spec
2
+
3
+ This directory is the authoritative cross-language contract for Foil server SDKs.
4
+
5
+ It defines:
6
+
7
+ - the supported server API surface
8
+ - the shared sealed token verification behavior
9
+ - the shared Gate delivery/webhook helper behavior
10
+ - golden fixtures for success, error, pagination, and helper flows
11
+
12
+ ## Scope
13
+
14
+ Server SDKs include only customer-facing APIs:
15
+
16
+ - `/v1/sessions`
17
+ - `/v1/fingerprints`
18
+ - `/v1/organizations`
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
+
26
+ Server SDKs do **not** include:
27
+
28
+ - collect endpoints
29
+ - internal scoring APIs
30
+ - dashboard/internal APIs
31
+ - framework adapters
32
+ - retry middleware
33
+ - policy helpers like `shouldBlock`
34
+
35
+ ## Required Namespaces
36
+
37
+ Every server SDK should expose these top-level capabilities:
38
+
39
+ - Sessions
40
+ - list
41
+ - get
42
+ - iterator / auto-pagination helper
43
+ - Fingerprints
44
+ - list
45
+ - get
46
+ - iterator / auto-pagination helper
47
+ - Organizations
48
+ - create
49
+ - get
50
+ - update
51
+ - Organization API keys
52
+ - create
53
+ - list
54
+ - revoke
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
62
+ - sealed token helpers
63
+ - strict verify
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
74
+
75
+ ## Shared Defaults
76
+
77
+ Every SDK should default to:
78
+
79
+ - `base_url = https://api.usefoil.com`
80
+ - `secret_key = env(FOIL_SECRET_KEY)`
81
+ - support for public, bearer-token, and secret-key auth as required by the Gate surface
82
+ - request timeout support
83
+ - no automatic retries
84
+
85
+ ## Pagination Normalization
86
+
87
+ List APIs should normalize cursor pagination into these fields using each language's native style:
88
+
89
+ - `items`
90
+ - `limit`
91
+ - `has_more`
92
+ - `next_cursor`
93
+
94
+ The underlying API responses remain cursor-based. SDKs may expose helper iterators/enumerators/page walkers on top.
95
+
96
+ ## Error Model
97
+
98
+ SDKs should parse API failures into structured errors with, at minimum:
99
+
100
+ - `status`
101
+ - `code`
102
+ - `message`
103
+ - `request_id`
104
+ - `field_errors`
105
+ - `docs_url`
106
+ - parsed raw body
107
+
108
+ Use the fixtures in `fixtures/errors/` as the source of truth for error-shape behavior.
109
+
110
+ ## Sealed Token Verification
111
+
112
+ `sealed-token.md` is the cross-language source of truth for verification behavior.
113
+
114
+ SDKs must implement verification natively in their own language runtime. They should not depend on another SDK implementation.
115
+
116
+ Use both:
117
+
118
+ - `fixtures/sealed-token/vector.v1.json`
119
+ - `fixtures/sealed-token/invalid.json`
120
+
121
+ to validate correctness and failure behavior.
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
+
133
+ ## Sync Model
134
+
135
+ This repo is the source of truth for the shared server SDK contract.
136
+
137
+ Each language SDK repo carries a synced copy of this repository in `spec/`, and the Foil monorepo vendors this repository as a submodule at `sdk-spec/server`.
138
+
139
+ Keep the synced copies and the monorepo submodule pointer current before advancing them.
140
+
141
+ ## SDK Authoring Checklist
142
+
143
+ When changing any server SDK:
144
+
145
+ - sync `spec/` before changing SDK code or tests
146
+ - do not expose collect or internal-only endpoints
147
+ - preserve the shared defaults:
148
+ - env-based secret key fallback
149
+ - `https://api.usefoil.com`
150
+ - request timeout support
151
+ - no automatic retries
152
+ - preserve pagination normalization:
153
+ - `items`
154
+ - `limit`
155
+ - `has_more`
156
+ - `next_cursor`
157
+ - preserve structured API errors
158
+ - keep sealed token golden-vector coverage
159
+ - keep one live smoke suite per SDK
160
+ - only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green