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.
@@ -68,10 +68,11 @@ module K9o
68
68
  #
69
69
  # `authenticated: false` sends NO credential: no api_key bearer, and any
70
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)
71
+ def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
72
+ raw_body: nil)
72
73
  retry_with_backoff(method, config.retry_config || RetryConfig.default) do
73
74
  request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
74
- authenticated: authenticated, multipart: multipart)
75
+ authenticated: authenticated, multipart: multipart, raw_body: raw_body)
75
76
  end
76
77
  end
77
78
 
@@ -116,7 +117,8 @@ module K9o
116
117
  end
117
118
 
118
119
  # 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
+ def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
121
+ raw_body: nil)
120
122
  url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
121
123
  if query && !query.empty?
122
124
  url += '?' + URI.encode_www_form(query)
@@ -149,7 +151,10 @@ module K9o
149
151
  req.delete('cookie')
150
152
  end
151
153
 
152
- if multipart
154
+ if raw_body
155
+ # Binary routes: the bytes go unchanged; the caller supplies the content-type in +headers+.
156
+ req.body = raw_body
157
+ elsif multipart
153
158
  # multipart/form-data: the encoder sets the boundary; never force JSON.
154
159
  data, content_type = Multipart.encode(multipart)
155
160
  req['content-type'] = content_type
@@ -215,7 +220,7 @@ module K9o
215
220
  # sentinel terminates the stream; non-2xx raises KError before any
216
221
  # event is yielded.
217
222
  #
218
- # Per the Ruby async-iteration idiom is Enumerator — caller
223
+ # The Ruby async-iteration idiom is Enumerator — caller
219
224
  # writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
220
225
  def stream_sse(config:, method:, path:, body: nil, query: nil)
221
226
  url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
data/lib/k9o/kibo.rb CHANGED
@@ -97,14 +97,14 @@ module K9o
97
97
  case e
98
98
  when KiboKmsRestFfi::ZkFfiUniffiError::PostureViolation
99
99
  K9o::KError.new(
100
- code: e.code, message: e.message,
100
+ code: e.code, message: e.detail,
101
101
  context: { dimension: e.dimension, requested: e.requested, declared: e.declared }
102
102
  )
103
103
  when KiboKmsRestFfi::ZkFfiUniffiError::Platform
104
- K9o::KError.new(code: e.code, message: e.message)
104
+ K9o::KError.new(code: e.code, message: e.detail)
105
105
  when KiboKmsRestFfi::ZkFfiUniffiError::UpstreamKms
106
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)
107
+ K9o::KError.new(code: 'UPSTREAM_KMS_ERROR', message: e.detail)
108
108
  else
109
109
  K9o::KError.new(code: 'KIBO_FFI_ERROR', message: e.message)
110
110
  end
data/lib/k9o/me.rb CHANGED
@@ -1,23 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- # Operator-self namespace — k9o.me.*. Cross-org;
5
- # org-plane routes under /me. Sub-namespaces: profile / sessions /
6
- # api_tokens / notifications / audit_log / data_export.
4
+ # Operator-self namespace — k9o.me.*. Cross-org; org-plane routes under /me.
5
+ # Sub-namespaces: profile / sessions / api_tokens.
7
6
  class Me
8
- attr_reader :profile, :sessions, :api_tokens, :notifications, :audit_log, :data_export
7
+ attr_reader :profile, :sessions, :api_tokens
9
8
 
10
9
  def initialize(config)
11
10
  @profile = Profile.new(config)
12
11
  @sessions = Sessions.new(config)
13
12
  @api_tokens = ApiTokens.new(config)
14
- @notifications = Notifications.new(config)
15
- @audit_log = AuditLog.new(config)
16
- @data_export = DataExport.new(config)
17
13
  end
18
14
 
19
15
  # k9o.me.profile.*
20
16
  class Profile
17
+ PATCH_FIELDS = { display_name: 'displayName', locale: 'locale', timezone: 'timezone' }.freeze
18
+
21
19
  def initialize(config)
