k9o 1.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +201 -0
- data/NOTICE +4 -0
- data/README.md +546 -0
- data/k9o.gemspec +41 -0
- data/lib/k9o/ai.rb +28 -0
- data/lib/k9o/audit.rb +29 -0
- data/lib/k9o/barbican.rb +239 -0
- data/lib/k9o/client.rb +59 -0
- data/lib/k9o/compute.rb +67 -0
- data/lib/k9o/data.rb +157 -0
- data/lib/k9o/envelope.rb +64 -0
- data/lib/k9o/functions.rb +51 -0
- data/lib/k9o/gate.rb +64 -0
- data/lib/k9o/http_transport.rb +318 -0
- data/lib/k9o/kerror.rb +52 -0
- data/lib/k9o/kibo.rb +114 -0
- data/lib/k9o/me.rb +160 -0
- data/lib/k9o/migrations.rb +244 -0
- data/lib/k9o/multipart.rb +42 -0
- data/lib/k9o/org.rb +213 -0
- data/lib/k9o/orgs.rb +33 -0
- data/lib/k9o/policies.rb +65 -0
- data/lib/k9o/projects.rb +33 -0
- data/lib/k9o/realtime.rb +80 -0
- data/lib/k9o/region.rb +95 -0
- data/lib/k9o/storage.rb +176 -0
- data/lib/k9o/studio.rb +175 -0
- data/lib/k9o/url_grammar.rb +113 -0
- data/lib/k9o/vault.rb +235 -0
- data/lib/k9o/version.rb +5 -0
- data/lib/k9o.rb +54 -0
- data/sig/k9o/ai.rbs +16 -0
- data/sig/k9o/gate.rbs +11 -0
- data/sig/k9o/migrations.rbs +138 -0
- data/sig/k9o/region.rbs +19 -0
- data/sig/k9o/storage.rbs +41 -0
- metadata +98 -0
data/lib/k9o/gate.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Gate (Identity-Aware Kernel) namespace. Project-scoped.
|
|
5
|
+
class Gate
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def sign_up(email:, password:, display_name: nil)
|
|
11
|
+
body = { 'email' => email, 'password' => password }
|
|
12
|
+
body['displayName'] = display_name if display_name
|
|
13
|
+
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signup'), body: body)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sign_in(email:, password:)
|
|
17
|
+
HttpTransport.request(
|
|
18
|
+
config: @config, method: 'POST', path: gate_path('signin'),
|
|
19
|
+
body: { 'email' => email, 'password' => password }
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sign_out(refresh_token: nil)
|
|
24
|
+
body = refresh_token ? { 'refreshToken' => refresh_token } : {}
|
|
25
|
+
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'), body: body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def whoami
|
|
29
|
+
HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def refresh_token(refresh_token:)
|
|
33
|
+
HttpTransport.request(
|
|
34
|
+
config: @config, method: 'POST', path: gate_path('refresh'),
|
|
35
|
+
body: { 'refreshToken' => refresh_token }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify_session
|
|
40
|
+
HttpTransport.request(config: @config, method: 'GET', path: gate_path('verify'))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The project's hosted sign-in URL and WebAuthn rpId:
|
|
44
|
+
# GET /@<slug>/gate/hosted. Unauthenticated and cacheable; no credential is
|
|
45
|
+
# sent even when an api_key is configured. Returns an Envelope whose `data`
|
|
46
|
+
# is { 'signInUrl' => String, 'rpId' => String }. Take both values as given:
|
|
47
|
+
# an unknown org, a deleted project and a non-Gate project all answer the
|
|
48
|
+
# same 404 K9O_PROJECT_NOT_FOUND.
|
|
49
|
+
def hosted
|
|
50
|
+
HttpTransport.request(config: @config, method: 'GET', path: gate_path('hosted'), authenticated: false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def gate_path(action)
|
|
56
|
+
raise KError.new(
|
|
57
|
+
code: 'K9O_MISSING_PROJECT_CONTEXT',
|
|
58
|
+
message: 'k9o.gate operations require project_slug bound on the client',
|
|
59
|
+
context: {}
|
|
60
|
+
) if @config.project_slug.nil? || @config.project_slug.empty?
|
|
61
|
+
UrlGrammar.project_path(@config.project_slug, "gate/#{action}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative 'multipart'
|
|
7
|
+
|
|
8
|
+
module K9o
|
|
9
|
+
# Retry + backoff policy. Applied to idempotent methods
|
|
10
|
+
# (GET/HEAD/OPTIONS/DELETE) on transient failures only (transport error /
|
|
11
|
+
# 502 / 503 / 504 / 429); semantic 4xx and non-idempotent methods are
|
|
12
|
+
# never retried.
|
|
13
|
+
class RetryConfig
|
|
14
|
+
attr_reader :max_retries, :backoff, :initial_delay_ms, :max_delay_ms
|
|
15
|
+
|
|
16
|
+
# backoff: 'exponential' | 'linear' | 'none'
|
|
17
|
+
def initialize(max_retries: 3, backoff: 'exponential', initial_delay_ms: 100, max_delay_ms: 5000)
|
|
18
|
+
@max_retries = max_retries
|
|
19
|
+
@backoff = backoff
|
|
20
|
+
@initial_delay_ms = initial_delay_ms
|
|
21
|
+
@max_delay_ms = max_delay_ms
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.default
|
|
25
|
+
new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Per-instance HTTP config bound on K9oClient.
|
|
30
|
+
class HttpClientConfig
|
|
31
|
+
attr_reader :base_url, :api_key, :org_slug, :project_slug, :default_headers, :http_client, :retry_config,
|
|
32
|
+
:region_mutex
|
|
33
|
+
# Cached regional origin (see K9o::Region); nil until a definite answer.
|
|
34
|
+
attr_accessor :regional_origin
|
|
35
|
+
|
|
36
|
+
def initialize(base_url:, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
|
|
37
|
+
retry_config: nil)
|
|
38
|
+
@base_url = base_url
|
|
39
|
+
@api_key = api_key
|
|
40
|
+
@org_slug = org_slug
|
|
41
|
+
@project_slug = project_slug
|
|
42
|
+
@default_headers = default_headers.freeze
|
|
43
|
+
@http_client = http_client
|
|
44
|
+
@retry_config = retry_config
|
|
45
|
+
@region_mutex = Mutex.new
|
|
46
|
+
@regional_origin = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# HTTP transport using Net::HTTP stdlib.
|
|
51
|
+
module HttpTransport
|
|
52
|
+
RETRYABLE_METHODS = %w[GET HEAD OPTIONS DELETE].freeze
|
|
53
|
+
RETRYABLE_STATUS = [502, 503, 504, 429].freeze
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Issue a request with retry on transient failures. Idempotent methods
|
|
58
|
+
# are retried with backoff per config.retry_config;
|
|
59
|
+
# semantic / non-idempotent failures raise immediately.
|
|
60
|
+
#
|
|
61
|
+
# `headers:` carries per-call header overrides on top of
|
|
62
|
+
# `config.default_headers` — e.g. `client.kibo.vault.secrets.reveal`'s
|
|
63
|
+
# single-use `X-K9O-Reveal-Challenge` header, which is per-request rather
|
|
64
|
+
# than a client-wide default.
|
|
65
|
+
#
|
|
66
|
+
# `multipart:` is an array of parts (see K9o::Multipart) sent as a
|
|
67
|
+
# multipart/form-data body instead of JSON.
|
|
68
|
+
#
|
|
69
|
+
# `authenticated: false` sends NO credential: no api_key bearer, and any
|
|
70
|
+
# authorization / cookie header from `default_headers` or `headers:` is dropped.
|
|
71
|
+
def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
|
|
72
|
+
retry_with_backoff(method, config.retry_config || RetryConfig.default) do
|
|
73
|
+
request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
|
|
74
|
+
authenticated: authenticated, multipart: multipart)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Run a block with retry-with-backoff. Extracted so the retry policy is
|
|
79
|
+
# unit-testable without a live HTTP round-trip.
|
|
80
|
+
def retry_with_backoff(method, retry_config)
|
|
81
|
+
last_error = nil
|
|
82
|
+
attempt = 0
|
|
83
|
+
while attempt <= retry_config.max_retries
|
|
84
|
+
begin
|
|
85
|
+
return yield
|
|
86
|
+
rescue KError => e
|
|
87
|
+
last_error = e
|
|
88
|
+
raise e if attempt == retry_config.max_retries || !retryable?(e, method)
|
|
89
|
+
|
|
90
|
+
delay_ms = compute_delay_ms(retry_config, attempt)
|
|
91
|
+
sleep(delay_ms / 1000.0) if delay_ms > 0
|
|
92
|
+
attempt += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
raise last_error
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def retryable?(error, method)
|
|
99
|
+
return false unless RETRYABLE_METHODS.include?(method.to_s.upcase)
|
|
100
|
+
return true if error.code == 'K9O_UPSTREAM_UNAVAILABLE'
|
|
101
|
+
|
|
102
|
+
RETRYABLE_STATUS.include?(error.status)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def compute_delay_ms(config, attempt)
|
|
106
|
+
return 0 if config.backoff == 'none'
|
|
107
|
+
|
|
108
|
+
base = if config.backoff == 'linear'
|
|
109
|
+
config.initial_delay_ms * (attempt + 1)
|
|
110
|
+
else
|
|
111
|
+
config.initial_delay_ms * (2**attempt)
|
|
112
|
+
end
|
|
113
|
+
capped = [base, config.max_delay_ms].min
|
|
114
|
+
jitter = capped * 0.25 * (rand * 2 - 1)
|
|
115
|
+
[(capped + jitter).to_i, 0].max
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Single-attempt request. `request` wraps this in retry-with-backoff.
|
|
119
|
+
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
|
|
120
|
+
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
121
|
+
if query && !query.empty?
|
|
122
|
+
url += '?' + URI.encode_www_form(query)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
uri = URI.parse(url)
|
|
126
|
+
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
127
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
128
|
+
http.read_timeout = 30
|
|
129
|
+
|
|
130
|
+
req_class = case method.to_s.upcase
|
|
131
|
+
when 'GET' then ::Net::HTTP::Get
|
|
132
|
+
when 'POST' then ::Net::HTTP::Post
|
|
133
|
+
when 'PATCH' then ::Net::HTTP::Patch
|
|
134
|
+
when 'PUT' then ::Net::HTTP::Put
|
|
135
|
+
when 'DELETE' then ::Net::HTTP::Delete
|
|
136
|
+
else raise ArgumentError, "Unsupported method: #{method}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
req = req_class.new(uri.request_uri)
|
|
140
|
+
req['accept'] = 'application/json'
|
|
141
|
+
config.default_headers.each { |k, v| req[k] = v }
|
|
142
|
+
req['x-k9o-project-slug'] = config.project_slug if config.project_slug
|
|
143
|
+
headers&.each { |k, v| req[k] = v }
|
|
144
|
+
req['x-k9o-org-slug'] = config.org_slug if config.org_slug
|
|
145
|
+
if authenticated
|
|
146
|
+
req['authorization'] = "Bearer #{config.api_key}" if config.api_key
|
|
147
|
+
else
|
|
148
|
+
req.delete('authorization')
|
|
149
|
+
req.delete('cookie')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if multipart
|
|
153
|
+
# multipart/form-data: the encoder sets the boundary; never force JSON.
|
|
154
|
+
data, content_type = Multipart.encode(multipart)
|
|
155
|
+
req['content-type'] = content_type
|
|
156
|
+
req.body = data
|
|
157
|
+
elsif body
|
|
158
|
+
req['content-type'] = 'application/json'
|
|
159
|
+
req.body = JSON.dump(body)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
response = http.request(req)
|
|
164
|
+
rescue => e
|
|
165
|
+
raise KError.new(
|
|
166
|
+
code: 'K9O_UPSTREAM_UNAVAILABLE',
|
|
167
|
+
message: "Network error reaching #{url}: #{e.message}",
|
|
168
|
+
context: { url: url },
|
|
169
|
+
recommendation: 'Check connectivity to the gateway; retry with backoff if transient.'
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
status = response.code.to_i
|
|
174
|
+
|
|
175
|
+
return Envelope.new(data: nil, meta: Meta.new) if status == 204
|
|
176
|
+
|
|
177
|
+
begin
|
|
178
|
+
parsed = JSON.parse(response.body || '')
|
|
179
|
+
rescue JSON::ParserError => e
|
|
180
|
+
raise KError.new(
|
|
181
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
182
|
+
message: "Failed to parse JSON response from #{url}: #{e.message}",
|
|
183
|
+
context: { url: url, status: status },
|
|
184
|
+
status: status
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if status >= 200 && status < 300
|
|
189
|
+
unless parsed.is_a?(Hash) && parsed.key?('data') && parsed.key?('meta')
|
|
190
|
+
raise KError.new(
|
|
191
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
192
|
+
message: "Malformed envelope from #{url}: expected {data, meta}",
|
|
193
|
+
context: { url: url, status: status },
|
|
194
|
+
status: status
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
return Envelope.from_wire(parsed)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
201
|
+
raise KError.from_wire(parsed, status)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
raise KError.new(
|
|
205
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
206
|
+
message: "Upstream returned #{status} with non-KError body",
|
|
207
|
+
context: { url: url, status: status },
|
|
208
|
+
status: status
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Open an SSE stream and return an Enumerator yielding decoded `data:`
|
|
213
|
+
# payloads (parsed JSON). Used by Compute LLMs #invoke_stream
|
|
214
|
+
# and the Realtime #subscribe SSE transport. The [DONE]
|
|
215
|
+
# sentinel terminates the stream; non-2xx raises KError before any
|
|
216
|
+
# event is yielded.
|
|
217
|
+
#
|
|
218
|
+
# Per the Ruby async-iteration idiom is Enumerator — caller
|
|
219
|
+
# writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
|
|
220
|
+
def stream_sse(config:, method:, path:, body: nil, query: nil)
|
|
221
|
+
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
222
|
+
if query && !query.empty?
|
|
223
|
+
url += '?' + URI.encode_www_form(query)
|
|
224
|
+
end
|
|
225
|
+
uri = URI.parse(url)
|
|
226
|
+
|
|
227
|
+
Enumerator.new do |yielder|
|
|
228
|
+
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
229
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
230
|
+
http.read_timeout = 300
|
|
231
|
+
|
|
232
|
+
req_class = case method.to_s.upcase
|
|
233
|
+
when 'GET' then ::Net::HTTP::Get
|
|
234
|
+
when 'POST' then ::Net::HTTP::Post
|
|
235
|
+
else raise ArgumentError, "Unsupported stream method: #{method}"
|
|
236
|
+
end
|
|
237
|
+
req = req_class.new(uri.request_uri)
|
|
238
|
+
req['accept'] = 'text/event-stream'
|
|
239
|
+
config.default_headers.each { |k, v| req[k] = v }
|
|
240
|
+
req['x-k9o-project-slug'] = config.project_slug if config.project_slug
|
|
241
|
+
req['x-k9o-org-slug'] = config.org_slug if config.org_slug
|
|
242
|
+
req['authorization'] = "Bearer #{config.api_key}" if config.api_key
|
|
243
|
+
if body
|
|
244
|
+
req['content-type'] = 'application/json'
|
|
245
|
+
req.body = JSON.dump(body)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
begin
|
|
249
|
+
http.request(req) do |response|
|
|
250
|
+
status = response.code.to_i
|
|
251
|
+
if status < 200 || status >= 300
|
|
252
|
+
raw = +''
|
|
253
|
+
response.read_body { |c| raw << c }
|
|
254
|
+
parsed = begin
|
|
255
|
+
JSON.parse(raw)
|
|
256
|
+
rescue JSON::ParserError
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
260
|
+
raise KError.from_wire(parsed, status)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
raise KError.new(
|
|
264
|
+
code: 'K9O_INTERNAL_ERROR',
|
|
265
|
+
message: "Upstream returned #{status} opening stream",
|
|
266
|
+
context: { url: url, status: status },
|
|
267
|
+
status: status
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
buffer = +''
|
|
272
|
+
data_lines = []
|
|
273
|
+
flush = lambda do
|
|
274
|
+
return true if data_lines.empty?
|
|
275
|
+
|
|
276
|
+
payload = data_lines.join("\n")
|
|
277
|
+
data_lines = []
|
|
278
|
+
return false if payload == '[DONE]'
|
|
279
|
+
|
|
280
|
+
begin
|
|
281
|
+
yielder << JSON.parse(payload)
|
|
282
|
+
rescue JSON::ParserError => e
|
|
283
|
+
raise KError.new(code: 'K9O_INTERNAL_ERROR', message: "failed to decode stream chunk: #{e.message}")
|
|
284
|
+
end
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
response.read_body do |chunk|
|
|
289
|
+
buffer << chunk
|
|
290
|
+
while (idx = buffer.index("\n"))
|
|
291
|
+
line = buffer.slice!(0..idx).chomp
|
|
292
|
+
if line.empty?
|
|
293
|
+
break unless flush.call
|
|
294
|
+
elsif line.start_with?(':')
|
|
295
|
+
next
|
|
296
|
+
elsif line.start_with?('data:')
|
|
297
|
+
rest = line[5..]
|
|
298
|
+
rest = rest[1..] if rest.start_with?(' ')
|
|
299
|
+
data_lines << rest
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
flush.call
|
|
304
|
+
end
|
|
305
|
+
rescue KError
|
|
306
|
+
raise
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
raise KError.new(
|
|
309
|
+
code: 'K9O_UPSTREAM_UNAVAILABLE',
|
|
310
|
+
message: "Network error reaching #{url}: #{e.message}",
|
|
311
|
+
context: { url: url },
|
|
312
|
+
recommendation: 'Check connectivity to the gateway; retry with backoff if transient.'
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
data/lib/k9o/kerror.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# KError — the closed-taxonomy error raised by every k9o surface
|
|
5
|
+
# on non-2xx gateway responses or SDK-side validation failures.
|
|
6
|
+
#
|
|
7
|
+
# Ruby idiomatic shape: raise K9o::KError.
|
|
8
|
+
# Code-narrowing: callers rescue K9o::KError and switch on e.code.
|
|
9
|
+
class KError < StandardError
|
|
10
|
+
attr_reader :code, :context, :recommendation, :status, :request_id
|
|
11
|
+
|
|
12
|
+
def initialize(code:, message:, context: {}, recommendation: nil, status: nil, request_id: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@code = code
|
|
15
|
+
@message_text = message # avoid infinite recursion in to_s (Exception#message calls to_s)
|
|
16
|
+
@context = context.freeze
|
|
17
|
+
@recommendation = recommendation
|
|
18
|
+
@status = status
|
|
19
|
+
@request_id = request_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_recommendation(rec)
|
|
23
|
+
KError.new(
|
|
24
|
+
code: @code, message: @message_text,
|
|
25
|
+
context: @context, recommendation: rec,
|
|
26
|
+
status: @status, request_id: @request_id
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Override message to return the raw text (Exception's default calls to_s
|
|
31
|
+
# which causes infinite recursion since we override to_s).
|
|
32
|
+
def message
|
|
33
|
+
@message_text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s
|
|
37
|
+
@status ? "[#{@code}] #{@message_text} (status=#{@status})" : "[#{@code}] #{@message_text}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Rehydrate a wire KError payload into a typed K9o::KError.
|
|
41
|
+
def self.from_wire(wire, status)
|
|
42
|
+
KError.new(
|
|
43
|
+
code: wire['code'] || 'K9O_INTERNAL_ERROR',
|
|
44
|
+
message: wire['message'] || '',
|
|
45
|
+
context: wire['context'] || {},
|
|
46
|
+
recommendation: wire['recommendation'],
|
|
47
|
+
status: status,
|
|
48
|
+
request_id: wire['requestId'] || wire['request_id']
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/k9o/kibo.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'kerror'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# Zero-Knowledge BYOK/HYOK vault crypto — +K9o::Kibo.encrypt+ / +.decrypt+.
|
|
7
|
+
#
|
|
8
|
+
# Thin wrapper over the bound core (+kibo-kms-rest-ffi+, piece C) via uniffi.
|
|
9
|
+
# ZERO crypto here: every wrap/unwrap, envelope framing, posture-floor
|
|
10
|
+
# enforcement, and KMS round-trip lives in the audited Rust core.
|
|
11
|
+
#
|
|
12
|
+
# Ruby has no async runtime, so this calls the SYNC +zk_encrypt_blocking+ /
|
|
13
|
+
# +zk_decrypt_blocking+ exports — which drive the SAME canonical async op to
|
|
14
|
+
# completion on a process-wide tokio runtime inside the core (crypto-identical
|
|
15
|
+
# to the async exports the async-native languages use; W7C-confirmed neutral).
|
|
16
|
+
#
|
|
17
|
+
# The generated uniffi binding + native lib (+lib/k9o/_ffi/+) are build
|
|
18
|
+
# artifacts (CI-injected per platform; gitignored). On a platform/Ruby without
|
|
19
|
+
# the lib, +require+ still succeeds and +encrypt+/+decrypt+ raise a clear
|
|
20
|
+
# +K9o::KError+ — so loading +k9o+ never breaks for the rest of the SDK.
|
|
21
|
+
module Kibo
|
|
22
|
+
# Attempt to load the native binding; never raises at require time.
|
|
23
|
+
NATIVE_AVAILABLE =
|
|
24
|
+
begin
|
|
25
|
+
require_relative '_ffi/kibo_kms_rest_ffi'
|
|
26
|
+
true
|
|
27
|
+
rescue LoadError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Re-export the binding's input/output + enum types for ergonomic use
|
|
32
|
+
# (+K9o::Kibo::EncryptInput+, +K9o::Kibo::FfiPosture::BYOK+, …).
|
|
33
|
+
if NATIVE_AVAILABLE
|
|
34
|
+
EncryptInput = KiboKmsRestFfi::EncryptInput
|
|
35
|
+
DecryptInput = KiboKmsRestFfi::DecryptInput
|
|
36
|
+
EncryptOutput = KiboKmsRestFfi::EncryptOutput
|
|
37
|
+
DecryptOutput = KiboKmsRestFfi::DecryptOutput
|
|
38
|
+
FfiCredential = KiboKmsRestFfi::FfiCredential
|
|
39
|
+
FfiKeyRef = KiboKmsRestFfi::FfiKeyRef
|
|
40
|
+
FfiProviderConfig = KiboKmsRestFfi::FfiProviderConfig
|
|
41
|
+
FfiKvPair = KiboKmsRestFfi::FfiKvPair
|
|
42
|
+
FfiPosturePolicy = KiboKmsRestFfi::FfiPosturePolicy
|
|
43
|
+
FfiAllowedOracleModes = KiboKmsRestFfi::FfiAllowedOracleModes
|
|
44
|
+
FfiPosture = KiboKmsRestFfi::FfiPosture
|
|
45
|
+
FfiStrategy = KiboKmsRestFfi::FfiStrategy
|
|
46
|
+
FfiOracleMode = KiboKmsRestFfi::FfiOracleMode
|
|
47
|
+
FfiAttestationTier = KiboKmsRestFfi::FfiAttestationTier
|
|
48
|
+
FfiMinPosture = KiboKmsRestFfi::FfiMinPosture
|
|
49
|
+
KmsProvider = KiboKmsRestFfi::KmsProvider
|
|
50
|
+
|
|
51
|
+
# uniffi-ruby's error variants are +< StandardError+ namespaced under the
|
|
52
|
+
# +ZkFfiUniffiError+ module but do NOT +include+ it, so +rescue+-ing the
|
|
53
|
+
# module misses them — rescue the concrete variant classes instead.
|
|
54
|
+
FFI_ERRORS = [
|
|
55
|
+
KiboKmsRestFfi::ZkFfiUniffiError::Platform,
|
|
56
|
+
KiboKmsRestFfi::ZkFfiUniffiError::UpstreamKms,
|
|
57
|
+
KiboKmsRestFfi::ZkFfiUniffiError::PostureViolation
|
|
58
|
+
].freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Encrypt +input.plaintext+ via the customer KMS, client-side. Returns the
|
|
62
|
+
# opaque ZKFE envelope (+EncryptOutput+). Raises +K9o::KError+ on failure.
|
|
63
|
+
def self.encrypt(input)
|
|
64
|
+
ensure_native!
|
|
65
|
+
begin
|
|
66
|
+
KiboKmsRestFfi.zk_encrypt_blocking(input)
|
|
67
|
+
rescue *FFI_ERRORS => e
|
|
68
|
+
raise to_kerror(e)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Decrypt a ZKFE +input.envelope+. +strategy+, +attestation_tier+, and
|
|
73
|
+
# +encryption_context+ MUST match the encrypt-time values (AAD-bound).
|
|
74
|
+
def self.decrypt(input)
|
|
75
|
+
ensure_native!
|
|
76
|
+
begin
|
|
77
|
+
KiboKmsRestFfi.zk_decrypt_blocking(input)
|
|
78
|
+
rescue *FFI_ERRORS => e
|
|
79
|
+
raise to_kerror(e)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Guard: raise a clear KError if the native lib is absent (evaluated BEFORE
|
|
84
|
+
# any reference to the +KiboKmsRestFfi+ namespace, which may be undefined).
|
|
85
|
+
def self.ensure_native!
|
|
86
|
+
return if NATIVE_AVAILABLE
|
|
87
|
+
|
|
88
|
+
raise K9o::KError.new(
|
|
89
|
+
code: 'KIBO_NATIVE_UNAVAILABLE',
|
|
90
|
+
message: 'kibo ZK native library not available for this platform/Ruby; install the platform-specific k9o gem.'
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :ensure_native!
|
|
94
|
+
|
|
95
|
+
# Map the bound core's uniffi error -> +K9o::KError+, codes passed through.
|
|
96
|
+
def self.to_kerror(e)
|
|
97
|
+
case e
|
|
98
|
+
when KiboKmsRestFfi::ZkFfiUniffiError::PostureViolation
|
|
99
|
+
K9o::KError.new(
|
|
100
|
+
code: e.code, message: e.message,
|
|
101
|
+
context: { dimension: e.dimension, requested: e.requested, declared: e.declared }
|
|
102
|
+
)
|
|
103
|
+
when KiboKmsRestFfi::ZkFfiUniffiError::Platform
|
|
104
|
+
K9o::KError.new(code: e.code, message: e.message)
|
|
105
|
+
when KiboKmsRestFfi::ZkFfiUniffiError::UpstreamKms
|
|
106
|
+
# Customer's own KMS failed — no K9O_* code (outside the platform error namespace).
|
|
107
|
+
K9o::KError.new(code: 'UPSTREAM_KMS_ERROR', message: e.message)
|
|
108
|
+
else
|
|
109
|
+
K9o::KError.new(code: 'KIBO_FFI_ERROR', message: e.message)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :to_kerror
|
|
113
|
+
end
|
|
114
|
+
end
|