k9o 1.2.0-aarch64-linux → 1.5.2-aarch64-linux

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -1
  3. data/README.md +1602 -180
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
  6. data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
  7. data/lib/k9o/ai.rb +15 -0
  8. data/lib/k9o/analytics.rb +403 -0
  9. data/lib/k9o/approvals.rb +253 -0
  10. data/lib/k9o/audit.rb +162 -14
  11. data/lib/k9o/client.rb +28 -9
  12. data/lib/k9o/compute.rb +32 -15
  13. data/lib/k9o/crypto.rb +471 -0
  14. data/lib/k9o/custody.rb +28 -0
  15. data/lib/k9o/data.rb +125 -5
  16. data/lib/k9o/ed25519.rb +182 -0
  17. data/lib/k9o/end_users.rb +93 -0
  18. data/lib/k9o/envelope.rb +20 -5
  19. data/lib/k9o/functions.rb +127 -9
  20. data/lib/k9o/gate.rb +52 -22
  21. data/lib/k9o/http_transport.rb +78 -16
  22. data/lib/k9o/jcs.rb +117 -0
  23. data/lib/k9o/kerror.rb +57 -5
  24. data/lib/k9o/keys.rb +131 -0
  25. data/lib/k9o/kibo.rb +3 -3
  26. data/lib/k9o/kibo_client_core.rb +385 -0
  27. data/lib/k9o/kibo_ffi_core.rb +178 -0
  28. data/lib/k9o/locate.rb +118 -0
  29. data/lib/k9o/me.rb +27 -87
  30. data/lib/k9o/metadata.rb +342 -0
  31. data/lib/k9o/moat.rb +443 -0
  32. data/lib/k9o/org.rb +80 -68
  33. data/lib/k9o/orgs.rb +1 -0
  34. data/lib/k9o/policies.rb +25 -6
  35. data/lib/k9o/projects.rb +43 -8
  36. data/lib/k9o/query.rb +174 -0
  37. data/lib/k9o/rampart.rb +273 -0
  38. data/lib/k9o/realtime.rb +261 -32
  39. data/lib/k9o/region.rb +20 -18
  40. data/lib/k9o/schedules.rb +109 -0
  41. data/lib/k9o/schema.rb +35 -0
  42. data/lib/k9o/streams.rb +94 -0
  43. data/lib/k9o/studio.rb +21 -11
  44. data/lib/k9o/url_grammar.rb +46 -20
  45. data/lib/k9o/vault.rb +79 -84
  46. data/lib/k9o/vectors.rb +124 -0
  47. data/lib/k9o/version.rb +1 -1
  48. data/lib/k9o.rb +17 -0
  49. data/sig/k9o/ai.rbs +4 -0
  50. data/sig/k9o/approvals.rbs +71 -0
  51. data/sig/k9o/datasets.rbs +125 -0
  52. data/sig/k9o/gate.rbs +9 -6
  53. data/sig/k9o/kerror.rbs +24 -0
  54. data/sig/k9o/kibo_client_core.rbs +25 -0
  55. data/sig/k9o/metadata.rbs +54 -0
  56. data/sig/k9o/moat.rbs +29 -0
  57. data/sig/k9o/query.rbs +26 -0
  58. data/sig/k9o/rampart.rbs +59 -0
  59. data/sig/k9o/realtime.rbs +34 -0
  60. data/sig/k9o/region.rbs +14 -3
  61. data/sig/k9o/workload.rbs +64 -0
  62. metadata +33 -4
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Shared argument checks for the approvals, approval policy and schema namespaces. A bad value raises
5
+ # K9o::KError K9O_INVALID_FIELD_VALUE before any request is sent.
6
+ module ApprovalChecks
7
+ module_function
8
+
9
+ def invalid(message, context = {})
10
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
11
+ end
12
+
13
+ def check_limit(limit)
14
+ return if limit.nil?
15
+ return if limit.is_a?(Integer) && limit >= 1 && limit <= 200
16
+
17
+ raise invalid('limit must be a whole number from 1 to 200', limit: limit)
18
+ end
19
+
20
+ def id_segment(value, what)
21
+ raise invalid("#{what} id must be a non-empty string") unless value.is_a?(String) && !value.empty?
22
+
23
+ ::URI.encode_www_form_component(value).gsub('+', '%20')
24
+ end
25
+
26
+ def reason!(reason)
27
+ raise invalid('reason is required and must be a non-empty string') unless reason.is_a?(String) && !reason.strip.empty?
28
+
29
+ reason
30
+ end
31
+
32
+ def predicate!(value, field)
33
+ raise invalid("#{field} must be a predicate object") unless value.is_a?(Hash)
34
+
35
+ value
36
+ end
37
+
38
+ def enum!(value, allowed, field)
39
+ text = value.is_a?(Symbol) ? value.to_s : value
40
+ return text if allowed.include?(text)
41
+
42
+ raise invalid("#{field} must be #{allowed.map { |a| "\"#{a}\"" }.join(', ')}", field.to_sym => value)
43
+ end
44
+ end
45
+
46
+ # True when a single-record write sent with +require_approval+ was queued for approval (HTTP 202) instead of
47
+ # applied. The queued answer carries +data+ = { 'id' => 'apr_...', 'status' => 'pending_approval', 'collection',
48
+ # 'operation', 'makerId', 'proposedPayload', 'targetRecordId' } and +meta.approval+ = { 'status' =>
49
+ # 'pending_approval' }; an +auto_approve+ policy applies the write and answers the plain row instead.
50
+ # Read the queued change in full with +approvals.get(id)+.
51
+ def self.queued_write?(result)
52
+ return false unless result.respond_to?(:data) && result.respond_to?(:meta)
53
+
54
+ approval = result.meta.respond_to?(:approval) ? result.meta.approval : nil
55
+ data = result.data
56
+ approval.is_a?(Hash) && approval['status'] == 'pending_approval' &&
57
+ data.is_a?(Hash) && data['status'] == 'pending_approval'
58
+ end
59
+
60
+ # Pending-change queue: k9o.approvals.*
61
+ #
62
+ # list(status:, limit:, cursor:) GET /@<slug>/pending-approvals
63
+ # get(id) GET /@<slug>/pending-approvals/<id>
64
+ # approve(id, reason:) POST /@<slug>/pending-approvals/<id>/approve
65
+ # reject(id, reason:) POST /@<slug>/pending-approvals/<id>/reject
66
+ # bulk(action:, criteria:, reason:) POST /@<slug>/pending-approvals/bulk
67
+ #
68
+ # +list+ returns one page, newest first: +limit+ changes (1 to 200, default 50). While more remain,
69
+ # +meta.cursor.next+ is a string to pass back as +cursor:+; on the last page it is +nil+. +approve+ can answer 200
70
+ # with +status+ still +pending_approval+: the approval is recorded and more approvers are needed. +bulk+ answers
71
+ # HTTP 200 even when rows fail; read +failed+ and compare +affectedIds+ with what you intended.
72
+ class Approvals
73
+ # The comment thread on a pending change: +approvals.comments+.
74
+ attr_reader :comments
75
+
76
+ def initialize(config)
77
+ @config = config
78
+ @comments = ApprovalComments.new(config)
79
+ end
80
+
81
+ # One page of pending changes, newest first. +status+ is one of pending_approval, approved, rejected, expired or
82
+ # redeemed (omitted: all). Pass +meta.cursor.next+ from the previous page as +cursor+, with the same +status+
83
+ # and +limit+; an invalid cursor raises K9o::KError K9O_INVALID_CURSOR.
84
+ def list(status: nil, limit: nil, cursor: nil)
85
+ ApprovalChecks.check_limit(limit)
86
+ query = {}
87
+ query['status'] = status.to_s unless status.nil?
88
+ query['limit'] = limit unless limit.nil?
89
+ query['cursor'] = cursor.to_s unless cursor.nil?
90
+ call('GET', path, query: query)
91
+ end
92
+
93
+ # One pending change. Members who are not queue admins can read only their own.
94
+ def get(id)
95
+ call('GET', path("/#{ApprovalChecks.id_segment(id, 'approval')}"))
96
+ end
97
+
98
+ # Approve a pending change. Read +status+ on the answer: +pending_approval+ means more approvers are needed.
99
+ def approve(id, reason: nil)
100
+ seg = ApprovalChecks.id_segment(id, 'approval')
101
+ call('POST', path("/#{seg}/approve"), body: reason.nil? ? {} : { 'reason' => reason })
102
+ end
103
+
104
+ # Reject a pending change. +reason+ is required and must not be empty.
105
+ def reject(id, reason:)
106
+ seg = ApprovalChecks.id_segment(id, 'approval')
107
+ call('POST', path("/#{seg}/reject"), body: { 'reason' => ApprovalChecks.reason!(reason) })
108
+ end
109
+
110
+ # Approve or reject every pending change matching +criteria+ (a predicate Hash). +reason+ is required to reject.
111
+ # The answer is HTTP 200 even when rows fail. +data+ holds +affectedIds+, +affectedCount+, +attestationId+,
112
+ # +action+, +reason+ and +failed+, a list of { 'id', 'code', 'reason' } (empty when none failed): compare
113
+ # +affectedIds+ with what you intended.
114
+ def bulk(action:, criteria:, reason: nil)
115
+ act = ApprovalChecks.enum!(action, %w[approve reject], 'action')
116
+ body = { 'action' => act, 'criteria' => ApprovalChecks.predicate!(criteria, 'criteria') }
117
+ if act == 'reject'
118
+ body['reason'] = ApprovalChecks.reason!(reason)
119
+ elsif !reason.nil?
120
+ body['reason'] = reason
121
+ end
122
+ res = call('POST', path('/bulk'), body: body)
123
+ res.data['failed'] = [] if res.data.is_a?(Hash) && !res.data['failed'].is_a?(Array)
124
+ res
125
+ end
126
+
127
+ private
128
+
129
+ def call(method, route, body: nil, query: nil)
130
+ HttpTransport.request(config: @config, method: method, path: route, body: body, query: query)
131
+ end
132
+
133
+ def path(tail = '')
134
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals#{tail}")
135
+ end
136
+ end
137
+
138
+ # Comments on a pending change: k9o.approvals.comments.*
139
+ #
140
+ # add(pending_id, body:, visibility:) POST /@<slug>/pending-approvals/<pending_id>/comments
141
+ # list(pending_id, limit:, cursor:) GET /@<slug>/pending-approvals/<pending_id>/comments
142
+ #
143
+ # A comment is never a decision: it changes no status and counts toward no approval rule. It cannot be edited or
144
+ # deleted, so a correction is a new comment. Posting needs a session: a personal access token alone, or a service,
145
+ # workload or extension token, is refused with K9o::KError K9O_RBAC_DENY. Other errors: K9O_APPROVAL_COMMENT_NOT_ELIGIBLE
146
+ # (403, the caller may not post this comment), K9O_APPROVAL_COMMENT_CLOSED (409, the change is decided, expired or
147
+ # cancelled), K9O_INVALID_FIELD_VALUE (400) and K9O_NOT_FOUND (404, no such pending change).
148
+ class ApprovalComments
149
+ VISIBILITIES = %w[everyone approvers_only].freeze
150
+ BODY_MAX = 4000
151
+
152
+ # One page of comments: an Envelope whose +data+ is the Array of comments, oldest first, and whose +next_cursor+
153
+ # is the +cursor:+ of the next page, or +nil+ on the last page.
154
+ class Page < Envelope
155
+ attr_reader :next_cursor
156
+
157
+ def initialize(data:, meta:, next_cursor: nil)
158
+ super(data: data, meta: meta)
159
+ @next_cursor = next_cursor
160
+ end
161
+ end
162
+
163
+ def initialize(config)
164
+ @config = config
165
+ end
166
+
167
+ # Post a comment. +body+ is plain text of 1 to 4000 characters. +visibility+ is required and fixed once posted:
168
+ # "everyone" reaches everyone who can read the change; "approvers_only" reaches only people eligible to decide it.
169
+ # Answers HTTP 201; +data+ is { 'id' (acm_...), 'caseId', 'authorId', 'visibility', 'body', 'createdAt' }.
170
+ def add(pending_id, body:, visibility:)
171
+ seg = ApprovalChecks.id_segment(pending_id, 'pending approval')
172
+ unless body.is_a?(String) && body.length >= 1 && body.length <= BODY_MAX
173
+ raise ApprovalChecks.invalid("body must be a string of 1 to #{BODY_MAX} characters")
174
+ end
175
+
176
+ payload = { 'body' => body, 'visibility' => ApprovalChecks.enum!(visibility, VISIBILITIES, 'visibility') }
177
+ call('POST', path(seg), body: payload)
178
+ end
179
+
180
+ # One page of the comments the caller may see, oldest first (+limit+ 1 to 200, default 50). While more remain,
181
+ # +next_cursor+ is a string to pass back as +cursor:+; on the last page it is +nil+. An invalid cursor raises
182
+ # K9o::KError K9O_INVALID_CURSOR.
183
+ def list(pending_id, limit: nil, cursor: nil)
184
+ seg = ApprovalChecks.id_segment(pending_id, 'pending approval')
185
+ ApprovalChecks.check_limit(limit)
186
+ query = {}
187
+ query['limit'] = limit unless limit.nil?
188
+ query['cursor'] = cursor.to_s unless cursor.nil?
189
+ envelope = call('GET', path(seg), query: query, bare: true)
190
+ raw = envelope.data
191
+ data = raw.is_a?(Hash) && raw['data'].is_a?(Array) ? raw['data'] : []
192
+ Page.new(data: data, meta: envelope.meta, next_cursor: raw.is_a?(Hash) ? raw['nextCursor'] : nil)
193
+ end
194
+
195
+ private
196
+
197
+ def call(method, route, body: nil, query: nil, bare: false)
198
+ HttpTransport.request(config: @config, method: method, path: route, body: body, query: query, bare: bare)
199
+ end
200
+
201
+ def path(seg)
202
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals/#{seg}/comments")
203
+ end
204
+ end
205
+
206
+ # Approval policies: k9o.approval_policies.*
207
+ #
208
+ # list GET /@<slug>/approval-policies
209
+ # create(collection:, operation:, action:, predicate:) POST /@<slug>/approval-policies
210
+ # delete(id) DELETE /@<slug>/approval-policies/<id>
211
+ class ApprovalPolicies
212
+ COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
213
+
214
+ def initialize(config)
215
+ @config = config
216
+ end
217
+
218
+ # All policies, oldest first. The first matching policy for a write decides.
219
+ def list
220
+ call('GET', path)
221
+ end
222
+
223
+ # Create a policy. Identical policies are not de-duplicated.
224
+ def create(collection:, operation:, action:, predicate:)
225
+ unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
226
+ raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$', collection: collection)
227
+ end
228
+
229
+ body = {
230
+ 'collection' => collection,
231
+ 'operation' => ApprovalChecks.enum!(operation, %w[insert update delete], 'operation'),
232
+ 'action' => ApprovalChecks.enum!(action, %w[auto_approve queue], 'action'),
233
+ 'predicate' => ApprovalChecks.predicate!(predicate, 'predicate')
234
+ }
235
+ call('POST', path, body: body)
236
+ end
237
+
238
+ # Delete a policy; the answer is { 'id' => ... }.
239
+ def delete(id)
240
+ call('DELETE', path("/#{ApprovalChecks.id_segment(id, 'policy')}"))
241
+ end
242
+
243
+ private
244
+
245
+ def call(method, route, body: nil)
246
+ HttpTransport.request(config: @config, method: method, path: route, body: body)
247
+ end
248
+
249
+ def path(tail = '')
250
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approval_policies'), "approval-policies#{tail}")
251
+ end
252
+ end
253
+ end
data/lib/k9o/audit.rb CHANGED
@@ -2,28 +2,176 @@
2
2
 
