onetime 0.5.1 → 0.6.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: 8a9d766fe9b66ef0f5617b2447910c268af2700b7bb70c59574f62033b712ad2
4
+ data.tar.gz: e1737aa5ee4f29fd5a63c8951bed264c614f9fb2f747e01c73396ac8e0a4bc27
5
+ SHA512:
6
+ metadata.gz: 878bb56df14b1ede5daccac8f08c652c29a4eed12ea1295730f8e322bc44449b5333a8d2967db2ab52dd24908aeb77a28a73a79c9e2e7f0053ad776454e109bd
7
+ data.tar.gz: e27104bb9373e1f6b9e68b54f38927679f98e87e0b6730fd6bdb9621f88abe93222a4a1c2260682e8ed764c1f969bf6a00a12f637897992e19020c88b012c058
data/CHANGES.txt CHANGED
@@ -1,5 +1,67 @@
1
1
  ONETIME, CHANGES
2
2
 
3
+ #### 0.6.0 (unreleased; tag v0.6.0 to publish) #########
4
+
5
+ The first release of the `onetime` gem since 0.5.1 (2013): the same gem,
6
+ cleaned up and brought current. The API surface changes completely, and
7
+ the command-line tool moves out to `onetime-cli`, so treat this as a
8
+ rewrite rather than a drop-in upgrade. See docs/releasing.md for the
9
+ RubyGems publishing steps.
10
+
11
+ * REWRITE: Modern, zero-dependency client built on stdlib Net::HTTP.
12
+ A clean break from 0.5.x (2013); no backward compatibility is maintained.
13
+
14
+ * ADDED: Onetime::AccountRequiredError for responses that require an
15
+ authenticated account. The service reports this inside a 400 form-error
16
+ body (field "requires_account"), which previously surfaced as
17
+ BadRequestError and sent callers auditing a request body that was fine.
18
+ It subclasses AuthenticationError and preserves #field. Also matched via
19
+ error_key, code (e.g. GUEST_CONCEAL_REQUIRES_ACCOUNT) and the
20
+ RequiresAccount / AccountRequired error types.
21
+
22
+ * ADDED: customer extid format validation at client construction. Values
23
+ that don't match the "ur..." extid shape are rejected immediately, with a
24
+ message that says where to find the right one.
25
+
26
+ * ADDED: Release workflow (.github/workflows/release.yml) publishing to
27
+ RubyGems via Trusted Publishing on a v* tag, with a tag/version guard,
28
+ plus docs/releasing.md.
29
+
30
+ * CHANGE: README installation instructions now ask for the `~> 0.6`
31
+ version constraint, and say why: the previous release under this gem
32
+ name is the 2013 command-line tool (0.5.1), which an unconstrained
33
+ dependency can still resolve to.
34
+
35
+ * CHANGE: README authentication section now describes the customer extid
36
+ format up front, rather than contrasting it with the pre-1.0 email custid.
37
+
38
+ * ADDED: Onetime::Client with secrets/receipts resources and meta methods.
39
+ * ADDED: Support for the v1 and v2 APIs (selectable via api_version).
40
+ * ADDED: Typed error hierarchy mapped to the ADR-013 wire format
41
+ ({ error, error_type, ... }) and legacy v1 { message } responses.
42
+ * ADDED: Indifferent-access Onetime::Response wrapper.
43
+ * ADDED: Anonymous / guest endpoint support for v2.
44
+ * ADDED: Retries with exponential backoff for idempotent requests.
45
+ * ADDED: Minitest test suite (unit + real Net::HTTP integration).
46
+
47
+ * CHANGE: Authentication uses the customer extid (the "ur..." identifier at
48
+ the bottom of the user menu) as the HTTP Basic username, with the API
49
+ token as the password. Set it via `customer:` / ONETIME_CUSTOMER_EXTID.
50
+ * CHANGE: base_url is now required (no default). Region-isolated deployments
51
+ mean there is no safe default; the apex onetimesecret.com (company website)
52
+ is rejected with guidance toward regional/self-hosted/custom hosts.
53
+ * CHANGE: Requires Ruby 3.1+.
54
+ * CHANGE: The `onetime` CLI moves to a separate `onetime-cli` gem.
55
+
56
+ * REMOVED: httparty, drydock, jeweler, and yajl dependencies.
57
+ * REMOVED: Legacy Onetime::API / OT::API shim, the OT top-level alias, the
58
+ `username:` option alias, and the old ONETIME_HOST / ONETIME_CUSTID /
59
+ ONETIME_APIKEY environment variables.
60
+ * REMOVED: The drydock-based `bin/onetime` executable and the gem signing
61
+ certificate.
62
+ * REMOVED: The redundant top-level VERSION file; lib/onetime/version.rb is
63
+ the single source of version truth.
64
+
3
65
  #### 0.5.1 (2013-02-12) ###############################
