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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 465e862ed29dc6dc0d5c4829472f739ddbaaace820845dd7c54bb6d9aabd77dc
4
+ data.tar.gz: 49e142ab37c383cd94b14aaa6f02a456e7a190bbdd961de45d9f4e215febe56c
5
+ SHA512:
6
+ metadata.gz: 6e26cf457a2f054a6d027c30a2fcb3b6335b220431de8d4b67541c6be824287a934ddc5b4509017602fc2949240489c7760fd85b4b024b00bbe609c82e43c078
7
+ data.tar.gz: ca148fd01bef837bc515093332b3908866a6ce6ebbae7ba02cc97bb84ca33e0869655d128681895d767a2b339ce130d302c55cb21b6c024423aadf64814400a1
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-06
9
+
10
+ ### Added
11
+
12
+ - Initial release of the official Ruby SDK for the Invoance compliance API.
13
+ - `Invoance::Client` with resource accessors: `events`, `documents`,
14
+ `attestations`, `traces`, and `audit` (sub-resources `audit.events`,
15
+ `audit.orgs`, `audit.streams`, `audit.portal_sessions`, `audit.exports`).
16
+ - Synchronous HTTP transport built on Ruby stdlib (`net/http`) with zero runtime
17
+ gem dependencies.
18
+ - Full error hierarchy under `Invoance::Error` (`AuthenticationError`,
19
+ `ForbiddenError`, `NotFoundError`, `ValidationError`, `ConflictError`,
20
+ `QuotaExceededError`, `ServerError`, `NetworkError`, `TimeoutError`) with
21
+ status-code mapping and `Retry-After` parsing.
22
+ - Client-side input validation for SHA-256 hex digests.
23
+ - Offline audit-log verification: `invoance.audit/1` canonicalization
24
+ (`Invoance::AuditCanonical`) and Ed25519 signature verification
25
+ (`Invoance::AuditVerify`) using stdlib OpenSSL.
26
+ - `attestations.verify_signature` and `attestations.verify_payload` for
27
+ client-side AI-attestation verification.
28
+ - `Invoance::Resources.content_idempotency_key` for content-stable idempotency keys.
29
+ - `Client#validate` for a non-raising API-key/connectivity probe.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Invoance, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # Invoance Ruby SDK
2
+
3
+ Official Ruby SDK for the [Invoance](https://invoance.com) compliance API —
4
+ cryptographic proof infrastructure for events, documents, AI attestations,
5
+ traces, and audit logs.
6
+
7
+ - **Synchronous** — methods return values and raise exceptions on error.
8
+ - **Zero runtime dependencies** — pure Ruby stdlib (`net/http`, `json`,
9
+ `openssl`, `digest`).
10
+ - **Offline verification** — check Ed25519 signatures and recompute audit-log
11
+ canonical hashes without trusting the server.
12
+
13
+ Requires **Ruby >= 3.0**.
14
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "invoance"
21
+ ```
22
+
23
+ Then:
24
+
25
+ ```console
26
+ $ bundle install
27
+ ```
28
+
29
+ Or install directly:
30
+
31
+ ```console
32
+ $ gem install invoance
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```ruby
38
+ require "invoance"
39
+
40
+ # Reads INVOANCE_API_KEY and INVOANCE_BASE_URL from the environment.
41
+ client = Invoance::Client.new
42
+
43
+ # Or pass the key explicitly:
44
+ client = Invoance::Client.new(api_key: "inv_live_abc123")
45
+
46
+ # Ingest a compliance event
47
+ event = client.events.ingest(
48
+ event_type: "user.login",
49
+ payload: { "user_id" => "u_42" }
50
+ )
51
+ puts event["event_id"]
52
+
53
+ # Verify the API key / connectivity (never raises)
54
+ result = client.validate
55
+ raise "bad config: #{result["reason"]}" unless result["valid"]
56
+ ```
57
+
58
+ Responses are plain Ruby `Hash`es with **string keys** matching the wire JSON
59
+ exactly — no symbolization.
60
+
61
+ ## Configuration
62
+
63
+ ```ruby
64
+ client = Invoance::Client.new(
65
+ api_key: "inv_live_...", # or ENV["INVOANCE_API_KEY"]
66
+ base_url: "https://api.invoance.com", # or ENV["INVOANCE_BASE_URL"]
67
+ api_version: "v1",
68
+ timeout: 30, # seconds
69
+ idempotency_key: nil, # default for mutating requests
70
+ extra_headers: {} # merged into every request
71
+ )
72
+ ```
73
+
74
+ ## Resources
75
+
76
+ ### Events
77
+
78
+ ```ruby
79
+ client.events.ingest(event_type:, payload:, event_time: nil, trace_id: nil, idempotency_key: nil)
80
+ client.events.list(page: nil, limit: nil, date_from: nil, date_to: nil, event_type: nil)
81
+ client.events.get(event_id)
82
+ client.events.verify(event_id, payload_hash: nil, payload: nil) # provide exactly one
83
+ ```
84
+
85
+ ### Documents
86
+
87
+ ```ruby
88
+ client.documents.anchor(document_hash:, document_ref: nil, event_type: nil,
89
+ original_bytes_b64: nil, metadata: nil, trace_id: nil,
90
+ idempotency_key: nil)
91
+
92
+ # Convenience: hash + base64 a file, then anchor.
93
+ client.documents.anchor_file(file: "./invoice.pdf", document_ref: "Invoice #1042")
94
+ # For raw bytes instead of a path:
95
+ client.documents.anchor_file(file: bytes, is_path: false, document_ref: "blob")
96
+
97
+ client.documents.list(...)
98
+ client.documents.get(event_id)
99
+ client.documents.get_original(event_id) # => raw binary String
100
+ client.documents.verify(event_id, document_hash:)
101
+ ```
102
+
103
+ ### AI Attestations
104
+
105
+ ```ruby
106
+ client.attestations.ingest(
107
+ type: "chat.completion",
108
+ input: { "prompt" => "..." },
109
+ output: { "text" => "..." },
110
+ model_provider: "openai",
111
+ model_name: "gpt-4o",
112
+ model_version: "2026-01",
113
+ subject: { user_id: "u_1", session_id: "s_9" } # optional
114
+ )
115
+
116
+ client.attestations.list(...)
117
+ client.attestations.get(id)
118
+ client.attestations.verify(id, content_hash:)
119
+ client.attestations.get_raw(id) # => Hash
120
+
121
+ # Client-side verification (no trust in the server):
122
+ client.attestations.verify_payload(id, raw_json_string_or_hash)
123
+ result = client.attestations.verify_signature(id)
124
+ result["valid"] # => true/false
125
+ ```
126
+
127
+ > **Note:** for `verify_payload`, pass the raw JSON string exactly as shown in
128
+ > the dashboard's "Raw immutable record" viewer. Key order is preserved (not
129
+ > sorted) because the backend hashes with struct field order.
130
+
131
+ ### Traces
132
+
133
+ ```ruby
134
+ trace = client.traces.create(label: "Batch 2026-07")
135
+ client.traces.list(status: "open")
136
+ client.traces.get(trace_id, event_page: 1, event_limit: 50)
137
+ client.traces.seal(trace_id)
138
+ client.traces.proof(trace_id) # => Hash
139
+ client.traces.proof_pdf(trace_id) # => raw binary PDF String
140
+ client.traces.delete(trace_id)
141
+ ```
142
+
143
+ ### Audit Logs
144
+
145
+ ```ruby
146
+ client.audit.orgs.create(organization_id: "org_1", name: "Acme")
147
+ client.audit.events.ingest(
148
+ organization_id: "org_1",
149
+ action: "invoice.approved",
150
+ actor: { "type" => "user", "id" => "u_1" },
151
+ targets: [{ "type" => "invoice", "id" => "inv_9" }]
152
+ )
153
+ client.audit.events.list(organization_id: "org_1", limit: 100)
154
+ client.audit.streams.create("org_1", url: "https://siem.example/hook")
155
+ client.audit.portal_sessions.create(organization_id: "org_1", intent: "viewer")
156
+ client.audit.exports.create(organization_id: "org_1", format: "csv")
157
+ ```
158
+
159
+ Offline verify an audit event returned by the API:
160
+
161
+ ```ruby
162
+ event = client.audit.events.get("evt_123")
163
+ result = Invoance::AuditVerify.verify_audit_event(event)
164
+ # => { "valid" => true, "reason" => nil, "payload_hash" => "...", "key_source" => "event" }
165
+
166
+ # Pin against the tenant's registered key for a real tamper guarantee:
167
+ Invoance::AuditVerify.verify_audit_event(event, public_key: registered_hex_key)
168
+ ```
169
+
170
+ ## Error handling
171
+
172
+ Everything the SDK raises inherits from `Invoance::Error`:
173
+
174
+ ```ruby
175
+ begin
176
+ client.events.ingest(event_type: "user.login", payload: {})
177
+ rescue Invoance::QuotaExceededError => e
178
+ puts "rate limited, retry after #{e.retry_after_seconds}s"
179
+ rescue Invoance::AuthenticationError
180
+ puts "check INVOANCE_API_KEY"
181
+ rescue Invoance::Error => e
182
+ puts "Invoance error: #{e.message} (status #{e.status_code})"
183
+ end
184
+ ```
185
+
186
+ Every error exposes `status_code`, `error_code`, `body`,
187
+ `retry_after_seconds`, and `request_context`.
188
+
189
+ ## Development
190
+
191
+ ```console
192
+ $ bundle install
193
+ $ bundle exec rspec
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Invoance
7
+ # `invoance.audit/1` canonical serializer (client-side).
8
+ #
9
+ # Reproduces the server's frozen canonicalization so an event's signature
10
+ # can be checked offline. Canonical bytes = build the signed object
11
+ # (signed fields present + non-nil, timestamps normalized, forced
12
+ # schema_id), strip nil members recursively, sort every object's keys
13
+ # deeply (alphabetical), emit compact UTF-8.
14
+ #
15
+ # NOTE: Ruby's JSON.generate produces compact output, does NOT escape
16
+ # forward slashes, and emits UTF-8 literally — matching Node's
17
+ # JSON.stringify.
18
+ module AuditCanonical
19
+ AUDIT_SCHEMA_ID = "invoance.audit/1"
20
+
21
+ SIGNED_FIELDS = %w[
22
+ org_id event_id seq ingested_at action occurred_at actor targets context metadata
23
+ ].freeze
24
+
25
+ REQUIRED_FIELDS = %w[
26
+ org_id event_id seq ingested_at action occurred_at actor targets
27
+ ].freeze
28
+
29
+ RFC3339 = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|z|[+-]\d{2}:\d{2})\z/.freeze
30
+
31
+ module_function
32
+
33
+ # RFC3339 -> the one canonical form: UTC, exactly 3 fractional digits, `Z`.
34
+ # Fractional seconds are TRUNCATED to millis (not rounded).
35
+ #
36
+ # @param value [String]
37
+ # @return [String]
38
+ def normalize_ts(value)
39
+ raise ArgumentError, "timestamp must be a string" unless value.is_a?(String)
40
+
41
+ m = RFC3339.match(value.strip)
42
+ raise ArgumentError, "invalid RFC3339 timestamp: #{value}" unless m
43
+
44
+ yr, mo, dy, hh, mi, ss, frac, off = m.captures
45
+ millis = ((frac || "") + "000")[0, 3].to_i
46
+
47
+ # Build UTC epoch (in milliseconds) manually — no rounding.
48
+ base = Time.utc(yr.to_i, mo.to_i, dy.to_i, hh.to_i, mi.to_i, ss.to_i)
49
+ epoch_ms = (base.to_i * 1000) + millis
50
+
51
+ unless off == "Z" || off == "z"
52
+ sign = off[0] == "+" ? 1 : -1
53
+ oh = off[1, 2].to_i
54
+ om = off[4, 2].to_i
55
+ epoch_ms -= sign * (oh * 3600 + om * 60) * 1000
56
+ end
57
+
58
+ secs, ms = epoch_ms.divmod(1000)
59
+ d = Time.at(secs).utc
60
+ format("%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
61
+ d.year, d.month, d.day, d.hour, d.min, d.sec, ms)
62
+ end
63
+
64
+ # @api private
65
+ def strip_nils(v)
66
+ case v
67
+ when Array
68
+ v.map { |x| strip_nils(x) }
69
+ when Hash
70
+ out = {}
71
+ v.each do |k, val|
72
+ next if val.nil?
73
+
74
+ out[k] = strip_nils(val)
75
+ end
76
+ out
77
+ else
78
+ v
79
+ end
80
+ end
81
+
82
+ # @api private
83
+ def sort_deep(v)
84
+ case v
85
+ when Array
86
+ v.map { |x| sort_deep(x) }
87
+ when Hash
88
+ v.keys.map(&:to_s).sort.each_with_object({}) do |k, out|
89
+ # Keys may be symbols or strings on the input; normalize lookup.
90
+ orig = v.key?(k) ? k : k.to_sym
91
+ out[k] = sort_deep(v[orig])
92
+ end
93
+ else
94
+ v
95
+ end
96
+ end
97
+
98
+ # @api private
99
+ def build_signed_object(event)
100
+ unless event.is_a?(Hash)
101
+ raise ArgumentError, "event must be a JSON object"
102
+ end
103
+
104
+ e = stringify_keys(event)
105
+ REQUIRED_FIELDS.each do |f|
106
+ if e[f].nil?
107
+ raise ArgumentError, "missing required field: #{f}"
108
+ end
109
+ end
110
+
111
+ out = {}
112
+ SIGNED_FIELDS.each do |f|
113
+ val = e[f]
114
+ next if val.nil?
115
+
116
+ out[f] = (f == "occurred_at" || f == "ingested_at") ? normalize_ts(val) : val
117
+ end
118
+ out["schema_id"] = AUDIT_SCHEMA_ID
119
+ out
120
+ end
121
+
122
+ # The canonical signed bytes for an audit event (binary String).
123
+ #
124
+ # @param event [Hash]
125
+ # @return [String] compact UTF-8 JSON
126
+ def canonical_audit_bytes(event)
127
+ signed = sort_deep(strip_nils(build_signed_object(event)))
128
+ JSON.generate(signed)
129
+ end
130
+
131
+ # payload_hash = SHA-256(canonical bytes), lowercase hex.
132
+ #
133
+ # @param canonical [String]
134
+ # @return [String]
135
+ def payload_hash_hex(canonical)
136
+ Digest::SHA256.hexdigest(canonical)
137
+ end
138
+
139
+ # @api private
140
+ def stringify_keys(hash)
141
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ require_relative "audit_canonical"
7
+
8
+ module Invoance
9
+ # Offline, client-side signature verification for audit events.
10
+ #
11
+ # Reconstructs the canonical signed bytes from an event returned by the API
12
+ # and checks the Ed25519 signature using stdlib OpenSSL (no external
13
+ # dependency, matching this SDK's zero-dependency design).
14
+ #
15
+ # Trust note: by default this verifies against the key embedded in the event
16
+ # (event["signing_public_key"]), which proves the payload is internally
17
+ # consistent with that key. For a real tamper guarantee, pass public_key =
18
+ # the tenant's registered key (the server pins it and never trusts the row's
19
+ # key).
20
+ #
21
+ # Ed25519 requires Ruby's OpenSSL to support Ed25519 (Ruby >= 3.0 with a
22
+ # modern OpenSSL). On older stacks the verify degrades to an invalid result
23
+ # with a descriptive reason rather than crashing. If you need to support
24
+ # such stacks, the `ed25519` gem is a drop-in alternative — but this SDK
25
+ # stays dependency-free.
26
+ module AuditVerify
27
+ # DER SPKI prefix for a raw 32-byte Ed25519 public key (RFC 8410).
28
+ SPKI_ED25519_PREFIX = ["302a300506032b6570032100"].pack("H*").freeze
29
+
30
+ module_function
31
+
32
+ # Ed25519-verify a raw message with a raw 32-byte public key.
33
+ # Returns true/false; never raises (OpenSSL errors → false).
34
+ #
35
+ # @param message [String] binary message bytes
36
+ # @param signature [String] binary signature bytes
37
+ # @param pubkey [String] raw 32-byte public key
38
+ # @return [Boolean]
39
+ def ed25519_verify(message, signature, pubkey)
40
+ der = SPKI_ED25519_PREFIX + pubkey.b
41
+ pkey = OpenSSL::PKey.read(der)
42
+ # nil digest for Ed25519 (single-shot, no separate hash).
43
+ pkey.verify(nil, signature.b, message.b)
44
+ rescue StandardError, OpenSSL::OpenSSLError
45
+ false
46
+ end
47
+
48
+ # Hex-decode a hex string into a binary String.
49
+ # @api private
50
+ def hex_to_bytes(hex)
51
+ [hex].pack("H*")
52
+ end
53
+
54
+ # Verify one audit event's signature offline.
55
+ #
56
+ # @param event [Hash] string-keyed event as returned by the API
57
+ # @param public_key [String, nil] hex-encoded pinned key to verify against
58
+ # @return [Hash] { "valid" =>, "reason" =>, "payload_hash" =>, "key_source" => }
59
+ def verify_audit_event(event, public_key: nil)
60
+ key_source = public_key.nil? ? "event" : "pinned"
61
+ e = stringify(event)
62
+
63
+ signed_input = {
64
+ "org_id" => e["org_id"],
65
+ "event_id" => (e["id"].nil? ? e["event_id"] : e["id"]),
66
+ "seq" => e["seq"],
67
+ "ingested_at" => e["ingested_at"],
68
+ "action" => e["action"],
69
+ "occurred_at" => e["occurred_at"],
70
+ "actor" => e["actor"],
71
+ "targets" => e["targets"]
72
+ }
73
+ signed_input["context"] = e["context"] unless e["context"].nil?
74
+ signed_input["metadata"] = e["metadata"] unless e["metadata"].nil?
75
+
76
+ begin
77
+ canonical = AuditCanonical.canonical_audit_bytes(signed_input)
78
+ rescue StandardError
79
+ return result(false, "canonicalization_failed", "", key_source)
80
+ end
81
+
82
+ recomputed = AuditCanonical.payload_hash_hex(canonical)
83
+ if !e["payload_hash"].nil? && e["payload_hash"] != recomputed
84
+ return result(false, "payload_hash_mismatch", recomputed, key_source)
85
+ end
86
+
87
+ key = public_key.nil? ? e["signing_public_key"] : public_key
88
+ return result(false, "no_public_key", recomputed, key_source) if key.nil? || key == ""
89
+
90
+ sig = e["signature"]
91
+ return result(false, "no_signature", recomputed, key_source) if sig.nil? || sig == ""
92
+
93
+ sig_bytes = sig.is_a?(String) ? hex_to_bytes(sig) : sig
94
+ key_bytes = key.is_a?(String) ? hex_to_bytes(key) : key
95
+ ok = ed25519_verify(canonical, sig_bytes, key_bytes)
96
+
97
+ if ok
98
+ result(true, nil, recomputed, key_source)
99
+ else
100
+ result(false, "signature_invalid", recomputed, key_source)
101
+ end
102
+ end
103
+
104
+ # @api private
105
+ def result(valid, reason, payload_hash, key_source)
106
+ {
107
+ "valid" => valid,
108
+ "reason" => reason,
109
+ "payload_hash" => payload_hash,
110
+ "key_source" => key_source
111
+ }
112
+ end
113
+
114
+ # @api private
115
+ def stringify(hash)
116
+ return {} unless hash.is_a?(Hash)
117
+
118
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "http"
5
+ require_relative "errors"
6
+ require_relative "resources/events"
7
+ require_relative "resources/documents"
8
+ require_relative "resources/attestations"
9
+ require_relative "resources/traces"
10
+ require_relative "resources/audit"
11
+
12
+ module Invoance
13
+ # Top-level SDK client.
14
+ #
15
+ # # Reads INVOANCE_API_KEY / INVOANCE_BASE_URL from env automatically
16
+ # client = Invoance::Client.new
17
+ #
18
+ # # Or pass explicitly to override
19
+ # client = Invoance::Client.new(api_key: "inv_live_abc123")
20
+ #
21
+ # event = client.events.ingest(event_type: "user.login", payload: { user_id: "u_42" })
22
+ class Client
23
+ attr_reader :events, :documents, :attestations, :traces, :audit
24
+
25
+ # @param api_key [String, nil]
26
+ # @param base_url [String, nil]
27
+ # @param api_version [String]
28
+ # @param timeout [Numeric] seconds
29
+ # @param idempotency_key [String, nil]
30
+ # @param extra_headers [Hash]
31
+ def initialize(api_key: nil, base_url: nil, api_version: "v1", timeout: 30,
32
+ idempotency_key: nil, extra_headers: {})
33
+ @config = Config.new(
34
+ api_key: api_key,
35
+ base_url: base_url,
36
+ api_version: api_version,
37
+ timeout: timeout,
38
+ idempotency_key: idempotency_key,
39
+ extra_headers: extra_headers
40
+ )
41
+ @http = Http.new(@config)
42
+
43
+ @events = Resources::Events.new(@http)
44
+ @documents = Resources::Documents.new(@http)
45
+ @attestations = Resources::Attestations.new(@http)
46
+ @traces = Resources::Traces.new(@http)
47
+ @audit = Resources::Audit.new(@http)
48
+ end
49
+
50
+ # The resolved base URL in use.
51
+ # @return [String]
52
+ def base_url
53
+ @config.base_url
54
+ end
55
+
56
+ # Probe a cheap authenticated endpoint (GET /v1/events?limit=1) to
57
+ # confirm the API key works. NEVER raises.
58
+ #
59
+ # @return [Hash] { "valid" =>, "reason" =>, "base_url" => }
60
+ def validate
61
+ base = @config.base_url
62
+ begin
63
+ @events.list(limit: 1)
64
+ { "valid" => true, "reason" => nil, "base_url" => base }
65
+ rescue AuthenticationError
66
+ { "valid" => false,
67
+ "reason" => "Authentication failed — check INVOANCE_API_KEY",
68
+ "base_url" => base }
69
+ rescue ForbiddenError
70
+ { "valid" => true,
71
+ "reason" => "API key authenticated but lacks permission to list events",
72
+ "base_url" => base }
73
+ rescue QuotaExceededError
74
+ { "valid" => true,
75
+ "reason" => "API key authenticated but currently rate limited",
76
+ "base_url" => base }
77
+ rescue NetworkError, TimeoutError => e
78
+ { "valid" => false, "reason" => "Server unreachable: #{e.message}", "base_url" => base }
79
+ rescue Error => e
80
+ { "valid" => false, "reason" => e.message, "base_url" => base }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoance
4
+ # Resolved, immutable SDK configuration.
5
+ #
6
+ # All fields are optional at the client boundary — when omitted the SDK
7
+ # reads from environment variables:
8
+ #
9
+ # * INVOANCE_API_KEY — API key (required)
10
+ # * INVOANCE_BASE_URL — API host (defaults to https://api.invoance.com)
11
+ class Config
12
+ DEFAULT_BASE_URL = "https://api.invoance.com"
13
+ DEFAULT_TIMEOUT = 30
14
+ ENV_API_KEY = "INVOANCE_API_KEY"
15
+ ENV_BASE_URL = "INVOANCE_BASE_URL"
16
+
17
+ attr_reader :api_key, :base_url, :api_version, :timeout, :idempotency_key, :extra_headers
18
+
19
+ # @param api_key [String, nil]
20
+ # @param base_url [String, nil]
21
+ # @param api_version [String]
22
+ # @param timeout [Numeric] request timeout in seconds
23
+ # @param idempotency_key [String, nil] default idempotency key for mutating requests
24
+ # @param extra_headers [Hash] merged into every request
25
+ def initialize(api_key: nil, base_url: nil, api_version: "v1", timeout: DEFAULT_TIMEOUT,
26
+ idempotency_key: nil, extra_headers: {})
27
+ resolved_key = api_key || ENV[ENV_API_KEY] || ""
28
+ if resolved_key.empty?
29
+ raise ArgumentError,
30
+ "api_key is required. Pass it explicitly or set the #{ENV_API_KEY} environment variable."
31
+ end
32
+
33
+ @api_key = resolved_key
34
+ @base_url = (base_url || ENV[ENV_BASE_URL] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
35
+ @api_version = (api_version || "v1").gsub(%r{\A/+|/+\z}, "")
36
+ @timeout = timeout || DEFAULT_TIMEOUT
37
+ @idempotency_key = idempotency_key
38
+ @extra_headers = extra_headers || {}
39
+ end
40
+ end
41
+ end