3
3
  module K9o
4
4
  # Project-plane audit log namespace — k9o.audit.*.
5
- # Distinct from k9o.org.audit_log + k9o.me.audit_log.
5
+ # Distinct from k9o.org.audit_log.
6
6
  class Audit
7
+ TOPIC = 'audit.project'
8
+ TYPE_PATTERN = /\A[A-Za-z0-9_][A-Za-z0-9_.\-]*\z/.freeze
9
+ DEFAULT_REPLAY_LIMIT = 1000
10
+ EVENT_ID_PATTERN = /\Aevt_[0-9A-Za-z]{1,40}\z/.freeze
11
+
7
12
  def initialize(config)
8
13
  @config = config
9
14
  end
10
15
 
11
- # Query the audit log (reverse-chronological; filters AND-compose).
12
- # Accepts keyword filters: since/until/type/actor_id/target_kind/
13
- # target_id/limit/cursor (wire camelCase mapping applied here).
14
- def query(since: nil, until_: nil, type: nil, actor_id: nil,
15
- target_kind: nil, target_id: nil, limit: nil, cursor: nil)
16
+ # GET /@<slug>/audit-log — reverse-chronological; filters AND-compose. Requires the AUDIT_READER role.
17
+ # event_type: audit-event name prefix (e.g. 'data.write.access') actor_id: filter by actor
18
+ # after / before: ISO 8601 bounds (events after / before this instant)
19
+ # limit: page size (bounded server-side) cursor: from the prior response's meta.cursor.next
20
+ def query(event_type: nil, actor_id: nil, after: nil, before: nil, limit: nil, cursor: nil)
16
21
  slug = Studio.require_project_slug(@config, 'audit')