4
66
 
5
67
  * ADDED: global recipient parameter for 'generate' and 'share' commands.
data/README.md CHANGED
@@ -1,101 +1,247 @@
1
- # One-Time Secret 0.5 #
1
+ # OnetimeSecret Ruby Client
2
2
 
3
- **Keep sensitive info out of your chat logs & email.**
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.
4
5
 
5
- ## Basic Usage ##
6
+ The gem is **`onetime`**. This repository, **`onetime-ruby`**, is the Ruby SDK
7
+ in the OnetimeSecret SDK family — each language has its own repository, and the
8
+ repository name is not the package name.
6
9
 
7
- Use `onetime` to share something you know (a secret phrase, a special link, etc) or to generate a secret (e.g. a temporary password).
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+**.
8
18
 
9
- ### onetime share ###
19
+ > The `onetime` command-line tool has moved to a separate `onetime-cli` gem so
20
+ > that this library stays dependency-free.
10
21
 
11
- You can pipe it:
22
+ ## Installation
12
23
 
13
- $ echo "I STILL WATCH NIGHT COURT." | onetime
14
- https://onetimesecret.com/secret/mc5o2649tva6885wvyspm0flobsasin
24
+ ```sh
25
+ gem install onetime
26
+ ```
15
27
 
16
- You can type it:
28
+ Or in a Gemfile:
17
29
 
18
- $ onetime
19
- Paste secret here (hit control-D to continue):
20
- I STILL WATCH NIGHT COURT.
21
- https://onetimesecret.com/secret/3djys3b7tridrcvbiprqjejz0c2g07x
30
+ ```ruby
31
+ gem "onetime", "~> 0.6" # the constraint matters, see below
32
+ ```
22
33
 
23
- And you can read from a file:
24
34
 
25
- $ <path/2/file onetime
26
- https://onetimesecret.com/secret/8f8k3ajhax87ctln3h6y11nsb4vf0wq
35
+ Maintainers: see [docs/releasing.md](docs/releasing.md).
27
36
 