22
20
  @config = config
23
21
  end
@@ -26,9 +24,17 @@ module K9o
26
24
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me'))
27
25
  end
28
26
 
29
- def update(display_name: nil)
30
- HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me'),
31
- body: { 'displayName' => display_name })
27
+ # PATCH /me. Pass only the fields to change (display_name, locale, timezone); an explicit +nil+ is sent as
28
+ # null.
29
+ def update(**fields)
30
+ patch = {}
31
+ fields.each do |key, value|
32
+ wire = PATCH_FIELDS[key]
33
+ raise ArgumentError, "unknown profile field: #{key}" if wire.nil?
34
+
35
+ patch[wire] = value
36
+ end
37
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me'), body: patch)
32
38
  end
33
39
  end
34
40
 
@@ -42,13 +48,15 @@ module K9o
42
48
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/sessions'))
43
49
  end
44
50
 
51
+ # POST /auth/sessions/<id>/revoke
45
52
  def revoke(session_id)
46
- HttpTransport.request(config: @config, method: 'DELETE',
47
- path: UrlGrammar.org_path("me/sessions/#{enc(session_id)}"))
53
+ HttpTransport.request(config: @config, method: 'POST',
54
+ path: UrlGrammar.org_path("auth/sessions/#{enc(session_id)}/revoke"))
48
55
  end
49
56
 
57
+ # POST /me/sessions/revoke-all
50
58
  def revoke_all
51
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path('me/sessions'))
59
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/sessions/revoke-all'))
52
60
  end
53
61
 
54
62
  private
@@ -68,10 +76,12 @@ module K9o
68
76
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/api-tokens'))
69
77
  end
70
78
 
71
- def create(name:, expires_in_days: nil, scope: nil)
72
- body = { 'name' => name }
73
- body['expiresInDays'] = expires_in_days if expires_in_days
74
- body['scope'] = scope if scope
79
+ # POST /me/api-tokens { label, scopeManifest, ttlSeconds? }. +scope_manifest+ is a Hash with the gateway's keys
80
+ # (each may be nil for "no restriction"): 'operations' (read / write / admin), 'projects', 'resourceClasses',
81
+ # 'collections', 'cidrs'. The response carries the token's plaintext once.
82
+ def create(label:, scope_manifest:, ttl_seconds: nil)
83
+ body = { 'label' => label, 'scopeManifest' => scope_manifest }
84
+ body['ttlSeconds'] = ttl_seconds unless ttl_seconds.nil?
75
85
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/api-tokens'), body: body)
76
86
  end
77
87
 
@@ -86,75 +96,5 @@ module K9o
86
96
  ::URI.encode_www_form_component(s)
87
97
  end
88
98
  end
89
-
90
- # k9o.me.notifications.*
91
- class Notifications
92
- def initialize(config)
93
- @config = config
94
- end
95
-
96
- def list(unread_only: nil, limit: nil, cursor: nil)
97
- q = {}
98
- q['unreadOnly'] = unread_only unless unread_only.nil?
99
- q['limit'] = limit if limit
100
- q['cursor'] = cursor unless cursor.nil?
101
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/notifications'), query: q)
102
- end
103
-
104
- def mark_read(notification_id)
105
- HttpTransport.request(config: @config, method: 'PATCH',
106
- path: UrlGrammar.org_path("me/notifications/#{enc(notification_id)}"), body: { 'read' => true })
107
- end
108
-
109
- def mark_all_read
110
- HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me/notifications'),
111
- body: { 'read' => true })
112
- end
113
-
114
- private
115
-
116
- def enc(s)
117
- ::URI.encode_www_form_component(s)
118
- end
119
- end
120
-
121
- # k9o.me.audit_log.*
122
- class AuditLog
123
- def initialize(config)
124
- @config = config
125
- end
126
-
127
- def query(since: nil, until_: nil, type: nil, limit: nil, cursor: nil)
128
- q = {}
129
- q['since'] = since if since
130
- q['until'] = until_ if until_
131
- q['type'] = type if type
132
- q['limit'] = limit if limit
133
- q['cursor'] = cursor if cursor
134
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/audit-log'), query: q)
135
- end
136
- end
137
-
138
- # k9o.me.data_export.*
139
- class DataExport
140
- def initialize(config)
141
- @config = config
142
- end
143
-
144
- def request(format: 'json')
145
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/data-export'),
146
- body: { 'format' => format })
147
- end
148
-
149
- def status(job_id)
150
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("me/data-export/#{enc(job_id)}"))
151
- end
152
-
153
- private
154
-
155
- def enc(s)
156
- ::URI.encode_www_form_component(s)
157
- end
158
- end
159
99
  end