17
22
  q = {}
18
- q['since'] = since if since
19
- q['until'] = until_ if until_
20
- q['type'] = type if type
21
- q['actorId'] = actor_id if actor_id
22
- q['targetKind'] = target_kind if target_kind
23
- q['targetId'] = target_id if target_id
24
- q['limit'] = limit if limit
25
- q['cursor'] = cursor if cursor
23
+ q['eventType'] = event_type unless event_type.nil?
24
+ q['actorId'] = actor_id unless actor_id.nil?
25
+ q['after'] = after unless after.nil?
26
+ q['before'] = before unless before.nil?
27
+ q['limit'] = limit unless limit.nil?
28
+ q['cursor'] = cursor unless cursor.nil?
26
29
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'audit-log'), query: q)
27
30
  end
31
+
32
+ # Live tail of the project's audit events: an Enumerator of { "auditEventId", "type", "actorType", "occurredAt",
33
+ # "outcome", "eventId" }. The first five are the audit event (read the full row with #query by event type and
34
+ # time); +eventId+ is the publisher's own id for the frame, which is not the +auditEventId+.
35
+ #
36
+ # +types+ is a list of audit event types: one type subscribes to +audit.project.<type>+, none to
37
+ # +audit.project.*+, several to +audit.project.*+ with the SDK keeping only those types. Needs ORG_OWNER,
38
+ # ORG_ADMIN, PROJECT_ADMIN or PROJECT_AUDITOR (re-checked every 60 seconds; others get 403 K9O_RBAC_DENY);
39
+ # extensions cannot subscribe. +warden.checkpoint+ events are not in the live tail: the audit log and #replay
40
+ # carry them.
41
+ #
42
+ # It is the same reconnecting reader as +realtime.subscribe+: it resumes with +Last-Event-ID+ (the frame's SSE
43
+ # +id+), drops events it already delivered (by the frame's +eventId+ and by +auditEventId+), ignores heartbeats
44
+ # and abandons a connection that has been silent for +Realtime.idle_timeout+ seconds. When the gateway says the
45
+ # position is lost (+event: resync+, action +replay-ledger+; audit streams cannot be resumed by sequence) the tail
46
+ # first reconnects fresh and buffers that connection's frames, then calls #replay after the last +auditEventId+ it
47
+ # delivered (page by page while there are more), yields the events it missed (they also carry +sequence+, and may
48
+ # include +warden.checkpoint+), calls +on_resync+ with the frame's hash, then yields the buffered and live frames,
49
+ # each event once. Nothing is lost: the connection is open before the replay runs. If it had delivered nothing yet
50
+ # it only reconnects (a new connection starts at the live head; a resync that opens a fresh connection is informational and ignored). Stop with +break+ or +first(n)+.
51
+ def tail(types: nil, on_resync: nil)
52
+ topic, keep = topic_for(types)
53
+ slug = Studio.require_project_slug(@config, 'audit')
54
+ catch_up = lambda do |_info, last_event|
55
+ last_event ? collect_replay(topic, last_event['auditEventId']) : []
56
+ end
57
+ decode = lambda do |data, frame|
58
+ payload = data.is_a?(Hash) ? data['payload'] : nil
59
+ next nil unless payload.is_a?(Hash) && payload['auditEventId'].is_a?(String)
60
+
61
+ event = {
62
+ 'auditEventId' => payload['auditEventId'],
63
+ 'type' => payload['type'].is_a?(String) ? payload['type'] : (data['eventType'] || frame[:event]),
64
+ 'actorType' => payload['actorType'],
65
+ 'occurredAt' => payload['occurredAt'],
66
+ 'outcome' => payload['outcome']
67
+ }
68
+ event['eventId'] = data['eventId'] if data['eventId'].is_a?(String)
69
+ event
70
+ end
71
+ keys = lambda do |event, _frame|
72
+ [event['eventId'], event['auditEventId']].compact
73
+ end
74
+ cursor = lambda do |event, frame|
75
+ (frame && frame[:id]) || event['eventId']
76
+ end
77
+ Realtime.new(@config).feed(UrlGrammar.project_path(slug, 'realtime/subscribe'), { 'topic' => topic }, nil,
78
+ on_resync, decode: decode, keys: keys, cursor: cursor, accept: keep,
79
+ catch_up: catch_up, fresh_head_resync: true)
80
+ end
81
+
82
+ # POST /@<slug>/realtime/replay for the audit tail: a batch of past audit events in order, including
83
+ # +warden.checkpoint+. +topic+ is +audit.project.*+ (default) or +audit.project.<type>+. Give
84
+ # +after_audit_event_id+ (the last +auditEventId+ you received; unknown ids answer 400 K9O_INVALID_CURSOR) or
85
+ # +from_sequence+ (+after_audit_event_id+ wins when both are given); +until_sequence+ and +limit+ bound the batch (+limit+ is
86
+ # 1000 when omitted, up to 10,000). Needs the audit log's access.
87
+ #
88
+ # +data+ is the typed page { "events" => [event], "nextCursor", "hasMore" }; each event is { "auditEventId", "type",
89
+ # "actorType", "occurredAt", "outcome", "sequence" } (+sequence+ is a String, as the gateway sends it).
90
+ def replay(topic: "#{TOPIC}.*", after_audit_event_id: nil, from_sequence: nil, until_sequence: nil, limit: nil)
91
+ unless topic.is_a?(String) && (topic == TOPIC || topic.start_with?("#{TOPIC}."))
92
+ raise ApprovalChecks.invalid('topic must be audit.project.* or audit.project.<type>', topic: topic)
93
+ end
94
+ unless after_audit_event_id.nil? || (after_audit_event_id.is_a?(String) && EVENT_ID_PATTERN.match?(after_audit_event_id))
95
+ raise ApprovalChecks.invalid('after_audit_event_id must look like evt_<id>', after_audit_event_id: after_audit_event_id)
96
+ end
97
+ {from_sequence: from_sequence, until_sequence: until_sequence}.each do |name, value|
98
+ next if value.nil? || (value.is_a?(Integer) && value >= 0)
99
+
100
+ raise ApprovalChecks.invalid("#{name} must be a whole number, 0 or more", name => value)
101
+ end
102
+ unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 10_000)
103
+ raise ApprovalChecks.invalid('limit must be a whole number from 1 to 10000', limit: limit)
104
+ end
105
+
106
+ body = { 'topic' => topic }
107
+ body['afterAuditEventId'] = after_audit_event_id unless after_audit_event_id.nil?
108
+ body['fromSequence'] = from_sequence unless from_sequence.nil?
109
+ body['untilSequence'] = until_sequence unless until_sequence.nil?
110
+ body['limit'] = limit.nil? ? DEFAULT_REPLAY_LIMIT : limit
111
+ slug = Studio.require_project_slug(@config, 'audit')
112
+ envelope = HttpTransport.request(config: @config, method: 'POST',
113
+ path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
114
+ Envelope.new(data: replay_page(envelope.data), meta: envelope.meta)
115
+ end
116
+
117
+ private
118
+
119
+ # [topic, filter or nil]
120
+ def topic_for(types)
121
+ list = types.nil? ? [] : Array(types).map { |t| t.is_a?(Symbol) ? t.to_s : t }
122
+ list.each do |t|
123
+ unless t.is_a?(String) && TYPE_PATTERN.match?(t)
124
+ raise ApprovalChecks.invalid('types must be audit event type names such as "data.write.access"', type: t)
125
+ end
126
+ end
127
+ list = list.uniq
128
+ return ["#{TOPIC}.*", nil] if list.empty?
129
+ return ["#{TOPIC}.#{list.first}", nil] if list.size == 1
130
+
131
+ ["#{TOPIC}.*", ->(event) { list.include?(event['type']) }]
132
+ end
133
+
134
+ # The replay answer with its events flattened to the tail's shape (+sequence+ kept).
135
+ def replay_page(data)
136
+ data = {} unless data.is_a?(Hash)
137
+ events = Array(data['events']).map { |e| flatten_replayed(e) }.compact
138
+ { 'events' => events, 'nextCursor' => data['nextCursor'], 'hasMore' => data['hasMore'] == true }
139
+ end
140
+
141
+ def flatten_replayed(raw)
142
+ return nil unless raw.is_a?(Hash)
143
+
144
+ payload = raw['payload'].is_a?(Hash) ? raw['payload'] : {}
145
+ return nil unless payload['auditEventId'].is_a?(String)
146
+
147
+ event = {
148
+ 'auditEventId' => payload['auditEventId'],
149
+ 'type' => payload['type'].is_a?(String) ? payload['type'] : raw['eventType'],
150
+ 'actorType' => payload['actorType'],
151
+ 'occurredAt' => payload['occurredAt'],
152
+ 'outcome' => payload['outcome']
153
+ }
154
+ event['sequence'] = raw['sequence'] if raw.key?('sequence')
155
+ event
156
+ end
157
+
158
+ # Events after +last_id+ from the replay route, page after page while it says there are more. The route has no
159
+ # page cursor: each next page starts after the last event of the one before.
160
+ def collect_replay(topic, last_id)
161
+ out = []
162
+ cursor = last_id
163
+ loop do
164
+ data = replay(topic: topic, after_audit_event_id: cursor).data
165
+ events = data['events']
166
+ break if events.empty?
167
+
168
+ out.concat(events)
169
+ next_cursor = events.last['auditEventId']
170
+ break unless data['hasMore'] && next_cursor != cursor
171
+
172
+ cursor = next_cursor
173
+ end
174
+ out
175
+ end
28
176
  end
29
177
  end
data/lib/k9o/client.rb CHANGED
@@ -3,8 +3,10 @@
3
3
  module K9o
4
4
  # The customer-facing client returned by K9o.create_client.
5
5
  class K9oClient
6
- attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :org,
7
- :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
6
+ include QueryMethods
7
+
8
+ attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :streams, :org,
9
+ :approvals, :approval_policies, :schedules, :vectors, :metadata, :rampart, :custody, :end_users, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage, :analytics, :rollups, :keys, :crypto
8
10
 
9
11
  def initialize(config)
10
12
  @config = config
@@ -13,6 +15,16 @@ module K9o
13
15
  @studio = Studio.new(config)
14
16
  @compute = Compute.new(config)
15
17
  @realtime = Realtime.new(config)
18
+ @streams = Streams.new(config)
19
+ @approvals = Approvals.new(config)
20
+ @approval_policies = ApprovalPolicies.new(config)
21
+ @schedules = Schedules.new(config)
22
+ @vectors = Vectors.new(config)
23
+ @metadata = Metadata.new(config)
24
+ @rampart = Rampart.new(config)
25
+ @custody = Custody.new(config)
26
+ @end_users = EndUsers.new(config)
27
+ @schema = Schema.new(config)
16
28
  @org = Org.new(config)
17
29
  @orgs = Orgs.new(config)
18
30
  @migrations = Migrations.new(config)
@@ -23,6 +35,10 @@ module K9o
23
35
  @kibo = KiboNamespace.new(config)
24
36
  @ai = Ai.new(config)
25
37
  @storage = Storage.new(config)
38
+ @analytics = Analytics.new(config)
39
+ @rollups = Rollups.new(config)
40
+ @keys = Keys.new(config)
41
+ @crypto = Crypto.new(config)
26
42
  end
27
43
 
28
44
  def from(collection)
@@ -36,23 +52,26 @@ module K9o
36
52
 
37
53
  # Factory — construct a K9oClient bound to the supplied options.
38
54
  #
39
- # Per spec sample:
40
55
  # k9o = K9o.create_client(
41
56
  # api_key: ENV["K9O_API_KEY"],
42
57
  # org_slug: "acme",
43
58
  # project_slug: "member-portal",
44
59
  # )
45
60
  #
46
- # base_url is OPTIONAL. When omitted it
47
- # defaults to the platform apex https://api.k9o.io; when the host is the
48
- # bare apex and org_slug is set, the canonical org-subdomain wire root
49
- # https://<org_slug>.api.k9o.io is composed. Any other host passes through.
50
- def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil)
61
+ # base_url is OPTIONAL. When omitted it defaults to the public-cloud platform host (a convenience only, never used
62
+ # to parse a host). When the host is a platform host (+api.<apex>+ or +api-staging.<apex>+, any apex) and org_slug
63
+ # is set, the org host +<org_slug>.<host>+ is composed. Any other host passes through unchanged.
64
+ #
65
+ # expected_key_refs is optional: an Array of the key identifiers (from your own cloud configuration) your BYOK or HYOK
66
+ # key may be. It is checked here (see Crypto.check_expected_key_refs) and handed to the client core on every crypto call.
67
+ def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
68
+ expected_key_refs: nil)
69
+ refs = Crypto.check_expected_key_refs(expected_key_refs)
51
70
  normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
