k9o 1.2.0 → 1.3.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/realtime.rb CHANGED
@@ -1,54 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- # Realtime Subscriptions namespace (ALL-LIVE primitive).
4
+ # Realtime Subscriptions namespace.
5
5
  #
6
- # Ruby idiom: #subscribe returns an Enumerator iterated via
7
- # `.each`. Per + the SDK-abstracted-primitive discipline the
8
- # SDK picks the transport; SPLIT (polling) is the internal fallback. The
9
- # Net::HTTP-based Ruby SDK uses SSE primary (no stdlib WebSocket);
10
- # #subscribe_poll exposes the poll fallback. #replay is one-shot catch-up.
6
+ # Transport: SSE over GET /@<slug>/realtime/subscribe. The gateway serves no WebSocket upgrade and no durable
7
+ # replay, so neither is offered; after an `event: resync` frame the caller re-reads current state from the data
8
+ # plane and resubscribes. #subscribe returns an Enumerator iterated via `.each`.
11
9
  class Realtime
12
10
  def initialize(config)
13
11
  @config = config
14
12
  end
15
13
 
16
- # Subscribe via SSE transport (primary); returns an
17
- # Enumerator yielding event hashes.
14
+ # Subscribe via SSE; returns an Enumerator yielding event hashes
15
+ # { 'topic', 'eventType', 'payload', 'occurredAt' } (the gateway's spelling). Control frames such as `resync`
16
+ # carry no +eventType+ and are skipped.
18
17
  def subscribe(topic, filter: nil)
19
18
  slug = Studio.require_project_slug(@config, 'realtime')
20
19
  q = { 'topic' => topic }
21
20
  q['filter'] = JSON.dump(filter) if filter
22
- HttpTransport.stream_sse(config: @config, method: 'GET',
23
- path: UrlGrammar.project_path(slug, 'realtime/subscribe'), query: q)
24
- end
25
-
26
- # One-shot historical replay (capped 10k server-side).
27
- def replay(topic, from_sequence:, filter: nil, until_sequence: nil, limit: nil)
28
- slug = Studio.require_project_slug(@config, 'realtime')
29
- unless from_sequence.is_a?(Integer) && from_sequence >= 0
30
- raise KError.new(
31
- code: 'K9O_INVALID_FILTER',
32
- message: 'replay from_sequence must be a non-negative integer',
33
- context: { fromSequence: from_sequence },
34
- recommendation: 'Pass the highest sequence number you have already observed (or 0 to replay from the start).'
35
- )
21
+ frames = HttpTransport.stream_sse(config: @config, method: 'GET',
22
+ path: UrlGrammar.project_path(slug, 'realtime/subscribe'), query: q)
23
+ Enumerator.new do |yielder|
24
+ frames.each do |event|
25
+ yielder << event if event.is_a?(Hash) && event['eventType'].is_a?(String)
26
+ end
36
27
  end
