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.
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