52
71
  config = HttpClientConfig.new(
53
72
  base_url: normalised,
54
73
  api_key: api_key, org_slug: org_slug, project_slug: project_slug,
55
- default_headers: default_headers, http_client: http_client
74
+ default_headers: default_headers, http_client: http_client, expected_key_refs: refs
56
75
  )
57
76
  K9oClient.new(config)
58
77
  end
data/lib/k9o/compute.rb CHANGED
@@ -4,30 +4,37 @@ module K9o
4
4
  # Compute namespace (Compute LLMs registry).
5
5
  # k9o.compute.llms.*
6
6
  class Compute
7
- # Curated 9-model closed enum (extensible v2.x).
7
+ # The curated model ids (closed enum).
8
8
  LLM_MODEL_IDS = %w[
9
9
  mistral-7b mistral-8x7b llama-3-8b llama-3-70b
10
10
  claude-3-5-sonnet gpt-4o gpt-4o-mini
11
11
  text-embedding-3-small text-embedding-3-large
12
12
  ].freeze
13
13
 
14
+ # Inference parameters the gateway reads (top-level body fields next to +prompt+). Snake_case spellings map to
15
+ # the gateway's.
16
+ PARAM_FIELDS = {
17
+ 'max_tokens' => 'maxTokens', 'maxTokens' => 'maxTokens',
18
+ 'temperature' => 'temperature',
19
+ 'usage_type' => 'usageType', 'usageType' => 'usageType'
20
+ }.freeze
21
+
14
22
  attr_reader :llms
