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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/lib/invoance/audit_canonical.rb +144 -0
- data/lib/invoance/audit_verify.rb +121 -0
- data/lib/invoance/client.rb +84 -0
- data/lib/invoance/config.rb +41 -0
- data/lib/invoance/errors.rb +104 -0
- data/lib/invoance/http.rb +174 -0
- data/lib/invoance/resources/attestations.rb +171 -0
- data/lib/invoance/resources/audit.rb +204 -0
- data/lib/invoance/resources/documents.rb +98 -0
- data/lib/invoance/resources/events.rb +71 -0
- data/lib/invoance/resources/traces.rb +60 -0
- data/lib/invoance/validate.rb +32 -0
- data/lib/invoance/version.rb +5 -0
- data/lib/invoance.rb +28 -0
- metadata +108 -0
|
@@ -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
|