onetime 0.6.0 → 0.7.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.
@@ -1,156 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "uri"
4
-
5
- module Onetime
6
- # Immutable-ish configuration for a Client instance.
7
- #
8
- # Authentication uses HTTP Basic, where the username slot carries the
9
- # *customer external id* (extid) — the identifier that begins with "ur" and
10
- # is shown (with a copy button) at the bottom of the user menu when signed
11
- # in. The password slot carries your API token.
12
- #
13
- # #validate! checks the extid's format, so a value of the wrong kind fails
14
- # at construction rather than as an opaque 401.
15
- #
16
- # Values fall back to environment variables:
17
- # ONETIME_BASE_URL -> base_url
18
- # ONETIME_CUSTOMER_EXTID -> customer
19
- # ONETIME_API_TOKEN -> api_token
20
- class Configuration
21
- DEFAULT_API_VERSION = :v2
22
- SUPPORTED_VERSIONS = %i[v1 v2].freeze
23
- DEFAULT_TIMEOUT = 30 # read timeout, seconds
24
- DEFAULT_OPEN_TIMEOUT = 10 # connect timeout, seconds
25
- DEFAULT_MAX_RETRIES = 2 # retries for idempotent requests
26
-
27
- # A customer extid is a short, opaque, case-insensitive identifier that
28
- # always begins with "ur" — e.g. "ur1abc23def".
29
- CUSTOMER_EXTID_PATTERN = /\Aur[a-z0-9]+\z/i
30
-
31
- # Included in the rejection message: an invalid-format error is only
32
- # actionable if it says where the valid value lives.
33
- CUSTOMER_EXTID_HINT =
34
- 'Your customer extid is the "ur…" identifier at the bottom of the ' \
35
- "user menu when you are signed in (there is a copy button next to it). " \
36
- "Pass it as customer: or set ONETIME_CUSTOMER_EXTID."
37
-
38
- # The apex domain and its www host serve the company website, not the
39
- # API. Regional deployments each have their own host.
40
- APEX_HOSTS = %w[onetimesecret.com www.onetimesecret.com].freeze
41
-
42
- # Known regional API hosts (per the API's published server list),
43
- # surfaced in error messages. Self-hosted and custom domains are also
44
- # valid base URLs.
45
- EXAMPLE_REGIONAL_HOSTS = %w[
46
- us.onetimesecret.com eu.onetimesecret.com uk.onetimesecret.com
47
- ca.onetimesecret.com nz.onetimesecret.com
48
- ].freeze
49
-
50
- attr_accessor :base_url, :api_version, :customer, :api_token,
51
- :timeout, :open_timeout, :max_retries,
52
- :user_agent, :logger, :transport, :default_headers
53
-
54
- def initialize(base_url: nil, api_version: nil, customer: nil,
55
- api_token: nil, timeout: nil, open_timeout: nil, max_retries: nil,
56
- user_agent: nil, logger: nil, transport: nil, default_headers: nil)
57
- @base_url = base_url || ENV["ONETIME_BASE_URL"]
58
- @api_version = normalize_version(api_version || DEFAULT_API_VERSION)
59
- @customer = customer || ENV["ONETIME_CUSTOMER_EXTID"]
60
- @api_token = api_token || ENV["ONETIME_API_TOKEN"]
61
- @timeout = timeout || DEFAULT_TIMEOUT
62
- @open_timeout = open_timeout || DEFAULT_OPEN_TIMEOUT
63
- @max_retries = max_retries.nil? ? DEFAULT_MAX_RETRIES : max_retries
64
- @user_agent = user_agent
65
- @logger = logger
66
- @transport = transport
67
- @default_headers = default_headers || {}
68
- end
69
-
70
- # True when no credentials are configured. Anonymous clients can still
71
- # use public and /guest/* endpoints.
72
- def anonymous?
73
- customer.to_s.empty? && api_token.to_s.empty?
74
- end
75
-
76
- # The mount prefix for the configured API version, e.g. "/api/v2".
77
- def api_path_prefix
78
- "/api/#{api_version}"
79
- end
80
-
81
- def validate!
82
- validate_api_version!
83
- validate_base_url!
84
- validate_credentials!
85
- self
86
- end
87
-
88
- private
89
-
90
- def validate_api_version!
91
- return if SUPPORTED_VERSIONS.include?(api_version)
92
-
93
- raise ConfigurationError,
94
- "Unsupported api_version #{api_version.inspect}; supported: #{SUPPORTED_VERSIONS.join(', ')}"
95
- end
96
-
97
- def validate_base_url!
98
- if base_url.to_s.empty?
99
- examples = EXAMPLE_REGIONAL_HOSTS.first(3).map { |h| "https://#{h}" }.join(", ")
100
- raise ConfigurationError,
101
- "base_url is required. Use your region's API host " \
102
- "(e.g. #{examples}), your self-hosted domain, or your custom " \
103
- "domain. Set it via the base_url: option or the ONETIME_BASE_URL " \
104
- "environment variable."
105
- end
106
-
107
- begin
108
- uri = URI.parse(base_url)
109
- rescue URI::InvalidURIError => e
110
- raise ConfigurationError, "Invalid base_url #{base_url.inspect}: #{e.message}"
111
- end
112
-
113
- unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
114
- raise ConfigurationError, "base_url must be an absolute http(s) URL, got #{base_url.inspect}"
115
- end
116
-
117
- return unless APEX_HOSTS.include?(uri.host.downcase)
118
-
119
- raise ConfigurationError,
120
- "#{uri.host} is the OneTimeSecret company website, not an API host. " \
121
- "Use a regional subdomain (e.g. https://#{EXAMPLE_REGIONAL_HOSTS.first}), " \
122
- "your self-hosted domain, or your custom domain."
123
- end
124
-
125
- def validate_credentials!
126
- validate_customer_format! unless customer.to_s.empty?
127
-
128
- # Partial credentials are almost always a mistake; fail loudly.
129
- return unless customer.to_s.empty? ^ api_token.to_s.empty?
130
-
131
- missing = customer.to_s.empty? ? "customer" : "api_token"
132
- raise ConfigurationError, "Incomplete credentials: #{missing} is missing"
133
- end
134
-
135
- # Catch an identifier of the wrong kind at construction time rather than
136
- # as a 401 several calls later.
137
- def validate_customer_format!
138
- value = customer.to_s
139
- return if CUSTOMER_EXTID_PATTERN.match?(value)
140
-
141
- raise ConfigurationError,
142
- "customer #{value.inspect} is not a customer extid: extids begin " \
143
- 'with "ur" (e.g. "ur1abc23def"). ' \
144
- "#{CUSTOMER_EXTID_HINT}"
145
- end
146
-
147
- def normalize_version(version)
148
- case version
149
- when Symbol then version
150
- when String then version.start_with?("v") ? version.to_sym : :"v#{version}"
151
- when Integer then :"v#{version}"
152
- else version
153
- end
154
- end
155
- end
156
- end
@@ -1,160 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Onetime
4
- # Base class for every error raised by this library.
5
- class Error < StandardError; end
6
-
7
- # Raised when the client is misconfigured (bad base_url, missing
8
- # credentials for an authenticated call, unsupported api_version, ...).
9
- class ConfigurationError < Error; end
10
-
11
- # Raised when an operation is not available on the configured API
12
- # version (e.g. asking the v1 API for a secret's status).
13
- class UnsupportedOperationError < Error; end
14
-
15
- # Raised for transport-level failures: DNS, connection refused, reset
16
- # connections, TLS errors. Wraps the underlying exception in #cause.
17
- class TransportError < Error; end
18
-
19
- # Raised when a request exceeds the configured open/read timeout.
20
- class TimeoutError < TransportError; end
21
-
22
- # Base class for errors returned by the API (HTTP status >= 400).
23
- #
24
- # Carries the structured fields from the ADR-013 wire format
25
- # ({ error:, error_type:, ... }) as well as the legacy v1 { message: }
26
- # shape, so callers get a consistent interface across API versions.
27
- class APIError < Error
28
- attr_reader :http_status, :error_type, :code, :field, :error_key,
29
- :retry_after, :entitlement, :body, :response
30
-
31
- def initialize(message = nil, http_status: nil, error_type: nil, code: nil,
32
- field: nil, error_key: nil, retry_after: nil, entitlement: nil,
33
- body: nil, response: nil)
34
- super(message)
35
- @http_status = http_status
36
- @error_type = error_type
37
- @code = code
38
- @field = field
39
- @error_key = error_key
40
- @retry_after = retry_after
41
- @entitlement = entitlement
42
- @body = body
43
- @response = response
44
- end
45
- end
46
-
47
- class BadRequestError < APIError; end # 400 / FormError
48
- class AuthenticationError < APIError; end # 401
49
- class ForbiddenError < APIError; end # 403 / Forbidden, GuestRoutesDisabled
50
- class EntitlementError < ForbiddenError; end # EntitlementRequired
51
- class NotFoundError < APIError; end # 404 / RecordNotFound
52
- class ConflictError < APIError; end # 409
53
- class RateLimitError < APIError; end # 429 / LimitExceeded
54
- class ServerError < APIError; end # 5xx / ServerError
55
-
56
- # The operation is only available to an authenticated account. The server
57
- # reports this as a field/key rather than a status of its own (historically
58
- # a 400 FormError with field "requires_account"), which made it look like a
59
- # malformed request body. It is an authentication problem, so it subclasses
60
- # AuthenticationError; #field is preserved for callers that need it.
61
- class AccountRequiredError < AuthenticationError; end
62
-
63
- # Builds the appropriate APIError subclass from an HTTP response.
64
- module Errors
65
- module_function
66
-
67
- # Used only when the server sends no message of its own.
68
- ACCOUNT_REQUIRED_MESSAGE =
69
- "This operation requires an authenticated account: send your " \
70
- "customer extid and API token (see Onetime::Client.new)."
71
-
72
- # Maps the ADR-013 error_type (the machine-readable class name the
73
- # server sends) to a client exception class. Falls through to status
74
- # code mapping when the type is absent or unrecognized.
75
- ERROR_TYPE_MAP = {
76
- "FormError" => BadRequestError,
77
- "RequiresAccount" => AccountRequiredError,
78
- "AccountRequired" => AccountRequiredError,
79
- "RecordNotFound" => NotFoundError,
80
- "NotFound" => NotFoundError,
81
- "Forbidden" => ForbiddenError,
82
- "GuestRoutesDisabled" => ForbiddenError,
83
- "EntitlementRequired" => EntitlementError,
84
- "LimitExceeded" => RateLimitError,
85
- "ServerError" => ServerError,
86
- }.freeze
87
-
88
- # The account requirement can arrive in any of several slots depending on
89
- # the endpoint and API version: as the FormError `field`, as the i18n
90
- # `error_key`, or as a `code`. Matching all of them keeps the mapping
91
- # stable across server versions.
92
- ACCOUNT_REQUIRED_KEYS = %w[error_type field error_key code].freeze
93
- # Matches the bare token as well as prefixed codes such as
94
- # "GUEST_CONCEAL_REQUIRES_ACCOUNT" (underscores are word characters, so
95
- # \b is no help here).
96
- ACCOUNT_REQUIRED_PATTERN =
97
- /(?:\A|[^a-z0-9])(requires[_-]?account|account[_-]?required)(?:\z|[^a-z0-9])/i
98
-
99
- STATUS_MAP = {
100
- 400 => BadRequestError,
101
- 401 => AuthenticationError,
102
- 403 => ForbiddenError,
103
- 404 => NotFoundError,
104
- 409 => ConflictError,
105
- 422 => BadRequestError,
106
- 429 => RateLimitError,
107
- }.freeze
108
-
109
- # @param response [Onetime::Response]
110
- # @return [Onetime::APIError]
111
- def from_response(response)
112
- body = response.data.is_a?(Hash) ? response.data : {}
113
- status = response.http_status
114
-
115
- account = account_required?(body)
116
- klass = account ? AccountRequiredError : error_class(body["error_type"], status)
117
- message = body["error"] || body["message"] ||
118
- (account ? ACCOUNT_REQUIRED_MESSAGE : default_message(status))
119
-
120
- klass.new(
121
- message,
122
- http_status: status,
123
- error_type: body["error_type"],
124
- code: body["code"],
125
- field: body["field"],
126
- error_key: body["error_key"],
127
- retry_after: body["retry_after"],
128
- entitlement: body["entitlement"],
129
- body: response.data,
130
- response: response,
131
- )
132
- end
133
-
134
- # True when the body says the operation needs an authenticated account,
135
- # in whichever slot this server version reports it.
136
- def account_required?(body)
137
- ACCOUNT_REQUIRED_KEYS.any? do |key|
138
- ACCOUNT_REQUIRED_PATTERN.match?(body[key].to_s)
139
- end
140
- end
141
-
142
- def error_class(error_type, status)
143
- ERROR_TYPE_MAP[error_type] ||
144
- STATUS_MAP[status] ||
145
- (status.to_i >= 500 ? ServerError : APIError)
146
- end
147
-
148
- def default_message(status)
149
- case status
150
- when 400 then "Bad request"
151
- when 401 then "Authentication failed"
152
- when 403 then "Forbidden"
153
- when 404 then "Not found"
154
- when 429 then "Rate limit exceeded"
155
- when 500..599 then "Server error (#{status})"
156
- else "Request failed (#{status})"
157
- end
158
- end
159
- end
160
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Onetime
4
- module Resources
5
- # Receipt operations (creator-facing secret metadata): show, recent,
6
- # burn, update.
7
- #
8
- # "Receipt" is the modern vocabulary; the v1 API also accepts the legacy
9
- # /private and /metadata aliases, but this client uses the canonical
10
- # /receipt paths that both versions support.
11
- class Receipts
12
- def initialize(client)
13
- @client = client
14
- end
15
-
16
- # Show a receipt by its key.
17
- def show(key, guest: false)
18
- identifier = key.to_s
19
- case version
20
- when :v1
21
- @client.request(:get, "/receipt/#{identifier}")
22
- when :v2
23
- path = guest ? "/guest/receipt/#{identifier}" : "/receipt/#{identifier}"
24
- @client.request(:get, path)
25
- end
26
- end
27
-
28
- # List the authenticated customer's recent receipts.
29
- def recent
30
- @client.request(:get, "/receipt/recent")
31
- end
32
-
33
- # Burn (destroy) an unread secret by its receipt key.
34
- def burn(key, passphrase: nil, continue: true, guest: false)
35
- identifier = key.to_s
36
- case version
37
- when :v1
38
- form = compact(passphrase: passphrase, continue: continue)
39
- @client.request(:post, "/receipt/#{identifier}/burn", form: form)
40
- when :v2
41
- path = guest ? "/guest/receipt/#{identifier}/burn" : "/receipt/#{identifier}/burn"
42
- @client.request(:post, path, body: compact(passphrase: passphrase, continue: continue))
43
- end
44
- end
45
-
46
- # Update a receipt's memo. (v2 only)
47
- def update(key, memo:)
48
- require_version!(:v2, "receipts.update")
49
- @client.request(:patch, "/receipt/#{key}", body: { memo: memo })
50
- end
51
-
52
- private
53
-
54
- def version
55
- @client.api_version
56
- end
57
-
58
- def compact(**attrs)
59
- attrs.reject { |_, v| v.nil? }
60
- end
61
-
62
- def require_version!(expected, operation)
63
- return if version == expected
64
-
65
- raise UnsupportedOperationError,
66
- "#{operation} is only available on API #{expected}; client is configured for #{version}"
67
- end
68
- end
69
- end
70
- end
@@ -1,134 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Onetime
4
- module Resources
5
- # Secret operations: conceal, generate, reveal, show, status.
6
- #
7
- # The request/response contracts differ between API versions:
8
- # - v1 uses flat form-encoded params and legacy endpoint names
9
- # (/share, /generate, POST /secret/:key for reveal).
10
- # - v2 nests create params under a `secret` object, uses JSON bodies,
11
- # and exposes richer REST endpoints (/secret/conceal, /secret/:id, ...).
12
- #
13
- # The version-specific branches are kept side by side in each method so
14
- # the differences are easy to audit.
15
- class Secrets
16
- def initialize(client)
17
- @client = client
18
- end
19
-
20
- # Conceal (share) a secret value you already have.
21
- #
22
- # @param secret [String] the secret content
23
- # @param ttl [Integer, nil] time-to-live in seconds
24
- # @param passphrase [String, nil] passphrase required to reveal
25
- # @param recipient [String, Array<String>, nil] email recipient(s)
26
- # @param share_domain [String, nil] custom share domain
27
- # @param guest [Boolean] use the anonymous /guest/* endpoint (v2)
28
- def conceal(secret:, ttl: nil, passphrase: nil, recipient: nil, share_domain: nil, guest: false)
29
- case version
30
- when :v1
31
- form = compact(secret: secret, ttl: ttl, passphrase: passphrase,
32
- recipient: recipient, share_domain: share_domain)
33
- @client.request(:post, "/share", form: form)
34
- when :v2
35
- path = guest ? "/guest/secret/conceal" : "/secret/conceal"
36
- @client.request(:post, path, body: { secret: secret_payload(
37
- secret: secret, ttl: ttl, passphrase: passphrase,
38
- recipient: recipient, share_domain: share_domain
39
- ) })
40
- end
41
- end
42
- alias share conceal
43
-
44
- # Generate a random secret value server-side.
45
- def generate(ttl: nil, passphrase: nil, recipient: nil, share_domain: nil, guest: false)
46
- case version
47
- when :v1
48
- form = compact(ttl: ttl, passphrase: passphrase,
49
- recipient: recipient, share_domain: share_domain)
50
- @client.request(:post, "/generate", form: form)
51
- when :v2
52
- path = guest ? "/guest/secret/generate" : "/secret/generate"
53
- @client.request(:post, path, body: { secret: secret_payload(
54
- ttl: ttl, passphrase: passphrase,
55
- recipient: recipient, share_domain: share_domain
56
- ) })
57
- end
58
- end
59
-
60
- # Reveal (consume) a secret by its key. This is a one-time action.
61
- #
62
- # @param key [String] the secret identifier
63
- # @param passphrase [String, nil] passphrase if one was set
64
- # @param continue [Boolean] confirm the destructive reveal
65
- def reveal(key, passphrase: nil, continue: true, guest: false)
66
- identifier = extract_secret_key(key)
67
- case version
68
- when :v1
69
- form = compact(passphrase: passphrase, continue: continue)
70
- @client.request(:post, "/secret/#{identifier}", form: form)
71
- when :v2
72
- path = guest ? "/guest/secret/#{identifier}/reveal" : "/secret/#{identifier}/reveal"
73
- @client.request(:post, path, body: compact(passphrase: passphrase, continue: continue))
74
- end
75
- end
76
-
77
- # Show a secret's metadata without revealing its value. (v2 only)
78
- def show(key, guest: false)
79
- require_version!(:v2, "secrets.show")
80
- identifier = extract_secret_key(key)
81
- path = guest ? "/guest/secret/#{identifier}" : "/secret/#{identifier}"
82
- @client.request(:get, path)
83
- end
84
-
85
- # Check a single secret's status. (v2 only)
86
- def status(key)
87
- require_version!(:v2, "secrets.status")
88
- @client.request(:get, "/secret/#{extract_secret_key(key)}/status")
89
- end
90
-
91
- # Check the status of multiple secrets in one call. (v2 only)
92
- #
93
- # @param keys [Array<String>, String] identifiers (array or CSV string)
94
- def status_list(keys)
95
- require_version!(:v2, "secrets.status_list")
96
- identifiers = keys.is_a?(Array) ? keys.join(",") : keys.to_s
97
- @client.request(:post, "/secret/status", body: { identifiers: identifiers })
98
- end
99
-
100
- private
101
-
102
- def version
103
- @client.api_version
104
- end
105
-
106
- # v2 create endpoints expect params nested under a `secret` object.
107
- def secret_payload(**attrs)
108
- compact(**attrs)
109
- end
110
-
111
- def compact(**attrs)
112
- attrs.reject { |_, v| v.nil? }
113
- end
114
-
115
- # Accept either a bare key or a full secret URL (e.g. the link a user
116
- # was given). Mirrors the convenience of the historical CLI.
117
- def extract_secret_key(key)
118
- str = key.to_s
119
- if (match = str.match(%r{/secret/([a-zA-Z0-9]+)}))
120
- match[1]
121
- else
122
- str
123
- end
124
- end
125
-
126
- def require_version!(expected, operation)
127
- return if version == expected
128
-
129
- raise UnsupportedOperationError,
130
- "#{operation} is only available on API #{expected}; client is configured for #{version}"
131
- end
132
- end
133
- end
134
- end
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Onetime
4
- # A thin, indifferent-access wrapper around a parsed API response.
5
- #
6
- # The parsed JSON body is exposed via #[], #dig, #fetch and #to_h, all of
7
- # which accept either String or Symbol keys. The raw body string and HTTP
8
- # status are also available.
9
- #
10
- # res = client.secrets.conceal(secret: "hi")
11
- # res[:record][:receipt]["identifier"]
12
- # res.dig("record", "secret", "secret_value")
13
- # res.success?
14
- class Response
15
- attr_reader :http_status, :headers, :raw_body, :data
16
-
17
- def initialize(http_status:, headers:, raw_body:, data:)
18
- @http_status = http_status
19
- @headers = headers || {}
20
- @raw_body = raw_body
21
- @data = data
22
- end
23
-
24
- # Indifferent key access. Returns nil for non-Hash bodies.
25
- def [](key)
26
- return nil unless data.is_a?(Hash)
27
-
28
- data[normalize(key)]
29
- end
30
-
31
- # Indifferent, deep access mirroring Hash#dig.
32
- def dig(*keys)
33
- keys.reduce(data) do |memo, key|
34
- case memo
35
- when Hash then memo[normalize(key)]
36
- when Array then memo[key]
37
- end
38
- end
39
- end
40
-
41
- def fetch(key, *default, &block)
42
- raise TypeError, "response body is not a Hash" unless data.is_a?(Hash)
43
-
44
- data.fetch(normalize(key), *default, &block)
45
- end
46
-
47
- def key?(key)
48
- data.is_a?(Hash) && data.key?(normalize(key))
49
- end
50
-
51
- # 2xx status.
52
- def success?
53
- (200..299).cover?(http_status.to_i)
54
- end
55
-
56
- # Alias used by the legacy Onetime::API compatibility shim.
57
- def code
58
- http_status
59
- end
60
-
61
- def to_h
62
- data
63
- end
64
-
65
- private
66
-
67
- # Bodies are parsed with string keys; coerce symbol lookups to strings.
68
- def normalize(key)
69
- key.is_a?(Symbol) ? key.to_s : key
70
- end
71
- end
72
- end