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.
- checksums.yaml +4 -4
- data/README.md +10 -240
- metadata +19 -18
- data/CHANGES.txt +0 -102
- data/lib/onetime/client.rb +0 -100
- data/lib/onetime/configuration.rb +0 -156
- data/lib/onetime/errors.rb +0 -160
- data/lib/onetime/resources/receipts.rb +0 -70
- data/lib/onetime/resources/secrets.rb +0 -134
- data/lib/onetime/response.rb +0 -72
- data/lib/onetime/transport.rb +0 -224
- data/lib/onetime/version.rb +0 -11
- data/lib/onetime.rb +0 -33
|
@@ -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
|
data/lib/onetime/errors.rb
DELETED
|
@@ -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
|
data/lib/onetime/response.rb
DELETED
|
@@ -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
|