160
100
  end
data/lib/k9o/org.rb CHANGED
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- # Org-plane bound namespace — k9o.org.*. Singular
5
- # bound-org namespace (distinct from k9o.orgs collection): members /
6
- # groups / smtp / audit_log / gate (roles + idp_providers) + default_llm.
7
- # Org-plane URL grammar routes against the org subdomain
8
- # without the @<projectSlug>/ prefix.
4
+ # Org-plane bound namespace — k9o.org.*. Singular bound-org namespace (distinct from the k9o.orgs collection):
5
+ # members / groups / smtp / audit_log / gate (roles + idp_providers) + default_llm.
6
+ # Org-plane routes live on the org host without the @<projectSlug>/ prefix.
9
7
  class Org
10
8
  attr_reader :members, :groups, :smtp, :audit_log, :gate, :default_llm
11
9
 
@@ -19,7 +17,7 @@ module K9o
19
17
  @default_llm = DefaultLlm.new(config)
20
18
  end
21
19
 
22
- # Read the current org (slug + name + status + settings).
20
+ # GET /org — the current org (slug + name + status).
23
21
  def get
24
22
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org'))
25
23
  end
@@ -34,64 +32,57 @@ module K9o
34
32
  @config = config
35
33
  end
36
34
 
37
- def list(status: nil)
38
- q = status ? { 'status' => status } : nil
35
+ # GET /members. +status+ is the stored membership status ('active' or 'suspended'); +acceptance_status+ is
36
+ # 'accepted' or 'pending'.
37
+ def list(user_id: nil, role: nil, acceptance_status: nil, status: nil, state: nil)
38
+ q = {}
39
+ q['userId'] = user_id unless user_id.nil?
40
+ q['role'] = role unless role.nil?
41
+ q['acceptanceStatus'] = acceptance_status unless acceptance_status.nil?
42
+ q['status'] = status unless status.nil?
43
+ q['state'] = state unless state.nil?
39
44
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('members'), query: q)
40
45
  end
41
46
 
47
+ # POST /members { email, role }
42
48
  def invite(email:, role:)
43
49
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('members'),
44
50
  body: { 'email' => email, 'role' => role })
45
51
  end
46
52
 
47
- def get(user_id)
48
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("members/#{Org.enc(user_id)}"))
49
- end
50
-
51
53
  def remove(user_id)
52
54
  HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("members/#{Org.enc(user_id)}"))
53
55
  end
54
56
  end
55
57
 
56
- # k9o.org.groups.*
58
+ # k9o.org.groups.* (read-only: the gateway has no group create or delete)
57
59
  class Groups
58
60
  def initialize(config)
59
61
  @config = config
60
62
  end
61
63
 
62
- def list
63
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('groups'))
64
- end
65
-
66
- def create(name:)
67
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('groups'), body: { 'name' => name })
64
+ # GET /groups — optionally only the groups +user_id+ belongs to.
65
+ def list(user_id: nil)
66
+ q = user_id.nil? ? nil : { 'userId' => user_id }
67
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('groups'), query: q)
68
68
  end
69
69
 
70
70
  def get(group_id)
71
71
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("groups/#{Org.enc(group_id)}"))
72
72
  end