37
- body = { 'topic' => topic, 'fromSequence' => from_sequence }
38
- body['filter'] = filter if filter
39
- body['untilSequence'] = until_sequence if until_sequence
40
- body['limit'] = limit if limit
41
- HttpTransport.request(config: @config, method: 'POST',
42
- path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
43
- end
44
-
45
- # Poll-transport fallback (SDK-internal SPLIT). Single batch
46
- # via replay endpoint; caller loops tracking highest observed sequence.
47
- def subscribe_poll(topic, from_sequence:, filter: nil)
48
- replay(topic, from_sequence: from_sequence, filter: filter)
49
28
  end
50
29
 
51
- # Typed builders for the 6 closed topic families.
52
30
  module Topic
53
31
  module_function
54
32
 
data/lib/k9o/region.rb CHANGED
@@ -6,30 +6,32 @@ require 'uri'
6
6
  module K9o
7
7
  # Regional host resolution.
8
8
  #
9
- # The region-free org host `<org>.api[-staging].k9o.io` redirects GET/HEAD
10
- # only and REFUSES a request carrying a body / Authorization / cookie, so
11
- # credentials must go to `<org>.api[-staging]-<rgn>.k9o.io`. The SDK learns
12
- # the region with ONE unauthenticated `HEAD https://<org>.api[-staging].k9o.io/`
13
- # (Net::HTTP never follows redirects, so the 308 is read, not followed),
9
+ # A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`)
10
+ # redirects GET/HEAD only and REFUSES a request carrying a body /
11
+ # Authorization / cookie, so credentials must go to `<org>.api-<rgn>.<rest>`.
12
+ # The SDK learns the region with ONE unauthenticated HEAD to the configured
13
+ # host (Net::HTTP never follows redirects, so the 308 is read, not followed),
14
14
  # takes the 308 `Location`, and caches the regional origin per client config.
15
15
  #
16
- # - Only a region-free org host is ever probed; any other base URL is used
17
- # exactly as given.
16
+ # Nothing here knows an apex: the host comes from the configured base URL and
17
+ # `<rest>` is whatever follows the gateway label, so public-cloud,
18
+ # internal-apex, air-gapped and localhost deployments all work.
19
+ #
20
+ # - Only a region-free org host (see UrlGrammar.parse_api_host) is ever
21
+ # probed; any other base URL is used exactly as given.
18
22
  # - The probe carries no Authorization, cookie or default header.
19
23
  # - The Location is trusted only if https, no userinfo, no port, and host is
20
- # exactly `<same org>.<same apex family>-<rgn>.k9o.io` (rgn = [a-z]{2}\d{2}).
24
+ # exactly the same org and the same remainder with a region code
25
+ # ([a-z]{2}\d{2}) appended to the gateway label.
21
26
  # - Any other status or failure keeps the configured base URL. A definite
22
27
  # answer is cached; a network failure of the probe is not.
23
28
  module Region
24
- REGION_FREE_ORG_HOST = /\A([a-z][a-z0-9-]{1,62})\.(api|api-staging)\.k9o\.io\z/.freeze
25
- REGION_CODE = /\A[a-z]{2}\d{2}\z/.freeze
26
-
27
29
  module_function
28
30
 
29
31
  # The regional origin `location` may name for `host`, or nil. Pure.
30
32
  def origin_from_location(host, location, base)
31
- m = REGION_FREE_ORG_HOST.match(host)
32
- return nil if m.nil?
33
+ from = UrlGrammar.parse_api_host(host)
34
+ return nil if from.nil? || from.org_slug.nil? || !from.region.nil?
33
35
 
34
36
  begin
35
37
  u = URI.join(base.sub(%r{/+\z}, '') + '/', location)
@@ -40,11 +42,10 @@ module K9o
40
42
  return nil unless u.userinfo.nil? && u.user.nil? && u.password.nil?
41
43
  return nil unless u.port == u.default_port
42
44
 
43
- org = Regexp.escape(m[1])
44
- apex = Regexp.escape(m[2])
45
45
  h = u.host.to_s.downcase
46
- r = /\A#{org}\.#{apex}-([a-z0-9]+)\.k9o\.io\z/.match(h)
47
- return nil if r.nil? || !REGION_CODE.match?(r[1])
46
+ to = UrlGrammar.parse_api_host(h)
47
+ return nil if to.nil? || to.region.nil?
48
+ return nil unless to.org_slug == from.org_slug && to.family == from.family && to.apex == from.apex
48
49
 
49
50
  "https://#{h}"
50
51
  end
@@ -70,7 +71,8 @@ module K9o
70
71
  rescue URI::Error
71
72
  return [configured, true]
72
73
  end
73
- return [configured, true] unless REGION_FREE_ORG_HOST.match?(host)
74
+ parsed_host = UrlGrammar.parse_api_host(host)
75
+ return [configured, true] if parsed_host.nil? || parsed_host.org_slug.nil? || !parsed_host.region.nil?
74
76
 
75
77
  begin
76
78
  probe_uri = URI.parse("#{configured.sub(%r{/+\z}, '')}/")
data/lib/k9o/studio.rb CHANGED
@@ -25,7 +25,7 @@ module K9o
25
25
  slug
26
26
  end
27
27
 
28
- # k9o.studio.workflows.*.
28
+ # k9o.studio.workflows.*
29
29
  class Workflows
30
30
  attr_reader :runs
31
31
 
@@ -34,17 +34,19 @@ module K9o
34
34
  @runs = Runs.new(config)
35
35
  end
36
36
 
37
- def list(status: nil, trigger_type: nil, limit: nil, cursor: nil)
37
+ # GET /@<slug>/workflows — the route filters by +status+ and pages with +limit+ / +cursor+.
38
+ def list(status: nil, limit: nil, cursor: nil)
38
39
  slug = Studio.require_project_slug(@config, 'studio.workflows')
39
40
  q = {}
40
41
  q['status'] = status if status
41
- q['triggerType'] = trigger_type if trigger_type
42
42
  q['limit'] = limit if limit
43
43
  q['cursor'] = cursor if cursor
44
44
  HttpTransport.request(config: @config, method: 'GET',
45
45
  path: UrlGrammar.project_path(slug, 'workflows'), query: q)
46
46
  end
47
47
 
48
+ # POST /@<slug>/workflows. +workflow+ is the gateway's body: 'name', 'triggerType', 'triggerConfig',
49
+ # 'actions' (each { 'index', 'action_type', 'config', ... }), optional 'description', 'status', 'rbacBinding'.
48
50
  def create(workflow)
49
51
  slug = Studio.require_project_slug(@config, 'studio.workflows')
50
52
  HttpTransport.request(config: @config, method: 'POST',
@@ -69,12 +71,12 @@ module K9o
69
71
  path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}"))
70
72
  end
71
73
 
72
- # Manual-trigger a run (trigger_type=manual only).
74
+ # Manual-trigger a run (manual workflows only): POST /@<slug>/workflows/<id>/runs { triggerContext }.
73
75
  def trigger(workflow_id, trigger_context = {})
74
76
  slug = Studio.require_project_slug(@config, 'studio.workflows')
75
77
  HttpTransport.request(config: @config, method: 'POST',
76
78
  path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}/runs"),
77
- body: trigger_context)
79
+ body: { 'triggerContext' => trigger_context })
78
80
  end
