invoance 0.1.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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoance
4
+ # Base error for everything the SDK raises — API responses, network
5
+ # failures, and client-side validation. A single
6
+ # `rescue Invoance::Error` catches anything the SDK throws.
7
+ #
8
+ # begin
9
+ # client.events.ingest(event_type: "user.login", payload: { user_id: "u_42" })
10
+ # rescue Invoance::QuotaExceededError
11
+ # puts "Upgrade your plan"
12
+ # rescue Invoance::TimeoutError
13
+ # puts "Request timed out — retrying"
14
+ # rescue Invoance::Error => e
15
+ # puts "Invoance error: #{e.message}"
16
+ # end
17
+ class Error < StandardError
18
+ attr_reader :status_code, :error_code, :body, :retry_after_seconds, :request_context
19
+
20
+ # @param message [String]
21
+ # @param status_code [Integer, nil]
22
+ # @param error_code [String, nil]
23
+ # @param body [Hash, nil] parsed JSON body (string keys)
24
+ # @param retry_after_seconds [Numeric, nil]
25
+ # @param request_context [Hash, nil] { method:, path: }
26
+ def initialize(message, status_code: nil, error_code: nil, body: nil,
27
+ retry_after_seconds: nil, request_context: nil)
28
+ super(message)
29
+ @status_code = status_code
30
+ @error_code = error_code
31
+ @body = body
32
+ @retry_after_seconds = retry_after_seconds
33
+ @request_context = request_context
34
+ end
35
+ end
36
+
37
+ class AuthenticationError < Error; end
38
+ class ForbiddenError < Error; end
39
+ class NotFoundError < Error; end
40
+
41
+ # 400 from the server, and also raised client-side for bad input.
42
+ class ValidationError < Error; end
43
+
44
+ class ConflictError < Error; end
45
+ class QuotaExceededError < Error; end
46
+ class ServerError < Error; end
47
+
48
+ # Raised when the request fails before a response is received —
49
+ # DNS failure, connection refused, TLS handshake error, etc.
50
+ class NetworkError < Error; end
51
+
52
+ # Raised when the request exceeds the configured timeout.
53
+ class TimeoutError < Error; end
54
+
55
+ # Status-code → error-class map. Anything >= 500 not listed maps to
56
+ # ServerError; any other unmapped status maps to the base Error.
57
+ STATUS_ERROR_MAP = {
58
+ 400 => ValidationError,
59
+ 401 => AuthenticationError,
60
+ 403 => ForbiddenError,
61
+ 404 => NotFoundError,
62
+ 409 => ConflictError,
63
+ 429 => QuotaExceededError
64
+ }.freeze
65
+
66
+ # Raise the appropriate error for a non-2xx response. No-op on 2xx.
67
+ #
68
+ # @param status_code [Integer]
69
+ # @param body [Hash, nil] parsed JSON body (string keys), or nil
70
+ # @param request_context [Hash, nil] { method:, path: }
71
+ # @param retry_after_seconds [Numeric, nil]
72
+ def self.throw_for_status(status_code, body, request_context: nil, retry_after_seconds: nil)
73
+ return if status_code >= 200 && status_code < 300
74
+
75
+ b = body || {}
76
+ error_code = b["error"] || "unknown"
77
+ server_message = b["message"]
78
+
79
+ message =
80
+ if server_message
81
+ server_message
82
+ elsif status_code == 429 && !retry_after_seconds.nil?
83
+ "HTTP 429#{describe_request(request_context)} — rate limited, retry after #{retry_after_seconds}s"
84
+ else
85
+ "HTTP #{status_code}#{describe_request(request_context)} (no response body)"
86
+ end
87
+
88
+ klass = STATUS_ERROR_MAP[status_code] || (status_code >= 500 ? ServerError : Error)
89
+
90
+ raise klass.new(
91
+ message,
92
+ status_code: status_code,
93
+ error_code: error_code,
94
+ body: b,
95
+ request_context: request_context,
96
+ retry_after_seconds: retry_after_seconds
97
+ )
98
+ end
99
+
100
+ # @api private
101
+ def self.describe_request(ctx)
102
+ ctx ? " on #{ctx[:method]} #{ctx[:path]}" : ""
103
+ end
104
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "time"
7
+ require "openssl"
8
+
9
+ require_relative "errors"
10
+ require_relative "version"
11
+
12
+ module Invoance
13
+ # Low-level HTTP transport using stdlib net/http. Zero external
14
+ # dependencies. Network-level failures are raised as {Invoance::Error}
15
+ # subclasses (NetworkError / TimeoutError) so a single rescue is
16
+ # exhaustive.
17
+ #
18
+ # @api private
19
+ class Http
20
+ # @param config [Invoance::Config]
21
+ def initialize(config)
22
+ @config = config
23
+ @base_headers = {
24
+ "Authorization" => "Bearer #{config.api_key}",
25
+ "Accept" => "application/json",
26
+ "Content-Type" => "application/json",
27
+ "User-Agent" => "invoance-ruby/#{Invoance::VERSION}"
28
+ }.merge(config.extra_headers)
29
+ end
30
+
31
+ # GET returning parsed JSON (Hash with string keys).
32
+ def get(path, params = nil)
33
+ uri = build_uri(path, params)
34
+ ctx = { method: "GET", path: path }
35
+ resp = do_request(Net::HTTP::Get.new(uri), uri, ctx, headers: @base_headers)
36
+ handle(resp, ctx)
37
+ end
38
+
39
+ # POST returning parsed JSON. Sends Idempotency-Key when available.
40
+ def post(path, body = nil, idempotency_key = nil)
41
+ uri = build_uri(path)
42
+ ctx = { method: "POST", path: path }
43
+ headers = @base_headers.dup
44
+ idem = idempotency_key || @config.idempotency_key
45
+ headers["Idempotency-Key"] = idem if idem
46
+ req = Net::HTTP::Post.new(uri)
47
+ req.body = JSON.generate(body) unless body.nil?
48
+ resp = do_request(req, uri, ctx, headers: headers)
49
+ handle(resp, ctx)
50
+ end
51
+
52
+ # PUT returning parsed JSON.
53
+ def put(path, body = nil)
54
+ uri = build_uri(path)
55
+ ctx = { method: "PUT", path: path }
56
+ req = Net::HTTP::Put.new(uri)
57
+ req.body = JSON.generate(body) unless body.nil?
58
+ resp = do_request(req, uri, ctx, headers: @base_headers)
59
+ handle(resp, ctx)
60
+ end
61
+
62
+ # DELETE returning parsed JSON.
63
+ def delete(path)
64
+ uri = build_uri(path)
65
+ ctx = { method: "DELETE", path: path }
66
+ resp = do_request(Net::HTTP::Delete.new(uri), uri, ctx, headers: @base_headers)
67
+ handle(resp, ctx)
68
+ end
69
+
70
+ # GET returning raw bytes (binary String). Sets
71
+ # Accept: application/octet-stream and drops Content-Type.
72
+ def get_bytes(path)
73
+ uri = build_uri(path)
74
+ ctx = { method: "GET", path: path }
75
+ headers = @base_headers.dup
76
+ headers["Accept"] = "application/octet-stream"
77
+ headers.delete("Content-Type")
78
+ resp = do_request(Net::HTTP::Get.new(uri), uri, ctx, headers: headers)
79
+
80
+ unless success?(resp)
81
+ body = parse_json_body(resp)
82
+ Invoance.throw_for_status(resp.code.to_i, body,
83
+ request_context: ctx,
84
+ retry_after_seconds: parse_retry_after(resp["retry-after"]))
85
+ end
86
+
87
+ body = resp.body || ""
88
+ body.dup.force_encoding(Encoding::BINARY)
89
+ end
90
+
91
+ # GET returning the decoded JSON value untyped (used by attestations raw).
92
+ def get_raw(path)
93
+ get(path)
94
+ end
95
+
96
+ private
97
+
98
+ def build_uri(path, params = nil)
99
+ base = "#{@config.base_url}/#{@config.api_version}#{path}"
100
+ uri = URI.parse(base)
101
+ if params
102
+ pairs = params.reject { |_, v| v.nil? }.map { |k, v| [k.to_s, v.to_s] }
103
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
104
+ end
105
+ uri
106
+ end
107
+
108
+ def do_request(req, uri, ctx, headers:)
109
+ headers.each { |k, v| req[k] = v }
110
+ http = Net::HTTP.new(uri.host, uri.port)
111
+ http.use_ssl = (uri.scheme == "https")
112
+ http.open_timeout = @config.timeout
113
+ http.read_timeout = @config.timeout
114
+ http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
115
+
116
+ begin
117
+ http.request(req)
118
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error => e
119
+ raise TimeoutError.new(
120
+ "Request timed out after #{@config.timeout}s on #{ctx[:method]} #{ctx[:path]}",
121
+ request_context: ctx
122
+ )
123
+ rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError => e
124
+ raise NetworkError.new(
125
+ "Network failure on #{ctx[:method]} #{ctx[:path]}: #{e.message}",
126
+ request_context: ctx
127
+ )
128
+ end
129
+ end
130
+
131
+ def handle(resp, ctx)
132
+ body = parse_json_body(resp)
133
+ Invoance.throw_for_status(resp.code.to_i, body,
134
+ request_context: ctx,
135
+ retry_after_seconds: parse_retry_after(resp["retry-after"]))
136
+ body
137
+ end
138
+
139
+ def parse_json_body(resp)
140
+ raw = resp.body
141
+ return nil if raw.nil? || raw.empty?
142
+
143
+ JSON.parse(raw)
144
+ rescue JSON::ParserError
145
+ nil
146
+ end
147
+
148
+ def success?(resp)
149
+ code = resp.code.to_i
150
+ code >= 200 && code < 300
151
+ end
152
+
153
+ # Parse the Retry-After header: numeric seconds, or an HTTP-date
154
+ # converted to a delta from now (floored at 0). Returns nil if absent
155
+ # or unparseable.
156
+ def parse_retry_after(value)
157
+ return nil if value.nil? || value.to_s.strip.empty?
158
+
159
+ s = value.to_s.strip
160
+ if s.match?(/\A\d+(\.\d+)?\z/)
161
+ seconds = s.to_f
162
+ return seconds if seconds >= 0
163
+ end
164
+
165
+ begin
166
+ ts = Time.httpdate(s)
167
+ delta = ts.to_f - Time.now.to_f
168
+ return delta.negative? ? 0.0 : delta
169
+ rescue ArgumentError
170
+ nil
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ require_relative "../validate"
7
+ require_relative "../audit_verify"
8
+
9
+ module Invoance
10
+ module Resources
11
+ # AI Attestations resource — client.attestations.*
12
+ class Attestations
13
+ # @param http [Invoance::Http]
14
+ def initialize(http)
15
+ @http = http
16
+ end
17
+
18
+ # POST /ai/attestations — Anchor an AI attestation.
19
+ #
20
+ # The body is NESTED and field order matters for the server's hashing
21
+ # (type, payload, context, subject) — do not reorder.
22
+ #
23
+ # @param type [String]
24
+ # @param input [Object]
25
+ # @param output [Object]
26
+ # @param model_provider [String, nil]
27
+ # @param model_name [String, nil]
28
+ # @param model_version [String, nil]
29
+ # @param subject [Hash, nil] user_id/session_id + arbitrary extra keys
30
+ # @param trace_id [String, nil]
31
+ # @param idempotency_key [String, nil]
32
+ # @return [Hash] IngestAttestationResponse
33
+ def ingest(type:, input:, output:, model_provider: nil, model_name: nil,
34
+ model_version: nil, subject: nil, trace_id: nil, idempotency_key: nil)
35
+ body = {
36
+ "type" => type,
37
+ "payload" => { "input" => input, "output" => output },
38
+ "context" => {
39
+ "model_provider" => model_provider,
40
+ "model_name" => model_name,
41
+ "model_version" => model_version
42
+ }
43
+ }
44
+
45
+ unless subject.nil?
46
+ s = symbolize_subject(subject)
47
+ user_id = s.delete(:user_id)
48
+ session_id = s.delete(:session_id)
49
+ subj = {}
50
+ subj["user_id"] = user_id unless user_id.nil?
51
+ subj["session_id"] = session_id unless session_id.nil?
52
+ # Pass through any remaining extra keys, preserving their string form.
53
+ s.each { |k, v| subj[k.to_s] = v }
54
+ body["subject"] = subj unless subj.empty?
55
+ end
56
+
57
+ body["trace_id"] = trace_id unless trace_id.nil?
58
+
59
+ @http.post("/ai/attestations", body, idempotency_key)
60
+ end
61
+
62
+ # GET /ai/attestations — Paginated attestation listing.
63
+ # @return [Hash] ListAttestationsResponse
64
+ def list(page: nil, limit: nil, date_from: nil, date_to: nil,
65
+ attestation_type: nil, model_provider: nil)
66
+ @http.get("/ai/attestations", {
67
+ "page" => page,
68
+ "limit" => limit,
69
+ "date_from" => date_from,
70
+ "date_to" => date_to,
71
+ "attestation_type" => attestation_type,
72
+ "model_provider" => model_provider
73
+ })
74
+ end
75
+
76
+ # GET /ai/attestations/:id — Retrieve a single attestation.
77
+ # @return [Hash] AiAttestation
78
+ def get(attestation_id)
79
+ @http.get("/ai/attestations/#{attestation_id}")
80
+ end
81
+
82
+ # POST /ai/attestations/:id/verify — Hash verification.
83
+ # @return [Hash] VerifyAttestationResponse
84
+ def verify(attestation_id, content_hash:)
85
+ Validate.assert_sha256_hex("content_hash", content_hash)
86
+ @http.post("/ai/attestations/#{attestation_id}/verify",
87
+ { "content_hash" => content_hash })
88
+ end
89
+
90
+ # GET /ai/attestations/:id/raw — Retrieve the canonical JSON payload.
91
+ # @return [Hash]
92
+ def get_raw(attestation_id)
93
+ @http.get_raw("/ai/attestations/#{attestation_id}/raw")
94
+ end
95
+
96
+ # Verify by raw payload — hashes client-side, then calls verify.
97
+ #
98
+ # Accepts the canonical JSON stored in Invoance as a raw String or a
99
+ # Hash. Key ORDER is PRESERVED (not sorted) because the backend hashes
100
+ # using serde_json struct field order (type, payload, context, subject).
101
+ #
102
+ # * String input: JSON.parse preserves object key order into an
103
+ # insertion-ordered Hash, and JSON.generate preserves it — giving
104
+ # compact order-preserving JSON (= JS JSON.stringify(JSON.parse(s))).
105
+ # * Hash input: JSON.generate preserves insertion order — pass the keys
106
+ # already in the correct order.
107
+ #
108
+ # @param attestation_id [String]
109
+ # @param payload [String, Hash]
110
+ # @return [Hash] VerifyAttestationResponse
111
+ def verify_payload(attestation_id, payload)
112
+ canonical =
113
+ if payload.is_a?(String)
114
+ JSON.generate(JSON.parse(payload))
115
+ else
116
+ JSON.generate(payload)
117
+ end
118
+
119
+ content_hash = Digest::SHA256.hexdigest(canonical)
120
+ verify(attestation_id, content_hash: content_hash)
121
+ end
122
+
123
+ # Verify the Ed25519 signature of an attestation — fully client-side.
124
+ #
125
+ # Fetches the attestation, then verifies the signature against
126
+ # signed_payload using public_key. Requires no trust in the server.
127
+ #
128
+ # @param attestation_id [String]
129
+ # @return [Hash] { "valid" =>, "reason" =>, "attestation" =>, "signed_data" => }
130
+ def verify_signature(attestation_id)
131
+ att = get(attestation_id)
132
+
133
+ signed_payload_bytes = hex_to_bytes(att["signed_payload"].to_s)
134
+ signature_bytes = hex_to_bytes(att["signature"].to_s)
135
+ public_key_bytes = hex_to_bytes(att["public_key"].to_s)
136
+
137
+ valid = false
138
+ reason = nil
139
+ begin
140
+ valid = AuditVerify.ed25519_verify(signed_payload_bytes, signature_bytes, public_key_bytes)
141
+ reason = "Signature does not match signed_payload + public_key" unless valid
142
+ rescue StandardError => e
143
+ valid = false
144
+ reason = e.message
145
+ end
146
+
147
+ signed_data =
148
+ begin
149
+ JSON.parse(signed_payload_bytes.dup.force_encoding(Encoding::UTF_8))
150
+ rescue JSON::ParserError
151
+ nil
152
+ end
153
+
154
+ { "valid" => valid, "reason" => reason, "attestation" => att, "signed_data" => signed_data }
155
+ end
156
+
157
+ private
158
+
159
+ def hex_to_bytes(hex)
160
+ [hex].pack("H*")
161
+ end
162
+
163
+ def symbolize_subject(subject)
164
+ subject.each_with_object({}) do |(k, v), out|
165
+ key = k.is_a?(String) ? k.to_sym : k
166
+ out[key] = v
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+
7
+ module Invoance
8
+ module Resources
9
+ # Deep-sort all Hash keys (alphabetically) then compact-serialize.
10
+ # Matches Node's stableStringify: no null-stripping, no schema forcing.
11
+ # @api private
12
+ def self.stable_stringify(value)
13
+ JSON.generate(sort_deep_for_stable(value))
14
+ end
15
+
16
+ # @api private
17
+ def self.sort_deep_for_stable(v)
18
+ case v
19
+ when Array
20
+ v.map { |x| sort_deep_for_stable(x) }
21
+ when Hash
22
+ v.keys.map(&:to_s).sort.each_with_object({}) do |k, out|
23
+ orig = v.key?(k) ? k : k.to_sym
24
+ out[k] = sort_deep_for_stable(v[orig])
25
+ end
26
+ else
27
+ v
28
+ end
29
+ end
30
+
31
+ # Derive a stable Idempotency-Key from an event body (safe-retry helper).
32
+ #
33
+ # @param body [Hash]
34
+ # @return [String]
35
+ def self.content_idempotency_key(body)
36
+ "idem_" + Digest::SHA256.hexdigest(stable_stringify(body))
37
+ end
38
+
39
+ # Audit Logs resource — client.audit.*
40
+ #
41
+ # Five sub-resources: events, orgs, streams, portal_sessions, exports.
42
+ class Audit
43
+ # audit.events.*
44
+ class Events
45
+ def initialize(http)
46
+ @http = http
47
+ end
48
+
49
+ # POST /audit/events — append one signed event.
50
+ # The ledger REQUIRES an Idempotency-Key; derive a content-stable one
51
+ # when none is provided.
52
+ # @return [Hash]
53
+ def ingest(organization_id:, action:, actor:, occurred_at: nil, targets: nil,
54
+ context: nil, metadata: nil, idempotency_key: nil)
55
+ body = {
56
+ "organization_id" => organization_id,
57
+ "action" => action,
58
+ "occurred_at" => occurred_at || Time.now.utc.iso8601,
59
+ "actor" => actor,
60
+ "targets" => targets.nil? ? [] : targets
61
+ }
62
+ body["context"] = context if context
63
+ body["metadata"] = metadata if metadata
64
+ idem = idempotency_key || Resources.content_idempotency_key(body)
65
+ @http.post("/audit/events", body, idem)
66
+ end
67
+
68
+ # GET /audit/events — keyset-paginated listing.
69
+ # @return [Hash] ListAuditEventsResponse
70
+ def list(organization_id: nil, actions: nil, actor_id: nil, target_id: nil,
71
+ range_start: nil, range_end: nil, limit: nil, cursor: nil)
72
+ @http.get("/audit/events", {
73
+ "organization_id" => organization_id,
74
+ "actions" => actions,
75
+ "actor_id" => actor_id,
76
+ "target_id" => target_id,
77
+ "range_start" => range_start,
78
+ "range_end" => range_end,
79
+ "limit" => limit,
80
+ "cursor" => cursor
81
+ })
82
+ end
83
+
84
+ # GET /audit/events/:id
85
+ # @return [Hash] AuditEvent
86
+ def get(event_id)
87
+ @http.get("/audit/events/#{event_id}")
88
+ end
89
+
90
+ # GET /audit/events/:id/verify — server-side verify (pinned key).
91
+ # @return [Hash]
92
+ def verify(event_id)
93
+ @http.get("/audit/events/#{event_id}/verify")
94
+ end
95
+ end
96
+
97
+ # audit.orgs.*
98
+ class Orgs
99
+ def initialize(http)
100
+ @http = http
101
+ end
102
+
103
+ # POST /audit/orgs
104
+ def create(organization_id:, name: nil)
105
+ body = { "organization_id" => organization_id }
106
+ body["name"] = name if name
107
+ @http.post("/audit/orgs", body)
108
+ end
109
+
110
+ # GET /audit/orgs
111
+ def list
112
+ @http.get("/audit/orgs")
113
+ end
114
+
115
+ # GET /audit/orgs/:org_id/integrity
116
+ def integrity(organization_id)
117
+ @http.get("/audit/orgs/#{organization_id}/integrity")
118
+ end
119
+
120
+ # PUT /audit/orgs/:org_id/retention
121
+ def set_retention(organization_id, days)
122
+ @http.put("/audit/orgs/#{organization_id}/retention", { "days" => days })
123
+ end
124
+ end
125
+
126
+ # audit.streams.*
127
+ class Streams
128
+ def initialize(http)
129
+ @http = http
130
+ end
131
+
132
+ # POST /audit/orgs/:org_id/streams — create a webhook stream.
133
+ # The signing secret is returned ONCE.
134
+ def create(organization_id, url:, type: "webhook")
135
+ @http.post("/audit/orgs/#{organization_id}/streams", {
136
+ "type" => type,
137
+ "url" => url
138
+ })
139
+ end
140
+
141
+ # GET /audit/orgs/:org_id/streams
142
+ def list(organization_id)
143
+ @http.get("/audit/orgs/#{organization_id}/streams")
144
+ end
145
+
146
+ # DELETE /audit/orgs/:org_id/streams/:stream_id
147
+ def delete(organization_id, stream_id)
148
+ @http.delete("/audit/orgs/#{organization_id}/streams/#{stream_id}")
149
+ end
150
+
151
+ # POST /audit/orgs/:org_id/streams/:stream_id/test
152
+ def test(organization_id, stream_id)
153
+ @http.post("/audit/orgs/#{organization_id}/streams/#{stream_id}/test")
154
+ end
155
+ end
156
+
157
+ # audit.portal_sessions.*
158
+ class PortalSessions
159
+ def initialize(http)
160
+ @http = http
161
+ end
162
+
163
+ # POST /audit/portal_sessions — mint a one-time hosted-viewer link.
164
+ def create(organization_id:, intent:, session_duration_seconds: nil,
165
+ link_duration_seconds: nil)
166
+ body = { "organization_id" => organization_id, "intent" => intent }
167
+ body["session_duration_seconds"] = session_duration_seconds unless session_duration_seconds.nil?
168
+ body["link_duration_seconds"] = link_duration_seconds unless link_duration_seconds.nil?
169
+ @http.post("/audit/portal_sessions", body)
170
+ end
171
+ end
172
+
173
+ # audit.exports.*
174
+ class Exports
175
+ def initialize(http)
176
+ @http = http
177
+ end
178
+
179
+ # POST /audit/exports — queue an async CSV/NDJSON export job.
180
+ # @param format [String] "csv" or "ndjson"
181
+ def create(organization_id:, format:, filters: nil)
182
+ body = { "organization_id" => organization_id, "format" => format }
183
+ body["filters"] = filters if filters
184
+ @http.post("/audit/exports", body)
185
+ end
186
+
187
+ # GET /audit/exports/:id — poll a job.
188
+ def get(export_id)
189
+ @http.get("/audit/exports/#{export_id}")
190
+ end
191
+ end
192
+
193
+ attr_reader :events, :orgs, :streams, :portal_sessions, :exports
194
+
195
+ def initialize(http)
196
+ @events = Events.new(http)
197
+ @orgs = Orgs.new(http)
198
+ @streams = Streams.new(http)
199
+ @portal_sessions = PortalSessions.new(http)
200
+ @exports = Exports.new(http)
201
+ end
202
+ end
203
+ end
204
+ end