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.
- checksums.yaml +7 -0
- data/LICENSE.txt +28 -0
- data/README.md +100 -0
- data/app/assets/javascripts/orbitcast/recovery.js +196 -0
- data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
- data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
- data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
- data/exe/orbitcast +11 -0
- data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
- data/lib/generators/orbitcast/install/install_generator.rb +223 -0
- data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
- data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
- data/lib/orbitcast/binary.rb +234 -0
- data/lib/orbitcast/channel.rb +459 -0
- data/lib/orbitcast/channel_registry.rb +131 -0
- data/lib/orbitcast/configuration.rb +636 -0
- data/lib/orbitcast/connection.rb +338 -0
- data/lib/orbitcast/control.rb +72 -0
- data/lib/orbitcast/dispatcher.rb +306 -0
- data/lib/orbitcast/engine.rb +98 -0
- data/lib/orbitcast/error.rb +54 -0
- data/lib/orbitcast/launcher.rb +302 -0
- data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
- data/lib/orbitcast/protocol/v1/effects.rb +172 -0
- data/lib/orbitcast/protocol/v1/reply.rb +268 -0
- data/lib/orbitcast/protocol/v1/request.rb +364 -0
- data/lib/orbitcast/protocol/v1.rb +291 -0
- data/lib/orbitcast/protocol.rb +19 -0
- data/lib/orbitcast/publisher/control.rb +67 -0
- data/lib/orbitcast/publisher/http.rb +180 -0
- data/lib/orbitcast/publisher/instrumentation.rb +47 -0
- data/lib/orbitcast/publisher/postgres.rb +407 -0
- data/lib/orbitcast/rails_endpoint.rb +207 -0
- data/lib/orbitcast/recovery.rb +166 -0
- data/lib/orbitcast/state_codec.rb +186 -0
- data/lib/orbitcast/streams.rb +129 -0
- data/lib/orbitcast/version.rb +10 -0
- data/lib/orbitcast.rb +68 -0
- data/vendor/x86_64-linux-gnu/bin/orbitcast +0 -0
- data/vendor/x86_64-linux-gnu/manifest.json +14 -0
- metadata +130 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_cable"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "protocol"
|
|
7
|
+
require_relative "state_codec"
|
|
8
|
+
|
|
9
|
+
module Orbitcast
|
|
10
|
+
# The Rails-dispatch channel base.
|
|
11
|
+
#
|
|
12
|
+
# module ApplicationCable
|
|
13
|
+
# class Channel < Orbitcast::Channel; end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# class ChatChannel < ApplicationCable::Channel
|
|
17
|
+
# orbitcast_state :last_read_id
|
|
18
|
+
#
|
|
19
|
+
# def subscribed
|
|
20
|
+
# room = Room.find_by(id: params[:room])
|
|
21
|
+
# return reject unless room && current_user.can_read?(room)
|
|
22
|
+
#
|
|
23
|
+
# stream_for room
|
|
24
|
+
# grant_presence broadcasting_for(room), subject_id: "user:#{current_user.id}"
|
|
25
|
+
# transmit(backlog: room.recent_messages)
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# def speak(data)
|
|
29
|
+
# room.messages.create!(body: data["body"], user: current_user)
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# ## How it reuses ActionCable
|
|
34
|
+
#
|
|
35
|
+
# It is a **subclass of `ActionCable::Channel::Base`**, so `subscribed`,
|
|
36
|
+
# `unsubscribed`, `before_subscribe`/`after_subscribe`, `reject`,
|
|
37
|
+
# `perform_action` with its action allowlist and `rescue_from` handlers,
|
|
38
|
+
# `broadcasting_for`, `broadcast_to`, and the `current_user`-style delegation
|
|
39
|
+
# to the connection's identifiers are all Rails' own code, running unmodified.
|
|
40
|
+
#
|
|
41
|
+
# What a subclass overrides is the four methods whose *implementation* is a
|
|
42
|
+
# live pub/sub subscription — `stream_from`, `stop_stream_from`,
|
|
43
|
+
# `stop_all_streams`, and (through `stream_from`) `stream_for`. In dispatch
|
|
44
|
+
# mode the relay owns the pub/sub side of a stream, so these record ordered
|
|
45
|
+
# `add_stream` / `remove_stream` / `remove_all_streams` effects instead of
|
|
46
|
+
# touching `connection.pubsub`. `transmit` needs no override at all: it
|
|
47
|
+
# already goes through `connection.transmit`, which is where the transmit
|
|
48
|
+
# effect is collected.
|
|
49
|
+
#
|
|
50
|
+
# Ordinary Ruby inheritance, and nothing else: no `prepend`, no patched
|
|
51
|
+
# `ActionCable` constant, no fake socket.
|
|
52
|
+
#
|
|
53
|
+
# ## What it adds
|
|
54
|
+
#
|
|
55
|
+
# `grant_presence` / `revoke_presence` are the one piece of DSL that is not
|
|
56
|
+
# ActionCable's. They exist because presence identity is an
|
|
57
|
+
# authorization decision: the relay will not invent a `subject_id`, and a
|
|
58
|
+
# client cannot supply one, so Rails hands it over explicitly — as an
|
|
59
|
+
# ordered effect, like every other thing an operation produces.
|
|
60
|
+
#
|
|
61
|
+
# ## What cannot work
|
|
62
|
+
#
|
|
63
|
+
# A stream transformation block and a periodic timer are Ruby that would have
|
|
64
|
+
# to run inside the Rust process on every message or every tick. They are
|
|
65
|
+
# refused loudly rather than silently ignored — use an ordinary server-side
|
|
66
|
+
# broadcast, or a job.
|
|
67
|
+
class Channel < ActionCable::Channel::Base
|
|
68
|
+
include StateCodec::Instance
|
|
69
|
+
extend StateCodec::Declaration
|
|
70
|
+
|
|
71
|
+
V1 = Protocol::V1
|
|
72
|
+
|
|
73
|
+
# The presence operations a grant may authorize, spelled exactly as
|
|
74
|
+
# `PresenceOp` spells them (`src/protocol/v1/presence.rs`). The same three
|
|
75
|
+
# strings are what a client's `orbitcast_presence` command may ask for.
|
|
76
|
+
PRESENCE_OPERATIONS = V1::PRESENCE_OPERATIONS
|
|
77
|
+
|
|
78
|
+
# Default lease lifetime, in seconds, when `grant_presence` is called
|
|
79
|
+
# without a `ttl:`. Short on purpose: a lease is refreshed by every `join`,
|
|
80
|
+
# and a tab that dies stops refreshing.
|
|
81
|
+
DEFAULT_PRESENCE_TTL = 45
|
|
82
|
+
|
|
83
|
+
# Public methods that must never become client-callable actions: the
|
|
84
|
+
# plumbing the RPC dispatcher calls, and the server-side presence DSL —
|
|
85
|
+
# `grant_presence` is how identity is decided, so it is the last method a
|
|
86
|
+
# client may ever name in `data["action"]`.
|
|
87
|
+
#
|
|
88
|
+
# `action_methods` subtracts `internal_methods`; that is the hook
|
|
89
|
+
# ActionCable's own `Callbacks` module uses, and it is the reason a channel
|
|
90
|
+
# can carry non-action public methods at all. Methods declared on
|
|
91
|
+
# `ActionCable::Channel::Base` (`stream_from`, `transmit`, ...) are
|
|
92
|
+
# subtracted by ActionCable itself; these are declared here, so they are
|
|
93
|
+
# ours to exclude.
|
|
94
|
+
INTERNAL_METHODS = %i[
|
|
95
|
+
orbitcast_effects
|
|
96
|
+
orbitcast_streams
|
|
97
|
+
orbitcast_installed_streams=
|
|
98
|
+
orbitcast_processable_action?
|
|
99
|
+
orbitcast_rejected?
|
|
100
|
+
orbitcast_dump_state
|
|
101
|
+
orbitcast_restore_state
|
|
102
|
+
grant_presence
|
|
103
|
+
revoke_presence
|
|
104
|
+
].freeze
|
|
105
|
+
|
|
106
|
+
# Action names beginning with this prefix belong to the relay.
|
|
107
|
+
#
|
|
108
|
+
# `orbitcast_presence` is the one that exists today (see
|
|
109
|
+
# `src/protocol/v1/presence.rs`): the server answers that action itself and
|
|
110
|
+
# never forwards it to Rails. The whole prefix is reserved so the next such
|
|
111
|
+
# action does not silently start shadowing an application method that was
|
|
112
|
+
# already deployed.
|
|
113
|
+
RESERVED_ACTION_PREFIX = "orbitcast_"
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# Periodic timers live in the ActionCable event loop, which is not where
|
|
117
|
+
# this channel runs.
|
|
118
|
+
def periodically(*, **, &)
|
|
119
|
+
raise UnsupportedChannelFeature,
|
|
120
|
+
"#{name || "this channel"} declares `periodically`, which needs an in-process " \
|
|
121
|
+
"ActionCable event loop. In Rails-dispatch mode the socket is held by the relay: " \
|
|
122
|
+
"use a recurring job that broadcasts to the stream instead."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Fail at class-definition time on a reserved action name.
|
|
126
|
+
#
|
|
127
|
+
# A public method on a channel *is* a client-callable action
|
|
128
|
+
# (`action_methods` minus `internal_methods`), so a channel defining
|
|
129
|
+
# `orbitcast_presence` would be declaring an action the relay intercepts
|
|
130
|
+
# and Rails is never asked about — the method would simply never run.
|
|
131
|
+
# Excluding it with a warning would leave that dead code in the
|
|
132
|
+
# application; raising here surfaces it on the line that wrote it, while
|
|
133
|
+
# the class body is still on the stack.
|
|
134
|
+
#
|
|
135
|
+
# Only *public* methods are actions, so a helper with the same prefix
|
|
136
|
+
# declared under a `private` (or `protected`) section is left alone —
|
|
137
|
+
# visibility is decided before `method_added` runs there, which the
|
|
138
|
+
# inline `private def` form cannot offer. The plumbing this base class
|
|
139
|
+
# declares is allowlisted in {INTERNAL_METHODS}.
|
|
140
|
+
def method_added(name)
|
|
141
|
+
super
|
|
142
|
+
|
|
143
|
+
if name.to_s.start_with?(RESERVED_ACTION_PREFIX) &&
|
|
144
|
+
!INTERNAL_METHODS.include?(name) &&
|
|
145
|
+
public_method_defined?(name)
|
|
146
|
+
raise UnsupportedChannelFeature,
|
|
147
|
+
"#{self.name || "this channel"} defines `#{name}`, and `" \
|
|
148
|
+
"#{RESERVED_ACTION_PREFIX}` is a reserved action namespace: the relay answers " \
|
|
149
|
+
"those actions itself, so Rails would never be asked to run this method. " \
|
|
150
|
+
"Rename it, or make it private if it is not meant to be a client-callable " \
|
|
151
|
+
"action."
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def internal_methods
|
|
158
|
+
super + INTERNAL_METHODS
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Record an `add_stream` effect for an owned subscription.
|
|
163
|
+
#
|
|
164
|
+
# @param broadcasting [String, #to_s] the logical stream name
|
|
165
|
+
def stream_from(broadcasting, callback = nil, coder: nil, &block)
|
|
166
|
+
return if unsubscribed?
|
|
167
|
+
|
|
168
|
+
if callback || block || coder
|
|
169
|
+
raise UnsupportedChannelFeature,
|
|
170
|
+
"stream_from with a callback, block, or coder cannot run in Rails-dispatch mode: " \
|
|
171
|
+
"the relay delivers published payloads to the browser without calling back into " \
|
|
172
|
+
"Ruby. Transform the payload where you broadcast it."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
broadcasting = String(broadcasting)
|
|
176
|
+
orbitcast_effects.add_stream(identifier, broadcasting)
|
|
177
|
+
orbitcast_streams[broadcasting] = true
|
|
178
|
+
broadcasting
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# `stream_for model` keeps working: the broadcasting name still comes from
|
|
182
|
+
# ActionCable's own `broadcasting_for`, so a `broadcast_to` from anywhere in
|
|
183
|
+
# the application reaches this subscription.
|
|
184
|
+
def stream_for(broadcastables, callback = nil, coder: nil, &block)
|
|
185
|
+
stream_from(broadcasting_for(broadcastables), callback || block, coder: coder)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def stop_stream_from(broadcasting)
|
|
189
|
+
broadcasting = String(broadcasting)
|
|
190
|
+
return unless orbitcast_streams.delete(broadcasting)
|
|
191
|
+
|
|
192
|
+
orbitcast_effects.remove_stream(identifier, broadcasting)
|
|
193
|
+
broadcasting
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def stop_stream_for(model)
|
|
197
|
+
stop_stream_from(broadcasting_for(model))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Registered by ActionCable itself as an `on_unsubscribe` callback, which is
|
|
201
|
+
# why the collector is suppressed for teardown operations: the relay has
|
|
202
|
+
# already dropped the subscription this would address.
|
|
203
|
+
def stop_all_streams
|
|
204
|
+
return if orbitcast_streams.empty?
|
|
205
|
+
|
|
206
|
+
orbitcast_streams.clear
|
|
207
|
+
orbitcast_effects.remove_all_streams(identifier)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Grant this subscription an authorized presence membership on `stream`.
|
|
211
|
+
#
|
|
212
|
+
# def subscribed
|
|
213
|
+
# stream_for room
|
|
214
|
+
# grant_presence broadcasting_for(room),
|
|
215
|
+
# subject_id: "user:#{current_user.id}",
|
|
216
|
+
# metadata: { name: current_user.name },
|
|
217
|
+
# ttl: 45
|
|
218
|
+
# end
|
|
219
|
+
#
|
|
220
|
+
# ## Identity is decided here, and only here
|
|
221
|
+
#
|
|
222
|
+
# A client's `orbitcast_presence` command carries the operation and a
|
|
223
|
+
# snapshot page window — nothing else. `subject_id` and `metadata` come
|
|
224
|
+
# from this grant, so a browser cannot name, impersonate, or decorate a
|
|
225
|
+
# subject: a `subject_id` it sends anyway is an unknown field on the
|
|
226
|
+
# command envelope and is ignored on decode.
|
|
227
|
+
#
|
|
228
|
+
# ## The grant is bound to a stream this subscription holds
|
|
229
|
+
#
|
|
230
|
+
# Call it *after* `stream_from` / `stream_for` for the same name. The relay
|
|
231
|
+
# refuses a grant for a stream the subscription does not stream from, and
|
|
232
|
+
# releases the grant (announcing the leave) the moment the subscription
|
|
233
|
+
# stops streaming from it — `stop_stream_from`, `stop_all_streams`,
|
|
234
|
+
# unsubscribe, or the socket closing. Granting before streaming is the
|
|
235
|
+
# one ordering mistake that is easy to make, so it raises here.
|
|
236
|
+
#
|
|
237
|
+
# A second grant for the same stream replaces the first: the newest
|
|
238
|
+
# subject, metadata, TTL, and operations win. If the client had joined, a
|
|
239
|
+
# different subject leaves (announced) before the new one joins, and the
|
|
240
|
+
# same subject keeps its presence with the new metadata.
|
|
241
|
+
#
|
|
242
|
+
# ## Arguments
|
|
243
|
+
#
|
|
244
|
+
# @param stream [String, Symbol] the logical stream the roster belongs to —
|
|
245
|
+
# the same name `stream_from` installs. A model is *not* converted for
|
|
246
|
+
# you: pass `broadcasting_for(model)`, exactly as `stream_for` does
|
|
247
|
+
# internally, so the roster and the fanout agree on one name.
|
|
248
|
+
# @param subject_id [String] the identity the roster reports, at most
|
|
249
|
+
# {Orbitcast::Protocol::V1::MAX_SUBJECT_ID_LEN} bytes. One subject, many
|
|
250
|
+
# sessions: tabs and devices of one subject produce one join and one
|
|
251
|
+
# leave.
|
|
252
|
+
# @param metadata [Hash] public per-subject data, encoded at most
|
|
253
|
+
# {Orbitcast::Protocol::V1::MAX_PRESENCE_METADATA_LEN} bytes. It is
|
|
254
|
+
# broadcast to every member of the stream — nothing private belongs in
|
|
255
|
+
# it. Keys travel as strings.
|
|
256
|
+
# @param ttl [Numeric, ActiveSupport::Duration, nil] lease lifetime in
|
|
257
|
+
# *seconds*; defaults to {DEFAULT_PRESENCE_TTL}. Emitted as `ttl_ms`.
|
|
258
|
+
# @param operations [Array<String, Symbol>] the subset of
|
|
259
|
+
# {PRESENCE_OPERATIONS} this grant authorizes. Defaults to all three.
|
|
260
|
+
# @return [Hash, nil] the recorded effect, or nil when the collector is
|
|
261
|
+
# suppressed (`close_channel` / `release_connection` address a
|
|
262
|
+
# subscription the relay has already tombstoned).
|
|
263
|
+
# @raise [ArgumentError] when a value is outside the limits the relay
|
|
264
|
+
# enforces, or when this subscription does not stream from `stream`.
|
|
265
|
+
# Rails fails here rather than emitting a reply Rust rejects.
|
|
266
|
+
def grant_presence(stream, subject_id:, metadata: {}, ttl: nil,
|
|
267
|
+
operations: PRESENCE_OPERATIONS)
|
|
268
|
+
orbitcast_effects.presence_grant(
|
|
269
|
+
identifier,
|
|
270
|
+
stream: presence_streamed!(presence_stream!(stream)),
|
|
271
|
+
subject_id: presence_subject_id!(subject_id),
|
|
272
|
+
metadata: presence_metadata!(metadata),
|
|
273
|
+
ttl_ms: presence_ttl_ms!(ttl),
|
|
274
|
+
operations: presence_operations!(operations)
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Drop a presence membership this subscription was granted.
|
|
279
|
+
#
|
|
280
|
+
# Idempotent by construction: the effect names `(identifier, stream,
|
|
281
|
+
# subject_id)`, and revoking a membership that is already gone is a no-op
|
|
282
|
+
# at the relay rather than an error here.
|
|
283
|
+
#
|
|
284
|
+
# @return [Hash, nil] the recorded effect, or nil when suppressed
|
|
285
|
+
# @raise [ArgumentError] on an unusable stream or subject id
|
|
286
|
+
def revoke_presence(stream, subject_id:)
|
|
287
|
+
orbitcast_effects.presence_revoke(
|
|
288
|
+
identifier,
|
|
289
|
+
stream: presence_stream!(stream),
|
|
290
|
+
subject_id: presence_subject_id!(subject_id)
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The streams this subscription holds, as the relay reported them.
|
|
295
|
+
def orbitcast_streams
|
|
296
|
+
@orbitcast_streams ||= {}
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Seed the channel with the streams the relay already installed, so
|
|
300
|
+
# `stop_stream_from` in a later `perform` knows what there is to remove.
|
|
301
|
+
def orbitcast_installed_streams=(streams)
|
|
302
|
+
@orbitcast_streams = Array(streams).each_with_object({}) { |stream, out| out[stream] = true }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Whether `data["action"]` names a callable action on this channel.
|
|
306
|
+
# ActionCable logs and ignores anything else; the RPC boundary answers with
|
|
307
|
+
# an explicit denial instead, so the relay can record it.
|
|
308
|
+
def orbitcast_processable_action?(data)
|
|
309
|
+
processable_action?(extract_action(data))
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# `subscription_rejected?` is private in ActionCable; the dispatcher needs
|
|
313
|
+
# the answer to turn `reject` into a denied outcome.
|
|
314
|
+
def orbitcast_rejected?
|
|
315
|
+
!!subscription_rejected?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def orbitcast_effects
|
|
319
|
+
connection.orbitcast_effects
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private
|
|
323
|
+
|
|
324
|
+
# --- presence argument checks ---------------------------------------------
|
|
325
|
+
#
|
|
326
|
+
# Each one mirrors the matching check in `Effect::validate` in
|
|
327
|
+
# `src/protocol/v1/rpc.rs`, so an unusable grant fails in the application's
|
|
328
|
+
# own callback — with the application's own stack trace — instead of
|
|
329
|
+
# arriving as a rejected reply the relay logs and the browser never sees.
|
|
330
|
+
# `ArgumentError` because this is the application-facing DSL, the same way
|
|
331
|
+
# `Orbitcast::Streams` and `Orbitcast::Recovery` report bad arguments; the
|
|
332
|
+
# `EffectCollector` behind it still raises `ProtocolError` for anything
|
|
333
|
+
# that reaches it by another path.
|
|
334
|
+
|
|
335
|
+
def presence_stream!(stream)
|
|
336
|
+
unless stream.is_a?(String) || stream.is_a?(Symbol)
|
|
337
|
+
raise ArgumentError,
|
|
338
|
+
"presence stream must be a String, got #{stream.class}. For a model pass " \
|
|
339
|
+
"broadcasting_for(model) — the same name stream_for installs."
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
stream = stream.to_s
|
|
343
|
+
raise ArgumentError, "presence stream must not be empty" if stream.empty?
|
|
344
|
+
|
|
345
|
+
if stream.bytesize > V1::MAX_STREAM_LEN
|
|
346
|
+
raise ArgumentError,
|
|
347
|
+
"presence stream is #{stream.bytesize} bytes; the maximum is #{V1::MAX_STREAM_LEN}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
stream
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The relay binds a grant to a stream the subscription holds and refuses
|
|
354
|
+
# anything else; failing here keeps that refusal out of the relay's log
|
|
355
|
+
# and in the callback that got the order wrong.
|
|
356
|
+
def presence_streamed!(stream)
|
|
357
|
+
return stream if orbitcast_streams.key?(stream)
|
|
358
|
+
|
|
359
|
+
raise ArgumentError,
|
|
360
|
+
"grant_presence(#{stream.inspect}) must follow stream_from/stream_for for that " \
|
|
361
|
+
"stream: the relay binds the roster to a stream this subscription holds " \
|
|
362
|
+
"(currently #{orbitcast_streams.keys.inspect})"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def presence_subject_id!(subject_id)
|
|
366
|
+
unless subject_id.is_a?(String)
|
|
367
|
+
raise ArgumentError, "subject_id must be a String, got #{subject_id.class}"
|
|
368
|
+
end
|
|
369
|
+
raise ArgumentError, "subject_id must not be empty" if subject_id.empty?
|
|
370
|
+
|
|
371
|
+
if subject_id.bytesize > V1::MAX_SUBJECT_ID_LEN
|
|
372
|
+
raise ArgumentError,
|
|
373
|
+
"subject_id is #{subject_id.bytesize} bytes; the maximum is " \
|
|
374
|
+
"#{V1::MAX_SUBJECT_ID_LEN}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
subject_id
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# The *encoded* size is what travels and what is stored, so one huge value
|
|
381
|
+
# is bounded exactly like many small ones.
|
|
382
|
+
def presence_metadata!(metadata)
|
|
383
|
+
unless metadata.is_a?(Hash)
|
|
384
|
+
raise ArgumentError, "metadata must be a Hash, got #{metadata.class}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
metadata = metadata.transform_keys do |key|
|
|
388
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
389
|
+
raise ArgumentError, "metadata keys must be Strings, got #{key.class}"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
key.to_s
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
encoded =
|
|
396
|
+
begin
|
|
397
|
+
JSON.generate(metadata)
|
|
398
|
+
rescue JSON::GeneratorError, SystemStackError => e
|
|
399
|
+
raise ArgumentError, "metadata must be JSON-encodable: #{e.message}"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
if encoded.bytesize > V1::MAX_PRESENCE_METADATA_LEN
|
|
403
|
+
raise ArgumentError,
|
|
404
|
+
"metadata encodes to #{encoded.bytesize} bytes; the maximum is " \
|
|
405
|
+
"#{V1::MAX_PRESENCE_METADATA_LEN}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
metadata
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Seconds in, milliseconds out: `ttl: 45` and `ttl: 45.seconds` are the
|
|
412
|
+
# same lease, and the wire field is `ttl_ms`.
|
|
413
|
+
def presence_ttl_ms!(ttl)
|
|
414
|
+
seconds = ttl.nil? ? DEFAULT_PRESENCE_TTL : ttl
|
|
415
|
+
unless seconds.is_a?(Numeric)
|
|
416
|
+
raise ArgumentError, "ttl must be a number of seconds, got #{seconds.class}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
ttl_ms = (seconds.to_f * 1000).round
|
|
420
|
+
unless ttl_ms.positive?
|
|
421
|
+
raise ArgumentError, "ttl must be greater than zero (it became #{ttl_ms}ms)"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
ttl_ms
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# A grant names what it authorizes. An operation the server does not know
|
|
428
|
+
# could never be performed, and an empty allowlist grants nothing at all —
|
|
429
|
+
# both are caller mistakes, not lenient defaults.
|
|
430
|
+
def presence_operations!(operations)
|
|
431
|
+
unless operations.is_a?(Array)
|
|
432
|
+
raise ArgumentError, "operations must be an Array, got #{operations.class}"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
operations = operations.map do |operation|
|
|
436
|
+
unless operation.is_a?(String) || operation.is_a?(Symbol)
|
|
437
|
+
raise ArgumentError, "operations must be Strings, got #{operation.class}"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
operation = operation.to_s
|
|
441
|
+
unless PRESENCE_OPERATIONS.include?(operation)
|
|
442
|
+
raise ArgumentError,
|
|
443
|
+
"unknown presence operation #{operation.inspect}; the operations are " \
|
|
444
|
+
"#{PRESENCE_OPERATIONS.join(", ")}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
operation
|
|
448
|
+
end.uniq
|
|
449
|
+
|
|
450
|
+
if operations.empty?
|
|
451
|
+
raise ArgumentError,
|
|
452
|
+
"operations must name at least one of #{PRESENCE_OPERATIONS.join(", ")}; " \
|
|
453
|
+
"a grant that authorizes nothing can never be used"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
operations
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module Orbitcast
|
|
6
|
+
# The allowlist of channel classes a client may subscribe to.
|
|
7
|
+
#
|
|
8
|
+
# ActionCable resolves `identifier["channel"]` with `safe_constantize`, which
|
|
9
|
+
# is fine when the only thing on the other end is a browser talking to your
|
|
10
|
+
# own process. Here the name arrives over an RPC boundary and is attacker
|
|
11
|
+
# controlled, so it is matched against an explicit list of strings *first* and
|
|
12
|
+
# only then resolved. Nothing unlisted is ever constantized, and a listed name
|
|
13
|
+
# that does not resolve to an `Orbitcast::Channel` is refused too:
|
|
14
|
+
# `"Kernel"` is a constant, it is just not a channel.
|
|
15
|
+
#
|
|
16
|
+
# config.orbitcast.channels = %w[ChatChannel Turbo::StreamsChannel]
|
|
17
|
+
# config.orbitcast.channels = :auto # derive from app/channels
|
|
18
|
+
#
|
|
19
|
+
# `:auto` derivation reads the application's `app/channels` tree, turns each
|
|
20
|
+
# file name into the constant Zeitwerk would define, and keeps the ones that
|
|
21
|
+
# are subclasses of `Orbitcast::Channel`. It is a convenience for development;
|
|
22
|
+
# an explicit list is what a reviewed deployment ships.
|
|
23
|
+
class ChannelRegistry
|
|
24
|
+
AUTO = :auto
|
|
25
|
+
|
|
26
|
+
# `ApplicationCable::Channel` is a base class, never a subscribable target.
|
|
27
|
+
NEVER_SUBSCRIBABLE = %w[ApplicationCable::Channel Orbitcast::Channel].freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# @param names [Array<String, Class>, :auto, nil]
|
|
31
|
+
def build(names, root: nil)
|
|
32
|
+
case names
|
|
33
|
+
when nil, AUTO, "auto" then auto(root)
|
|
34
|
+
when Array then new(names)
|
|
35
|
+
else
|
|
36
|
+
raise ConfigurationError,
|
|
37
|
+
"channels must be an Array of channel class names or :auto, got #{names.class}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Derive the allowlist from `app/channels/**/*_channel.rb`.
|
|
42
|
+
def auto(root = nil)
|
|
43
|
+
root ||= default_root
|
|
44
|
+
return new([]) if root.nil?
|
|
45
|
+
|
|
46
|
+
base = File.join(root.to_s, "app", "channels")
|
|
47
|
+
names = Dir[File.join(base, "**", "*_channel.rb")].sort.map do |path|
|
|
48
|
+
constant_name_for(path.delete_prefix("#{base}/"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
new(names - NEVER_SUBSCRIBABLE)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_root
|
|
55
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
56
|
+
|
|
57
|
+
::Rails.root
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# "chat/room_channel.rb" -> "Chat::RoomChannel", the constant Zeitwerk
|
|
61
|
+
# defines for that path. Acronym inflections are respected when
|
|
62
|
+
# ActiveSupport is loaded.
|
|
63
|
+
def constant_name_for(relative_path)
|
|
64
|
+
relative_path.delete_suffix(".rb").split("/").map { |part| camelize(part) }.join("::")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def camelize(part)
|
|
70
|
+
if part.respond_to?(:camelize)
|
|
71
|
+
part.camelize
|
|
72
|
+
else
|
|
73
|
+
part.split("_").map(&:capitalize).join
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize(names)
|
|
79
|
+
@names = Array(names).map { |name| name.is_a?(Class) ? name.name : name.to_s }.uniq.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
attr_reader :names
|
|
83
|
+
|
|
84
|
+
def empty?
|
|
85
|
+
@names.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def include?(name)
|
|
89
|
+
@names.include?(name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param name [String] the client-supplied `identifier["channel"]`
|
|
93
|
+
# @return [Class, nil] the channel class, or nil when the name is not
|
|
94
|
+
# allowlisted or does not resolve to a usable channel
|
|
95
|
+
def resolve(name)
|
|
96
|
+
return nil unless name.is_a?(String)
|
|
97
|
+
return nil unless include?(name)
|
|
98
|
+
|
|
99
|
+
constant = constantize(name)
|
|
100
|
+
return nil unless constant.is_a?(Class)
|
|
101
|
+
return nil unless defined?(Orbitcast::Channel) && constant <= Orbitcast::Channel
|
|
102
|
+
|
|
103
|
+
constant
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Boot-time check: every allowlisted name must actually be a channel, so a
|
|
107
|
+
# typo is a deployment error rather than a runtime denial.
|
|
108
|
+
def validate!
|
|
109
|
+
unresolved = @names.reject { |name| resolve(name) }
|
|
110
|
+
return self if unresolved.empty?
|
|
111
|
+
|
|
112
|
+
raise ConfigurationError,
|
|
113
|
+
"config.orbitcast.channels lists #{unresolved.join(", ")}, which " \
|
|
114
|
+
"#{unresolved.one? ? "is not a" : "are not"} subclass#{unresolved.one? ? "" : "es"} " \
|
|
115
|
+
"of Orbitcast::Channel"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Only ever called with a name that is already on the allowlist.
|
|
121
|
+
def constantize(name)
|
|
122
|
+
if name.respond_to?(:safe_constantize)
|
|
123
|
+
name.safe_constantize
|
|
124
|
+
else
|
|
125
|
+
Object.const_get(name)
|
|
126
|
+
end
|
|
127
|
+
rescue NameError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|