73
-
74
- def delete(group_id)
75
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("groups/#{Org.enc(group_id)}"))
76
- end
77
73
  end
78
74
 
79
- # k9o.org.audit_log.* (org-plane; distinct from project Audit).
75
+ # k9o.org.audit_log.* (org-plane; distinct from project Audit). The org audit route reads only +type+ (exact
76
+ # event type) and +limit+ (1 to 500, default 100).
80
77
  class AuditLog
81
78
  def initialize(config)
82
79
  @config = config
83
80
  end
84
81
 
85
- def query(since: nil, until_: nil, type: nil, actor_id: nil, target_kind: nil, target_id: nil, limit: nil, cursor: nil)
82
+ def query(type: nil, limit: nil)
86
83
  q = {}
87
- q['since'] = since if since
88
- q['until'] = until_ if until_
89
- q['type'] = type if type
90
- q['actorId'] = actor_id if actor_id
91
- q['targetKind'] = target_kind if target_kind
92
- q['targetId'] = target_id if target_id
93
- q['limit'] = limit if limit
94
- q['cursor'] = cursor if cursor
84
+ q['type'] = type unless type.nil?
85
+ q['limit'] = limit unless limit.nil?
95
86
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('audit-log'), query: q)
96
87
  end
97
88
  end
@@ -105,88 +96,110 @@ module K9o
105
96
  @idp_providers = IdpProviders.new(config)
106
97
  end
107
98
 
108
- # k9o.org.gate.roles.*
99
+ # k9o.org.gate.roles.* — org role grants.
109
100
  class Roles
110
101
  def initialize(config)
111
102
  @config = config
112
103
  end
113
104
 
114
- def list(user_id: nil, role: nil)
115
- q = {}
116
- q['userId'] = user_id if user_id
117
- q['role'] = role if role
118
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org-role-grants'), query: q)
105
+ # GET /role-grants (active grants).
106
+ def list
107
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('role-grants'))
119
108
  end
120
109
 
121
- def grant(user_id:, role:)
122
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('org-role-grants'),
123
- body: { 'userId' => user_id, 'role' => role })
110
+ # POST /role-grants { userId, role, decisionReason? }
111
+ def grant(user_id:, role:, decision_reason: nil)
112
+ body = { 'userId' => user_id, 'role' => role }
113
+ body['decisionReason'] = decision_reason unless decision_reason.nil?
114
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('role-grants'), body: body)
124
115
  end
125
116
 
126
117
  def get(grant_id)
127
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("org-role-grants/#{Org.enc(grant_id)}"))
118
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("role-grants/#{Org.enc(grant_id)}"))
128
119
  end
129
120
 
130
- def revoke(grant_id)
131
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("org-role-grants/#{Org.enc(grant_id)}"))
121
+ # DELETE /role-grants/<id> with an optional { decisionReason } body.
122
+ def revoke(grant_id, decision_reason: nil)
123
+ body = decision_reason.nil? ? nil : { 'decisionReason' => decision_reason }
124
+ HttpTransport.request(config: @config, method: 'DELETE',
125
+ path: UrlGrammar.org_path("role-grants/#{Org.enc(grant_id)}"), body: body)
132
126
  end
133
127
  end
134
128
 
135
- # k9o.org.gate.idp_providers.*
129
+ # k9o.org.gate.idp_providers.* — the org's identity providers (/identity-providers).
136
130
  class IdpProviders
131
+ CREATE_OPTIONAL = {
132
+ redirect_uri: 'redirectUri', metadata_url: 'metadataUrl', attribute_map: 'attributeMap', enabled: 'enabled'
133
+ }.freeze
134
+
137
135
  def initialize(config)
138
136
  @config = config
139
137
  end
140
138
 
141
139
  def list
142
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('idp-providers'))
140
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('identity-providers'))
143
141
  end
144
142
 