79
81
 
80
82
  private
@@ -83,15 +85,18 @@ module K9o
83
85
  ::URI.encode_www_form_component(s)
84
86
  end
85
87
 
86
- # k9o.studio.workflows.runs.*.
88
+ # k9o.studio.workflows.runs.*
87
89
  class Runs
88
90
  def initialize(config)
89
91
  @config = config
90
92
  end
91
93
 
92
- def list(workflow_id, status: nil)
94
+ # GET /@<slug>/workflows/<id>/runs — pages with +limit+ / +cursor+ (the route has no status filter).
95
+ def list(workflow_id, limit: nil, cursor: nil)
93
96
  slug = Studio.require_project_slug(@config, 'studio.workflows')
94
- q = status ? { 'status' => status } : nil
97
+ q = {}
98
+ q['limit'] = limit if limit
99
+ q['cursor'] = cursor if cursor
95
100
  HttpTransport.request(config: @config, method: 'GET',
96
101
  path: UrlGrammar.project_path(slug, "workflows/#{enc(workflow_id)}/runs"), query: q)
97
102
  end
@@ -116,15 +121,20 @@ module K9o
116
121
  end
117
122
  end
118
123
 
119
- # k9o.studio.dashboards.*.
124
+ # k9o.studio.dashboards.* — widget fields on the wire are snake_case ('widget_type', 'data_source', 'config').
120
125
  class Dashboards
121
126
  def initialize(config)
122
127
  @config = config
123
128
  end
124
129
 
125
- def list(status: nil)
130
+ # GET /@<slug>/dashboards — filters: +status+, +pinned_only+; pages with +limit+ / +cursor+.
131
+ def list(status: nil, pinned_only: nil, limit: nil, cursor: nil)
126
132
  slug = Studio.require_project_slug(@config, 'studio.dashboards')
127
- q = status ? { 'status' => status } : nil
133
+ q = {}
134
+ q['status'] = status if status
135
+ q['pinnedOnly'] = pinned_only.to_s unless pinned_only.nil?
136
+ q['limit'] = limit if limit
137
+ q['cursor'] = cursor if cursor
128
138
  HttpTransport.request(config: @config, method: 'GET',
129
139
  path: UrlGrammar.project_path(slug, 'dashboards'), query: q)
130
140
  end
@@ -7,11 +7,18 @@ module K9o
7
7
  module UrlGrammar
8
8
  SLUG_RE = /\A[a-z][a-z0-9-]{1,62}\z/
9
9
 
