orbitcast 0.3.1-x86_64-linux-gnu

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +28 -0
  3. data/README.md +100 -0
  4. data/app/assets/javascripts/orbitcast/recovery.js +196 -0
  5. data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
  6. data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
  7. data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
  8. data/exe/orbitcast +11 -0
  9. data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
  10. data/lib/generators/orbitcast/install/install_generator.rb +223 -0
  11. data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
  12. data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
  13. data/lib/orbitcast/binary.rb +234 -0
  14. data/lib/orbitcast/channel.rb +459 -0
  15. data/lib/orbitcast/channel_registry.rb +131 -0
  16. data/lib/orbitcast/configuration.rb +636 -0
  17. data/lib/orbitcast/connection.rb +338 -0
  18. data/lib/orbitcast/control.rb +72 -0
  19. data/lib/orbitcast/dispatcher.rb +306 -0
  20. data/lib/orbitcast/engine.rb +98 -0
  21. data/lib/orbitcast/error.rb +54 -0
  22. data/lib/orbitcast/launcher.rb +302 -0
  23. data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
  24. data/lib/orbitcast/protocol/v1/effects.rb +172 -0
  25. data/lib/orbitcast/protocol/v1/reply.rb +268 -0
  26. data/lib/orbitcast/protocol/v1/request.rb +364 -0
  27. data/lib/orbitcast/protocol/v1.rb +291 -0
  28. data/lib/orbitcast/protocol.rb +19 -0
  29. data/lib/orbitcast/publisher/control.rb +67 -0
  30. data/lib/orbitcast/publisher/http.rb +180 -0
  31. data/lib/orbitcast/publisher/instrumentation.rb +47 -0
  32. data/lib/orbitcast/publisher/postgres.rb +407 -0
  33. data/lib/orbitcast/rails_endpoint.rb +207 -0
  34. data/lib/orbitcast/recovery.rb +166 -0
  35. data/lib/orbitcast/state_codec.rb +186 -0
  36. data/lib/orbitcast/streams.rb +129 -0
  37. data/lib/orbitcast/version.rb +10 -0
  38. data/lib/orbitcast.rb +68 -0
  39. data/vendor/x86_64-linux-gnu/bin/orbitcast +0 -0
  40. data/vendor/x86_64-linux-gnu/manifest.json +14 -0
  41. metadata +130 -0
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+
7
+ require_relative "../error"
8
+ require_relative "../streams"
9
+ require_relative "instrumentation"
10
+
11
+ module Orbitcast
12
+ module Publisher
13
+ # Direct PostgreSQL publisher.
14
+ #
15
+ # Rails encodes a broadcast, this wraps it in a versioned notice envelope and
16
+ # emits `SELECT pg_notify($1, $2)` on a bounded, fork-safe connection pool.
17
+ # Delivery back to the publishing node happens through that node's own
18
+ # PostgreSQL listener, so there is no local fan-out here.
19
+ #
20
+ # A publication too large to inline either raises `Orbitcast::PayloadTooLarge`
21
+ # or, with `overflow_enabled`, is relayed: the row and its reference are
22
+ # written in one transaction, so `NOTIFY` becomes visible only once the row
23
+ # is readable, and a rollback sends nothing.
24
+ #
25
+ # ## Which connection
26
+ #
27
+ # `config.connection` picks it:
28
+ #
29
+ # * `:pool` (default) — a bounded, fork-safe dedicated `pg` pool for
30
+ # `database_url`. Independent of Active Record: a broadcast is visible as
31
+ # soon as the statement commits, whatever the application's transaction
32
+ # is doing. Safe behind PgBouncer transaction pooling.
33
+ # * `:active_record` — the application's Active Record connection
34
+ # (`ActiveRecord::Base.with_connection { |c| c.raw_connection }`). The
35
+ # `NOTIFY` (and the overflow row) join the surrounding
36
+ # `ActiveRecord::Base.transaction`, so a rollback sends nothing and a
37
+ # commit publishes atomically with the application's writes. The cable
38
+ # database must then be the application's primary database.
39
+ #
40
+ # Either way, every publish emits `publish.orbitcast` through
41
+ # `ActiveSupport::Notifications` (`stream:`, `bytes:`, `via: :postgres`),
42
+ # including when it raises.
43
+ class Postgres
44
+ # PostgreSQL's default NOTIFY payload limit is 8000 bytes *including* the
45
+ # terminator; the encoded notice must stay strictly below it.
46
+ MAX_NOTIFY_BYTES = 8000
47
+
48
+ CHANNEL_PREFIX = "orbitcast_"
49
+ CHANNEL_DIGEST_LENGTH = 48
50
+
51
+ NOTICE_VERSION = 1
52
+ PUBLICATION_VERSION = 1
53
+ INLINE_KIND = "inline"
54
+ OVERFLOW_KIND = "overflow"
55
+
56
+ NOTIFY_SQL = "SELECT pg_notify($1, $2)"
57
+
58
+ # Column contract shared with the Rust server and
59
+ # db/orbitcast_migrate/001_create_orbitcast_payloads.rb. `expires_at` is
60
+ # computed from the *database* clock, never from this process's clock.
61
+ OVERFLOW_INSERT_SQL = <<~SQL.freeze
62
+ INSERT INTO orbitcast_payloads (id, namespace, stream, json_payload, expires_at)
63
+ VALUES ($1::text::uuid, $2, $3, $4, now() + ($5::bigint * interval '1 second'))
64
+ SQL
65
+
66
+ MIGRATION_HINT =
67
+ "run the overflow relay migration on the cable database " \
68
+ "(bin/rails orbitcast:install:migrations && bin/rails db:migrate)"
69
+
70
+ # Routing, not authorization: `orbitcast_` + the first 48 lowercase hex
71
+ # characters of SHA-256 over the length-prefixed UTF-8 (namespace, stream)
72
+ # tuple. 58 ASCII bytes, below PostgreSQL's 63-byte identifier limit.
73
+ #
74
+ # Length prefixes make the tuple unambiguous: ("ab", "c") and ("a", "bc")
75
+ # must not collide. Both parts are hashed as raw bytes (`String#b`) so a
76
+ # Unicode stream name does not raise an encoding error during concat.
77
+ def self.channel_name(namespace, stream)
78
+ namespace = namespace.to_s
79
+ stream = stream.to_s
80
+
81
+ tuple = +"".b
82
+ tuple << [namespace.bytesize].pack("N") << namespace.b
83
+ tuple << [stream.bytesize].pack("N") << stream.b
84
+
85
+ "#{CHANNEL_PREFIX}#{OpenSSL::Digest::SHA256.hexdigest(tuple)[0, CHANNEL_DIGEST_LENGTH]}"
86
+ end
87
+
88
+ # @param configuration [Orbitcast::Configuration]
89
+ # @param connection_factory [#call] override used by tests; must return an
90
+ # object responding to `exec_params`, `close`, and `finished?`
91
+ # @param active_record [#with_connection, #transaction] the Active Record
92
+ # base used by `connection: :active_record`; defaults to
93
+ # `::ActiveRecord::Base`, loaded on first use
94
+ # @param instrumenter [#instrument] defaults to
95
+ # `ActiveSupport::Notifications` when loaded
96
+ def initialize(configuration = Orbitcast.config, connection_factory: nil,
97
+ active_record: nil, instrumenter: nil)
98
+ @configuration = configuration.validate!
99
+ @active_record = active_record
100
+ @instrumenter = Instrumentation.resolve(instrumenter)
101
+ @pool =
102
+ if configuration.active_record_connection?
103
+ nil
104
+ else
105
+ Pool.new(
106
+ size: configuration.pool_size,
107
+ timeout: configuration.pool_timeout,
108
+ &(connection_factory || method(:connect))
109
+ )
110
+ end
111
+ end
112
+
113
+ attr_reader :configuration
114
+
115
+ # @param stream [String] logical stream name
116
+ # @param payload [String] JSON text already encoded by ActionCable; it is
117
+ # embedded verbatim, never re-encoded
118
+ # @return [String] publication UUID
119
+ def publish(stream, payload)
120
+ # Both guards run before anything is instrumented or touches SQL: an
121
+ # invalid stream (empty, over 1024 UTF-8 bytes) or a non-String payload
122
+ # is a caller error, not a publication that failed.
123
+ stream = Streams.validated_stream(stream)
124
+ payload = validated_payload(payload)
125
+
126
+ Instrumentation.publish(@instrumenter, stream: stream, bytes: payload.bytesize, via: :postgres) do
127
+ if payload.bytesize > configuration.max_publication_size
128
+ raise PayloadTooLarge,
129
+ "publication payload is #{payload.bytesize} bytes; the configured maximum is " \
130
+ "#{configuration.max_publication_size} bytes (max_publication_size). The overflow " \
131
+ "relay carries large Turbo HTML, not arbitrarily large objects."
132
+ end
133
+
134
+ publication_id = SecureRandom.uuid
135
+ notice = inline_notice(publication_id, stream, payload)
136
+ channel = self.class.channel_name(configuration.namespace, stream)
137
+
138
+ if notice.bytesize < MAX_NOTIFY_BYTES
139
+ run { |connection| connection.exec_params(NOTIFY_SQL, [channel, notice]) }
140
+ next publication_id
141
+ end
142
+
143
+ unless configuration.overflow_enabled?
144
+ raise PayloadTooLarge,
145
+ "encoded notice is #{notice.bytesize} bytes; PostgreSQL NOTIFY payloads must be " \
146
+ "under #{MAX_NOTIFY_BYTES} bytes. Enable the overflow relay " \
147
+ "(config.orbitcast.overflow_enabled = true) and #{MIGRATION_HINT}, or broadcast a " \
148
+ "smaller payload (for example a reference the client re-fetches)."
149
+ end
150
+
151
+ publish_overflow(channel, publication_id, stream, payload)
152
+ publication_id
153
+ end
154
+ end
155
+
156
+ def shutdown
157
+ @pool&.shutdown
158
+ end
159
+
160
+ private
161
+
162
+ # Store the payload and notify its reference in ONE transaction.
163
+ #
164
+ # A listener that receives the reference is therefore guaranteed to be
165
+ # able to read the row (NOTIFY is delivered on commit), and a failure
166
+ # anywhere in the sequence sends nothing and leaves no row: the pool
167
+ # discards a connection whose statement raised, and closing it aborts the
168
+ # open transaction server-side. On the Active Record connection the same
169
+ # two statements join the application's transaction instead.
170
+ def publish_overflow(channel, publication_id, stream, payload)
171
+ notice = overflow_notice(publication_id, stream)
172
+
173
+ run(transaction: true) do |connection|
174
+ connection.exec_params(
175
+ OVERFLOW_INSERT_SQL,
176
+ [publication_id, configuration.namespace, stream, payload, configuration.overflow_ttl]
177
+ )
178
+ connection.exec_params(NOTIFY_SQL, [channel, notice])
179
+ end
180
+ rescue StandardError => e
181
+ raise OverflowTableMissing, "#{MIGRATION_HINT}: #{e.message}" if overflow_table_missing?(e)
182
+
183
+ raise
184
+ end
185
+
186
+ # Yield a raw PostgreSQL connection (`#exec_params`) from wherever the
187
+ # configuration says it comes from, optionally inside a transaction.
188
+ def run(transaction: false, &block)
189
+ return run_on_active_record(transaction, &block) if configuration.active_record_connection?
190
+
191
+ @pool.with do |connection|
192
+ next yield(connection) unless transaction
193
+
194
+ connection.exec_params("BEGIN", [])
195
+ result = yield(connection)
196
+ connection.exec_params("COMMIT", [])
197
+ result
198
+ end
199
+ end
200
+
201
+ # `ActiveRecord::Base.transaction` joins an open transaction rather than
202
+ # starting a second one, which is the whole point: the NOTIFY commits —
203
+ # or rolls back — together with the application's own writes.
204
+ def run_on_active_record(transaction)
205
+ base = active_record
206
+ body = -> { base.with_connection { |connection| yield connection.raw_connection } }
207
+
208
+ transaction ? base.transaction(&body) : body.call
209
+ end
210
+
211
+ def active_record
212
+ @active_record ||= begin
213
+ require "active_record"
214
+ ::ActiveRecord::Base
215
+ end
216
+ end
217
+
218
+ # PG raises `PG::UndefinedTable` (SQLSTATE 42P01); the class check is
219
+ # guarded so this file still works with a test double.
220
+ def overflow_table_missing?(error)
221
+ return true if defined?(PG::UndefinedTable) && error.is_a?(PG::UndefinedTable)
222
+
223
+ error.message.to_s.match?(/orbitcast_payloads.*does not exist/m)
224
+ end
225
+
226
+ def inline_notice(publication_id, stream, payload)
227
+ JSON.generate(
228
+ version: NOTICE_VERSION,
229
+ kind: INLINE_KIND,
230
+ publication: {
231
+ version: PUBLICATION_VERSION,
232
+ namespace: configuration.namespace,
233
+ id: publication_id,
234
+ stream: stream,
235
+ json_payload: payload
236
+ }
237
+ )
238
+ end
239
+
240
+ # The reference is routing, not authorization: the server re-validates
241
+ # namespace and stream against the row it fetches.
242
+ def overflow_notice(publication_id, stream)
243
+ JSON.generate(
244
+ version: NOTICE_VERSION,
245
+ kind: OVERFLOW_KIND,
246
+ id: publication_id,
247
+ namespace: configuration.namespace,
248
+ stream: stream
249
+ )
250
+ end
251
+
252
+ def validated_payload(payload)
253
+ unless payload.is_a?(String)
254
+ raise ArgumentError,
255
+ "payload must be the JSON text encoded by ActionCable, got #{payload.class}"
256
+ end
257
+
258
+ unless payload.valid_encoding?
259
+ raise ArgumentError, "payload is not valid #{payload.encoding}"
260
+ end
261
+
262
+ payload.encoding == Encoding::UTF_8 ? payload : payload.encode(Encoding::UTF_8)
263
+ end
264
+
265
+ def connect
266
+ require "pg"
267
+ PG.connect(configuration.database_url)
268
+ end
269
+
270
+ # Bounded, fork-safe connection pool.
271
+ #
272
+ # No `connection_pool` dependency: this needs one primitive, and the
273
+ # fork-safety rule (a child must never touch a socket inherited from its
274
+ # parent) is easier to state than to configure.
275
+ class Pool
276
+ def initialize(size:, timeout:, &factory)
277
+ @size = Integer(size)
278
+ @timeout = Float(timeout)
279
+ @factory = factory
280
+ @mutex = Mutex.new
281
+ @resource = ConditionVariable.new
282
+ @available = []
283
+ @created = 0
284
+ @pid = Process.pid
285
+ @shutdown = false
286
+ end
287
+
288
+ def with
289
+ connection = checkout
290
+ begin
291
+ yield connection
292
+ rescue StandardError
293
+ # A connection that raised may be in an unknown protocol state.
294
+ discard(connection)
295
+ raise
296
+ else
297
+ checkin(connection)
298
+ end
299
+ end
300
+
301
+ def shutdown
302
+ connections =
303
+ @mutex.synchronize do
304
+ @shutdown = true
305
+ taken = @available
306
+ @available = []
307
+ @created = 0
308
+ taken
309
+ end
310
+
311
+ connections.each { |connection| close(connection) }
312
+ nil
313
+ end
314
+
315
+ private
316
+
317
+ def checkout
318
+ deadline = now + @timeout
319
+
320
+ loop do
321
+ connection = nil
322
+
323
+ @mutex.synchronize do
324
+ raise Orbitcast::Error, "publisher pool is shut down" if @shutdown
325
+
326
+ discard_after_fork
327
+
328
+ if (pooled = @available.pop)
329
+ connection = pooled
330
+ elsif @created < @size
331
+ @created += 1
332
+ connection = :build
333
+ else
334
+ remaining = deadline - now
335
+ if remaining <= 0
336
+ raise PoolTimeout,
337
+ "timed out after #{@timeout}s waiting for one of #{@size} orbitcast " \
338
+ "publisher connections"
339
+ end
340
+ @resource.wait(@mutex, remaining)
341
+ end
342
+ end
343
+
344
+ next if connection.nil?
345
+ return connection unless connection == :build
346
+
347
+ return build
348
+ end
349
+ end
350
+
351
+ def build
352
+ @factory.call
353
+ rescue StandardError
354
+ @mutex.synchronize do
355
+ @created -= 1
356
+ @resource.signal
357
+ end
358
+ raise
359
+ end
360
+
361
+ def checkin(connection)
362
+ closed =
363
+ @mutex.synchronize do
364
+ if @shutdown || @pid != Process.pid
365
+ @created -= 1
366
+ true
367
+ else
368
+ @available.push(connection)
369
+ @resource.signal
370
+ false
371
+ end
372
+ end
373
+
374
+ close(connection) if closed
375
+ end
376
+
377
+ def discard(connection)
378
+ @mutex.synchronize do
379
+ @created -= 1
380
+ @resource.signal
381
+ end
382
+ close(connection)
383
+ end
384
+
385
+ # Sockets inherited across `fork` belong to the parent process: drop the
386
+ # references without closing them, and let this process open its own.
387
+ def discard_after_fork
388
+ return if @pid == Process.pid
389
+
390
+ @available = []
391
+ @created = 0
392
+ @pid = Process.pid
393
+ end
394
+
395
+ def close(connection)
396
+ connection.close if connection.respond_to?(:close)
397
+ rescue StandardError
398
+ nil
399
+ end
400
+
401
+ def now
402
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
403
+ end
404
+ end
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/security_utils"
4
+ require "json"
5
+
6
+ require_relative "error"
7
+ require_relative "protocol"
8
+
9
+ module Orbitcast
10
+ # The private Rack endpoint the relay calls.
11
+ #
12
+ # POST {mount}/{operation}
13
+ # Authorization: Bearer <rpc_token>
14
+ # Content-Type: application/json
15
+ #
16
+ # Mounted by `Orbitcast::Engine` only in `mode: :dispatch`, at
17
+ # `config.orbitcast.rpc_mount_path` (default `/_orbitcast/v1/rails`).
18
+ #
19
+ # !!! This endpoint speaks for every user of the application. Block its path
20
+ # !!! at the public proxy *and* keep the service credential rotating: the
21
+ # !!! bearer check here is the second lock, not the only one.
22
+ #
23
+ # ## Where an error goes
24
+ #
25
+ # Two different failures need two different answers, and conflating them is
26
+ # how a transport bug turns into a silent authorization change:
27
+ #
28
+ # * **Transport and envelope problems answer with an HTTP status.** A missing
29
+ # or wrong credential, an unknown operation path, a body that is not JSON, a
30
+ # body whose `operation` disagrees with the route, a foreign namespace, an
31
+ # envelope version this release cannot read. None of these can produce a
32
+ # *correlated* reply — the correlation fields are in the very envelope that
33
+ # failed to parse — so there is nothing honest to put in a 200 body. The
34
+ # relay treats a non-2xx as a temporary backend failure and closes affected
35
+ # sessions with `reconnect: true`; it never downgrades to acceptance.
36
+ #
37
+ # * **Application problems answer `200` with `outcome: "fault"`.** The
38
+ # envelope was fine, Rails simply could not run it: an exception in a
39
+ # callback, a pool timeout, a missing record. The reply correlates, so the
40
+ # relay can tie the failure to the session and command that caused it.
41
+ #
42
+ # A refusal is neither: `denied` is a decision and travels as a `200`.
43
+ #
44
+ # Every non-2xx body is JSON: `{"version":1,"error":"<code>","message":"..."}`
45
+ # with `content-type: application/json` — the same envelope the relay's
46
+ # ingress uses for its own errors.
47
+ class RailsEndpoint
48
+ # Ceiling on one request body. Matches the relay's own reply cap: neither
49
+ # side reads an unbounded body from the other.
50
+ MAX_REQUEST_BYTES = 2 * 1024 * 1024
51
+
52
+ JSON_CONTENT_TYPE = "application/json"
53
+ BEARER_PREFIX = "Bearer "
54
+
55
+ def initialize(config: nil, dispatcher: nil, executor: nil)
56
+ @config = config
57
+ @dispatcher = dispatcher
58
+ @executor = executor
59
+ end
60
+
61
+ def config
62
+ @config || Orbitcast.config
63
+ end
64
+
65
+ def dispatcher
66
+ @dispatcher ||= begin
67
+ require_relative "dispatcher"
68
+ Dispatcher.new(config: config)
69
+ end
70
+ end
71
+
72
+ # Every call runs inside the Rails executor: `CurrentAttributes` are reset
73
+ # and Active Record connection leases are released when it completes, so a
74
+ # WebSocket's lifetime never pins a database connection. Mounted in the
75
+ # application's routes the request is already inside the reloader, and a
76
+ # nested wrap is a no-op; driven directly (tests, a bare Rack server) this
77
+ # is the only thing that cleans up.
78
+ def executor
79
+ @executor ||= if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
80
+ ::Rails.application.executor
81
+ else
82
+ NullExecutor
83
+ end
84
+ end
85
+
86
+ def call(env)
87
+ executor.wrap { handle(env) }
88
+ end
89
+
90
+ private
91
+
92
+ def handle(env)
93
+ return error(405, "method_not_allowed", "method not allowed") unless env["REQUEST_METHOD"] == "POST"
94
+
95
+ # Authentication first: an unauthenticated caller learns nothing about
96
+ # which operations exist or what this endpoint thinks of its body.
97
+ return error(401, "unauthorized", "unauthorized") unless authenticated?(env)
98
+
99
+ operation = operation_from(env)
100
+ return error(404, "not_found", "unknown operation") if operation.nil?
101
+
102
+ content_type = env["CONTENT_TYPE"].to_s
103
+ unless content_type.empty? || content_type.split(";").first.to_s.strip == JSON_CONTENT_TYPE
104
+ return error(415, "unsupported_media_type", "request body must be application/json")
105
+ end
106
+
107
+ body = read_body(env)
108
+ return error(413, "payload_too_large", "request body exceeds #{MAX_REQUEST_BYTES} bytes") if body == :too_large
109
+
110
+ decoded =
111
+ begin
112
+ JSON.parse(body)
113
+ rescue JSON::ParserError
114
+ return error(400, "malformed_json", "malformed JSON")
115
+ end
116
+
117
+ unless decoded.is_a?(Hash)
118
+ return error(400, "invalid_request", "request body must be a JSON object")
119
+ end
120
+
121
+ # The path segment and the envelope must agree, which is what stops a
122
+ # captured body being replayed against a different operation.
123
+ unless decoded["operation"] == operation
124
+ return error(400, "operation_mismatch", "operation does not match the request path")
125
+ end
126
+
127
+ request =
128
+ begin
129
+ Protocol::V1::Request.from(decoded).validate!
130
+ rescue ProtocolError => e
131
+ return error(400, "invalid_request", "invalid request envelope: #{e.message}")
132
+ end
133
+
134
+ unless request.namespace == config.namespace
135
+ return error(403, "namespace_mismatch", "namespace mismatch")
136
+ end
137
+
138
+ reply = dispatcher.call(request)
139
+ json(200, reply.to_json)
140
+ rescue ProtocolError => e
141
+ # The dispatcher produced a reply this process would itself reject.
142
+ # Fail closed rather than emit it.
143
+ log_error("Orbitcast endpoint refused to emit an invalid reply: #{e.message}")
144
+ error(500, "invalid_reply", "invalid reply envelope")
145
+ rescue StandardError => e
146
+ log_error("Orbitcast endpoint failed: #{e.class}: #{e.message}")
147
+ error(500, "internal_error", "internal error")
148
+ end
149
+
150
+ # The operation is the last path segment, and must be one this release
151
+ # implements. Anything else is a 404: unknown routes do not exist here.
152
+ def operation_from(env)
153
+ segment = env["PATH_INFO"].to_s.split("/").last
154
+ Protocol::V1::OPERATIONS.include?(segment) ? segment : nil
155
+ end
156
+
157
+ def authenticated?(env)
158
+ token = config.rpc_token
159
+ return false if token.nil? || token.empty?
160
+
161
+ header = env["HTTP_AUTHORIZATION"].to_s
162
+ return false unless header.start_with?(BEARER_PREFIX)
163
+
164
+ ActiveSupport::SecurityUtils.secure_compare(header[BEARER_PREFIX.length..].to_s, token)
165
+ end
166
+
167
+ # Bounded read: one byte over the ceiling and the body is refused unread.
168
+ def read_body(env)
169
+ input = env["rack.input"]
170
+ return "" if input.nil?
171
+
172
+ length = env["CONTENT_LENGTH"].to_s
173
+ return :too_large if !length.empty? && length.to_i > MAX_REQUEST_BYTES
174
+
175
+ body = input.read(MAX_REQUEST_BYTES + 1).to_s
176
+ return :too_large if body.bytesize > MAX_REQUEST_BYTES
177
+
178
+ body
179
+ ensure
180
+ input.rewind if input.respond_to?(:rewind)
181
+ end
182
+
183
+ def json(status, body)
184
+ [status, { "content-type" => JSON_CONTENT_TYPE, "content-length" => body.bytesize.to_s }, [body]]
185
+ end
186
+
187
+ # Every non-2xx answer is the shared error envelope — `version`, a stable
188
+ # machine-readable `error` code, and a human `message` — so the relay can
189
+ # log one shape for an ingress refusal and a Rails-endpoint refusal alike.
190
+ def error(status, code, message)
191
+ json(status, JSON.generate(version: Protocol::V1::VERSION, error: code, message: message))
192
+ end
193
+
194
+ def log_error(message)
195
+ logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
196
+ logger&.error(message)
197
+ end
198
+
199
+ # Used only when the endpoint runs outside a booted application (the
200
+ # plain-ruby unit suite). A real deployment always has Rails' executor.
201
+ module NullExecutor
202
+ def self.wrap
203
+ yield
204
+ end
205
+ end
206
+ end
207
+ end