15
23
 
16
24
  def initialize(config)
17
25
  @llms = Llms.new(config)
18
26
  end
19
27
 
20
- # k9o.compute.llms.*.
28
+ # k9o.compute.llms.*
21
29
  class Llms
22
30
  def initialize(config)
23
31
  @config = config
24
32
  end
25
33
 
26
- def list(modality: nil)
34
+ # GET /@<slug>/compute/llms
35
+ def list
27
36
  slug = Studio.require_project_slug(@config, 'compute.llms')
28
- q = modality ? { 'modality' => modality } : nil
29
- HttpTransport.request(config: @config, method: 'GET',
30
- path: UrlGrammar.project_path(slug, 'compute/llms'), query: q)
37
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'compute/llms'))
31
38
  end
32
39
 
33
40
  def read(model_id)
@@ -36,29 +43,39 @@ module K9o
36
43
  path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}"))
37
44
  end
38
45
 
39
- # Synchronous inference (PROJECT_AUTHOR/ADMIN).
46
+ # Synchronous inference: POST /@<slug>/compute/llms/<id>/invoke { prompt, maxTokens?, temperature?,
47
+ # usageType? }. +params+ takes max_tokens / temperature / usage_type (the gateway's spellings also work).
48
+ # The envelope's data is { 'modelId', 'content', 'finishReason', 'usage' => { 'promptTokens',
49
+ # 'completionTokens', 'totalTokens' }, 'usageType' }.
40
50
  def invoke(model_id, prompt:, params: nil)
