bellhop-rails 1.0.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +270 -0
  5. data/Rakefile +12 -0
  6. data/app/controllers/bellhop/admin/agents_controller.rb +37 -0
  7. data/app/controllers/bellhop/admin/base_controller.rb +38 -0
  8. data/app/controllers/bellhop/claims_controller.rb +60 -0
  9. data/app/controllers/bellhop/transport_controller.rb +71 -0
  10. data/app/controllers/bellhop/webhooks_controller.rb +31 -0
  11. data/app/models/bellhop/agent.rb +154 -0
  12. data/app/models/bellhop/application_record.rb +7 -0
  13. data/app/models/bellhop/print_job.rb +129 -0
  14. data/app/models/bellhop/session.rb +60 -0
  15. data/app/views/bellhop/admin/_bell.html.erb +5 -0
  16. data/app/views/bellhop/admin/_style.html.erb +761 -0
  17. data/app/views/bellhop/admin/agents/index.html.erb +170 -0
  18. data/app/views/bellhop/admin/agents/pair.html.erb +45 -0
  19. data/app/views/bellhop/admin/agents/problem.html.erb +22 -0
  20. data/app/views/layouts/bellhop/admin.html.erb +52 -0
  21. data/config/routes.rb +30 -0
  22. data/lib/bellhop/backplane.rb +28 -0
  23. data/lib/bellhop/cable/connection.rb +121 -0
  24. data/lib/bellhop/cable.rb +64 -0
  25. data/lib/bellhop/claim_exchange.rb +43 -0
  26. data/lib/bellhop/configuration.rb +150 -0
  27. data/lib/bellhop/connection.rb +46 -0
  28. data/lib/bellhop/doctor.rb +152 -0
  29. data/lib/bellhop/engine.rb +26 -0
  30. data/lib/bellhop/errors.rb +51 -0
  31. data/lib/bellhop/licensing.rb +105 -0
  32. data/lib/bellhop/print_options.rb +108 -0
  33. data/lib/bellhop/protocol.rb +102 -0
  34. data/lib/bellhop/refresh_credentials_job.rb +10 -0
  35. data/lib/bellhop/registry.rb +160 -0
  36. data/lib/bellhop/renew_credentials_job.rb +10 -0
  37. data/lib/bellhop/retire_deactivated_agents_job.rb +10 -0
  38. data/lib/bellhop/testing.rb +145 -0
  39. data/lib/bellhop/tokens.rb +30 -0
  40. data/lib/bellhop/version.rb +6 -0
  41. data/lib/bellhop/webhook_verifier.rb +83 -0
  42. data/lib/bellhop-rails.rb +4 -0
  43. data/lib/bellhop.rb +175 -0
  44. data/lib/generators/bellhop/admin/admin_generator.rb +45 -0
  45. data/lib/generators/bellhop/install/install_generator.rb +57 -0
  46. data/lib/generators/bellhop/install/templates/initializer.rb +41 -0
  47. data/lib/generators/bellhop/install/templates/migration.rb +77 -0
  48. data/lib/tasks/bellhop.rake +61 -0
  49. metadata +107 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1927cc2855283474e17da014fdcb1f995f1204315a74d19ccd55ba280cf604a1
