blackevin 0.1.1

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: 3c81bfd943d2a666cb4708cbf7fb68dda7e9bfa73a4563ad3ec444475031bc62
4
+ data.tar.gz: ef8433cd9a72fe8429521b93e2724044ec5fac06a94af1053962db3e67468379
5
+ SHA512:
6
+ metadata.gz: f11f01f55bbc204af64100bffb53e88540c3c8374c80df2d42a68b551279f9f3b80c0fc3611458fcd5643645a6efbad48b8f4a073af44c76c2b96e4811959193
7
+ data.tar.gz: d4fe938a5b52edfe02b9cf9e86b300b6ebe98fcceb22807dc0c7d7eb937b39f8f010a4a9de9c0f804f61359338934e523f536f9bd15ecac2484729a768d846e6
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Remove lint from CI
6
+
7
+ ## 0.1.0
8
+
9
+ First release. Server-side REST client, standard library only.
10
+
11
+ - `Blackevin::Rest` — `auth.create_token_request` (local HMAC signing), `auth.request_token`,
12
+ `channels.get(name).publish / history`, `presence.get / history`, `clients.disconnect`,
13
+ and `queues` (list, upsert, update, delete, rules, add_rule, delete_rule).
14
+ - `Blackevin.configure` and `Blackevin.rest` for a process-wide client; reads `BLACKEVIN_KEY`,
15
+ `BLACKEVIN_REST_ENDPOINT` and `BLACKEVIN_ENDPOINT`.
16
+ - `Blackevin::TokenEndpoint`, a Rack application for the browser's `authUrl`, mountable in Rails,
17
+ Sinatra and Hanami.
18
+ - An optional Railtie: `config.blackevin.*` and `credentials.blackevin.key`.
19
+ - `Blackevin::Error` (`status_code`, `reason`, `quota?`), `Blackevin::ConnectionError`,
20
+ `Blackevin::ConfigurationError`.
21
+ - Pattern matching (`deconstruct_keys`) on errors, token requests, token details and every returned value.
22
+ - Ruby 3.0 and up. The suite runs on 3.0 through 3.4.
23
+ - Tested against the language-neutral contract in `spec/contract`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thadeu Esteves Jr
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,248 @@
1
+ # blackevin
2
+
3
+ The server-side Ruby SDK for [Blackevin](https://blackevin.com) realtime.
4
+
5
+ Sign token requests for your browsers, publish from a job or a webhook, read
6
+ history and presence, manage queues.
7
+
8
+ **No runtime dependencies.** `net/http`, `openssl` and `json` from the standard
9
+ library — nothing that can break under you in an upgrade. Ruby 3.0 and up, in
10
+ Rails, Sinatra, Hanami or a plain script.
11
+
12
+ ```ruby
13
+ gem "blackevin"
14
+ ```
15
+
16
+ ## Configure
17
+
18
+ With `BLACKEVIN_KEY` in the environment there is nothing to configure:
19
+
20
+ ```ruby
21
+ Blackevin.rest.channels.get("room:42").publish("greeting", {text: "hi"})
22
+ ```
23
+
24
+ Or say it explicitly, once, at boot:
25
+
26
+ ```ruby
27
+ Blackevin.configure do |config|
28
+ config.key = ENV.fetch("BLACKEVIN_KEY") # secret.keyId, from the console
29
+ config.open_timeout = 5 # seconds
30
+ config.read_timeout = 10
31
+ end
32
+ ```
33
+
34
+ `Blackevin.rest` is one shared, thread-safe client. For more than one key, build
35
+ your own: `Blackevin::Rest.new(key: …)`.
36
+
37
+ Against a local or self-hosted node, which serves REST and the socket from one
38
+ origin, set `BLACKEVIN_ENDPOINT=ws://localhost:3000` (or `config.endpoint`) and the
39
+ REST base is derived from it.
40
+
41
+ ## Token endpoint — the part every app needs
42
+
43
+ Your API key is your account, so it never goes to a browser. The browser asks
44
+ **your** server for a TokenRequest: signed with the key, scoped to what this
45
+ user may touch, dead after its `ttl`. Signing is local — no call to Blackevin, so
46
+ your sign-in never depends on us being reachable.
47
+
48
+ The browser side is the JavaScript SDK with `authUrl: "/blackevin/token"`.
49
+
50
+ ### Rails
51
+
52
+ ```ruby
53
+ # app/controllers/blackevin_tokens_controller.rb
54
+ class BlackevinTokensController < ApplicationController
55
+ before_action :authenticate_user!
56
+
57
+ def show
58
+ render json: Blackevin.rest.auth.create_token_request(
59
+ client_id: current_user.id,
60
+ ttl: 1.hour.in_milliseconds,
61
+ capability: {
62
+ "team:#{current_user.team_id}" => %w[subscribe publish],
63
+ "presence:team:#{current_user.team_id}" => %w[presence]
64
+ }
65
+ )
66
+ end
67
+ end
68
+
69
+ # config/routes.rb
70
+ resource :blackevin_token, only: :show, path: "blackevin/token"
71
+ ```
72
+
73
+ The key can live in credentials instead of the environment — the Railtie reads
74
+ `credentials.blackevin.key`, and `config.blackevin.*` in an environment file:
75
+
76
+ ```ruby
77
+ # config/environments/development.rb
78
+ config.blackevin.endpoint = "ws://localhost:3000"
79
+ ```
80
+
81
+ ### Sinatra
82
+
83
+ ```ruby
84
+ require "sinatra"
85
+ require "blackevin"
86
+
87
+ get "/blackevin/token" do
88
+ halt 401 unless current_user
89
+
90
+ content_type :json
91
+ Blackevin.rest.auth.create_token_request(client_id: current_user.id).to_json
92
+ end
93
+ ```
94
+
95
+ ### Anything Rack (Hanami, Roda, `config.ru`)
96
+
97
+ `Blackevin::TokenEndpoint` is a Rack application. The block receives the Rack env
98
+ and answers who is asking; `nil` is a 401.
99
+
100
+ ```ruby
101
+ TOKENS = Blackevin::TokenEndpoint.new do |env|
102
+ user = env["warden"]&.user
103
+
104
+ next unless user
105
+
106
+ {client_id: user.id, capability: {"team:#{user.team_id}" => %w[subscribe publish]}}
107
+ end
108
+
109
+ # Hanami: mount TOKENS, at: "/blackevin/token"
110
+ # Rails: mount TOKENS, at: "/blackevin/token"
111
+ # Rack: map("/blackevin/token") { run TOKENS }
112
+ ```
113
+
114
+ ## Publish
115
+
116
+ ```ruby
117
+ channel = Blackevin.rest.channels.get("orders:#{order.id}")
118
+
119
+ channel.publish("status", {state: "shipped"})
120
+ ```
121
+
122
+ It goes through the same fanout a socket publish does: subscribers, account
123
+ queues and integrations all see it. One HTTP request per publish — from a Rails
124
+ request, prefer a job:
125
+
126
+ ```ruby
127
+ class BlackevinPublishJob < ApplicationJob
128
+ retry_on Blackevin::ConnectionError, wait: :polynomially_longer
129
+
130
+ def perform(channel, event, data)
131
+ Blackevin.rest.channels.get(channel).publish(event, data)
132
+ end
133
+ end
134
+ ```
135
+
136
+ ## History and presence
137
+
138
+ ```ruby
139
+ channel.history(limit: 50) # => [Blackevin::Message], newest first
140
+ channel.presence.get # => [Blackevin::PresenceMember]
141
+ channel.presence.history # => [Blackevin::PresenceEvent]
142
+
143
+ message = channel.history.first
144
+ message.name # "status"
145
+ message.data # {"state" => "shipped"}
146
+ message.time # a Time; message.timestamp is the wire value, in ms
147
+ ```
148
+
149
+ ## Sign a user out everywhere
150
+
151
+ ```ruby
152
+ Blackevin.rest.clients.disconnect(user.id) # => how many connections were closed
153
+ ```
154
+
155
+ ## Queues
156
+
157
+ The key needs the `amqp-subscribe` capability. A queue is addressed by its `id`.
158
+
159
+ ```ruby
160
+ queues = Blackevin.rest.queues
161
+
162
+ queue = queues.upsert(name: "inbox", max_length: 10_000)
163
+ queues.add_rule(queue.id, source_pattern: "orders:*")
164
+
165
+ queues.update(queue.id, enabled: false) # pause
166
+ queues.list(all: true) # paused ones included
167
+ queues.delete(queue.id)
168
+ ```
169
+
170
+ ## Errors
171
+
172
+ One rescue covers everything the SDK raises:
173
+
174
+ ```ruby
175
+ begin
176
+ channel.publish("status", payload)
177
+ rescue Blackevin::Error => error
178
+ error.status_code # 403, 429, … or nil when no response arrived
179
+ error.reason # "queue_limit", "connection_limit", or nil
180
+ error.quota? # a plan ceiling — show an upgrade prompt, retry later
181
+ error.message # the server's own sentence; do not branch on it
182
+ end
183
+ ```
184
+
185
+ Errors, token requests and every returned value support pattern matching, so a
186
+ rescue can branch by shape:
187
+
188
+ ```ruby
189
+ rescue Blackevin::Error => error
190
+ case error
191
+ in {reason: "queue_limit"} then redirect_to upgrade_path
192
+ in {status_code: 401 | 403} then raise
193
+ in {status_code: nil} then retry_job wait: 30.seconds
194
+ end
195
+ ```
196
+
197
+ - `Blackevin::ConnectionError` — DNS, refused, TLS, timeout. Worth retrying.
198
+ - `Blackevin::ConfigurationError` — a missing or malformed key, raised before any request.
199
+
200
+ ## Acting as one client
201
+
202
+ A token instead of a key restricts the client to that token's capability:
203
+
204
+ ```ruby
205
+ details = Blackevin.rest.auth.request_token(
206
+ Blackevin.rest.auth.create_token_request(client_id: "worker-1", capability: {"jobs:*" => %w[publish]})
207
+ )
208
+
209
+ Blackevin::Rest.new(token: details.token).channels.get("jobs:1").publish("done")
210
+ ```
211
+
212
+ ## Testing your app
213
+
214
+ Swap the transport and nothing leaves the process:
215
+
216
+ ```ruby
217
+ Blackevin.configure do |config|
218
+ config.key = "secret.test"
219
+ config.transport = ->(request) { Blackevin::Response.new(status: 200, body: "{}") }
220
+ end
221
+ ```
222
+
223
+ ## Development
224
+
225
+ ```sh
226
+ bundle install
227
+ bundle exec rake # rspec + standard
228
+ bundle exec rake contract:sync # refresh spec/contract from ../blackevin/spec
229
+ ```
230
+
231
+ ### Releasing
232
+
233
+ Bump `lib/blackevin/version.rb`, add the entry to `CHANGELOG.md`, commit, then:
234
+
235
+ ```sh
236
+ bundle exec rake tag # tags vX.Y.Z from version.rb and pushes it
237
+ ```
238
+
239
+ The tag runs `.github/workflows/release.yml`: lint, tests, publish to RubyGems by
240
+ Trusted Publishing (no API key), and a GitHub release with the `.gem` attached.
241
+
242
+ `spec/contract` is a copy of the language-neutral contract — an OpenAPI file and
243
+ JSON fixtures — that every Blackevin SDK is tested against. The suite fails when
244
+ the contract gains an operation this gem does not implement.
245
+
246
+ ## License
247
+
248
+ MIT.
data/blackevin.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/blackevin/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "blackevin"
7
+ spec.version = Blackevin::VERSION
8
+ spec.authors = ["Blackevin"]
9
+
10
+ spec.summary = "Blackevin Ruby SDK"
11
+ spec.description = "Sign token requests, publish, read history and presence, and manage queues on Blackevin. " \
12
+ "Standard library only: no runtime dependencies. Ruby 3.0 and up. Works in Rails, Sinatra, Hanami or plain Ruby."
13
+ spec.homepage = "https://blackevin.com"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0"
16
+
17
+ spec.metadata = {
18
+ "rubygems_mfa_required" => "true",
19
+ "documentation_uri" => "https://docs.blackevin.com",
20
+ "source_code_uri" => "https://github.com/thadeu/blackevin-ruby",
21
+ "changelog_uri" => "https://github.com/thadeu/blackevin-ruby/blob/main/CHANGELOG.md"
22
+ }
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ Dir["README.md", "CHANGELOG.md", "LICENSE", "blackevin.gemspec", "lib/**/*.rb"]
26
+ end
27
+
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_development_dependency "rake", ">= 13.0"
31
+ spec.add_development_dependency "rspec", ">= 3.13", "< 4.0"
32
+ spec.add_development_dependency "standard", ">= 1.0"
33
+ spec.add_development_dependency "railties", ">= 7.0"
34
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ # An API key, written +secret.keyId+.
5
+ #
6
+ # Split on the LAST dot: the keyId carries none, so everything before it is
7
+ # the secret verbatim. Same rules as the node, pinned by the contract fixtures.
8
+ class ApiKey
9
+ attr_reader :key_name, :secret
10
+
11
+ # @param raw [String] the key as copied from the console
12
+ # @raise [Blackevin::ConfigurationError] when it is not +secret.keyId+
13
+ def self.parse(raw)
14
+ case raw.to_s.strip.rpartition('.')
15
+ in [secret, '.', key_name] unless secret.empty? || key_name.empty?
16
+ new(key_name, secret)
17
+ else
18
+ raise ConfigurationError, 'malformed API key: expected secret.keyId'
19
+ end
20
+ end
21
+
22
+ def initialize(key_name, secret)
23
+ @key_name = key_name
24
+ @secret = secret
25
+ end
26
+
27
+ # Never print the secret: this object ends up in logs and error reports.
28
+ def inspect = "#<Blackevin::ApiKey key_name=#{key_name.inspect} secret=[FILTERED]>"
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ # Process-wide defaults for {Blackevin.rest}.
5
+ #
6
+ # Blackevin.configure do |config|
7
+ # config.key = ENV.fetch("BLACKEVIN_KEY")
8
+ # end
9
+ #
10
+ # Nothing here is required: with +BLACKEVIN_KEY+ in the environment,
11
+ # {Blackevin.rest} works unconfigured.
12
+ class Configuration
13
+ ENV_KEY = 'BLACKEVIN_KEY'
14
+
15
+ # @return [String, nil] the full API key, +secret.keyId+
16
+ attr_writer :key
17
+
18
+ # @return [String, nil] REST base URL; see {Blackevin::Endpoints}
19
+ attr_accessor :rest_endpoint
20
+
21
+ # @return [String, nil] socket endpoint, read only to derive the REST base for a single-origin node
22
+ attr_accessor :endpoint
23
+
24
+ # @return [Numeric] seconds
25
+ attr_accessor :open_timeout, :read_timeout
26
+
27
+ # @return [#call, nil] replaces the Net::HTTP transport
28
+ attr_accessor :transport
29
+
30
+ def initialize
31
+ @open_timeout = 5
32
+ @read_timeout = 10
33
+ end
34
+
35
+ def key = @key || ENV[ENV_KEY]
36
+
37
+ # @return [Hash] the options {Blackevin::Rest.new} takes
38
+ def to_rest_options
39
+ {
40
+ key: key,
41
+ rest_endpoint: rest_endpoint,
42
+ endpoint: endpoint,
43
+ open_timeout: open_timeout,
44
+ read_timeout: read_timeout,
45
+ transport: transport
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ # Which hosts the SDK talks to: explicit option, then environment, then the
5
+ # production default.
6
+ #
7
+ # The REST base is derived from the socket endpoint only when that endpoint
8
+ # was given, because a dev or self-hosted node serves both from one origin.
9
+ # Production serves them from two names, so the default never derives.
10
+ module Endpoints
11
+ DEFAULT_WS = 'wss://ws.blackevin.com'
12
+ DEFAULT_REST = 'https://api.blackevin.com'
13
+
14
+ ENV_WS = 'BLACKEVIN_ENDPOINT'
15
+ ENV_REST = 'BLACKEVIN_REST_ENDPOINT'
16
+
17
+ Resolved = Struct.new(:endpoint, :rest_endpoint, keyword_init: true)
18
+
19
+ module_function
20
+
21
+ # @param endpoint [String, nil] socket endpoint, e.g. "ws://localhost:3000"
22
+ # @param rest_endpoint [String, nil] REST base URL
23
+ # @param env [#[]] injected for tests; defaults to ENV
24
+ # @return [Resolved]
25
+ def resolve(endpoint: nil, rest_endpoint: nil, env: ENV)
26
+ explicit_ws = present(endpoint) || present(env[ENV_WS])
27
+
28
+ rest = present(rest_endpoint) ||
29
+ present(env[ENV_REST]) ||
30
+ (explicit_ws ? derive_rest(explicit_ws) : DEFAULT_REST)
31
+
32
+ Resolved.new(endpoint: explicit_ws || DEFAULT_WS, rest_endpoint: rest)
33
+ end
34
+
35
+ def derive_rest(ws_endpoint) = ws_endpoint.sub(/\Aws/i, 'http').delete_suffix('/')
36
+
37
+ def present(value) = value.to_s.strip.then { _1.empty? ? nil : _1 }
38
+ end
39
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Blackevin
6
+ # What the server said when it refused.
7
+ #
8
+ # Branch on {#status_code} and {#reason}, never on the message: the message
9
+ # is the server's sentence, written for a person.
10
+ class Error < StandardError
11
+ QUOTA_STATUS = 429
12
+
13
+ # @return [Integer, nil] the HTTP status, or nil when no response arrived
14
+ attr_reader :status_code
15
+
16
+ # @return [String, nil] a stable slug such as "queue_limit", when the server sent one
17
+ attr_reader :reason
18
+
19
+ def initialize(message, status_code = nil, reason = nil)
20
+ super(message)
21
+
22
+ @status_code = status_code
23
+ @reason = reason
24
+ end
25
+
26
+ # True when this is a plan ceiling rather than a bug or a bad credential.
27
+ def quota? = status_code == QUOTA_STATUS
28
+
29
+ # Lets a rescue branch by shape instead of by a chain of conditionals:
30
+ #
31
+ # case error
32
+ # in {reason: "queue_limit"} then upgrade_prompt
33
+ # in {status_code: 401 | 403} then rotate_key
34
+ # in {status_code: nil} then retry_later
35
+ # end
36
+ def deconstruct_keys(_keys) = { status_code: status_code, reason: reason, message: message }
37
+
38
+ # Builds the error for a non-2xx response, keeping the server's sentence.
39
+ #
40
+ # A body that is not JSON degrades to the status alone: a proxy answering
41
+ # HTML on a 502 must not replace a useful error with a parse failure.
42
+ #
43
+ # @param what [String] the operation label, e.g. "publish"
44
+ # @param status [Integer]
45
+ # @param body [String, nil] the raw response body
46
+ # @return [Blackevin::Error]
47
+ def self.from_response(what, status, body)
48
+ parsed = parse(body)
49
+
50
+ detail = case parsed
51
+ in { error: String => sentence } unless sentence.empty? then sentence
52
+ else status
53
+ end
54
+
55
+ reason = case parsed
56
+ in { reason: String => slug } then slug
57
+ else nil
58
+ end
59
+
60
+ new("#{what} failed: #{detail}", status, reason)
61
+ end
62
+
63
+ def self.parse(body)
64
+ case JSON.parse(body.to_s, symbolize_names: true)
65
+ in Hash => parsed then parsed
66
+ else {}
67
+ end
68
+ rescue JSON::ParserError
69
+ {}
70
+ end
71
+
72
+ private_class_method :parse
73
+ end
74
+
75
+ # The request never produced a response: DNS, refused connection, TLS, timeout.
76
+ class ConnectionError < Error
77
+ def initialize(message)
78
+ super(message, nil, nil)
79
+ end
80
+ end
81
+
82
+ # The SDK was given something it cannot work with, before any network call.
83
+ class ConfigurationError < Error
84
+ def initialize(message)
85
+ super(message, nil, nil)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ # Loaded only when Rails is. Lets the key live where a Rails app keeps secrets:
5
+ #
6
+ # # config/credentials.yml.enc
7
+ # blackevin:
8
+ # key: ck_live_….kAbC
9
+ #
10
+ # and lets an environment file set the rest:
11
+ #
12
+ # config.blackevin.rest_endpoint = "http://localhost:3000"
13
+ #
14
+ # An explicit +Blackevin.configure+ or +BLACKEVIN_KEY+ still wins over credentials.
15
+ class Railtie < Rails::Railtie
16
+ SETTINGS = %i[key rest_endpoint endpoint open_timeout read_timeout transport].freeze
17
+
18
+ config.blackevin = ActiveSupport::OrderedOptions.new
19
+
20
+ initializer 'blackevin.configure' do |app|
21
+ options = app.config.blackevin
22
+ credentials_key = app.credentials.dig(:blackevin, :key) if app.respond_to?(:credentials)
23
+
24
+ Blackevin.configure do |config|
25
+ SETTINGS.each do |setting|
26
+ value = options[setting]
27
+
28
+ config.public_send(:"#{setting}=", value) unless value.nil?
29
+ end
30
+
31
+ config.key = credentials_key if config.key.nil? && credentials_key
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Blackevin
7
+ class Rest
8
+ # Token requests: signing one locally, and exchanging one for a token.
9
+ class Auth
10
+ DEFAULT_TTL_MS = 3_600_000
11
+ DEFAULT_CAPABILITY = {"*" => %w[subscribe publish presence history]}.freeze
12
+
13
+ def initialize(rest, clock: nil, nonce: nil)
14
+ @rest = rest
15
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
16
+ @nonce = nonce || -> { SecureRandom.hex(16) }
17
+ end
18
+
19
+ # Signs a TokenRequest for a browser to exchange.
20
+ #
21
+ # Local: no network call. Your server holds the secret and hands out
22
+ # something scoped and short-lived, so Blackevin is never on the critical
23
+ # path of your own sign-in.
24
+ #
25
+ # @param client_id [String, Integer, nil] who the token will act as
26
+ # @param ttl [Integer, nil] milliseconds; default one hour, maximum 48 hours
27
+ # @param capability [Hash, String, nil] channel pattern to allowed operations.
28
+ # A Hash is serialised compactly in insertion order; a String is signed byte for byte.
29
+ # @param timestamp [Integer, nil] milliseconds since the epoch; default now
30
+ # @param nonce [String, nil] default random
31
+ # @return [Blackevin::TokenRequest] signed
32
+ # @raise [Blackevin::ConfigurationError] without a usable API key
33
+ def create_token_request(client_id: nil, ttl: nil, capability: nil, timestamp: nil, nonce: nil)
34
+ api_key = @rest.api_key
35
+
36
+ request = TokenRequest.new(
37
+ key_name: api_key.key_name,
38
+ ttl: ttl || DEFAULT_TTL_MS,
39
+ capability: serialise(capability),
40
+ client_id: client_id&.to_s,
41
+ timestamp: timestamp || @clock.call,
42
+ nonce: nonce || @nonce.call
43
+ )
44
+
45
+ request.sign(api_key.secret)
46
+ end
47
+
48
+ # Exchanges a TokenRequest for a token.
49
+ #
50
+ # Usually the browser's job. A server wanting a token for its own use —
51
+ # to act as one client under a narrow capability — does it here.
52
+ #
53
+ # @param token_request [Blackevin::TokenRequest, Hash]
54
+ # @return [Blackevin::TokenDetails]
55
+ # @raise [Blackevin::Error]
56
+ def request_token(token_request)
57
+ wire =
58
+ case token_request
59
+ in TokenRequest => signed then signed.to_h
60
+ in Hash => hash then TokenRequest.from_h(hash).to_h
61
+ else raise ConfigurationError, "request_token takes a TokenRequest or a Hash, got #{token_request.class}"
62
+ end
63
+ path = "/keys/#{Rest.escape(wire.fetch("keyName"))}/requestToken"
64
+
65
+ TokenDetails.from_h(@rest.request("requestToken", "POST", path, body: wire, authorize: false))
66
+ end
67
+
68
+ private
69
+
70
+ def serialise(capability)
71
+ case capability
72
+ in String => signed_as_is then signed_as_is
73
+ in Hash => map then JSON.generate(map)
74
+ in nil then JSON.generate(DEFAULT_CAPABILITY)
75
+ else raise ConfigurationError, "capability must be a Hash or a JSON String, got #{capability.class}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end