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 +7 -0
- data/CHANGES.txt +62 -0
- data/README.md +220 -74
- data/lib/onetime/client.rb +100 -0
- data/lib/onetime/configuration.rb +156 -0
- data/lib/onetime/errors.rb +160 -0
- data/lib/onetime/resources/receipts.rb +70 -0
- data/lib/onetime/resources/secrets.rb +134 -0
- data/lib/onetime/response.rb +72 -0
- data/lib/onetime/transport.rb +224 -0
- data/lib/onetime/version.rb +11 -0
- data/lib/onetime.rb +33 -0
- metadata +44 -90
- data/Rakefile +0 -50
- data/VERSION +0 -1
- data/bin/onetime +0 -228
- data/gem-public_cert.pem +0 -20
- data/lib/onetime/api.rb +0 -136
- data/onetime.gemspec +0 -56
- data.tar.gz.sig +0 -0
- metadata.gz.sig +0 -1
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
|
-
#
|
|
1
|
+
# OnetimeSecret Ruby Client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
## Installation
|
|
12
23
|
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
```sh
|
|
25
|
+
gem install onetime
|
|
26
|
+
```
|
|
15
27
|
|
|
16
|
-
|
|
28
|
+
Or in a Gemfile:
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
https://onetimesecret.com/secret/8f8k3ajhax87ctln3h6y11nsb4vf0wq
|
|
35
|
+
Maintainers: see [docs/releasing.md](docs/releasing.md).
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
## Quick start
|
|
29
38
|
|
|
39
|
+
```ruby
|
|
40
|
+
require "onetime"
|
|
30
41
|
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
# Service status (works on v1 and v2)
|
|
62
|
+
client.status
|
|
63
|
+
```
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
[...SECRET...]
|
|
65
|
+
### Authentication
|
|
44
66
|
|
|
45
|
-
|
|
67
|
+
Authentication uses HTTP Basic, where the **customer extid** is the username
|
|
68
|
+
and your **API token** is the password.
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
91
|
+
### Base URL (required)
|
|
63
92
|
|
|
64
|
-
|
|
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
|
-
|
|
97
|
+
Regional API hosts:
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|