# frozen_string_literal: true # Legendum SDK for Ruby # Zero dependencies beyond net/http (stdlib). # # Usage: # require_relative "legendum" # # # Configure via environment variables: # # LEGENDUM_API_KEY — service API key (lpk_...) # # LEGENDUM_SECRET — service secret (lsk_...) # # LEGENDUM_BASE_URL — base URL (default: https://legendum.co.uk) # # # Or pass config: # client = Legendum.create(api_key: "lpk_...", secret: "lsk_...", base_url: "https://legendum.co.uk") # # Error handling: # All methods raise Legendum::Error on failure. # err.message — human-readable description # err.code — machine-readable code (e.g. "insufficient_funds") # err.status — HTTP status code (e.g. 402) # # JSON error bodies from the Legendum API use { "ok" => false, "error", "message" } — # +message+ for humans, +error+ for the code. The SDK maps them onto Error#message / #code. # If a response omits +error+, #code may be +nil+ (use #status and #message as fallbacks). # # Error codes (aligned with +public/sdk/legendum.js+): # "unauthorized" (401) — missing or invalid API key / secret # "bad_request" (400) — missing required fields or invalid values # "token_not_found" (404) — account token not found or inactive # "insufficient_funds" (402) — balance too low for the charge or reservation # "invalid_state" (409) — reservation is not in 'held' state # "expired" (410) — reservation has expired # "link_expired" (410) — pairing code has expired # "not_found" (404) — pairing code not found # "invalid_code" (400) — wrong email confirmation code # "forbidden" (403) — operation not allowed (e.g. +issue_key+ without +can_issue_keys+) # "rate_limited" (429) — too many requests (e.g. +issue_key+ hourly cap) # "no_link" (409) — Rack middleware POST …/issue-key with no stored account token # "http_" (5xx) — non-JSON response (server crash, proxy error page, bad base URL) # # Example: # begin # client.charge(token, 100, "API call") # rescue Legendum::Error => e # warn "top up" if e.code == "insufficient_funds" # end # # Testing: # Legendum.mock( # charge: ->(token, amount, desc, **opts) { { email: "mock@test.com", transaction_id: 1, balance: 50 } } # ) # # configured? returns true, all methods use mock handlers # Legendum.unmock require "net/http" require "uri" require "json" module Legendum VERSION = "1.0.0" class Error < StandardError attr_reader :code, :status def initialize(message, code: nil, status: nil) super(message) @code = code @status = status end end # A reservation returned by Client#reserve. class Reservation attr_reader :id, :amount def initialize(id:, amount:, client:) @id = id @amount = amount @client = client end # Settle the reservation (finalise the charge). # # @return [Hash] Same success payload as Client#charge: "email", "transaction_id", "balance". def settle(amount = nil) @client.send(:request, :post, "/api/settle", { reservation_id: @id, amount: amount, }) end # Release the reservation (cancel, no charge). def release @client.send(:request, :post, "/api/release", { reservation_id: @id, }) end end # A tab that accumulates micro-charges and flushes at a threshold. # # tab = client.tab("tok_...", "AI tokens", threshold: 100) # tab.add # +1 # tab.add(5) # +5 # tab.flush # settle running total, leave tab open # tab.close # flush remainder and close class Tab attr_reader :total def initialize(account_token:, description:, threshold:, amount: 1, client:) raise ArgumentError, "threshold must be positive" unless threshold.is_a?(Numeric) && threshold > 0 @account_token = account_token @description = description @threshold = threshold @default_amount = amount @client = client @total = 0 @closed = false end def add(amount = nil) raise "tab is closed" if @closed n = amount.nil? ? @default_amount : amount raise ArgumentError, "tab.add requires a positive finite number" unless n.is_a?(Numeric) && n.finite? && n > 0 @total += n do_flush if @total >= @threshold end # Settle the running total without closing the tab. The tab remains # usable — further +add+ calls are allowed. Useful for periodic settlement # of partial balances that sit below the threshold. No-op if total is zero. def flush raise "tab is closed" if @closed do_flush end def close return if @closed @closed = true do_flush end private def do_flush # Floor — never round up. Fractional remainder stays in @total for the next # flush; sub-credit dust at close is dropped. whole = (@total + 1e-9).floor return if whole <= 0 @total -= whole begin @client.charge(@account_token, whole, @description) rescue StandardError => e @total += whole raise e end end end # The main SDK client. All API methods live here. class Client def initialize(api_key:, secret:, base_url: "https://legendum.co.uk") @api_key = api_key @secret = secret @base_url = base_url.chomp("/") end # Charge credits from a linked account. # # @param account_token [String] # @param amount [Integer] credits to charge # @param description [String] # @param key [String, nil] idempotency key # @param meta [Hash, nil] arbitrary metadata # @return [Hash] { email:, transaction_id:, balance: } (string keys from JSON) def charge(account_token, amount, description, key: nil, meta: nil) body = { account_token: account_token, amount: amount, description: description } body[:key] = key if key body[:meta] = meta if meta request(:post, "/api/charge", body) end # Get balance for a linked account. # # @param account_token [String] # @return [Hash] { balance:, held: } def balance(account_token) request(:get, "/api/balance?account_token=#{URI.encode_www_form_component(account_token)}") end # Reserve credits (hold for up to 15 minutes). # # @param account_token [String] # @param amount [Integer] # @param description [String, nil] # @return [Reservation] def reserve(account_token, amount, description = nil) body = { account_token: account_token, amount: amount } body[:description] = description if description data = request(:post, "/api/reserve", body) Reservation.new(id: data["reservation_id"], amount: amount, client: self) end # Request a pairing code for account linking. # # @return [Hash] { code:, request_id: } def request_link request(:post, "/api/link", {}) end # Poll for a link request result. # # @param request_id [String] # @return [Hash] { status:, account_token?: } def poll_link(request_id) request(:get, "/api/link/#{URI.encode_www_form_component(request_id)}") end # Poll until link is confirmed or expired. # # @param request_id [String] # @param interval [Float] seconds between polls (default 2) # @param timeout [Float] seconds before giving up (default 600) # @return [Hash] { account_token: } def wait_for_link(request_id, interval: 2, timeout: 600) deadline = Time.now + timeout loop do result = poll_link(request_id) return result if result["status"] == "confirmed" raise Error.new("Link request expired", code: "link_expired") if result["status"] == "expired" raise Error.new("Link polling timed out", code: "timeout") if Time.now >= deadline sleep(interval) end end # Build a "Login with Legendum" authorize URL. # # @param redirect_uri [String] your callback URL (must be registered) # @param state [String] CSRF token # @return [String] the authorize URL def auth_url(redirect_uri:, state:) "#{@base_url}/auth/authorize" \ "?client_id=#{URI.encode_www_form_component(@api_key)}" \ "&redirect_uri=#{URI.encode_www_form_component(redirect_uri)}" \ "&state=#{URI.encode_www_form_component(state)}" end # Build a "Login and link with Legendum" authorize URL (identity + service pairing in one flow). # Call after +request_link+; pass the returned pairing code as +link_code+. # Requires backend support for +intent=login_link+. # # @param redirect_uri [String] your callback URL (must be registered) # @param state [String] CSRF token # @param link_code [String] pairing code from +request_link+ # @return [String] the authorize URL def auth_and_link_url(redirect_uri:, state:, link_code:) raise ArgumentError, "link_code is required" if link_code.to_s.empty? "#{@base_url}/auth/authorize" \ "?client_id=#{URI.encode_www_form_component(@api_key)}" \ "&redirect_uri=#{URI.encode_www_form_component(redirect_uri)}" \ "&state=#{URI.encode_www_form_component(state)}" \ "&intent=login_link" \ "&link_code=#{URI.encode_www_form_component(link_code)}" end # Exchange a one-time auth code for user info. # # @param code [String] # @param redirect_uri [String] # @return [Hash] { email:, linked:, account_token: (optional) } def exchange_code(code, redirect_uri) request(:post, "/api/auth/token", { code: code, redirect_uri: redirect_uri }) end # Link this service to a Legendum account using the user's account key (lak_…). # Creates the account-service link and returns the per-service +account_token+ # you'll persist on the user row and pass to +charge+ / +balance+ / +reserve+ / +tab+. # # @param account_key [String] the account key (lak_...) # @return [Hash] { account_token:, email: } def link_key(account_key) request(:post, "/api/agent/link-key", { api_key: @api_key, secret: @secret, account_key: account_key }) end # Issue a Legendum Account Key for a user this service is already linked to. # Requires the calling service to have +can_issue_keys: true+ in # services.yml. The returned +key+ is shown once — store it (encrypted) and # pass it as +lak_…+ to downstream services. # # @param account_token [String] the caller's existing account_token for this user # @param label [String, nil] optional label shown on /account # @return [Hash] +{ "key" => ..., "key_prefix" => ..., "label" => ..., "id" => ... }+ # @raise [Error] +code: "forbidden"+ (403) — service lacks +can_issue_keys+ flag # @raise [Error] +code: "unauthorized"+ (401) — bad service creds, or +account_token+ unknown / inactive / belongs to another service # @raise [Error] +code: "rate_limited"+ (429) — too many issues for this (service, account) — currently 10/hour def issue_key(account_token, label: nil) body = { api_key: @api_key, secret: @secret, account_token: account_token } body[:label] = label if label request(:post, "/api/agent/keys", body) end # Create a tab for batched micro-charges. # # @param account_token [String] # @param description [String] # @param threshold [Integer] flush when total reaches this # @param amount [Integer] default per add() call (default 1) # @return [Tab] def tab(account_token, description, threshold:, amount: 1) Tab.new( account_token: account_token, description: description, threshold: threshold, amount: amount, client: self, ) end private def request(method, path, body = nil) uri = URI("#{@base_url}#{path}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" req = case method when :get then Net::HTTP::Get.new(uri) when :post then Net::HTTP::Post.new(uri) else raise ArgumentError, "unsupported method: #{method}" end req["X-API-Key"] = @api_key req["Authorization"] = "Bearer #{@secret}" if body req["Content-Type"] = "application/json" req.body = JSON.generate(body) end res = http.request(req) begin data = JSON.parse(res.body) rescue JSON::ParserError # Non-JSON response: server crash, proxy error page, captive portal, # misconfigured base URL, etc. Surface a structured error matching # the documented contract instead of a raw ParserError. status = res.code.to_i raise Error.new("Legendum API error (HTTP #{status})", code: "http_#{status}", status: status) end unless data["ok"] raise Error.new( data["message"] || data["error"] || "Legendum API error", code: data["error"], status: res.code.to_i, ) end data["data"] end end # Account client for account-holder operations. # Uses an account key (lak_...) to act on behalf of a human user. # # acct = Legendum.account("lak_...") # acct.whoami # acct.balance # acct.link("ABC123") class AccountClient def initialize(account_key, base_url: "https://legendum.co.uk") @account_key = account_key @base_url = base_url.chomp("/") end # Verified account email (GET /api/agent/whoami). # # @return [Hash] +{ "email" => String }+ (string keys from JSON) def whoami request(:get, "/api/agent/whoami") end # Get account balance and linked services. def balance request(:get, "/api/agent/balance") end # Get recent transactions. def transactions(limit = 20) request(:get, "/api/agent/transactions?limit=#{limit}") end # Link to a service using a pairing code. def link(code) request(:post, "/api/agent/link", { code: code }) end # Unlink from a service. def unlink(domain) request(:delete, "/api/agent/link/#{URI.encode_www_form_component(domain)}") end # Authorize with a third-party service (Login with Legendum, no browser). # # @param client_id [String] # @param redirect_uri [String] # @param state [String] # @return [Hash] { code:, redirect_uri:, state: } def authorize(client_id:, redirect_uri:, state:) request(:post, "/api/agent/authorize", { client_id: client_id, redirect_uri: redirect_uri, state: state, }) end private def request(method, path, body = nil) uri = URI("#{@base_url}#{path}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" req = case method when :get then Net::HTTP::Get.new(uri) when :post then Net::HTTP::Post.new(uri) when :delete then Net::HTTP::Delete.new(uri) else raise ArgumentError, "unsupported method: #{method}" end req["Authorization"] = "Bearer #{@account_key}" if body req["Content-Type"] = "application/json" req.body = JSON.generate(body) end res = http.request(req) begin data = JSON.parse(res.body) rescue JSON::ParserError # Non-JSON response: server crash, proxy error page, captive portal, # misconfigured base URL, etc. Surface a structured error matching # the documented contract instead of a raw ParserError. status = res.code.to_i raise Error.new("Legendum API error (HTTP #{status})", code: "http_#{status}", status: status) end unless data["ok"] raise Error.new( data["message"] || data["error"] || "Legendum API error", code: data["error"], status: res.code.to_i, ) end data["data"] end end # Wrap a client so every method returns { ok:, data:, error:, code: } # instead of raising. class SafeClient def initialize(client) @client = client end def charge(...) = safe { @client.charge(...) } def balance(...) = safe { @client.balance(...) } def reserve(...) = safe { @client.reserve(...) } def request_link = safe { @client.request_link } def poll_link(...) = safe { @client.poll_link(...) } def wait_for_link(...) = safe { @client.wait_for_link(...) } def exchange_code(...) = safe { @client.exchange_code(...) } def link_key(...) = safe { @client.link_key(...) } def issue_key(...) = safe { @client.issue_key(...) } def auth_url(...) = @client.auth_url(...) def auth_and_link_url(...) = @client.auth_and_link_url(...) def tab(...) = @client.tab(...) private def safe data = yield { ok: true, data: data } rescue Error => e { ok: false, error: e.message, code: e.code } end end # Mock client for testing. Returns sensible defaults for unspecified methods. # # Handler keys in +handlers+ match +Legendum.mock+ (symbols): +:charge+, +:balance+, +:reserve+, # +:request_link+, +:poll_link+, +:wait_for_link+, +:exchange_code+, +:link_key+, +:issue_key+, # +:auth_url+, +:auth_and_link_url+, +:tab+. class MockClient def initialize(handlers = {}) @handlers = handlers end def charge(token, amount, description, **opts) if @handlers[:charge] @handlers[:charge].call(token, amount, description, **opts) else { "email" => "mock@test.com", "transaction_id" => 1, "balance" => 0 } end end def balance(token) @handlers[:balance] ? @handlers[:balance].call(token) : { "balance" => 0, "held" => 0 } end def reserve(token, amount, description = nil) if @handlers[:reserve] @handlers[:reserve].call(token, amount, description) else Reservation.new(id: 1, amount: amount, client: self) end end def request_link @handlers[:request_link] ? @handlers[:request_link].call : { "code" => "MOCK", "request_id" => "mock_req" } end def poll_link(request_id) @handlers[:poll_link] ? @handlers[:poll_link].call(request_id) : { "status" => "pending" } end def wait_for_link(request_id, **opts) @handlers[:wait_for_link] ? @handlers[:wait_for_link].call(request_id, **opts) : { "account_token" => "mock_token" } end def auth_url(redirect_uri:, state:) if @handlers[:auth_url] @handlers[:auth_url].call(redirect_uri: redirect_uri, state: state) else "http://mock.legendum.test/auth/authorize?state=#{state}" end end def auth_and_link_url(redirect_uri:, state:, link_code:) if @handlers[:auth_and_link_url] @handlers[:auth_and_link_url].call(redirect_uri: redirect_uri, state: state, link_code: link_code) else "http://mock.legendum.test/auth/authorize?state=#{state}&intent=login_link&link_code=#{URI.encode_www_form_component(link_code)}" end end def exchange_code(code, redirect_uri) if @handlers[:exchange_code] @handlers[:exchange_code].call(code, redirect_uri) else { "email" => "mock@test.com", "linked" => false } end end def link_key(account_key) handler = @handlers[:link_key] handler ? handler.call(account_key) : { "account_token" => "mock_account_token", "email" => "mock@test.com" } end def issue_key(account_token, label: nil) if @handlers[:issue_key] @handlers[:issue_key].call(account_token, label: label) else { "key" => "lak_mock0000000000000000000000000000", "key_prefix" => "lak_mock0000", "label" => label || "mock", "id" => 1, } end end def tab(account_token, description, threshold:, amount: 1) Tab.new(account_token: account_token, description: description, threshold: threshold, amount: amount, client: self) end end # Rack middleware that handles Legendum linking routes. # # Routes: # POST {prefix}/link — request a pairing code # POST {prefix}/auth-link — pairing code + login-and-link authorize URL (JSON: redirect_uri, state) # POST {prefix}/link-key — Bearer lak_ → account_token + email # POST {prefix}/issue-key — issue a fresh lak_ for the current user (requires get_token) # POST {prefix}/confirm — poll for link confirmation # GET {prefix}/status — check linked state and balance # # Usage (config.ru or Rails): # use Legendum::Middleware, # prefix: "/settings/legendum", # get_token: ->(env) { User.find(env["current_user_id"])&.account_token }, # set_token: ->(env, account_token) { User.find(env["current_user_id"]).update!(account_token: account_token) }, # clear_token: ->(env) { ... } # optional; called when stored token is invalid (e.g. token_not_found) class Middleware def initialize(app, prefix: "/legendum", get_token:, set_token:, client: nil, clear_token: nil, on_link: nil, on_link_key: nil, on_issue_key: nil) @app = app @prefix = prefix.chomp("/") @get_token = get_token @set_token = set_token @clear_token = clear_token || ->(_env) {} @on_link = on_link @on_link_key = on_link_key @on_issue_key = on_issue_key @client = client end def call(env) path = env["PATH_INFO"] return @app.call(env) unless path.start_with?("#{@prefix}/") || path == @prefix route = path[@prefix.length..] method = env["REQUEST_METHOD"] case [method, route] when ["POST", "/link"] handle_link(env) when ["POST", "/auth-link"] handle_auth_link(env) when ["POST", "/link-key"] handle_link_key(env) when ["POST", "/issue-key"] handle_issue_key(env) when ["POST", "/confirm"] handle_confirm(env) when ["GET", "/status"] handle_status(env) else @app.call(env) end end private def sdk_client @client || Legendum.send(:default_client) end def json_response(data, status: 200) [status, { "content-type" => "application/json" }, [JSON.generate(data)]] end def read_json_body(env) body = env["rack.input"]&.read env["rack.input"]&.rewind body && !body.empty? ? JSON.parse(body) : {} end # Middleware JSON errors align with the Legendum API: +message+ (human) and +error+ (code). def error_json(message, status:, error: nil) h = { ok: false, message: message } h[:error] = error if error json_response(h, status: status) end def error_from_caught(err, status, fallback_error = nil) code = err.respond_to?(:code) && err.code ? err.code : fallback_error msg = err.message.to_s.empty? ? "Legendum error" : err.message error_json(msg, status: status, error: code) end def handle_link(env) data = sdk_client.request_link json_response({ ok: true, code: data["code"], request_id: data["request_id"] }) rescue Error => e error_from_caught(e, 500, "internal") end def handle_auth_link(env) body = read_json_body(env) redirect_uri = body["redirect_uri"] || body["redirectUri"] st = body["state"] if redirect_uri.to_s.empty? || st.nil? return error_json("redirect_uri and state are required", status: 400, error: "bad_request") end data = sdk_client.request_link url = sdk_client.auth_and_link_url( redirect_uri: redirect_uri, state: st.to_s, link_code: data["code"], ) json_response({ ok: true, url: url, request_id: data["request_id"] }) rescue Error => e error_from_caught(e, 500, "internal") end def handle_link_key(env) auth = env["HTTP_AUTHORIZATION"].to_s m = auth.match(/\ABearer\s+(\S+)/i) unless m return error_json("Authorization: Bearer required", status: 401, error: "unauthorized") end data = sdk_client.link_key(m[1]) if @on_link_key begin @on_link_key.call(env, data["account_token"], data["email"]) rescue StandardError # best-effort: a failing on_link_key side effect must not break the response end end json_response({ "account_token" => data["account_token"], "email" => data["email"] }) rescue Error => e status = e.status.to_i if status == 401 || e.code.to_s == "unauthorized" error_from_caught(e, 401, "unauthorized") elsif status >= 400 && status < 500 error_from_caught(e, status, "bad_request") else status = 500 if status < 400 || status >= 600 error_from_caught(e, status, "internal") end end def handle_issue_key(env) body = read_json_body(env) token = @get_token.call(env) return error_json("no_link", status: 409, error: "no_link") unless token data = sdk_client.issue_key(token, label: body["label"]) if @on_issue_key begin @on_issue_key.call(env, data["key"], data["key_prefix"]) rescue StandardError # best-effort: a failing on_issue_key side effect must not break the response end end json_response({ "key" => data["key"], "key_prefix" => data["key_prefix"], "label" => data["label"], "id" => data["id"], }) rescue Error => e status = e.status.to_i status = 500 if status < 400 || status >= 600 fallback = status >= 500 ? "internal" : "bad_request" error_from_caught(e, status, fallback) end def handle_confirm(env) body = read_json_body(env) unless body["request_id"] return error_json("request_id is required", status: 400, error: "bad_request") end data = sdk_client.poll_link(body["request_id"]) if data["status"] == "confirmed" && data["account_token"] @set_token.call(env, data["account_token"]) if @on_link begin @on_link.call(env, data["account_token"], data["email"]) rescue StandardError # best-effort: a failing on_link side effect must not break the link flow end end return json_response({ ok: true, status: "confirmed" }) end json_response({ ok: true, status: data["status"] }) rescue Error => e error_from_caught(e, (e.status || 500).to_i, "internal") end def handle_status(env) token = @get_token.call(env) return json_response({ legendum_linked: false }) unless token data = sdk_client.balance(token) json_response({ legendum_linked: true, balance: data["balance"] }) rescue Error => e if e.code == "token_not_found" @clear_token.call(env) return json_response({ legendum_linked: false }) end json_response({ legendum_linked: true }) end end class << self # Create a new client with explicit config. def create(api_key:, secret:, base_url: "https://legendum.co.uk") Client.new(api_key: api_key, secret: secret, base_url: base_url) end # Alias for create. alias_method :service, :create # Create an account client for account-holder operations. # # @param account_key [String] the account key (lak_...) # @param base_url [String] # @return [AccountClient] def account(account_key, base_url: ENV.fetch("LEGENDUM_BASE_URL", "https://legendum.co.uk")) AccountClient.new(account_key, base_url: base_url) end # Get a safe (non-raising) wrapper around the default client. def client(c = nil) SafeClient.new(c || default_client) end # Whether the SDK can be used (env vars present, or mock active). def configured? return true if @mock_client !!(ENV["LEGENDUM_API_KEY"] && ENV["LEGENDUM_SECRET"]) end # Enable mock mode. +handlers+ is a Hash of optional procs; omitted keys use +MockClient+ defaults. # Keys: +:charge+, +:balance+, +:reserve+, +:request_link+, +:poll_link+, +:wait_for_link+, # +:exchange_code+, +:link_key+, +:issue_key+, +:auth_url+, +:auth_and_link_url+, +:tab+. # # Legendum.mock( # charge: ->(token, amount, desc, **opts) { { "email" => "mock@test.com", "transaction_id" => 1, "balance" => 50 } }, # ) def mock(handlers = {}) @mock_client = MockClient.new(handlers) end # Disable mock mode. def unmock @mock_client = nil @default_client = nil end # Build a "Login with Legendum" authorize URL. def auth_url(redirect_uri:, state:) default_client.auth_url(redirect_uri: redirect_uri, state: state) end # Build a "Login and link with Legendum" authorize URL. def auth_and_link_url(redirect_uri:, state:, link_code:) default_client.auth_and_link_url(redirect_uri: redirect_uri, state: state, link_code: link_code) end # Delegate top-level methods to the default client. def charge(...) = default_client.charge(...) def balance(...) = default_client.balance(...) def reserve(...) = default_client.reserve(...) def request_link = default_client.request_link def poll_link(...) = default_client.poll_link(...) def wait_for_link(...) = default_client.wait_for_link(...) def exchange_code(...) = default_client.exchange_code(...) def link_key(...) = default_client.link_key(...) def issue_key(...) = default_client.issue_key(...) def tab(...) = default_client.tab(...) private def default_client return @mock_client if @mock_client @default_client ||= create( api_key: ENV.fetch("LEGENDUM_API_KEY"), secret: ENV.fetch("LEGENDUM_SECRET"), base_url: ENV.fetch("LEGENDUM_BASE_URL", "https://legendum.co.uk"), ) end end end