145
- def create(name:, kind:, config:)
146
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('idp-providers'),
147
- body: { 'name' => name, 'kind' => kind, 'config' => config })
143
+ # POST /identity-providers { idpProvider, displayName, clientId, clientSecretRef, tierGating, ... }
144
+ # idp_provider: google github microsoft oidc saml scim ldap active_directory apple facebook twitter
145
+ # tier_gating: 'free_tier_ok' or 'ridge_plus'
146
+ # client_secret_ref: a reference to the stored client secret, never the secret itself
147
+ def create(idp_provider:, display_name:, client_id:, client_secret_ref:, tier_gating:, **opts)
148
+ body = { 'idpProvider' => idp_provider, 'displayName' => display_name, 'clientId' => client_id,
149
+ 'clientSecretRef' => client_secret_ref, 'tierGating' => tier_gating }
150
+ opts.each do |key, value|
151
+ wire = CREATE_OPTIONAL[key]
152
+ raise ArgumentError, "unknown idp_providers.create option: #{key}" if wire.nil?
153
+
154
+ body[wire] = value
155
+ end
156
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('identity-providers'), body: body)
148
157
  end
149
158
 
150
159
  def get(idp_id)
151
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"))
160
+ HttpTransport.request(config: @config, method: 'GET',
161
+ path: UrlGrammar.org_path("identity-providers/#{Org.enc(idp_id)}"))
152
162
  end
153
163
 
164
+ # PATCH /identity-providers/<id>. +patch+ keys are the gateway's: displayName, clientId, clientSecretRef,
165
+ # redirectUri, metadataUrl, attributeMap, enabled.
154
166
  def configure(idp_id, patch)
155
167
  HttpTransport.request(config: @config, method: 'PATCH',
156
- path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"), body: patch)
168
+ path: UrlGrammar.org_path("identity-providers/#{Org.enc(idp_id)}"), body: patch)
157
169
  end
158
170
 
159
171
  def delete(idp_id)
160
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"))
172
+ HttpTransport.request(config: @config, method: 'DELETE',
173
+ path: UrlGrammar.org_path("identity-providers/#{Org.enc(idp_id)}"))
161
174
  end
162
175
  end
163
176
  end
164
177
 
165
- # k9o.org.smtp.*
178
+ # k9o.org.smtp.* — the org's SMTP relay, stored in the org record (PATCH /org { smtpConfig }).
166
179
  class Smtp
167
180
  def initialize(config)
168
181
  @config = config
169
182
  end
170
183
 
171
- def get
172
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org/smtp'))
173
- end
174
-
175
- def set(host:, port:, username:, password:, from_email:, from_name:, encryption:, reply_to_email: nil)
184
+ # PATCH /org { smtpConfig: { host, port, username, passwordRef, fromAddress, fromName? } }.
185
+ # +password_ref+ references the stored SMTP password; it is never the password itself.
186
+ def set(host:, port:, username:, password_ref:, from_address:, from_name: nil)
176
187
  smtp = {
177
- 'host' => host, 'port' => port, 'username' => username, 'password' => password,
178
- 'fromEmail' => from_email, 'fromName' => from_name, 'encryption' => encryption
188
+ 'host' => host, 'port' => port, 'username' => username, 'passwordRef' => password_ref,
189
+ 'fromAddress' => from_address
179
190
  }
180
- smtp['replyToEmail'] = reply_to_email if reply_to_email
191
+ smtp['fromName'] = from_name unless from_name.nil?
181
192
  HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('org'), body: { 'smtpConfig' => smtp })
182
193
  end
183
194
 
184
- def test(to:, from_override: nil)
195
+ # POST /org/smtp/test-email { to, subject? }
196
+ def test(to:, subject: nil)
185
197
  body = { 'to' => to }
186
- body['fromOverride'] = from_override if from_override
198
+ body['subject'] = subject unless subject.nil?
187
199
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('org/smtp/test-email'), body: body)
188
200
  end
189
201
 
202
+ # PATCH /org { smtpConfig: null }
190
203
  def unset