28
- *Note that we don't use `cat` in that example! We don't want to provoke the [ire of Randal L. Schwartz](http://partmaps.org/era/unix/award.html).*
37
+ ## Quick start
29
38
 
39
+ ```ruby
40
+ require "onetime"
30
41
 
31
- ### onetime generate ###
42
+ client = Onetime::Client.new(
43
+ base_url: "https://ca.onetimesecret.com", # choose your region's API host (required)
44
+ customer: "ur1abc23defghijklmnop", # customer extid (see below)
45
+ api_token: ENV["ONETIME_API_TOKEN"], # API token from your account page
46
+ api_version: :v2, # :v1 or :v2 (default)
47
+ )
32
48
 
33
- $ onetime generate
34
- Your secret: CttenFwzVpjs
35
- https://onetimesecret.com/secret/er5djg1wodsp5m32oyym489bnuhsfp6
49
+ # Conceal a secret you already have
50
+ res = client.secrets.conceal(secret: "hunter2", ttl: 3600, passphrase: "pw12")
51
+ res.dig("record", "receipt", "identifier") # the receipt (creator) key
52
+ res.dig("record", "secret", "identifier") # the secret (recipient) key
36
53
 
54
+ # Generate a random secret server-side
55
+ client.secrets.generate(ttl: 86_400)
37
56
 
38
- ### onetime get ###
57
+ # Reveal (consume) a secret — one-time only
58
+ secret = client.secrets.reveal("abc123secretkey", passphrase: "pw12")
59
+ secret.dig("record", "secret_value")
39
60
 
40
- You can retrieve a secret too:
61
+ # Service status (works on v1 and v2)
62
+ client.status
63
+ ```
41
64
 
42
- $ onetime get er5djg1wodsp5m32oyym489bnuhsfp6
43
- [...SECRET...]
65
+ ### Authentication
44
66
 
45
- The URI works too:
67
+ Authentication uses HTTP Basic, where the **customer extid** is the username
68
+ and your **API token** is the password.
46
69
 
47
- $ onetime get https://onetimesecret.com/secret/er5djg1wodsp5m32oyym489bnuhsfp6
48
- [...SECRET...]
70
+ The customer extid is a short, opaque identifier that begins with `ur` — for
71
+ example `ur1abc23def`. It is shown, with a copy button, at the bottom of the
72
+ user menu when you are signed in. Other identifiers the API uses are not
73
+ interchangeable with it.
49
74
 
50
- ## Output Format ##
75
+ The format is checked at construction, so a value of the wrong kind fails
76
+ immediately rather than at the first request:
51
77
 
52
- `onetime` also supports YAML, JSON, and to a limited extent CSV outputs. Specify the format using the `-f ` global option:
78
+ ```ruby
79
+ Onetime::Client.new(base_url: "https://ca.onetimesecret.com",
80
+ customer: "acct-123", api_token: ENV["ONETIME_API_TOKEN"])
81
+ # => Onetime::ConfigurationError: customer "acct-123" is not a customer extid:
82
+ # extids begin with "ur" (e.g. "ur1abc23def"). ...
83
+ ```
53
84
 
54
- $ onetime -f yaml generate
55
- ---
56
- value: MhYcuge9VxtX
57
- metadata_key: 4j1122kpd6clemp80gpobu9xfxsp7zu
58
- secret_key: 8f8k3ajhax87ctln3h6y11nsb4vf0wq
59
- ttl: 172800
60
- passphrase_required: false
85
+ > Self-hosted servers older than
86
+ > [onetimesecret#3945](https://github.com/onetimesecret/onetimesecret/pull/3945)
87
+ > silently accept invalid credentials and create the secret **anonymously**
88
+ > instead of returning 401 — the call succeeds, but the secret never appears in
89
+ > your account. Current servers return 401.
61
90
 
62
- ## Installation ###
91
+ ### Base URL (required)
63
92
 
64
- $ [sudo] gem install onetime
93
+ `base_url` must be the API host for your region, your self-hosted domain, or
94
+ your custom domain. There is no default: deployments are region-isolated for
95
+ data sovereignty, so the client cannot guess one for you.
65
96
 
66
- However, in order to be sure the code you're installing hasn't been tampered with, it's recommended that you verify the signiture[http://docs.rubygems.org/read/chapter/21]. To do this, you need to add my public key as a trusted certificate (you only need to do this once):
97
+ Regional API hosts:
67
98
 
68
- # Add the public key as a trusted certificate
69
- # (You only need to do this once)
70
- $ curl -O https://raw.github.com/onetimesecret/onetime-ruby/master/gem-public_cert.pem
71
- $ gem cert --add gem-public_cert.pem
72
-
73
- Then, when install the gem, do so with high security:
74
-
75
- $ gem install onetime -P HighSecurity
76
-
77
- If you don't add the public key, you'll see an error like "Couldn't verify data signature". If you're still having trouble let me know and I'll give you a hand.
78
-
79
- ## More Info ##
80
-
81
- * [API docs](https://onetimesecret.com/docs/api)
82
- * [Codes](https://github.com/onetimesecret/onetime-ruby)
83
- * [Rubgems](https://rubygems.org/gems/onetime)
84
- * [One-Time Secret](https://onetimesecret.com/)
85
-
86
- See also:
87
-
88
- $ onetime -h
89
- $ onetime show-commands
90
- $ onetime share -h
91
- $ onetime generate -h
92
-
93
- ## Thanks ##
94
-
95
- * Kyle Dawkins for the [perl lib](https://github.com/quile/onetime-perl)
96
- * Drew Carey for [tweeting about us](https://twitter.com/DrewFromTV/status/142730130689761280)
97
-
98
-
99
- ## License ##
100
-
101
- See LICENSE.txt
99
+ | Region | Host |
100
+ |---|---|
101
+ | United States | `https://us.onetimesecret.com` |
102
+ | Europe | `https://eu.onetimesecret.com` |
103
+ | United Kingdom | `https://uk.onetimesecret.com` |
104
+ | Canada | `https://ca.onetimesecret.com` |
105
+ | Aotearoa New Zealand | `https://nz.onetimesecret.com` |
106
+
107
+ > The apex `onetimesecret.com` is the company website, not an API host, and
108
+ > is rejected with a helpful error.
109
+
110
+ ### Configuration
111
+
112
+ | Option | Default | Notes |
113
+ |----------------|-----------------------------------------------|-----------------------------------------|
114
+ | `base_url` | — (**required**) | Region/self-hosted/custom domain |
115
+ | `api_version` | `:v2` | `:v1` or `:v2` |
116
+ | `customer` | `ENV["ONETIME_CUSTOMER_EXTID"]` | Customer extid (`ur...`) |
117
+ | `api_token` | `ENV["ONETIME_API_TOKEN"]` | API token |
118
+ | `timeout` | `30` | Read timeout (seconds) |
119
+ | `open_timeout` | `10` | Connect timeout (seconds) |
120
+ | `max_retries` | `2` | Retries for idempotent (GET) requests |
121
+ | `logger` | `nil` | Receives per-request `debug` lines |
122
+
123
+ Environment fallbacks: `base_url` ← `ONETIME_BASE_URL`; `customer` ←
124
+ `ONETIME_CUSTOMER_EXTID`; `api_token` ← `ONETIME_API_TOKEN`.
125
+
126
+ Clients are thread-safe: they hold only configuration and a stateless
127
+ transport, opening a fresh connection per request.
128
+
129
+ ## Anonymous and guest usage
130
+
131
+ A client created without credentials is anonymous and can use public and guest
132
+ endpoints:
133
+
134
+ ```ruby
135
+ guest = Onetime::Client.new(base_url: "https://ca.onetimesecret.com") # no credentials
136
+ guest.secrets.conceal(secret: "no account needed", guest: true)
137
+ ```
138
+
139
+ ## Resources
140
+
141
+ ### `client.secrets`
142
+
143
+ | Method | v1 | v2 |
144
+ |---|----|----|
145
+ | `conceal(secret:, ttl:, passphrase:, recipient:, share_domain:)` | yes | yes |
146
+ | `generate(ttl:, passphrase:, recipient:, share_domain:)` | yes | yes |
147
+ | `reveal(key, passphrase:, continue:)` | yes | yes |
148
+ | `show(key)` | — | yes |
149
+ | `status(key)` | — | yes |
150
+ | `status_list(keys)` | — | yes |
151
+
152
+ `conceal` is also available as `share`. `reveal`/`show` accept either a bare
153
+ key or a full secret URL.
154
+
155
+ ### `client.receipts`
156
+
157
+ | Method | v1 | v2 |
158
+ |---|----|----|
159
+ | `show(key)` | yes | yes |
160
+ | `recent` | yes | yes |
161
+ | `burn(key, passphrase:, continue:)` | yes | yes |
162
+ | `update(key, memo:)` | — | yes |
163
+
164
+ ### Meta
165
+
166
+ `client.status` (v1 & v2), `client.version` / `client.supported_locales`
167
+ (v2 only), `client.authcheck` (v1 only).
168
+
169
+ ## Responses
170
+
171
+ Resource methods return an `Onetime::Response` with indifferent (String or
172
+ Symbol) key access:
173
+
174
+ ```ruby
175
+ res = client.secrets.conceal(secret: "hi")
176
+ res["record"] # Hash
177
+ res.dig(:record, :secret, :secret_value) # deep access
178
+ res.success? # 2xx?
179
+ res.http_status # Integer
180
+ res.to_h # the parsed body
181
+ ```
182
+
183
+ The response shape is the API's, unchanged. v1 and v2 differ on purpose —
184
+ v1 returns flat, all-string fields; v2 nests data under `record`/`details`
185
+ with richer typing — and the client does not reconcile them. Read the
186
+ [API docs](https://docs.onetimesecret.com/) for the shape of the version
187
+ you target.
188
+
189
+ ## Errors
190
+
191
+ HTTP errors (status >= 400) raise typed exceptions following the API's
192
+ ADR-013 error contract (`{ error:, error_type:, ... }`):
193
+
194
+ ```ruby
195
+ begin
196
+ client.secrets.reveal("missing")
197
+ rescue Onetime::NotFoundError => e
198
+ e.message # human-readable message ("error" field)
199
+ e.error_type # machine-readable type ("RecordNotFound")
200
+ e.http_status # 404
201
+ rescue Onetime::RateLimitError => e
202
+ e.retry_after
203
+ rescue Onetime::APIError => e
204
+ # any other API error
205
+ end
206
+ ```
207
+
208
+ | Exception | When |
209
+ |---|---|
210
+ | `Onetime::BadRequestError` | 400 / `FormError` (see `#field`) |
211
+ | `Onetime::AuthenticationError` | 401 |
212
+ | `Onetime::AccountRequiredError` | the operation needs an authenticated account (`requires_account`); subclasses `AuthenticationError`, keeps `#field` |
213
+ | `Onetime::ForbiddenError` | 403 / `Forbidden`, `GuestRoutesDisabled` |
214
+ | `Onetime::EntitlementError` | `EntitlementRequired` (see `#entitlement`) |
215
+ | `Onetime::NotFoundError` | 404 / `RecordNotFound` |
216
+ | `Onetime::RateLimitError` | 429 / `LimitExceeded` (see `#retry_after`) |
217
+ | `Onetime::ServerError` | 5xx |
218
+ | `Onetime::TransportError` / `TimeoutError` | network failures |
219
+ | `Onetime::ConfigurationError` | bad `base_url`, malformed `customer` extid, incomplete credentials (raised at construction) |
220
+
221
+ All inherit from `Onetime::Error`.
222
+
223
+ `AccountRequiredError` exists because the service reports "this needs an
224
+ account" inside a `400` form-error body (`field: "requires_account"`). Mapping
225
+ that to `BadRequestError` sent people auditing their request payload for a
226
+ problem that was never there, so it gets its own class under
227
+ `AuthenticationError`:
228
+
229
+ ```ruby
230
+ begin
231
+ client.secrets.conceal(secret: "hi")
232
+ rescue Onetime::AccountRequiredError => e
233
+ e.field # "requires_account" — preserved
234
+ e.http_status # whatever the server sent (400 today)
235
+ end
236
+ ```
237
+
238
+ ## Development
239
+
240
+ ```sh
241
+ bundle install
242
+ rake test
243
+ ```
244
+
245
+ ## License
246
+
247
+ 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