41
51
  slug = Studio.require_project_slug(@config, 'compute.llms')
42
- body = { 'prompt' => prompt }
43
- body['params'] = params if params
44
52
  HttpTransport.request(config: @config, method: 'POST',
45
- path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"), body: body)
53
+ path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"),
54
+ body: invoke_body(prompt, params))
46
55
  end
47
56
 
48
- # Streaming inference (SSE per-token). Returns an
49
- # Enumerator of chunk hashes (Ruby idiom). Consume with
57
+ # Streaming inference (SSE per-token). Returns an Enumerator of chunk hashes. Consume with
50
58
  # `.each { |chunk| ... }`.
51
59
  def invoke_stream(model_id, prompt:, params: nil)
52
60
  slug = Studio.require_project_slug(@config, 'compute.llms')
53
- body = { 'prompt' => prompt }
54
- body['params'] = params if params
55
61
  HttpTransport.stream_sse(config: @config, method: 'POST',
56
62
  path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke-stream"),
57
- body: body)
63
+ body: invoke_body(prompt, params))
58
64
  end
59
65
 
60
66
  private
61
67
 
68
+ def invoke_body(prompt, params)
69
+ body = { 'prompt' => prompt }
70
+ (params || {}).each do |key, value|
71
+ wire = PARAM_FIELDS[key.to_s]
72
+ raise ArgumentError, "unknown inference parameter: #{key}" if wire.nil?
73
+
74
+ body[wire] = value unless value.nil?
75
+ end
76
+ body
77
+ end
78
+
62
79
  def enc(s)
63
80
  ::URI.encode_www_form_component(s)
64
81
  end