191
204
  HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('org'), body: { 'smtpConfig' => nil })
192
205
  end
@@ -198,15 +211,15 @@ module K9o
198
211
  @config = config
199
212
  end
200
213
 
201
- # Read the org-default LLM model id (nil when unset → mistral-7b).
214
+ # GET /default-llm
202
215
  def get
203
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org/default-llm'))
216
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('default-llm'))
204
217
  end
205
218
 
206
- # Set the org-default LLM (ORG_ADMIN; tier-gated).
219
+ # PATCH /default-llm { modelId }
207
220
  def set(model_id)
208
221
  HttpTransport.request(config: @config, method: 'PATCH',
209
- path: UrlGrammar.org_path('org/default-llm'), body: { 'modelId' => model_id })
222
+ path: UrlGrammar.org_path('default-llm'), body: { 'modelId' => model_id })
210
223
  end
211
224
  end
212
225
  end
data/lib/k9o/orgs.rb CHANGED
@@ -13,6 +13,7 @@ module K9o
13
13
  end
14
14
 
15
15
  def get(org_slug)
16
+ UrlGrammar.validate_slug(org_slug, 'org')
16
17
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("orgs/#{enc(org_slug)}"))
17
18
  end
18
19
 
data/lib/k9o/policies.rb CHANGED
@@ -2,11 +2,18 @@
2
2
 
3
3
  module K9o
4
4
  # Policy bundle lifecycle namespace — k9o.policies.*.
5
+ #
6
+ # The policy document is a Hash of sections keyed by resource kind ('collections', 'storage', 'tools',
7
+ # 'functions', 'secrets', 'streams', 'analytics'; other keys are allowed). Only authoring roles write policy and
8
+ # only custodian roles publish; +validate+ is read-only.
5
9
  class Policies
10
+ EDITOR_MODES = %w[visual json unspecified].freeze
11
+
6
12
  def initialize(config)
7
13
  @config = config
8
14
  end
9
15
 
16
+ # GET /@<slug>/policy — the currently active policy (data is nil when none is published).
10
17
  def current
11
18
  slug = require_project_slug
12
19
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policy'))
@@ -18,16 +25,18 @@ module K9o
18
25
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policies'), query: q)
19
26
  end
20
27
 
21
- def create(name:, rules:)
28
+ # POST /@<slug>/policies — create a draft bundle from a policy document.
29
+ def create(policy:, rules: nil, editor_mode: nil)
22
30
  slug = require_project_slug
23
31
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies'),
24
- body: { 'name' => name, 'rules' => rules })
32
+ body: policy_body(policy, rules, editor_mode))
25
33
  end
26
34
 
27
- def validate(rules)
35
+ # POST /@<slug>/policies/validate — lint a candidate policy document without persisting.
36
+ def validate(policy:, rules: nil)
28
37
  slug = require_project_slug
29
38
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies/validate'),
30
- body: { 'rules' => rules })
39
+ body: policy_body(policy, rules, nil))
31
40
  end
32
41
 
33
42
  def get(bundle_id)
@@ -35,12 +44,15 @@ module K9o
35
44
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
36
45
  end
37
46
 
38
- def update(bundle_id, patch)
47
+ # PATCH /@<slug>/policies/<id> — replace a draft bundle's policy document. Published bundles cannot be patched.
48
+ def update(bundle_id, policy:, rules: nil, editor_mode: nil)
39
49
  slug = require_project_slug
40
50
  HttpTransport.request(config: @config, method: 'PATCH',
41
- path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"), body: patch)
51
+ path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"),
52
+ body: policy_body(policy, rules, editor_mode))
42
53
  end
43
54
 
55
+ # Delete a draft bundle. Published bundles cannot be deleted.
44
56
  def delete(bundle_id)
45
57
  slug = require_project_slug
46
58
  HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
@@ -54,6 +66,13 @@ module K9o
54
66
 
55
67
  private
56
68
 
