k9o 1.3.0 → 1.5.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +1516 -159
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/ai.rb +15 -0
  6. data/lib/k9o/analytics.rb +403 -0
  7. data/lib/k9o/approvals.rb +253 -0
  8. data/lib/k9o/audit.rb +150 -0
  9. data/lib/k9o/client.rb +25 -4
  10. data/lib/k9o/crypto.rb +471 -0
  11. data/lib/k9o/custody.rb +28 -0
  12. data/lib/k9o/data.rb +125 -5
  13. data/lib/k9o/ed25519.rb +182 -0
  14. data/lib/k9o/end_users.rb +93 -0
  15. data/lib/k9o/envelope.rb +20 -5
  16. data/lib/k9o/functions.rb +82 -3
  17. data/lib/k9o/gate.rb +46 -7
  18. data/lib/k9o/http_transport.rb +71 -14
  19. data/lib/k9o/jcs.rb +117 -0
  20. data/lib/k9o/kerror.rb +57 -5
  21. data/lib/k9o/keys.rb +131 -0
  22. data/lib/k9o/kibo_client_core.rb +385 -0
  23. data/lib/k9o/kibo_ffi_core.rb +178 -0
  24. data/lib/k9o/locate.rb +118 -0
  25. data/lib/k9o/me.rb +1 -1
  26. data/lib/k9o/metadata.rb +342 -0
  27. data/lib/k9o/moat.rb +443 -0
  28. data/lib/k9o/org.rb +1 -1
  29. data/lib/k9o/query.rb +174 -0
  30. data/lib/k9o/rampart.rb +273 -0
  31. data/lib/k9o/realtime.rb +261 -10
  32. data/lib/k9o/schedules.rb +109 -0
  33. data/lib/k9o/schema.rb +35 -0
  34. data/lib/k9o/streams.rb +94 -0
  35. data/lib/k9o/vault.rb +3 -4
  36. data/lib/k9o/vectors.rb +124 -0
  37. data/lib/k9o/version.rb +1 -1
  38. data/lib/k9o.rb +17 -0
  39. data/sig/k9o/ai.rbs +4 -0
  40. data/sig/k9o/approvals.rbs +71 -0
  41. data/sig/k9o/datasets.rbs +125 -0
  42. data/sig/k9o/gate.rbs +2 -0
  43. data/sig/k9o/kerror.rbs +24 -0
  44. data/sig/k9o/kibo_client_core.rbs +25 -0
  45. data/sig/k9o/metadata.rbs +54 -0
  46. data/sig/k9o/moat.rbs +29 -0
  47. data/sig/k9o/query.rbs +26 -0
  48. data/sig/k9o/rampart.rbs +59 -0
  49. data/sig/k9o/realtime.rbs +34 -0
  50. data/sig/k9o/workload.rbs +64 -0
  51. 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
@@ -4,6 +4,11 @@ module K9o
4
4
  # Project-plane audit log namespace — k9o.audit.*.
5
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
@@ -23,5 +28,150 @@ module K9o
23
28
  q['cursor'] = cursor unless cursor.nil?
24
29
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'audit-log'), query: q)
25
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
26
176
  end
27
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)
@@ -45,12 +61,17 @@ module K9o
45
61
  # base_url is OPTIONAL. When omitted it defaults to the public-cloud platform host (a convenience only, never used
46
62
  # to parse a host). When the host is a platform host (+api.<apex>+ or +api-staging.<apex>+, any apex) and org_slug
47
63
  # is set, the org host +<org_slug>.<host>+ is composed. Any other host passes through unchanged.
48
- def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil)
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)
49
70
  normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
50
71
  config = HttpClientConfig.new(
51
72
  base_url: normalised,
52
73
  api_key: api_key, org_slug: org_slug, project_slug: project_slug,
53
- default_headers: default_headers, http_client: http_client
74
+ default_headers: default_headers, http_client: http_client, expected_key_refs: refs
54
75
  )
55
76
  K9oClient.new(config)
56
77
  end