4
+ data.tar.gz: 647ab3f40a1d4f59b7522ba81e072e0c22544da07f6940113f9fb3ff8c9e4fed
5
+ SHA512:
6
+ metadata.gz: c03cef5dfaae91754005bbcfed545b4af33ebc0114f8f5345444301edf1ee39a21edd4f980e335deaaaf3b731f97a7eb74b543e2bc89d7ffc66af24e7fb688b4
7
+ data.tar.gz: e267b73e0d9b43c5b4d016a40f24aa5ae29b92bf4e73ba061fd6de0ce07cbc821a6e5fb8d73d25c813c9cf5f046985650e364e86f83e0df574a0c74593e7bbf9
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First release.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Fae Software Limited
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # Bellhop for Rails
2
+
3
+ Print to the label printers and read the USB scales at a physical location,
4
+ from your Rails application. The connection runs from your server to the
5
+ machine at that location, and nothing sits in the middle of it.
6
+
7
+ You need an app on [bellhop.dev](https://bellhop.dev). That is where agents
8
+ are licensed and where your secret key comes from.
9
+
10
+ ```bash
11
+ bundle add bellhop-rails
12
+ bin/rails generate bellhop:install
13
+ bin/rails db:migrate
14
+ bin/rails bellhop:doctor
15
+ ```
16
+
17
+ The generator writes the initializer and the migration, and mounts the engine
18
+ at `/bellhop`. Two settings in the initializer matter.
19
+
20
+ `secret_key` is on your app's page on bellhop.dev. Keep it in credentials.
21
+
22
+ `public_url` is your application's public base URL. Its host is the **pairing
23
+ host**: it goes in every pairing link, every credential is bound to it, and
24
+ the agent compares it byte for byte. Pick it once. Changing it after agents
25
+ have paired means all of them re-pair. Your socket can live on another host;
26
+ the `transports` block in the claim response is for that.
27
+
28
+ Ruby 3.2 and Rails 7.2 or later.
29
+
30
+ ## Using it
31
+
32
+ ```ruby
33
+ # Add a location, then put the link in front of whoever is at the desk.
34
+ agent = Bellhop::Agent.provision(label: "Shipping Desk")
35
+ agent.pairing_link # bellhop://pair?server=…&claim=…
36
+
37
+ agent.print(kind: "label", format: :zpl, data: zpl)
38
+
39
+ # Anything large goes by URL. The block runs once the job exists, so a
40
+ # signed URL can name it.
41
+ agent.print(kind: "packing_slip", format: :pdf) { |job| document_url(job, sig: sign(job)) }
42
+
43
+ # Name a printer, and say how, when the desk's default is not what you want.
44
+ agent.print(
45
+ kind: "packing_slip", format: :pdf,
46
+ printer: "Office_HP_LaserJet",
47
+ options: { copies: 2, duplex: "long-edge", paper: "Letter" }
48
+ ) { |job| document_url(job, sig: sign(job)) }
49
+
50
+ # What the desk shared in its most recent hello.
51
+ agent.printers
52
+ # => [{ "id" => "Office_HP_LaserJet", "name" => "Office HP LaserJet",
53
+ # "capabilities" => { "papers" => ["Letter", "Legal", "A4"], "duplex" => true, ... } }]
54
+
55
+ # Hear back from it.
56
+ ActiveSupport::Notifications.subscribe("weight.bellhop") do |event|
57
+ ShippingForm.fill(event.payload[:agent], grams: event.payload[:grams])
58
+ end
59
+
60
+ ActiveSupport::Notifications.subscribe("ack.bellhop") do |event|
61
+ job = event.payload[:job]
62
+ Rails.logger.warn("#{job.id} failed: #{job.error}") if job.status == "failed"
63
+ end
64
+ ```
65
+
66
+ `kind` is yours; the agent never reads it. Formats are `zpl`, `pdf`, `gif`,
67
+ and `raw`. Raw delivers any byte stream to the queue untouched: ESC/POS
68
+ receipts, EPL, whatever your hardware speaks.
69
+
70
+ A job that names a `printer` goes to it, by the `id` from the agent's
71
+ inventory. A job that names none routes by format to the printer the operator
72
+ picked for labels or for documents. If the agent is offline the job stays
73
+ pending and goes out at its next handshake. Machines sleep overnight; that is
74
+ normal.
75
+
76
+ `options` is a closed set: `copies`, `duplex`, `paper`, `bin`, `dpi`,
77
+ `color`, `pages`, `rotate`, `fit`, `collate`, `nup`. An option you leave out
78
+ means whatever the printer does by default.
79
+
80
+ Anything the gem can check without an agent, it checks before sending, and
81
+ raises `Bellhop::AgentError` at your own line: a format the agent has not
82
+ advertised, a printer its last hello did not report, an option the protocol
83
+ does not define, an option that does not apply to the format, a value outside
84
+ its range, a malformed page range, an inline document over 50 MB. What only
85
+ the desk can know (does that laser printer hold Letter?) the agent checks
86
+ before printing and reports in the ack, with an `error_code` your code can
87
+ branch on.
88
+
89
+ ## When it does not pair
90
+
91
+ ```
92
+ $ bin/rails bellhop:doctor
93
+
94
+ ✓ pairing host deliver.example.com
95
+ ✓ secret key authenticated as "Deliver"
96
+ ✓ plan fleet, 34/100 agents
97
+ ✓ scales allowed on this plan
98
+ ✓ signing keys https://bellhop.dev publishes 2026-08
99
+ ✓ cable adapter solid_cable
100
+ ✓ transports websocket and http
101
+ ✓ poll budget poll_seconds is 0, so no request is ever held
102
+ ```
103
+
104
+ Every one of those failures looks the same from the outside. Start here.
105
+
106
+ ## Transports
107
+
108
+ Both are mounted. The agent prefers the socket and falls back to HTTP by
109
+ itself after three failed upgrades, which is what gets a shipping desk behind
110
+ a corporate proxy working.
111
+
112
+ The socket is Action Cable's server with Action Cable's protocol stripped off.
113
+ The wire carries flat JSON: no subscription handshake, no channel identifier,
114
+ no JSON inside JSON, and no welcome message. What Action Cable provides is
115
+ connection lifecycle and a pubsub backplane, so a print created on one Puma
116
+ worker reaches a socket held by another. The engine runs its own
117
+ `ActionCable::Server::Base`, so your application's cable configuration is
118
+ untouched. Request forgery protection is off on that instance only. The agent
119
+ is a native application and sends no `Origin` header, and Action Cable
120
+ refuses a connection without one. There is no browser and no cookie in play,
121
+ so there is nothing to protect.
122
+
123
+ No Action Cable at all, as in an API-only or `--minimal` application? The
124
+ engine skips the socket route, advertises HTTP alone, and `doctor` says so.
125
+
126
+ ### `poll_seconds` is 0 by default
127
+
128
+ Rails serves requests from a fixed Puma thread pool, three per worker out of
129
+ the box. A held poll occupies one thread for its whole duration. Three agents
130
+ on a 25 second poll would park every thread and your application would stop
131
+ serving.
132
+
133
+ At 0 the request returns at once, the agent settles into polling every 3
134
+ seconds, and a label comes out within 3 seconds of being created. Nobody
135
+ standing at a printer notices that. Raise it only after counting your
136
+ threads; `doctor` does the arithmetic against your agent count.
137
+
138
+ ## Running more than one process
139
+
140
+ The socket needs a real pubsub adapter: `solid_cable` on a default Rails 8
141
+ app, or Redis. With the `async` adapter a print created by one worker cannot
142
+ reach a socket held by another, and `doctor` warns you. HTTP has no such
143
+ need. Its queue is a table row, so any process can write to it.
144
+
145
+ One caveat on `solid_cable`. It polls the database from every process, and
146
+ this gem subscribes one channel per connected agent, so the backplane itself
147
+ becomes database load as the fleet grows. It is fine to about a thousand
148
+ agents. Plan on Redis beyond that; `doctor` tracks your fleet size and says
149
+ when.
150
+
151
+ Bellhop's socket server inherits the app's cable adapter. To give it its
152
+ own, set `config.cable` to a hash in the shape of `cable.yml`. An app on
153
+ AnyCable has to: AnyCable's adapter only broadcasts to the Go server and
154
+ cannot subscribe from Rails, so a print created on one worker would never
155
+ reach a socket held by another, and the engine refuses to boot on it.
156
+ Point Bellhop at Solid Cable or Redis instead:
157
+
158
+ ```ruby
159
+ config.cable = { adapter: "solid_cable" }
160
+ config.cable = { adapter: "redis", url: ENV["REDIS_URL"] }
161
+ ```
162
+
163
+ Solid Cable reads `connects_to` and its polling settings from `cable.yml`
164
+ whatever adapter line that file carries, so those stay put. The agent's
165
+ socket stays on Puma and never touches AnyCable.
166
+
167
+ ## Plan changes
168
+
169
+ Entitlements live in the credential and are read when it is minted. On its
170
+ own, a plan change waits for the next renewal. Renewal is automatic as agents
171
+ connect, and `rails bellhop:renew` sweeps on demand.
172
+
173
+ Register `https://deliver.example.com/bellhop/webhook` on your app's page on
174
+ bellhop.dev and the wait goes away. bellhop.dev calls when the plan changes,
175
+ the engine verifies the delivery against the published signing keys, and
176
+ every paired agent is pushed a credential with the new entitlements.
177
+ bellhop.dev also calls when an agent is removed there, and the engine retires
178
+ it here too: connection closed, row gone. There is no webhook secret to hold.
179
+ Behind it are `Bellhop.refresh!` and `Bellhop.retire_deactivated!`, which you
180
+ can also call by hand. Each runs from Active Job when your app has it and
181
+ inline when it does not.
182
+
183
+ ## The admin
184
+
185
+ It is at the engine's mount point. It is open in development and refuses to
186
+ render anywhere else until you say who may see it:
187
+
188
+ ```ruby
189
+ config.admin_authenticator = ->(controller) { controller.current_user&.admin? }
190
+ ```
191
+
192
+ It lists your agents and can print to them or unpair them, so it should not
193
+ become reachable just because the engine is mounted.
194
+
195
+ It ships with its own standalone layout and a `<style>` block, in the same
196
+ plum and blush as bellhop.dev. Nothing to precompile, nothing that leaks into
197
+ the rest of your application. The fonts load from Google Fonts; delete that
198
+ `<link>` from the layout and the stacks fall back to Georgia and the system
199
+ sans.
200
+
201
+ To change how it looks, or to put it inside your own layout:
202
+
203
+ ```bash
204
+ bin/rails generate bellhop:admin
205
+ ```
206
+
207
+ That copies the controllers and views into your application, stylesheet
208
+ partial included. Rails resolves your paths ahead of an engine's, so from
209
+ then on yours render.
210
+
211
+ ### The test label
212
+
213
+ The admin's **Label** button prints `Bellhop.test_label`: the Bellhop mark and
214
+ two lines of text, anchored to the top left corner so it comes out whole on
215
+ anything from 1.25in stock up. ZPL has no way to ask a printer how wide its
216
+ stock is, so the default never assumes a width.
217
+
218
+ When you know the width, say so in dots and the label is centred across it:
219
+
220
+ ```ruby
221
+ Bellhop.test_label(width: 812) # 4in at 203 dpi
222
+ ```
223
+
224
+ ## Testing
225
+
226
+ Printing to a real agent is the right way to check that a label comes out.
227
+ For CI, where no agent exists:
228
+
229
+ ```ruby
230
+ require "bellhop/testing"
231
+
232
+ agent = Bellhop::Agent.provision(label: "Test")
233
+ agent = Bellhop::Testing::FakeAgent.claim(agent)
234
+
235
+ agent.print(kind: "label", format: :zpl, data: zpl)
236
+
237
+ assert_equal 1, agent.printed.size
238
+ assert_includes agent.printed.first[:data], "^XA"
239
+ ```
240
+
241
+ `FakeAgent` speaks the protocol the way the real agent does. It deduplicates
242
+ by job id and answers `ping`. `fail_prints:` exercises your error path,
243
+ `capabilities:` exercises the gate on formats an agent cannot handle,
244
+ `printers:` shapes the inventory it advertises, and `default_printers:` sets
245
+ the role map. Each entry in `printed` carries the `printer` and `options` the
246
+ job named.
247
+
248
+ ## What it will not do
249
+
250
+ **Validate inbound messages against the schemas.** Both sides ignore what
251
+ they do not recognise. That rule is the protocol's only forward-compatibility
252
+ mechanism, and enforcing schemas would break it.
253
+
254
+ **Log tokens.** Claim tokens, agent tokens, credentials, and the
255
+ `Authorization` header stay out of log lines. `bellhop:pair` prints a link
256
+ that nothing else records, because the link is the claim token.
257
+
258
+ **Redeliver on every `hello`.** A `hello` arrives mid-session whenever an
259
+ operator changes a printer. Re-sending outstanding jobs each time can outrun
260
+ the agent's deduplication ledger and print the same label several times. Only
261
+ the first `hello` on a connection redelivers. A reconnect is a new
262
+ connection, so at-least-once delivery still holds.
263
+
264
+ ## Reference
265
+
266
+ The protocol reference at https://bellhop.dev/docs/protocol is normative.
267
+ Where this gem disagrees with it, the gem is wrong. The integration guide is
268
+ at https://bellhop.dev/docs.
269
+
270
+ MIT licence.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/**/*_test.rb"
8
+ t.verbose = false
9
+ t.warning = false
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bellhop
4
+ module Admin
5
+ class AgentsController < BaseController
6
+ def index
7
+ @agents = Agent.order(:id)
8
+ @jobs = PrintJob.recent.limit(20).includes(:agent)
9
+ end
10
+
11
+ def create
12
+ @agent = Agent.provision(label: params.require(:label))
13
+ render :pair
14
+ rescue LicensingError => e
15
+ @error = e
16
+ render :problem, status: :bad_gateway
17
+ end
18
+
19
+ def repair
20
+ @agent = Agent.find(params[:id]).repair!
21
+ render :pair
22
+ end
23
+
24
+ def print
25
+ Agent.find(params[:id]).print(kind: "label", format: :zpl, data: Bellhop.test_label)
26
+ redirect_to agents_path
27
+ rescue AgentError => e
28
+ redirect_to agents_path, alert: e.message
29
+ end
30
+
31
+ def remove
32
+ Agent.find(params[:id]).decommission!
33
+ redirect_to agents_path
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bellhop
4
+ module Admin
5
+ # Open in development, and refuses to render anywhere else until
6
+ # `config.admin_authenticator` is set. `rails generate bellhop:admin`
7
+ # copies these controllers and views into your application.
8
+ class BaseController < ActionController::Base
9
+ protect_from_forgery with: :exception
10
+
11
+ layout "bellhop/admin"
12
+
13
+ before_action :authenticate_admin
14
+
15
+ private
16
+ def authenticate_admin
17
+ authenticator = Bellhop.config.admin_authenticator
18
+
19
+ if authenticator
20
+ head :forbidden unless authenticator.call(self)
21
+ elsif !Rails.env.local?
22
+ render plain: <<~MESSAGE, status: :forbidden
23
+ The Bellhop admin is not configured.
24
+
25
+ It lists your agents and can print to them or unpair them, so it
26
+ refuses to render outside development until you say who may see it:
27
+
28
+ Bellhop.configure do |config|
29
+ config.admin_authenticator = ->(controller) { controller.current_user&.admin? }
30
+ end
31
+
32
+ Or stop mounting the engine's admin and use your own.
33
+ MESSAGE
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bellhop
4
+ # Pairing. Runs once per agent, in front of a person at a printer, and is
5
+ # the only unauthenticated route the engine owns. The exchange itself lives
6
+ # in Bellhop::ClaimExchange; this is the HTTP shell around it.
7
+ class ClaimsController < ActionController::API
8
+ before_action :enforce_rate_limit
9
+
10
+ def create
11
+ if params[:claim_token].blank?
12
+ return fail_with(:bad_request, "invalid_request", "That pairing link is missing its claim token.")
13
+ end
14
+
15
+ result = ClaimExchange.perform(params[:claim_token])
16
+
17
+ render json: {
18
+ # Appears in this response and nowhere else.
19
+ agent_token: result.agent_token,
20
+ agent_name: result.agent.label,
21
+ app_name: result.agent.app_name.presence || Bellhop.config.app_name || "Bellhop",
22
+ accent_color: result.agent.accent_color.presence || Bellhop.config.accent_color,
23
+ credential: result.activation["credential"],
24
+ transports: transports
25
+ }.compact
26
+ rescue ClaimExchange::ExpiredClaim
27
+ fail_with(:not_found, "claim_expired", "That pairing link has expired. Ask for a new one.")
28
+ rescue LicensingError => e
29
+ Bellhop.logger.warn { "[bellhop] activation failed (#{e.code}); the claim token stays valid" }
30
+ fail_with(:bad_gateway, e.code, e.operator_message)
31
+ end
32
+
33
+ private
34
+ # Where to connect, most preferred first. This is the hook for moving
35
+ # the socket to another host; the credential does not care where it is.
36
+ def transports
37
+ list = []
38
+ list << { type: "websocket", url: Bellhop.config.socket_url } if Bellhop.cable_available?
39
+ list << { type: "http", url: Bellhop.config.http_url }
40
+ list
41
+ end
42
+
43
+ # The agent shows `message` to whoever is at the printer.
44
+ def fail_with(status, code, message)
45
+ render status: status, json: { error: { code: code, message: message } }
46
+ end
47
+
48
+ def enforce_rate_limit
49
+ limit = Bellhop.config.claim_rate_limit
50
+ return if limit.blank?
51
+
52
+ key = "bellhop:claim:#{request.remote_ip}"
53
+ Rails.cache.write(key, 0, expires_in: 1.minute, unless_exist: true)
54
+ count = Rails.cache.increment(key, 1) || 1
55
+ return if count <= limit
56
+
57
+ fail_with(:too_many_requests, "too_many_requests", "Too many pairing attempts. Wait a minute and try again.")
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bellhop
4
+ # The HTTP transport: three routes and a table. This is what works behind
5
+ # corporate proxies that break WebSocket upgrades, and it is the whole
6
+ # transport on a host with no Action Cable.
7
+ class TransportController < ActionController::API
8
+ before_action :authenticate
9
+ before_action :find_session, except: :create
10
+
11
+ # POST /sessions. The body is `hello`.
12
+ def create
13
+ hello = params.to_unsafe_h.slice(*%w[type protocol_version agent_version platform session_id capabilities printers default_printers])
14
+ return render(status: :bad_request, json: { error: "expected_hello" }) if hello["type"] != "hello"
15
+
16
+ if hello["protocol_version"].to_i != PROTOCOL_VERSION
17
+ return render(status: :upgrade_required, json: { error: "unsupported_version" })
18
+ end
19
+
20
+ session = Session.open!(@agent)
21
+ connection = HttpConnection.new(session, handshake: true)
22
+
23
+ # Protocol.receive queues `ready` and then any outstanding jobs. `ready`
24
+ # goes inline in this response; the rest waits for the first poll.
25
+ Protocol.receive(@agent, connection, hello)
26
+ queued = session.drain
27
+ ready = queued.shift
28
+ queued.each { |message| session.push(message) }
29
+
30
+ render status: :created, json: {
31
+ session_id: session.id,
32
+ poll_seconds: Bellhop.config.poll_seconds,
33
+ message: ready
34
+ }
35
+ end
36
+
37
+ # GET /sessions/:id/messages. An empty result is normal.
38
+ def index
39
+ render json: { messages: @session.drain }
40
+ end
41
+
42
+ # POST /sessions/:id/messages, batched. Anything queued meanwhile rides
43
+ # back in the response.
44
+ def update
45
+ connection = HttpConnection.new(@session, handshake: false)
46
+ Array(params[:messages]).each do |message|
47
+ Protocol.receive(@agent, connection, message.to_unsafe_h)
48
+ end
49
+
50
+ render json: { messages: @session.drain }
51
+ end
52
+
53
+ # DELETE /sessions/:id. Expiry covers the case where it never arrives.
54
+ def destroy
55
+ @session.destroy
56
+ render json: {}
57
+ end
58
+
59
+ private
60
+ def authenticate
61
+ @agent = Agent.authenticate(Tokens.bearer(request.headers["Authorization"]))
62
+ render(status: :unauthorized, json: { error: "unauthorized" }) unless @agent
63
+ end
64
+
65
+ # A 404 means "open a new one".
66
+ def find_session
67
+ @session = @agent.sessions.find_by(id: params[:id])
68
+ render(status: :not_found, json: { error: "no_such_session" }) unless @session
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bellhop
4
+ # bellhop.dev's webhook. The event name is signed, so a verified delivery is
5
+ # safe to dispatch on. `agent.deactivated` retires the removed agents.
6
+ # Anything else, including events this version has never heard of, means
7
+ # entitlements moved: re-mint every paired agent's credential. The work runs
8
+ # from a job so this answers at once however large the fleet.
9
+ class WebhooksController < ActionController::API
10
+ def create
11
+ unless WebhookVerifier.valid?(request.headers["Bellhop-Signature"],
12
+ event: params[:event].to_s, app: params[:app].to_s)
13
+ return render status: :unauthorized, json: { error: "invalid_signature" }
14
+ end
15
+
16
+ case params[:event]
17
+ when "agent.deactivated"
18
+ Bellhop.logger.info { "[bellhop] webhook received (#{params[:event]}); retiring removed agents" }
19
+ Bellhop.retire_deactivated_later
20
+ else
21
+ Bellhop.logger.info { "[bellhop] webhook received (#{params[:event]}); refreshing credentials" }
22
+ Bellhop.refresh_later
23
+ end
24
+
25
+ render status: :accepted, json: { ok: true }
26
+ rescue LicensingError
27
+ # The key set could not be fetched. A 5xx makes bellhop.dev redeliver.
28
+ head :service_unavailable
29
+ end
30
+ end
31
+ end