k9o 1.2.0 → 1.4.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/vault.rb CHANGED
@@ -4,45 +4,33 @@ require 'base64'
4
4
  require 'uri'
5
5
 
6
6
  module K9o
7
- # `k9o.kibo.vault.secrets.*` — the typed secrets surface
7
+ # `k9o.kibo.vault.secrets.*` - the typed secrets surface
8
8
  # (`services/gateway/src/routes/vault-secrets.ts`). Full family: list / get /
9
9
  # create / reveal_challenge / reveal / update / rotate / move / delete.
10
10
  #
11
- # Contract live-verified against the real staging gateway (not just the
12
- # route's OpenAPI schema, which is wrong/incomplete in several places noted
13
- # below) by the `@k9o/kibo` TS reference build — this wrapper mirrors that
14
- # verified behavior exactly:
15
- #
16
- # - Every operation on a NAMED secret (everything except `list`) REQUIRES
17
- # `region` on the query string. Omitting it fails with
18
- # K9O_INVALID_FIELD_VALUE ("this request requires an authoritative
19
- # region"). NOT documented in the route's OpenAPI schema. `list` is the
20
- # one exception (verified it does NOT need `region`).
21
- # - The wire `value` field is base64 in BOTH directions, contradicting the
22
- # OpenAPI's plain-language "the plaintext to seal" description (reads
23
- # like it should be raw). A raw non-base64 `value` on create/update is
24
- # rejected as unparseable. `create`/`update` take plaintext as a normal
25
- # Ruby String and base64-encode it here; `reveal` base64-decodes the
26
- # wire `value` back to plaintext before returning it — callers never
27
- # see base64 on either side.
28
- # - `update`/`rotate`/`move` responses are LIVE-VERIFIED NARROWER than (or
29
- # entirely undocumented vs.) the OpenAPI's claimed `SecretSummary` — see
30
- # the comment on each method below.
11
+ # - `region` is OPTIONAL on every named-secret operation: the gateway
12
+ # ignores any caller-supplied region and uses its own. It is sent on the
13
+ # query string only when the caller passes one (never by `get`, which
14
+ # reads the list route).
15
+ # - The wire `value` field is base64 in BOTH directions. `create`/`update`
16
+ # take plaintext as a normal Ruby String and base64-encode it here;
17
+ # `reveal` base64-decodes the wire `value` back to plaintext before
18
+ # returning it - callers never see base64 on either side.
19
+ # - `update`/`rotate`/`move` responses are narrower than a full summary -
20
+ # see the comment on each method below.
31
21
  #
32
22
  # `data` is returned as the raw wire Hash (camelCase string keys), matching
33
- # this package's existing convention (Projects/Policies/Studio/etc. all
34
- # return `envelope.data` untransformed — there is no generic
35
- # camelCase<->snake_case response transform anywhere in this gem); `reveal`
36
- # is the one exception, where the `value` key is base64-decoded in place
37
- # (mirroring the TS reference's own `value`-only transform).
23
+ # this package's existing convention; `reveal` is the one exception, where
24
+ # the `value` key is base64-decoded in place.
38
25
  class VaultSecrets
26
+ MAX_GET_PAGES = 20
27
+
39
28
  def initialize(config)
40
29
  @config = config
41
30
  end
42
31
 
43
32
  # List secrets (metadata only — never lists ciphertext).
44
33
  # `GET /@<project_slug>/vault/secrets`.
45
- # The one secrets operation that does NOT need `region` (live-verified).
46
34
  def list(prefix: nil, cursor: nil, limit: nil)
47
35
  slug = require_project_slug
48
36
  q = {}
@@ -56,40 +44,42 @@ module K9o
56
44
  )
57
45
  end
58
46
 
59
- # Read a secret's metadata (never its value — use `reveal_challenge` +
47
+ # Read a secret's metadata (never its value - use `reveal_challenge` +
60
48
  # `reveal` for that).
61
- # `GET /@<project_slug>/vault/secrets/<name>?region=<region>`.
62
49
  #