10
- # Bare platform apex hosts org-subdomain derivation applies to —
11
- # production and staging share the <org_slug>.<apex> wire-routing
12
- # scheme (confirmed live). A third apex needs the same confirmation
13
- # before joining this set.
14
- ORG_DERIVABLE_APEXES = %w[api.k9o.io api-staging.k9o.io].freeze
10
+ # The convenience default when the caller passes no base_url. A default value only: never used to parse a host.
11
+ DEFAULT_BASE_URL = 'https://api.k9o.io'
12
+
13
+ # `api` or `api-staging`, optionally followed by `-<rgn>`: the label that marks a gateway host.
14
+ API_LABEL = /\A(api|api-staging)(?:-([a-z]{2}\d{2}))?\z/.freeze
15
+
16
+ # The structure of a gateway API host, read from the host itself.
17
+ # org_slug: first label of an org host (+acme+ in +acme.api.corp.internal+); nil for a platform host.
18
+ # family: +api+ or +api-staging+.
19
+ # region: 4-character region code when the host is regional (+api-de01+), else nil.
20
+ # apex: everything after the gateway label; never matched against a list.
21
+ ApiHost = Struct.new(:org_slug, :family, :region, :apex)
15
22
 
16
23
  module_function
17
24
 
@@ -65,24 +72,43 @@ module K9o
65
72
  "#{uri.scheme}://#{host_port}"
66
73
  end
67
74
 
68
- # Resolve the effective base_url. base_url is
69
- # OPTIONAL — it defaults to the platform apex https://api.k9o.io. When the
70
- # (validated) host is a bare apex in ORG_DERIVABLE_APEXES and org_slug is
71
- # supplied, the SDK composes the canonical org-subdomain wire root
72
- # https://<org_slug>.<apex>. An explicit <org_slug> subdomain,
73
- # a self-hosted / Sovereign host, or a dev host (anything not in that
74
- # set) passes through unchanged — org_slug is intentionally NOT composed
75
- # onto a non-apex host.
76
- #
77
- # Fixed 2026-08-30 (SDK-2): this used to silently ignore org_slug for
78
- # api-staging.k9o.io too, not just genuine self-hosted/custom hosts,
79
- # since only the literal production apex string was checked.
75
+ # Read the org slug and region out of a gateway host without knowing its apex. The gateway label is +api+ or
76
+ # +api-staging+, optionally followed by +-<rgn>+; the label before it is the org slug and whatever follows it is
77
+ # the apex. Returns nil for any other shape (localhost, an IP, a single-label host, a host with no gateway
78
+ # label), which callers use exactly as given.
79
+ def parse_api_host(host)
80
+ labels = host.to_s.downcase.split('.', -1)
81
+ return nil if labels.empty? || labels.any?(&:empty?)
82
+
83
+ build = lambda do |gateway_label, org_slug, rest|
84
+ m = API_LABEL.match(gateway_label)
85
+ next nil if m.nil? || rest.empty?
86
+
87
+ ApiHost.new(org_slug, m[1], m[2], rest.join('.'))
88
+ end
89
+ # Org host first: <org>.<api[-rgn]>.<apex>.
90
+ if labels.length >= 3 && labels[0].match?(SLUG_RE) && !labels[0].end_with?('-') && API_LABEL.match?(labels[1])
91
+ return build.call(labels[1], labels[0], labels[2..-1])
92
+ end
93
+ # Platform host: api.<apex>.
94
+ return build.call(labels[0], nil, labels[1..-1]) if labels.length >= 2 && API_LABEL.match?(labels[0])
95
+
96
+ nil
97
+ end
98
+
99
+ # Resolve the effective base_url. base_url is OPTIONAL: omitted, it defaults to DEFAULT_BASE_URL (the public
100
+ # cloud, a convenience only). When the (validated) host is a platform host (+api.<apex>+ or +api-staging.<apex>+,
101
+ # any apex) and org_slug is supplied, the canonical org host +<org_slug>.<host>+ is composed. Any other host (an
102
+ # org host already, an internal or air-gapped host, localhost) passes through unchanged; org_slug is then only
103
+ # sent as a header.
80
104
  def resolve_base_url(base_url, org_slug = nil)
81
- validated = validate_base_url(base_url.nil? ? 'https://api.k9o.io' : base_url)
105
+ validated = validate_base_url(base_url.nil? ? DEFAULT_BASE_URL : base_url)
82
106
  uri = URI.parse(validated)
83
- if ORG_DERIVABLE_APEXES.include?(uri.host) && org_slug
107
+ host = parse_api_host(uri.host)
108
+ if !host.nil? && host.org_slug.nil? && host.region.nil? && org_slug
84
109
  validate_slug(org_slug, 'org')
85
- return "#{uri.scheme}://#{org_slug}.#{uri.host}"
110
+ host_port = uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
111
+ return "#{uri.scheme}://#{org_slug}.#{host_port}"
86
112
  end
87
113
  validated
88
114
  end
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.3.0'
5
5
  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