vatio-sdk 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/LICENSE.txt +21 -0
- data/README.md +423 -0
- data/lib/vatio/identity/authentication.rb +67 -0
- data/lib/vatio/identity/config.rb +124 -0
- data/lib/vatio/identity/railtie.rb +15 -0
- data/lib/vatio/identity/version.rb +7 -0
- data/lib/vatio/identity.rb +168 -0
- data/lib/vatio/sdk.rb +5 -0
- data/lib/vatio/server/client.rb +142 -0
- data/lib/vatio/server/config.rb +75 -0
- data/lib/vatio/server/errors.rb +129 -0
- data/lib/vatio/server/message.rb +85 -0
- data/lib/vatio/server/send_message_job.rb +72 -0
- data/lib/vatio/server/template.rb +34 -0
- data/lib/vatio/server/testing.rb +322 -0
- data/lib/vatio/server/transport.rb +50 -0
- data/lib/vatio/server/webhook.rb +85 -0
- data/lib/vatio/server/webhook_receiver.rb +88 -0
- data/lib/vatio/server.rb +85 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a3ca193521e55a9d1c5c6918c5f7100f632effa64168fa4ccd8bc1ba27918357
|
|
4
|
+
data.tar.gz: 71f39cb25a704a68b4cd47d0cd45fb7ca7a0162a879a1c281b4a47d86f2d911f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8755693383c6f26e929c9dc0be96de067fdbaeed3d2d5dff227b9eed9fe034cfab244d17fa0f8b5baa0464c9849efffcb8b390039c672b4aeafdf87d6129f004
|
|
7
|
+
data.tar.gz: a5f349288b444d346dcb2d951e7a5305aef998f2564120306b40ea39c8a5b74dc9bd123e9e897de36df7dcc8517db30c05c196fa9fe7ee113915c0c4a6d5d0e6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Urcalab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# vatio-sdk
|
|
2
|
+
|
|
3
|
+
Signs and verifies the one credential a Vatio workspace authenticates its users
|
|
4
|
+
with: a JWT your application signed with its own private key.
|
|
5
|
+
|
|
6
|
+
Vatio holds only the public half, so it can check a token and never mint one.
|
|
7
|
+
That is the property that makes it reasonable for a platform to be verifying
|
|
8
|
+
identity at all — and it is also why everything dangerous about the arrangement
|
|
9
|
+
sits on *your* side of the line. This gem is that side, done once.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "vatio-sdk", "~> 0.6"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
On [RubyGems](https://rubygems.org/gems/vatio-sdk) from 0.6.0. Until then it was
|
|
16
|
+
called `vatio-identity` and installed from git; a Gemfile that still says
|
|
17
|
+
`gem "vatio-identity", git: "https://github.com/vatio-ai/ruby"` keeps working
|
|
18
|
+
and installs this, so switch when you next touch it. The code, the requires and the `Vatio::Identity` and
|
|
19
|
+
`Vatio::Server` modules did not change.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
The keypair comes from the CLI, not from here — `vatio auth --new-key` writes
|
|
24
|
+
`identity.pub` and `identity.pem` in the workspace, gitignores the private half
|
|
25
|
+
and prints the `vatio.yml` to go with it. There is deliberately no rake task
|
|
26
|
+
that does the same thing differently.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/initializers/vatio.rb
|
|
30
|
+
Vatio::Identity.configure do |c|
|
|
31
|
+
c.audience = "acme" # your workspace slug
|
|
32
|
+
c.private_key = ENV.fetch("VATIO_IDENTITY_PRIVATE_KEY")
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`algorithm` is inferred from the key — the curve fixes it for EC, RS256 for RSA
|
|
37
|
+
— and has to match `auth.algorithm` in `vatio.yml`.
|
|
38
|
+
|
|
39
|
+
## The web
|
|
40
|
+
|
|
41
|
+
```erb
|
|
42
|
+
<%= raw Vatio::Identity.widget_tag(
|
|
43
|
+
workspace: "acme",
|
|
44
|
+
token: "vatpub_...",
|
|
45
|
+
subject: current_user&.id,
|
|
46
|
+
claims: { name: current_user&.name, email: current_user&.email }) %>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No `subject`, no visitor token, and the agent answers with its public tools —
|
|
50
|
+
which is what a signed-out visitor should get.
|
|
51
|
+
|
|
52
|
+
Add `metadata` to `claims` and whoever answers in the inbox sees a link to the
|
|
53
|
+
person in your admin, plus badges:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
claims: {
|
|
57
|
+
name: current_user.name,
|
|
58
|
+
metadata: {
|
|
59
|
+
profile: admin_user_url(current_user),
|
|
60
|
+
badges: { plan: { value: current_user.plan, tone: "green" }, country: current_user.country }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See [Enrich the inbox](https://vatio.ai/docs/authentication/#enrich-the-inbox).
|
|
66
|
+
|
|
67
|
+
**Watch the cache.** Rails renders this per request, so it is safe by default.
|
|
68
|
+
The moment it lands in a page cache, a CDN or `caches_action`, you are serving
|
|
69
|
+
one customer's identity to the next visitor. If anything caches the page,
|
|
70
|
+
render it without a token and call `window.VatioWidget.identify(token)` from an
|
|
71
|
+
uncached endpoint instead.
|
|
72
|
+
|
|
73
|
+
## WhatsApp and Instagram
|
|
74
|
+
|
|
75
|
+
A WhatsApp or Instagram chat has no page to put a token on, so when a private
|
|
76
|
+
tool needs one Vatio sends the contact a "Sign in" button. It opens
|
|
77
|
+
`auth.sign_in_url` from `vatio.yml` with a `code`; sign them in the way your
|
|
78
|
+
site already does, then send them back:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# auth:
|
|
82
|
+
# public_key: identity.pub
|
|
83
|
+
# sign_in_url: https://acme.com/vatio/sign-in
|
|
84
|
+
class VatioSignInsController < ApplicationController
|
|
85
|
+
before_action :authenticate_user! # your own login
|
|
86
|
+
|
|
87
|
+
def show
|
|
88
|
+
# Back from a Turbo login form: fetch cannot follow a redirect to vatio.ai.
|
|
89
|
+
if request.headers["X-Turbo-Request-Id"]
|
|
90
|
+
return render html: helpers.tag.meta(name: "turbo-visit-control", content: "reload")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
redirect_to Vatio::Identity.sign_in_redirect_url(
|
|
94
|
+
code: params[:code],
|
|
95
|
+
subject: current_user.id,
|
|
96
|
+
claims: { name: current_user.name, phone_number: current_user.phone }
|
|
97
|
+
), allow_other_host: true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The token lasts a week by default (`expires_in:` to change it); when it runs
|
|
103
|
+
out the button goes out again. A `phone_number` claim that matches the WhatsApp
|
|
104
|
+
number skips the "is this yours?" confirmation; on Instagram it is always asked.
|
|
105
|
+
|
|
106
|
+
Keep the Turbo lines if your login form uses Turbo: after signing in, the form's
|
|
107
|
+
`fetch` follows the redirect back here and cannot follow the next one to
|
|
108
|
+
vatio.ai, so the login page would just sit there. The reload makes it a real
|
|
109
|
+
page load.
|
|
110
|
+
|
|
111
|
+
## Your own API, behind a private tool
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class Api::BookingsController < ApplicationController
|
|
115
|
+
include Vatio::Identity::Authentication
|
|
116
|
+
before_action :authenticate_vatio!
|
|
117
|
+
|
|
118
|
+
def index
|
|
119
|
+
render json: Booking.where(user_id: vatio_subject)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Scope by `vatio_subject` and nothing else. Vatio already refuses to deploy a
|
|
125
|
+
private tool that takes `user_id` as a parameter — the model fills parameters
|
|
126
|
+
and can be talked into filling that one with someone else's id — and the same
|
|
127
|
+
reasoning applies one layer down: an endpoint that accepts an id has to
|
|
128
|
+
remember to scope it every time, forever; one that reads the token cannot
|
|
129
|
+
forget.
|
|
130
|
+
|
|
131
|
+
## Several workspaces
|
|
132
|
+
|
|
133
|
+
`configure` sets one workspace for the whole app. An app that serves several —
|
|
134
|
+
each customer with their own Vatio workspace and key — builds a `Config` per
|
|
135
|
+
workspace instead and passes it as `config:`:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
config = Vatio::Identity::Config.new(audience: tenant.vatio_slug, public_key: tenant.vatio_public_key)
|
|
139
|
+
|
|
140
|
+
Vatio::Identity.verify(token, config: config)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`token_for`, `widget_tag` and `sign_in_redirect_url` take it too; those sign,
|
|
144
|
+
so that config needs `private_key:`. Behind a private tool, the controller
|
|
145
|
+
says which workspace the request is for, and `nil` for a tenant that has not
|
|
146
|
+
connected Vatio answers 401:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class Api::BookingsController < ApplicationController
|
|
150
|
+
include Vatio::Identity::Authentication
|
|
151
|
+
before_action :authenticate_vatio!
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def vatio_identity_config
|
|
156
|
+
return if current_tenant.vatio_public_key.blank?
|
|
157
|
+
|
|
158
|
+
Vatio::Identity::Config.new(audience: current_tenant.vatio_slug, public_key: current_tenant.vatio_public_key)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Pick the tenant from the request — the host, the path — and never from the
|
|
164
|
+
token's own `aud`: the token is what is being checked.
|
|
165
|
+
|
|
166
|
+
A server client for another workspace takes its identity config the same way,
|
|
167
|
+
so a `subject:` is signed with that workspace's key:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
Vatio::Server::Client.new(server_key: tenant.vatio_server_key, workspace: tenant.vatio_slug,
|
|
171
|
+
identity: Vatio::Identity::Config.new(audience: tenant.vatio_slug, private_key: tenant.vatio_private_key))
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`SendMessageJob` always uses the global configuration.
|
|
175
|
+
|
|
176
|
+
## Server API (messages)
|
|
177
|
+
|
|
178
|
+
Everything above runs without a network call. This part is the opposite: a
|
|
179
|
+
client for the server API, which has the agent write to someone first on
|
|
180
|
+
WhatsApp — an order that shipped, an appointment tomorrow, a quote that is
|
|
181
|
+
ready — and the receiver for the webhooks that say how it went. It is a
|
|
182
|
+
separate require, so an app that only signs tokens never loads it:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# config/initializers/vatio.rb
|
|
186
|
+
require "vatio/server"
|
|
187
|
+
|
|
188
|
+
Vatio::Server.configure do |c|
|
|
189
|
+
c.server_key = ENV.fetch("VATIO_SERVER_KEY") # vsk_..., from `vatio keys create`
|
|
190
|
+
c.workspace = "acme" # defaults to Vatio::Identity's audience
|
|
191
|
+
c.webhook_secret = ENV["VATIO_WEBHOOK_SECRET"] # whsec_..., for the receiver below
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A server key belongs to one workspace and one environment, and a preview key
|
|
196
|
+
can only write to a test phone verified under Test → WhatsApp in the console. Keep
|
|
197
|
+
it on your server: it can message your customers. Net::HTTP does the talking
|
|
198
|
+
(`base_url`, `open_timeout` and `read_timeout` are there if you need them);
|
|
199
|
+
there is no other dependency.
|
|
200
|
+
|
|
201
|
+
### Sending
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
message = Vatio::Server.send_message(
|
|
205
|
+
to: "+56912345678",
|
|
206
|
+
brief: "Order #1042 shipped today with Starken, tracking 99812. Offer to send the tracking link.",
|
|
207
|
+
external_ref: "order-1042-shipped"
|
|
208
|
+
)
|
|
209
|
+
message.queued? # => true -- recorded and on its way
|
|
210
|
+
message.id # => 81
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
What goes out depends on WhatsApp's 24-hour window, which Vatio checks for
|
|
214
|
+
you:
|
|
215
|
+
|
|
216
|
+
- **`brief:`** — the agent writes the message, and on every later turn it is
|
|
217
|
+
shown why it wrote, so "yes, send it" gets an answer that knows what "it"
|
|
218
|
+
is. Write it the way you would brief a colleague: the agent uses no facts
|
|
219
|
+
beyond the brief and its tools.
|
|
220
|
+
- **`text:`** — your own words, sent as is.
|
|
221
|
+
- **`template:`** — `{ name:, language:, params: [] }`, used only when the
|
|
222
|
+
window is closed, because then WhatsApp delivers nothing else. Send it
|
|
223
|
+
along with the brief to always reach the person. `Vatio::Server.templates`
|
|
224
|
+
lists what the number can send, with each body's parameter count:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
shipped = Vatio::Server.templates.find { |t| t.name == "order_shipped" }
|
|
228
|
+
Vatio::Server.send_message(to: phone, brief: brief, template: shipped.with_params("Ana", "#1042"))
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The response only says it is queued: the agent's turn and Meta's delivery
|
|
232
|
+
both happen after it. `Vatio::Server.message_status(id)` reads it back,
|
|
233
|
+
with `content` (what reached the phone) once it is sent, and
|
|
234
|
+
`Vatio::Server.messages(external_ref:, status:, limit:, before:)` lists
|
|
235
|
+
them, newest first — or, better, the webhook tells you.
|
|
236
|
+
|
|
237
|
+
**Who it is.** If the person is one of your users, pass `subject:` (and
|
|
238
|
+
`claims:`, as for the widget) and the message carries an identity signed
|
|
239
|
+
with your key, so a private tool can run when they answer:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
Vatio::Server.send_message(to: user.phone, brief: brief, external_ref: "invoice-#{invoice.id}-due",
|
|
243
|
+
subject: user.id, claims: { name: user.name })
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The token's `exp` is `identity_ttl` from now, 48 hours by default, and it has
|
|
247
|
+
to cover how long the person may take to answer: past it, they are answered
|
|
248
|
+
as a stranger. Pass a longer one for a message people sit on. Or sign it
|
|
249
|
+
yourself and pass `identity:`.
|
|
250
|
+
|
|
251
|
+
### Idempotency
|
|
252
|
+
|
|
253
|
+
A request that timed out may still have gone through, so a retry has to be
|
|
254
|
+
safe. It is when it carries an idempotency key: the same key with the same
|
|
255
|
+
request answers with the message the first one made (`replayed?` is true)
|
|
256
|
+
and sends nothing. `idempotency_key:` defaults to `external_ref:`, so give
|
|
257
|
+
each *message* its own ref — `"order-1042-shipped"`, not `"order-1042"` —
|
|
258
|
+
or pass a key explicitly. The same key with a different request is a
|
|
259
|
+
`Conflict`, not a second message.
|
|
260
|
+
|
|
261
|
+
If you retry a call with `subject:` yourself, sign once with
|
|
262
|
+
`Vatio::Server.identity_for(subject:, claims:)` and pass that as `identity:`:
|
|
263
|
+
a token signed again is a different request to that check.
|
|
264
|
+
|
|
265
|
+
### Errors
|
|
266
|
+
|
|
267
|
+
Every refusal raises a `Vatio::Server::Error` with `code`, `message`,
|
|
268
|
+
`status`, `details` and `request_id` (quote it when you ask us about one).
|
|
269
|
+
The subclasses are the decisions you actually make:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
begin
|
|
273
|
+
Vatio::Server.send_message(to: phone, brief: brief, external_ref: ref)
|
|
274
|
+
rescue Vatio::Server::OutsideWindow => e
|
|
275
|
+
# Has not written in 24 hours and no template was sent. Only you know
|
|
276
|
+
# whether a template is the right thing instead. e.last_inbound_at
|
|
277
|
+
rescue Vatio::Server::OptedOut, Vatio::Server::HumanAnswering
|
|
278
|
+
# They asked not to be written to, or a person has the conversation
|
|
279
|
+
# (human_takeover, handoff_pending). Leave it.
|
|
280
|
+
rescue Vatio::Server::RateLimited, Vatio::Server::Unavailable => e
|
|
281
|
+
# e.retryable? is true for these two only. Retry with the same key.
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
| Class | When |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `OutsideWindow` | `outside_window`, with `last_inbound_at` |
|
|
288
|
+
| `OptedOut` | `opted_out` |
|
|
289
|
+
| `HumanAnswering` | `human_takeover`, `handoff_pending` |
|
|
290
|
+
| `TemplateError` | `template_not_found` (often created in Meta a minute ago: Vatio is syncing, retry shortly), `template_not_approved`, `template_params`, `template_required` |
|
|
291
|
+
| `InvalidRequest` | Any other 422: `invalid_recipient`, `content_required`, `invalid_identity`, `not_a_test_phone`… |
|
|
292
|
+
| `Unauthorized` | 401 / 403: the key is wrong, revoked, or another workspace's |
|
|
293
|
+
| `Conflict` | Any other 409: `idempotency_conflict` (`details["message_id"]`), `identity_conflict`, `whatsapp_not_connected`, `whatsapp_paused` |
|
|
294
|
+
| `NotFound` | `message_status` with an id this key cannot see |
|
|
295
|
+
| `RateLimited` | 429, more than 60 a minute on one key |
|
|
296
|
+
| `Unavailable` | 5xx, timeouts, refused connections |
|
|
297
|
+
|
|
298
|
+
A missing `server_key` or `workspace` is a `Vatio::Server::ConfigurationError`,
|
|
299
|
+
not one of these: it is your bug, not Vatio's answer.
|
|
300
|
+
|
|
301
|
+
### ActiveJob
|
|
302
|
+
|
|
303
|
+
Where ActiveJob is loaded, `Vatio::Server::SendMessageJob` takes the same
|
|
304
|
+
keywords:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
Vatio::Server::SendMessageJob.perform_later(to: order.phone, brief: brief,
|
|
308
|
+
external_ref: "order-#{order.id}-shipped", subject: order.user_id)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
It retries `RateLimited` and `Unavailable` with backoff, ten attempts over
|
|
312
|
+
about four hours, and never the rest — a closed window is the same answer
|
|
313
|
+
on the tenth try — which it logs and discards. To act on one, subclass and
|
|
314
|
+
`discard_on` it; the later declaration wins:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
class ShippedJob < Vatio::Server::SendMessageJob
|
|
318
|
+
discard_on(Vatio::Server::OutsideWindow) { |job, error| ShippedMailer.notify(job.arguments.first[:to]).deliver_later }
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Pass an `external_ref` or `idempotency_key` so a retry is a replay rather than
|
|
323
|
+
a second message; without one the job makes a key when it is enqueued. An
|
|
324
|
+
identity for `subject:` is signed at enqueue as well, once, for the same
|
|
325
|
+
reason — which means it waits in your queue, and its `exp` counts from then.
|
|
326
|
+
|
|
327
|
+
### Webhooks
|
|
328
|
+
|
|
329
|
+
Vatio POSTs `message.sent`, `message.failed`, `message.delivered`,
|
|
330
|
+
`message.read`, `message.delivery_failed` and `message.replied` to your
|
|
331
|
+
endpoint, signed with its `whsec_` secret. `data["message"]` is the message
|
|
332
|
+
as `message_status` returns it, and `message.replied` adds `data["reply"]`
|
|
333
|
+
(`chat_message_id`, `content`, `at`):
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# config/routes.rb
|
|
337
|
+
post "/webhooks/vatio", to: "vatio_webhooks#create"
|
|
338
|
+
|
|
339
|
+
class VatioWebhooksController < ActionController::API
|
|
340
|
+
include Vatio::Server::WebhookReceiver
|
|
341
|
+
|
|
342
|
+
on_vatio_event "message.replied" do |event|
|
|
343
|
+
next if ProcessedEvent.exists?(event_id: event["id"])
|
|
344
|
+
|
|
345
|
+
message = Vatio::Server::Message.from(event["data"]["message"])
|
|
346
|
+
Order.find_by(ref: message.external_ref)&.update!(customer_replied_at: message.replied_at)
|
|
347
|
+
ProcessedEvent.create!(event_id: event["id"])
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The receiver checks `Vatio-Signature` against the raw body and refuses a
|
|
353
|
+
delivery more than five minutes old, so a captured one cannot be replayed;
|
|
354
|
+
it answers 400 to anything that does not verify and 200 otherwise. Define
|
|
355
|
+
`vatio_webhook_secret` in the controller to read the secret from elsewhere,
|
|
356
|
+
or override `handle_vatio_event(event)` to see every event. Outside Rails,
|
|
357
|
+
`Vatio::Server::Webhook.verify!(payload:, signature:, secret:)` returns the
|
|
358
|
+
parsed event or raises `Webhook::InvalidSignature`.
|
|
359
|
+
|
|
360
|
+
**Delivery is at least once.** A delivery your server took too long to
|
|
361
|
+
answer is sent again, with the same `event["id"]`: dedupe on it. A handler
|
|
362
|
+
that raises answers 500 and the delivery is retried later, which is what you
|
|
363
|
+
want when your database was down — and why a handler should be safe to run
|
|
364
|
+
twice.
|
|
365
|
+
|
|
366
|
+
### Testing
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
require "vatio/server/testing"
|
|
370
|
+
|
|
371
|
+
setup { Vatio::Server::Testing.fake! }
|
|
372
|
+
teardown { Vatio::Server::Testing.real! }
|
|
373
|
+
|
|
374
|
+
test "shipping tells the customer" do
|
|
375
|
+
Order.ship!(order)
|
|
376
|
+
sent = Vatio::Server::Testing.messages.last
|
|
377
|
+
assert_equal "order-#{order.id}-shipped", sent.external_ref
|
|
378
|
+
assert_equal order.user_id.to_s, sent.subject
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
test "a closed window falls back to email" do
|
|
382
|
+
Vatio::Server::Testing.fail_next!(:outside_window)
|
|
383
|
+
assert_emails(1) { Order.ship!(order) }
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`fake!` swaps the HTTP transport for an in-memory one — no network, no key
|
|
388
|
+
needed — that answers as the API does: a queued message, a replay for a key
|
|
389
|
+
it has seen, `Conflict` for one reused with a different request. The answers
|
|
390
|
+
go through the same parsing and error mapping as real ones, so what your code
|
|
391
|
+
rescues in a test is what it rescues in production. `fail_next!` takes any
|
|
392
|
+
refusal code (or `:timeout`); `window_open = false` makes a message without
|
|
393
|
+
a template raise `OutsideWindow`; `templates =` sets what `templates`
|
|
394
|
+
returns; `webhook("message.replied", message: {...})` builds a signed
|
|
395
|
+
request for testing your receiver. With RSpec loaded:
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
expect { Order.ship!(order) }.to have_sent_message(to: order.phone, brief: a_string_including("#1042"))
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
See [Messages](https://vatio.ai/docs/api/messages) for the API itself.
|
|
402
|
+
|
|
403
|
+
## What it does not do
|
|
404
|
+
|
|
405
|
+
Rotate keys, or cache tokens. Verification is repeated on every gate by
|
|
406
|
+
design, so there is no conclusion worth caching: a token cached as "valid"
|
|
407
|
+
has its `exp` checked exactly once.
|
|
408
|
+
|
|
409
|
+
`vatio/identity` never talks to Vatio: signing and verifying are local, and
|
|
410
|
+
nothing about the widget or your private tools makes a request from here.
|
|
411
|
+
`vatio/server` is the one part that does, only when you require it and call
|
|
412
|
+
it, and it does not retry on its own — a retry belongs to whoever knows
|
|
413
|
+
whether the message still makes sense, which is why `SendMessageJob` is where
|
|
414
|
+
the retries are.
|
|
415
|
+
|
|
416
|
+
## Issues
|
|
417
|
+
|
|
418
|
+
Bugs and questions go to [issues](https://github.com/vatio-ai/ruby/issues).
|
|
419
|
+
This repository is a read-only mirror of the gem as it ships inside Vatio, so
|
|
420
|
+
a pull request cannot be merged here: open an issue describing the change
|
|
421
|
+
instead.
|
|
422
|
+
|
|
423
|
+
MIT licensed.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vatio
|
|
4
|
+
module Identity
|
|
5
|
+
# For the other end: your own API, behind a private tool.
|
|
6
|
+
#
|
|
7
|
+
# class Api::BookingsController < ApplicationController
|
|
8
|
+
# include Vatio::Identity::Authentication
|
|
9
|
+
# before_action :authenticate_vatio!
|
|
10
|
+
#
|
|
11
|
+
# def index
|
|
12
|
+
# render json: Booking.where(user_id: vatio_subject)
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Scope by `vatio_subject` and nothing else. Vatio already refuses to
|
|
17
|
+
# deploy a private tool that takes `user_id` as a parameter -- the model
|
|
18
|
+
# fills parameters and can be talked into filling that one with somebody
|
|
19
|
+
# else's id -- and the same reasoning applies one layer down: an endpoint
|
|
20
|
+
# that accepts an id has to remember to scope it every time, forever, and
|
|
21
|
+
# one that reads the token cannot forget.
|
|
22
|
+
#
|
|
23
|
+
# An app serving several workspaces says which one the request is for,
|
|
24
|
+
# and nil for one that has not connected Vatio, which is a 401:
|
|
25
|
+
#
|
|
26
|
+
# def vatio_identity_config
|
|
27
|
+
# current_tenant.vatio_identity_config
|
|
28
|
+
# end
|
|
29
|
+
module Authentication
|
|
30
|
+
def self.included(base)
|
|
31
|
+
base.helper_method(:vatio_subject, :vatio_principal) if base.respond_to?(:helper_method)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def vatio_principal
|
|
35
|
+
return @vatio_principal if defined?(@vatio_principal)
|
|
36
|
+
|
|
37
|
+
config = vatio_identity_config
|
|
38
|
+
@vatio_principal = config && Identity.verify(vatio_bearer_token, config: config)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def vatio_subject
|
|
42
|
+
vatio_principal&.subject
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def vatio_signed_in?
|
|
46
|
+
!vatio_principal.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def authenticate_vatio!
|
|
50
|
+
return if vatio_signed_in?
|
|
51
|
+
|
|
52
|
+
render json: { error: "unauthorized" }, status: :unauthorized
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def vatio_identity_config
|
|
58
|
+
Identity.config
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def vatio_bearer_token
|
|
62
|
+
header = request.headers["Authorization"].to_s
|
|
63
|
+
header[/\ABearer (.+)\z/i, 1]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Vatio
|
|
6
|
+
module Identity
|
|
7
|
+
# Everything the signer needs, and nothing that could be guessed wrong.
|
|
8
|
+
#
|
|
9
|
+
# `audience` is the workspace slug, always. Vatio requires `aud` and checks
|
|
10
|
+
# it against the slug, so a token signed for the wrong audience is not a
|
|
11
|
+
# subtly weaker token -- it is rejected outright and the visitor comes
|
|
12
|
+
# through anonymous.
|
|
13
|
+
#
|
|
14
|
+
# `Vatio::Identity.configure` fills the one most apps need. An app that
|
|
15
|
+
# serves several workspaces builds one per workspace and passes it as
|
|
16
|
+
# `config:`:
|
|
17
|
+
#
|
|
18
|
+
# Vatio::Identity::Config.new(audience: tenant.slug, public_key: tenant.vatio_public_key)
|
|
19
|
+
class Config
|
|
20
|
+
# An EC key names its own algorithm: the curve fixes the hash size, so
|
|
21
|
+
# there is exactly one right answer and asking for it would only be a
|
|
22
|
+
# chance to get it wrong. RSA does not -- RS* and PS* share a key -- so
|
|
23
|
+
# RS256 is the default. Mirrors the table the manifest validates against.
|
|
24
|
+
CURVE_ALGORITHMS = {
|
|
25
|
+
"prime256v1" => "ES256",
|
|
26
|
+
"secp256r1" => "ES256",
|
|
27
|
+
"secp384r1" => "ES384",
|
|
28
|
+
"secp521r1" => "ES512"
|
|
29
|
+
}.freeze
|
|
30
|
+
RSA_DEFAULT = "RS256"
|
|
31
|
+
|
|
32
|
+
# Long enough that a visitor is not signed out mid-conversation, short
|
|
33
|
+
# enough that a token scraped from a page stops working the same morning.
|
|
34
|
+
# The web refreshes it on every page load anyway.
|
|
35
|
+
DEFAULT_EXPIRES_IN = 3600
|
|
36
|
+
|
|
37
|
+
attr_accessor :audience
|
|
38
|
+
attr_writer :algorithm, :expires_in
|
|
39
|
+
|
|
40
|
+
def initialize(audience: nil, private_key: nil, public_key: nil, algorithm: nil,
|
|
41
|
+
expires_in: DEFAULT_EXPIRES_IN)
|
|
42
|
+
@audience = audience
|
|
43
|
+
@algorithm = algorithm
|
|
44
|
+
@expires_in = expires_in
|
|
45
|
+
self.private_key = private_key
|
|
46
|
+
self.public_key = public_key
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The PEM, not a path. Read it from an env var or Rails credentials --
|
|
50
|
+
# a private key checked into the repo is a private key that leaked.
|
|
51
|
+
def private_key=(pem)
|
|
52
|
+
@private_key = load_key(pem, "private_key")
|
|
53
|
+
return if @private_key.nil? || @private_key.private?
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError,
|
|
56
|
+
"vatio identity: private_key is a PUBLIC key -- it cannot sign. " \
|
|
57
|
+
"Point it at identity.pem, not identity.pub"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Optional. Only needed to verify tokens coming back into your own API
|
|
61
|
+
# from a private tool; if unset, the private key verifies its own
|
|
62
|
+
# signatures, which is the same check.
|
|
63
|
+
def public_key=(pem)
|
|
64
|
+
@public_key = load_key(pem, "public_key")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def private_key
|
|
68
|
+
@private_key || raise(ConfigurationError, missing("private_key"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def public_key
|
|
72
|
+
@public_key || @private_key || raise(ConfigurationError, missing("public_key"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def expires_in = Integer(@expires_in)
|
|
76
|
+
|
|
77
|
+
def audience!
|
|
78
|
+
value = audience.to_s.strip
|
|
79
|
+
return value unless value.empty?
|
|
80
|
+
|
|
81
|
+
raise ConfigurationError, missing("audience (your Vatio workspace slug)")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Inferred from the key unless pinned. Whatever it ends up being has to
|
|
85
|
+
# equal `auth.algorithm` in vatio.yml, and it is never read from the
|
|
86
|
+
# token's own header on either side: that header is attacker-controlled,
|
|
87
|
+
# and believing it is exactly the algorithm-confusion bug.
|
|
88
|
+
def algorithm
|
|
89
|
+
return @algorithm.to_s if @algorithm
|
|
90
|
+
|
|
91
|
+
key = @private_key || @public_key
|
|
92
|
+
raise ConfigurationError, missing("private_key") if key.nil?
|
|
93
|
+
|
|
94
|
+
case key
|
|
95
|
+
when OpenSSL::PKey::EC
|
|
96
|
+
curve = key.group.curve_name.to_s
|
|
97
|
+
CURVE_ALGORITHMS.fetch(curve) do
|
|
98
|
+
raise ConfigurationError,
|
|
99
|
+
"vatio identity: curve #{curve.inspect} has no matching JWT algorithm; set config.algorithm"
|
|
100
|
+
end
|
|
101
|
+
when OpenSSL::PKey::RSA then RSA_DEFAULT
|
|
102
|
+
else
|
|
103
|
+
raise ConfigurationError,
|
|
104
|
+
"vatio identity: #{key.class} keys cannot sign a Vatio token; use RSA or EC"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def load_key(pem, name)
|
|
111
|
+
value = pem.to_s.strip
|
|
112
|
+
return nil if value.empty?
|
|
113
|
+
|
|
114
|
+
OpenSSL::PKey.read(value)
|
|
115
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError => e
|
|
116
|
+
raise ConfigurationError, "vatio identity: #{name} is not a readable PEM (#{e.class})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def missing(name)
|
|
120
|
+
"vatio identity: #{name} is not configured -- see Vatio::Identity.configure"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Vatio
|
|
6
|
+
module Identity
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
initializer "vatio.identity.authentication" do
|
|
9
|
+
ActiveSupport.on_load(:action_controller) do
|
|
10
|
+
require_relative "authentication"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|