onetimesecret 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f1df67fea362be27b61836f79fa41f0988658791171961427d10acf221af30a7
4
+ data.tar.gz: 39cca2de91250e4db32e68758c4718b687b90e7ef82ff05f934f1b15c3993c75
5
+ SHA512:
6
+ metadata.gz: e5b5a3c0ed18e4fb7f811eb56894b90a1a6bb9336c734137e333bce0588be900f02904eb5d13d78e002af2c11fe0334b722757e1471a725cbafb198994ee445c
7
+ data.tar.gz: 8c1db673913c37a5c7317ea8969ad8956199455ac7ef67b7dc8788c6a9d1fc231e1a1358a08404e04396603dbf907350b73f4370a7b387be91c3474bdf009b0e
data/CHANGES.txt ADDED
@@ -0,0 +1,110 @@
1
+ ONETIMESECRET, CHANGES
2
+
3
+ #### 0.7.0 (unreleased; tag v0.7.0 to publish) #########
4
+
5
+ * CHANGE: The canonical RubyGems package is now `onetimesecret`. Replace
6
+ `gem "onetime"` with `gem "onetimesecret"` in Gemfiles. The Ruby namespace
7
+ remains `Onetime`, and both `require "onetimesecret"` and `require "onetime"`
8
+ are supported. See docs/releasing.md for release and migration details.
9
+
10
+ * DEPRECATED: `onetime` 0.7 is a dependency-only compatibility package that
11
+ installs `onetimesecret` and directs users to the canonical package.
12
+
13
+ #### 0.6.0 (2026-07-30) ###############################
14
+
15
+ The first release of the `onetime` gem since 0.5.1 (2013): the same gem,
16
+ cleaned up and brought current. The API surface changes completely, and
17
+ the command-line tool moves out to `onetime-cli`, so treat this as a
18
+ rewrite rather than a drop-in upgrade. See docs/releasing.md for the
19
+ RubyGems publishing steps.
20
+
21
+ * REWRITE: Modern, zero-dependency client built on stdlib Net::HTTP.
22
+ A clean break from 0.5.x (2013); no backward compatibility is maintained.
23
+
24
+ * ADDED: Onetime::AccountRequiredError for responses that require an
25
+ authenticated account. The service reports this inside a 400 form-error
26
+ body (field "requires_account"), which previously surfaced as
27
+ BadRequestError and sent callers auditing a request body that was fine.
28
+ It subclasses AuthenticationError and preserves #field. Also matched via
29
+ error_key, code (e.g. GUEST_CONCEAL_REQUIRES_ACCOUNT) and the
30
+ RequiresAccount / AccountRequired error types.
31
+
32
+ * ADDED: customer extid format validation at client construction. Values
33
+ that don't match the "ur..." extid shape are rejected immediately, with a
34
+ message that says where to find the right one.
35
+
36
+ * ADDED: Release workflow (.github/workflows/release.yml) publishing to
37
+ RubyGems via Trusted Publishing on a v* tag, with a tag/version guard,
38
+ plus docs/releasing.md.
39
+
40
+ * CHANGE: README installation instructions now ask for the `~> 0.6`
41
+ version constraint, and say why: the previous release under this gem
42
+ name is the 2013 command-line tool (0.5.1), which an unconstrained
43
+ dependency can still resolve to.
44
+
45
+ * CHANGE: README authentication section now describes the customer extid
46
+ format up front, rather than contrasting it with the pre-1.0 email custid.
47
+
48
+ * ADDED: Onetime::Client with secrets/receipts resources and meta methods.
49
+ * ADDED: Support for the v1 and v2 APIs (selectable via api_version).
50
+ * ADDED: Typed error hierarchy mapped to the ADR-013 wire format
51
+ ({ error, error_type, ... }) and legacy v1 { message } responses.
52
+ * ADDED: Indifferent-access Onetime::Response wrapper.
53
+ * ADDED: Anonymous / guest endpoint support for v2.
54
+ * ADDED: Retries with exponential backoff for idempotent requests.
55
+ * ADDED: Minitest test suite (unit + real Net::HTTP integration).
56
+
57
+ * CHANGE: Authentication uses the customer extid (the "ur..." identifier at
58
+ the bottom of the user menu) as the HTTP Basic username, with the API
59
+ token as the password. Set it via `customer:` / ONETIME_CUSTOMER_EXTID.
60
+ * CHANGE: base_url is now required (no default). Region-isolated deployments
61
+ mean there is no safe default; the apex onetimesecret.com (company website)
62
+ is rejected with guidance toward regional/self-hosted/custom hosts.
63
+ * CHANGE: Requires Ruby 3.1+.
64
+ * CHANGE: The `onetime` CLI moves to a separate `onetime-cli` gem.
65
+
66
+ * REMOVED: httparty, drydock, jeweler, and yajl dependencies.
67
+ * REMOVED: Legacy Onetime::API / OT::API shim, the OT top-level alias, the
68
+ `username:` option alias, and the old ONETIME_HOST / ONETIME_CUSTID /
69
+ ONETIME_APIKEY environment variables.
70
+ * REMOVED: The drydock-based `bin/onetime` executable and the gem signing
71
+ certificate.
72
+ * REMOVED: The redundant top-level VERSION file; lib/onetime/version.rb is
73
+ the single source of version truth.
74
+
75
+ #### 0.5.1 (2013-02-12) ###############################
76
+
77
+ * ADDED: global recipient parameter for 'generate' and 'share' commands.
78
+
79
+ #### 0.5.0 (2013-02-12) ###############################
80
+
81
+ * ADDED: Support for getting secrets based on the URI
82
+ * ADDED: Command alias 'get' for 'secret'
83
+ * ADDED: Explicit support for string format
84
+ * FIXED: Confusion around csv support (there is no csv support)
85
+ * CHANGE: Disabled checking for tty. Just assume string format.
86
+ * CHANGE: 'secret' command returns just the secret message by default (not yaml)
87
+
88
+
89
+ #### 0.4.1 (2013-02-12) ###############################
90
+
91
+ * CHANGE: All releases will now be signed. See "Installation" in the readme.
92
+ * CHANGE: json dependency upgraded from 1.6.4 to 1.6.8
93
+
94
+ #### 0.4.0 (2012-01-07) ###############################
95
+
96
+ * ADDED: Support for ttl, passphrase, and email recipient
97
+ * ADDED: Better error handling
98
+ * CHANGE: env variable ONETIME_ACCOUNT -> ONETIME_CUSTID
99
+ * CHANGE: global option -u is now -H
100
+
101
+
102
+ #### 0.3.1 (2012-01-06) ###############################
103
+
104
+ * FIXED: Added httparty to gemspec dependencies
105
+ * ADDED: X-Onetime-Client header
106
+
107
+
108
+ #### 0.3.0 (2012-01-06) ###############################
109
+
110
+ Initial public release
data/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2011,2012 Delano Mandelbaum
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # Onetime Secret Ruby Client
2
+
3
+ The official Ruby client for the [OnetimeSecret](https://onetimesecret.com)
4
+ API. Share sensitive information through a link that can only be viewed once.
5
+
6
+ The canonical gem is **`onetimesecret`**. This repository, **`onetime-ruby`**,
7
+ is the Ruby SDK in the OnetimeSecret SDK family — each language has its own
8
+ repository, and the repository name is not the package name.
9
+
10
+ - **Zero runtime dependencies** — built entirely on the Ruby standard library
11
+ (`net/http`, `uri`, `json`), so it drops into any environment without pulling
12
+ transitive gems.
13
+ - **Supports API v1 and v2** — pick a version per client. The same resource
14
+ methods exist for both, but each version returns its own response shape (they
15
+ are different APIs; the client does not normalize them). **v1 is deprecated**
16
+ and included only because the service still serves it — use v2 for new work.
17
+ - **Ruby 3.1+**.
18
+
19
+ > The `onetime` command-line tool has moved to a separate `onetime-cli` gem so
20
+ > that this library stays dependency-free.
21
+
22
+ ## Installation
23
+
24
+ ```sh
25
+ gem install onetimesecret
26
+ ```
27
+
28
+ Or in a Gemfile:
29
+
30
+ ```ruby
31
+ gem "onetimesecret", "~> 0.7.0"
32
+ ```
33
+
34
+ The package was published as `onetime` through version 0.6.0. The `onetime`
35
+ 0.7 compatibility package installs `onetimesecret`, but existing applications
36
+ should update their Gemfiles directly. The Ruby namespace remains `Onetime`,
37
+ and `require "onetime"` remains supported.
38
+
39
+ Maintainers: see [docs/releasing.md](docs/releasing.md).
40
+
41
+ ## Quick start
42
+
43
+ ```ruby
44
+ require "onetimesecret"
45
+
46
+ client = Onetime::Client.new(
47
+ base_url: "https://ca.onetimesecret.com", # choose your region's API host (required)
48
+ customer: "ur1abc23defghijklmnop", # customer extid (see below)
49
+ api_token: ENV["ONETIME_API_TOKEN"], # API token from your account page
50
+ api_version: :v2, # :v1 or :v2 (default)
51
+ )
52
+
53
+ # Conceal a secret you already have
54
+ res = client.secrets.conceal(secret: "hunter2", ttl: 3600, passphrase: "pw12")
55
+ res.dig("record", "receipt", "identifier") # the receipt (creator) key
56
+ res.dig("record", "secret", "identifier") # the secret (recipient) key
57
+
58
+ # Generate a random secret server-side
59
+ client.secrets.generate(ttl: 86_400)
60
+
61
+ # Reveal (consume) a secret — one-time only
62
+ secret = client.secrets.reveal("abc123secretkey", passphrase: "pw12")
63
+ secret.dig("record", "secret_value")
64
+
65
+ # Service status (works on v1 and v2)
66
+ client.status
67
+ ```
68
+
69
+ ### Authentication
70
+
71
+ Authentication uses HTTP Basic, where the **customer extid** is the username
72
+ and your **API token** is the password.
73
+
74
+ The customer extid is a short, opaque identifier that begins with `ur` — for
75
+ example `ur1abc23def`. It is shown, with a copy button, at the bottom of the
76
+ user menu when you are signed in. Other identifiers the API uses are not
77
+ interchangeable with it.
78
+
79
+ The format is checked at construction, so a value of the wrong kind fails
80
+ immediately rather than at the first request:
81
+
82
+ ```ruby
83
+ Onetime::Client.new(base_url: "https://ca.onetimesecret.com",
84
+ customer: "acct-123", api_token: ENV["ONETIME_API_TOKEN"])
85
+ # => Onetime::ConfigurationError: customer "acct-123" is not a customer extid:
86
+ # extids begin with "ur" (e.g. "ur1abc23def"). ...
87
+ ```
88
+
89
+ > Self-hosted servers older than
90
+ > [onetimesecret#3945](https://github.com/onetimesecret/onetimesecret/pull/3945)
91
+ > silently accept invalid credentials and create the secret **anonymously**
92
+ > instead of returning 401 — the call succeeds, but the secret never appears in
93
+ > your account. Current servers return 401.
94
+
95
+ ### Base URL (required)
96
+
97
+ `base_url` must be the API host for your region, your self-hosted domain, or
98
+ your custom domain. There is no default: deployments are region-isolated for
99
+ data sovereignty, so the client cannot guess one for you.
100
+
101
+ Regional API hosts:
102
+
103
+ | Region | Host |
104
+ |---|---|
105
+ | United States | `https://us.onetimesecret.com` |
106
+ | Europe | `https://eu.onetimesecret.com` |
107
+ | United Kingdom | `https://uk.onetimesecret.com` |
108
+ | Canada | `https://ca.onetimesecret.com` |
109
+ | Aotearoa New Zealand | `https://nz.onetimesecret.com` |
110
+
111
+ > The apex `onetimesecret.com` is the company website, not an API host, and
112
+ > is rejected with a helpful error.
113
+
114
+ ### Configuration
115
+
116
+ | Option | Default | Notes |
117
+ |----------------|-----------------------------------------------|-----------------------------------------|
118
+ | `base_url` | — (**required**) | Region/self-hosted/custom domain |
119
+ | `api_version` | `:v2` | `:v1` or `:v2` |
120
+ | `customer` | `ENV["ONETIME_CUSTOMER_EXTID"]` | Customer extid (`ur...`) |
121
+ | `api_token` | `ENV["ONETIME_API_TOKEN"]` | API token |
122
+ | `timeout` | `30` | Read timeout (seconds) |
123
+ | `open_timeout` | `10` | Connect timeout (seconds) |
124
+ | `max_retries` | `2` | Retries for idempotent (GET) requests |
125
+ | `logger` | `nil` | Receives per-request `debug` lines |
126
+
127
+ Environment fallbacks: `base_url` ← `ONETIME_BASE_URL`; `customer` ←
128
+ `ONETIME_CUSTOMER_EXTID`; `api_token` ← `ONETIME_API_TOKEN`.
129
+
130
+ Clients are thread-safe: they hold only configuration and a stateless
131
+ transport, opening a fresh connection per request.
132
+
133
+ ## Anonymous and guest usage
134
+
135
+ A client created without credentials is anonymous and can use public and guest
136
+ endpoints:
137
+
138
+ ```ruby
139
+ guest = Onetime::Client.new(base_url: "https://ca.onetimesecret.com") # no credentials
140
+ guest.secrets.conceal(secret: "no account needed", guest: true)
141
+ ```
142
+
143
+ ## Resources
144
+
145
+ ### `client.secrets`
146
+
147
+ | Method | v1 | v2 |
148
+ |---|----|----|
149
+ | `conceal(secret:, ttl:, passphrase:, recipient:, share_domain:)` | yes | yes |
150
+ | `generate(ttl:, passphrase:, recipient:, share_domain:)` | yes | yes |
151
+ | `reveal(key, passphrase:, continue:)` | yes | yes |
152
+ | `show(key)` | — | yes |
153
+ | `status(key)` | — | yes |
154
+ | `status_list(keys)` | — | yes |
155
+
156
+ `conceal` is also available as `share`. `reveal`/`show` accept either a bare
157
+ key or a full secret URL.
158
+
159
+ ### `client.receipts`
160
+
161
+ | Method | v1 | v2 |
162
+ |---|----|----|
163
+ | `show(key)` | yes | yes |
164
+ | `recent` | yes | yes |
165
+ | `burn(key, passphrase:, continue:)` | yes | yes |
166
+ | `update(key, memo:)` | — | yes |
167
+
168
+ ### Meta
169
+
170
+ `client.status` (v1 & v2), `client.version` / `client.supported_locales`
171
+ (v2 only), `client.authcheck` (v1 only).
172
+
173
+ ## Responses
174
+
175
+ Resource methods return an `Onetime::Response` with indifferent (String or
176
+ Symbol) key access:
177
+
178
+ ```ruby
179
+ res = client.secrets.conceal(secret: "hi")
180
+ res["record"] # Hash
181
+ res.dig(:record, :secret, :secret_value) # deep access
182
+ res.success? # 2xx?
183
+ res.http_status # Integer
184
+ res.to_h # the parsed body
185
+ ```
186
+
187
+ The response shape is the API's, unchanged. v1 and v2 differ on purpose —
188
+ v1 returns flat, all-string fields; v2 nests data under `record`/`details`
189
+ with richer typing — and the client does not reconcile them. Read the
190
+ [API docs](https://docs.onetimesecret.com/) for the shape of the version
191
+ you target.
192
+
193
+ ## Errors
194
+
195
+ HTTP errors (status >= 400) raise typed exceptions following the API's
196
+ ADR-013 error contract (`{ error:, error_type:, ... }`):
197
+
198
+ ```ruby
199
+ begin
200
+ client.secrets.reveal("missing")
201
+ rescue Onetime::NotFoundError => e
202
+ e.message # human-readable message ("error" field)
203
+ e.error_type # machine-readable type ("RecordNotFound")
204
+ e.http_status # 404
205
+ rescue Onetime::RateLimitError => e
206
+ e.retry_after
207
+ rescue Onetime::APIError => e
208
+ # any other API error
209
+ end
210
+ ```
211
+
212
+ | Exception | When |
213
+ |---|---|
214
+ | `Onetime::BadRequestError` | 400 / `FormError` (see `#field`) |
215
+ | `Onetime::AuthenticationError` | 401 |
216
+ | `Onetime::AccountRequiredError` | the operation needs an authenticated account (`requires_account`); subclasses `AuthenticationError`, keeps `#field` |
217
+ | `Onetime::ForbiddenError` | 403 / `Forbidden`, `GuestRoutesDisabled` |
218
+ | `Onetime::EntitlementError` | `EntitlementRequired` (see `#entitlement`) |
219
+ | `Onetime::NotFoundError` | 404 / `RecordNotFound` |
220
+ | `Onetime::RateLimitError` | 429 / `LimitExceeded` (see `#retry_after`) |
221
+ | `Onetime::ServerError` | 5xx |
222
+ | `Onetime::TransportError` / `TimeoutError` | network failures |
223
+ | `Onetime::ConfigurationError` | bad `base_url`, malformed `customer` extid, incomplete credentials (raised at construction) |
224
+
225
+ All inherit from `Onetime::Error`.
226
+
227
+ `AccountRequiredError` exists because the service reports "this needs an
228
+ account" inside a `400` form-error body (`field: "requires_account"`). Mapping
229
+ that to `BadRequestError` sent people auditing their request payload for a
230
+ problem that was never there, so it gets its own class under
231
+ `AuthenticationError`:
232
+
233
+ ```ruby
234
+ begin
235
+ client.secrets.conceal(secret: "hi")
236
+ rescue Onetime::AccountRequiredError => e
237
+ e.field # "requires_account" — preserved
238
+ e.http_status # whatever the server sent (400 today)
239
+ end
240
+ ```
241
+
242
+ ## Development
243
+
244
+ ```sh
245
+ bundle install
246
+ rake test
247
+ ```
248
+
249
+ ## License
250
+
251
+ See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "transport"
5
+ require_relative "resources/secrets"
6
+ require_relative "resources/receipts"
7
+
8
+ module Onetime
9
+ # The main entry point for the OnetimeSecret API client.
10
+ #
11
+ # client = Onetime::Client.new(
12
+ # base_url: "https://ca.onetimesecret.com",
13
+ # customer: "ur1abc23def", # customer extid
14
+ # api_token: ENV["ONETIME_API_TOKEN"],
15
+ # api_version: :v2, # :v1 or :v2
16
+ # )
17
+ #
18
+ # client.secrets.conceal(secret: "hunter2", ttl: 3600)
19
+ # client.receipts.recent
20
+ # client.status
21
+ #
22
+ # A client is safe to share across threads: it holds configuration and a
23
+ # stateless transport, and creates a fresh Net::HTTP connection per request.
24
+ class Client
25
+ attr_reader :config, :transport
26
+
27
+ # Accepts the same keyword arguments as Onetime::Configuration, or an
28
+ # already-built Configuration via `config:`.
29
+ def initialize(config: nil, **options)
30
+ @config = config || Configuration.new(**options)
31
+ @config.validate!
32
+ @transport = @config.transport || Transport.new(@config)
33
+ end
34
+
35
+ def api_version
36
+ config.api_version
37
+ end
38
+
39
+ # Secret resource accessor (conceal/generate/reveal/show/status).
40
+ def secrets
41
+ @secrets ||= Resources::Secrets.new(self)
42
+ end
43
+
44
+ # Receipt resource accessor (show/recent/burn/update).
45
+ def receipts
46
+ @receipts ||= Resources::Receipts.new(self)
47
+ end
48
+
49
+ # --- Meta / public endpoints -------------------------------------------
50
+
51
+ # Service status. Available on both v1 and v2.
52
+ def status
53
+ request(:get, "/status")
54
+ end
55
+
56
+ # Service version. (v2 only)
57
+ def version
58
+ require_version!(:v2, "version")
59
+ request(:get, "/version")
60
+ end
61
+
62
+ # Locales supported by the service. (v2 only)
63
+ def supported_locales
64
+ require_version!(:v2, "supported_locales")
65
+ request(:get, "/supported-locales")
66
+ end
67
+
68
+ # Verify the configured credentials are valid. (v1 only)
69
+ def authcheck
70
+ require_version!(:v1, "authcheck")
71
+ request(:get, "/authcheck")
72
+ end
73
+
74
+ # --- Low-level request helper ------------------------------------------
75
+
76
+ # Issue a request against the configured API version. The path is
77
+ # relative to the version prefix (e.g. "/secret/conceal").
78
+ #
79
+ # @return [Onetime::Response]
80
+ def request(method, path, query: nil, body: nil, form: nil, raise_on_error: true)
81
+ transport.request(
82
+ method, full_path(path),
83
+ query: query, body: body, form: form, raise_on_error: raise_on_error
84
+ )
85
+ end
86
+
87
+ private
88
+
89
+ def full_path(path)
90
+ "#{config.api_path_prefix}#{path}"
91
+ end
92
+
93
+ def require_version!(expected, operation)
94
+ return if api_version == expected
95
+
96
+ raise UnsupportedOperationError,
97
+ "#{operation} is only available on API #{expected}; client is configured for #{api_version}"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,156 @@
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