63
- # KNOWN LIVE BUG (staging, 2026-09-05): this call currently fails with
64
- # K9O_REVEAL_CHALLENGE_REQUIRED on every attempt — the gateway does not
65
- # honor its own documented "no X-K9O-Reveal-Challenge header => return
66
- # metadata" behavior; it demands a challenge unconditionally. Implemented
67
- # per the documented contract anyway (this is a backend defect to flag,
68
- # not something to design around) — `list(prefix: exact_name)` is the
69
- # current working alternative for a single secret's metadata until the
70
- # gateway side is fixed.
71
- def get(name, region:)
72
- slug = require_project_slug
73
- HttpTransport.request(
74
- config: @config, method: 'GET',
75
- path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
76
- query: { 'region' => region }
50
+ # `GET /@<project>/vault/secrets/<name>` is a reveal and always demands an
51
+ # `X-K9O-Reveal-Challenge` header, so metadata is read from the list route:
52
+ # list with the exact name as `prefix` (limit 100, following `nextCursor`
53
+ # for up to MAX_GET_PAGES pages) and return the secret whose `name` equals
54
+ # the argument. Raises `KError` `K9O_NOT_FOUND` (status 404) when none
55
+ # matches. `region` is accepted for symmetry and not sent.
56
+ def get(name, region: nil) # rubocop:disable Lint/UnusedMethodArgument
57
+ cursor = nil
58
+ MAX_GET_PAGES.times do
59
+ env = list(prefix: name, cursor: cursor, limit: 100)
60
+ data = env.data || {}
61
+ secret = (data['secrets'] || []).find { |s| s['name'] == name }
62
+ return Envelope.new(data: secret, meta: env.meta) if secret
63
+
64
+ cursor = data['nextCursor']
65
+ break if cursor.nil? || cursor.to_s.empty?
66
+ end
67
+ raise KError.new(
68
+ code: 'K9O_NOT_FOUND',
69
+ message: "No secret named '#{name}' in this project.",
70
+ context: { 'name' => name },
71
+ status: 404
77
72
  )
78
73
  end
79
74
 
80
75
  # Create a secret under the caller's own auth.
81
- # `POST /@<project_slug>/vault/secrets/<name>?region=<region>` → the full
82
- # `sec_<base62>` summary.
83
- #
84
- # `region` scopes the vault WRITE itself (residency guard) and is
85
- # REQUIRED — live-verified against staging, not documented in the route's
86
- # OpenAPI schema.
76
+ # `POST /@<project_slug>/vault/secrets/<name>` (+ `region` when given) -> the
77
+ # full `sec_<base62>` summary.
87
78
  #
88
79
  # `visibility` (`{'kind' => 'private'}` or
89
80
  # `{'kind' => 'shared', 'principals' => [...], 'roles' => [...]}`) and
90
- # `metadata` (`Hash[String, String]`) are passed through verbatim — their
91
- # own field names are already single words, so no wire-casing applies.
92
- def create(name:, value:, region:, visibility: nil, metadata: nil)
81
+ # `metadata` (`Hash[String, String]`) are passed through verbatim.
82
+ def create(name:, value:, region: nil, visibility: nil, metadata: nil)
93
83
  slug = require_project_slug
94
84
  body = { 'value' => Base64.strict_encode64(value) }
95
85
  body['visibility'] = visibility if visibility
@@ -97,34 +87,34 @@ module K9o
97
87
  HttpTransport.request(
98
88
  config: @config, method: 'POST',
99
89
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
100
- query: { 'region' => region },
90
+ query: region_query(region),
101
91
  body: body
102
92
  )
103
93
  end
104
94
 
105
95
  # Mint a single-use reveal challenge — present it as
106
96
  # `X-K9O-Reveal-Challenge` on the immediately-following `reveal` call.
107
- # `POST .../<name>?op=reveal-challenge&region=<region>`.
108
- def reveal_challenge(name, region:)
97
+ # `POST .../<name>?op=reveal-challenge`.
98
+ def reveal_challenge(name, region: nil)
109
99
  slug = require_project_slug
110
100
  HttpTransport.request(
111
101
  config: @config, method: 'POST',
112
102
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
113
- query: { 'op' => 'reveal-challenge', 'region' => region }
103
+ query: region_query(region, 'op' => 'reveal-challenge')
114
104
  )
115
105
  end
116
106
 
117
107
  # Reveal a secret's plaintext value using a single-use challenge from
118
- # `reveal_challenge`. `GET .../<name>?region=<region>` with
108
+ # `reveal_challenge`. `GET .../<name>` with
119
109
  # `X-K9O-Reveal-Challenge`. Base64-decodes the wire's `value` back to
120
- # plaintext (live-verified: the reveal response's `value` is base64,
121
- # symmetric with `create`'s encode) before returning it to the caller.
122
- def reveal(name, region:, challenge:)
110
+ # plaintext (the reveal response's `value` is base64, symmetric with
111
+ # `create`'s encode) before returning it to the caller.
112
+ def reveal(name, challenge:, region: nil)
123
113
  slug = require_project_slug
124
114
  envelope = HttpTransport.request(
125
115
  config: @config, method: 'GET',
126
116
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
127
- query: { 'region' => region },
117
+ query: region_query(region),
128
118
  headers: { 'X-K9O-Reveal-Challenge' => challenge }
129
119
  )
130
120
  data = envelope.data
@@ -135,67 +125,73 @@ module K9o
135
125
  end
136
126
 
137
127
  # Update a secret's value (bumps version) — owner-only.
138
- # `PUT .../<name>?region=<region>`.
128
+ # `PUT .../<name>`.
139
129
  #
140
- # LIVE-VERIFIED NARROWER than documented: the gateway returns only
130
+ # Narrower than a full summary: the gateway returns only
141
131
  # `{id, name, version}` on a real PUT, not the full summary the OpenAPI
142
132
  # schema claims — call `get` afterward if the full summary is needed.
143
- def update(name, region:, value:, metadata: nil)
133
+ def update(name, value:, region: nil, metadata: nil)
144
134
  slug = require_project_slug
145
135
  body = { 'value' => Base64.strict_encode64(value) }
146
136
  body['metadata'] = metadata if metadata
147
137
  HttpTransport.request(
148
138
  config: @config, method: 'PUT',
149
139
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
150
- query: { 'region' => region },
140
+ query: region_query(region),
151
141
  body: body
152
142
  )
153
143
  end
154
144
 
155
145
  # Rotate a secret's wrapping key (re-wraps under a fresh `kid`; content
156
- # unchanged) — owner-only. `POST .../<name>?op=rotate&region=<region>`.
146
+ # unchanged) — owner-only. `POST .../<name>?op=rotate`.
157
147
  #
158
- # LIVE-VERIFIED undocumented shape: `{id, name, version, rotatedAt,
148
+ # Response shape: `{id, name, version, rotatedAt,
159
149
  # ledgerSequenceNumber}` — the route's OpenAPI schema doesn't document
160
150
  # this response at all.
161
- def rotate(name, region:)
151
+ def rotate(name, region: nil)
162
152
  slug = require_project_slug
163
153
  HttpTransport.request(
164
154
  config: @config, method: 'POST',
165
155
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
166
- query: { 'op' => 'rotate', 'region' => region }
156
+ query: region_query(region, 'op' => 'rotate')
167
157
  )
168
158
  end
169
159
 
170
160
  # Move (rename) a secret's hierarchical path — owner-only.
171
- # `POST .../<name>?op=move&region=<region>` with `{newPath}`.
161
+ # `POST .../<name>?op=move` with `{newPath}`.
172
162
  #
173
- # LIVE-VERIFIED undocumented shape: `{id, name, event}` (observed
163
+ # Response shape: `{id, name, event}` (observed
174
164
  # `event: "SECRET_PATH_CHANGED"`; treat as a plain String, not a closed
175
165
  # enum — only one sample was observed).
176
- def move(name, region:, new_path:)
166
+ def move(name, new_path:, region: nil)
177
167
  slug = require_project_slug
178
168
  HttpTransport.request(
179
169
  config: @config, method: 'POST',
180
170
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
181
- query: { 'op' => 'move', 'region' => region },
171
+ query: region_query(region, 'op' => 'move'),
182
172
  body: { 'newPath' => new_path }
183
173
  )
184
174
  end
185
175
 
186
- # Retire a secret — owner-only. `DELETE .../<name>?region=<region>` →
187
- # `204 No Content` (HttpTransport already maps that to `data: nil`).
188
- def delete(name, region:)
176
+ # Retire a secret - owner-only. `DELETE .../<name>`; any 2xx is success
177
+ # (204 No Content maps to `data: nil`).
178
+ def delete(name, region: nil)
189
179
  slug = require_project_slug
190
180
  HttpTransport.request(
191
181
  config: @config, method: 'DELETE',
192
182
  path: UrlGrammar.project_path(slug, "vault/secrets/#{enc(name)}"),
193
- query: { 'region' => region }
183
+ query: region_query(region)
194
184
  )
195
185
  end
196
186
 
197
187
  private
198
188
 
189
+ def region_query(region, extra = {})
190
+ q = extra.dup
191
+ q['region'] = region if region
192
+ q
193
+ end
194
+
199
195
  def require_project_slug
200
196
  Studio.require_project_slug(@config, 'kibo.vault.secrets')
201
197
  end
@@ -206,8 +202,8 @@ module K9o
206
202
  end
207
203
 
208
204
  # `k9o.kibo.vault.*` — the Kibo vault surface (member-plane, project-scoped).
209
- # Currently just `secrets`; a `zk_deks` accessor
210
- # (F.5, `POST vault/zk-deks` / `GET vault/zk-deks/<id>`) would
205
+ # Currently just `secrets` ; a `zk_deks` accessor
206
+ # (`POST vault/zk-deks` / `GET vault/zk-deks/<id>`) would
211
207
  # nest here alongside it if/when that surface is built for Ruby — out of
212
208
  # scope for this wrapper, which only covers the vault-secrets audit gap.
213
209
  class KiboVault
@@ -220,7 +216,7 @@ module K9o
220
216
 
221
217
  # `k9o.kibo.*` — per-client namespace root for the Kibo vault REST surface.
222
218
  # Distinct from the module-level `K9o::Kibo` (native ZK `encrypt`/`decrypt`
223
- # FFI bindings, /, called directly as
219
+ # FFI bindings, called directly as
224
220
  # `K9o::Kibo.encrypt`/`.decrypt` since that surface needs no per-client HTTP
225
221
  # config): this class exists so `client.kibo.vault.secrets.*` is reachable
226
222
  # per client instance, mirroring the TS reference's
data/lib/k9o/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- VERSION = '1.2.0'
4
+ VERSION = '1.4.0'
5
5
  end
data/lib/k9o.rb CHANGED
@@ -29,6 +29,7 @@ require_relative 'k9o/url_grammar'
29
29
  require_relative 'k9o/region'
30
30
  require_relative 'k9o/http_transport'
31
31
  require_relative 'k9o/data'
32
+ require_relative 'k9o/query'
32
33
  require_relative 'k9o/projects'
33
34
  require_relative 'k9o/gate'
34
35
  require_relative 'k9o/studio'
@@ -36,6 +37,9 @@ require_relative 'k9o/compute'
36
37
  require_relative 'k9o/ai'
37
38
  require_relative 'k9o/storage'
38
39
  require_relative 'k9o/realtime'
40
+ require_relative 'k9o/streams'
41
+ require_relative 'k9o/approvals'
42
+ require_relative 'k9o/schema'
39
43
  require_relative 'k9o/migrations'
40
44
  require_relative 'k9o/functions'
41
45
  require_relative 'k9o/policies'
data/sig/k9o/ai.rbs CHANGED
@@ -8,6 +8,10 @@ module K9o
8
8
  # { "embeddings" => Array[Array[Float]], "model" => String, "dimensions" => Integer }.
9
9
  def initialize: (untyped config) -> void
10
10
  def embed: (input: (String | Array[String]), ?model: String?) -> Envelope
11
+
12
+ # Only the fields given are sent. Returns an Envelope whose `data` is
13
+ # { "modelId" => String, "content" => String, "finishReason" => String, "usage" => Hash, "usageType" => String }.
14
+ def generate: (String prompt, ?model: String?, ?max_tokens: Integer?, ?temperature: Numeric?, ?usage_type: String?) -> Envelope
11
15
  end
12
16
 
13
17
  class K9oClient
@@ -0,0 +1,49 @@
1
+ # RBS signatures for `client.approvals`, `client.approval_policies` and `client.schema`.
2
+
3
+ module K9o
4
+ module ApprovalChecks
5
+ def self.invalid: (String message, ?Hash[Symbol, untyped] context) -> KError
6
+ def self.check_limit: (untyped limit) -> void
7
+ def self.id_segment: (untyped value, String what) -> String
8
+ def self.reason!: (untyped reason) -> String
9
+ def self.predicate!: (untyped value, String field) -> Hash[untyped, untyped]
10
+ def self.enum!: (untyped value, Array[String] allowed, String field) -> String
11
+ end
12
+
13
+ class Approvals
14
+ def initialize: (untyped config) -> void
15
+ # Not paginated: only `limit` (1 to 200, default 50) caps the result.
16
+ def list: (?status: (String | Symbol)?, ?limit: Integer?) -> Envelope
17
+ def get: (String id) -> Envelope
18
+ # `status` on the answer stays "pending_approval" while more approvers are needed.
19
+ def approve: (String id, ?reason: String?) -> Envelope
20
+ def reject: (String id, reason: String) -> Envelope
21
+ # Failed rows are skipped without a report: compare `affectedIds` with what you intended.
22
+ def bulk: (action: (String | Symbol), criteria: Hash[(String | Symbol), untyped], ?reason: String?) -> Envelope
23
+ end
24
+
25
+ class ApprovalPolicies
26
+ COLLECTION_RE: Regexp
27
+
28
+ def initialize: (untyped config) -> void
29
+ def list: () -> Envelope
30
+ def create: (collection: String, operation: (String | Symbol), action: (String | Symbol), predicate: Hash[(String | Symbol), untyped]) -> Envelope
31
+ def delete: (String id) -> Envelope
32
+ end
33
+
34
+ class SchemaCollections
35
+ def initialize: (untyped config) -> void
36
+ def list: (?limit: Integer?, ?cursor: String?) -> Envelope
37
+ end
38
+
39
+ class Schema
40
+ attr_reader collections: SchemaCollections
41
+ def initialize: (untyped config) -> void
42
+ end
43
+
44
+ class K9oClient
45
+ attr_reader approvals: Approvals
46
+ attr_reader approval_policies: ApprovalPolicies
47
+ attr_reader schema: Schema
48
+ end
49
+ end
data/sig/k9o/gate.rbs CHANGED
@@ -1,11 +1,12 @@
1
- # RBS signatures for the `client.gate` namespace (additions).
1
+ # RBS signatures for the `client.gate` namespace.
2
2
 
3
3
  module K9o
4
4
  class Gate
5
- # GET /@<slug>/gate/hosted. Unauthenticated (no credential sent).
6
- # Returns an Envelope whose `data` is { "signInUrl" => String, "rpId" => String }.
7
- # Raises KError (K9O_MISSING_PROJECT_CONTEXT) without a project slug; the
8
- # uniform 404 K9O_PROJECT_NOT_FOUND for unknown org / deleted / non-Gate project.
9
- def hosted: () -> Envelope
5
+ def sign_up: (email: String, password: String, ?display_name: String?) -> Envelope
6
+ def sign_in: (email: String, password: String) -> Envelope
7
+ # The refresh token is required: the gateway answers 400 without it.
8
+ def sign_out: (refresh_token: String) -> Envelope
9
+ def whoami: () -> Envelope
10
+ def refresh_token: (refresh_token: String) -> Envelope
10
11
  end
11
12
  end
data/sig/k9o/query.rbs ADDED
@@ -0,0 +1,26 @@
1
+ # RBS signatures for `client.query`, `client.search` and the select builder's search methods.
2
+
3
+ module K9o
4
+ module Query
5
+ type value = String | Integer | Float | bool | nil
6
+
7
+ # Rewrites :name placeholders to $n; returns [sql, positional values].
8
+ def self.translate_named_params: (String sql, Hash[(String | Symbol), untyped] named) -> [String, Array[value]]
9
+ end
10
+
11
+ module QueryMethods
12
+ # An Array binds positionally to $1, $2, ...; a Hash binds :name placeholders. `data` is the array of rows.
13
+ def query: (String sql, ?(Array[untyped] | Hash[(String | Symbol), untyped])? params) -> Envelope
14
+
15
+ def search: (String collection, column: String, q: String, ?language: String?, ?type: String?, ?limit: Integer?, ?cursor: String?) -> Envelope
16
+ end
17
+
18
+ class K9oClient
19
+ include QueryMethods
20
+ end
21
+
22
+ class SelectBuilder
23
+ def text_search: (String column, String query, ?language: String, ?type: (String | Symbol)) -> self
24
+ def vector_search: (String column, Array[Numeric] embedding, ?metric: (String | Symbol), ?limit: Integer?) -> self
25
+ end
26
+ end
data/sig/k9o/region.rbs CHANGED
@@ -2,9 +2,6 @@
2
2
 
3
3
  module K9o
4
4
  module Region
5
- REGION_FREE_ORG_HOST: Regexp
6
- REGION_CODE: Regexp
7
-
8
5
  # The regional origin `location` may name for `host`, or nil.
9
6
  def self.origin_from_location: (String host, String location, String base) -> String?
10
7
  # The origin requests go to: cached regional origin, else the configured base URL. Never raises.
@@ -17,3 +14,17 @@ module K9o
17
14
  attr_accessor regional_origin: String?
18
15
  end
19
16
  end
17
+
18
+ module K9o
19
+ module UrlGrammar
20
+ # The org, family, region and apex of a gateway host (`acme.api-de01.corp.internal`), or nil for any other shape.
21
+ class ApiHost < Struct[untyped]
22
+ attr_reader org_slug: String?
23
+ attr_reader family: String
24
+ attr_reader region: String?
25
+ attr_reader apex: String
26
+ end
27
+
28
+ def self.parse_api_host: (String host) -> ApiHost?
29
+ end
30
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: k9o
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kilimanjaro
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-25 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -39,6 +39,7 @@ files:
39
39
  - k9o.gemspec
40
40
  - lib/k9o.rb
41
41
  - lib/k9o/ai.rb
42
+ - lib/k9o/approvals.rb
42
43
  - lib/k9o/audit.rb
43
44
  - lib/k9o/barbican.rb
44
45
  - lib/k9o/client.rb
@@ -57,16 +58,21 @@ files:
57
58
  - lib/k9o/orgs.rb
58
59
  - lib/k9o/policies.rb
59
60
  - lib/k9o/projects.rb
61
+ - lib/k9o/query.rb
60
62
  - lib/k9o/realtime.rb
61
63
  - lib/k9o/region.rb
64
+ - lib/k9o/schema.rb
62
65
  - lib/k9o/storage.rb
66
+ - lib/k9o/streams.rb
63
67
  - lib/k9o/studio.rb
64
68
  - lib/k9o/url_grammar.rb
65
69
  - lib/k9o/vault.rb
66
70
  - lib/k9o/version.rb
67
71
  - sig/k9o/ai.rbs
72
+ - sig/k9o/approvals.rbs
68
73
  - sig/k9o/gate.rbs
69
74
  - sig/k9o/migrations.rbs
75
+ - sig/k9o/query.rbs
70
76
  - sig/k9o/region.rbs
71
77
  - sig/k9o/storage.rbs
72
78
  homepage: https://kilimanjaro.dev