69
+ def policy_body(policy, rules, editor_mode)
70
+ body = { 'policy' => policy }
71
+ body['rules'] = rules unless rules.nil?
72
+ body['editorMode'] = editor_mode unless editor_mode.nil?
73
+ body
74
+ end
75
+
57
76
  def require_project_slug
58
77
  Studio.require_project_slug(@config, 'policies')
59
78
  end
data/lib/k9o/projects.rb CHANGED
@@ -2,32 +2,67 @@
2
2
 
3
3
  module K9o
4
4
  # Project lifecycle namespace — k9o.projects.* (Ruby snake_case).
5
+ #
6
+ # All four operations are org-plane: the client needs an org context (an
7
+ # +org_slug+ or a bearer token that carries one). Option names are snake_case
8
+ # spellings of the gateway's body property names.
5
9
  class Projects
10
+ CREATE_FIELDS = {
11
+ slug: 'slug', product_scope: 'productScope', region: 'region',
12
+ default_project_region: 'defaultProjectRegion', custody_mode: 'custodyMode',
13
+ processing_contexts: 'processingContexts', exec_seam_confined: 'execSeamConfined',
14
+ showroom_vertical: 'showroomVertical', persona: 'persona', showroom_locale: 'showroomLocale',
15
+ idempotency_key: 'idempotencyKey'
16
+ }.freeze
17
+
18
+ UPDATE_FIELDS = {
19
+ slug: 'slug', display_name: 'displayName', description: 'description', region: 'region',
20
+ product_scope: 'productScope', soft_delete_retention_days: 'softDeleteRetentionDays',
21
+ processing_contexts: 'processingContexts'
22
+ }.freeze
23
+
6
24
  def initialize(config)
7
25
  @config = config
8
26
  end
9
27
 
28
+ # GET /projects
10
29
  def list
11
30
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('projects'))
12
31
  end
13
32
 
14
- def create(slug:, name:, custody_mode: nil)
15
- UrlGrammar.validate_slug(slug, 'project')
16
- body = { 'slug' => slug, 'name' => name }
17
- body['custodyMode'] = custody_mode if custody_mode
33
+ # POST /projects. +display_name+ is required (1 to 128 characters); the gateway derives a slug from it when
34
+ # +slug+ is omitted. +custody_mode+ is one of PLATFORM_MANAGED, SKR, BYOK, HYOK. +idempotency_key+ makes a
35
+ # retried create return the first result instead of a second project.
36
+ def create(display_name:, **opts)
37
+ body = { 'displayName' => display_name }
38
+ opts.each do |key, value|
39
+ wire = CREATE_FIELDS[key]
40
+ raise ArgumentError, "unknown projects.create option: #{key}" if wire.nil?
41
+
42
+ body[wire] = value unless value.nil?
43
+ end
44
+ UrlGrammar.validate_slug(opts[:slug], 'project') unless opts[:slug].nil?
18
45
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('projects'), body: body)
19
46
  end
20
47
 
48
+ # GET /projects/<slug>
21
49
  def get(slug)
22
50
  UrlGrammar.validate_slug(slug, 'project')
23
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'project'))
51
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("projects/#{slug}"))
24
52
  end
25
53
 
26
- def update(slug, name: nil)
54
+ # PATCH /projects/<slug>. Pass only the fields to change; an explicit +nil+ is sent as null
55
+ # (for +description+ that clears it).
56
+ def update(slug, **fields)
27
57
  UrlGrammar.validate_slug(slug, 'project')
28
58
  patch = {}
29
- patch['name'] = name if name
30
- HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.project_path(slug, 'project'), body: patch)
59
+ fields.each do |key, value|
60
+ wire = UPDATE_FIELDS[key]
61
+ raise ArgumentError, "unknown projects.update field: #{key}" if wire.nil?
62
+
63
+ patch[wire] = value
64
+ end
65
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path("projects/#{slug}"), body: patch)
31
66
  end
32
67
  end
33
68
  end