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,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
|
|
9
|
+
module Orbitcast
|
|
10
|
+
# Recovery tokens: the capability that lets one browser session replay
|
|
11
|
+
# retained history on `Orbitcast::RecoveryChannel`.
|
|
12
|
+
#
|
|
13
|
+
# base64(JSON(claims)) + "--" + hex(HMAC-SHA256(recovery_secret, base64_part))
|
|
14
|
+
#
|
|
15
|
+
# Same wire shape as `Orbitcast::Streams`, and deliberately **not** the same
|
|
16
|
+
# key or the same purpose:
|
|
17
|
+
#
|
|
18
|
+
# * the key is `config.recovery_secret`, exported to the relay as
|
|
19
|
+
# `ORBITCAST_RECOVERY_SECRET_BASE64`. There is no fallback to
|
|
20
|
+
# `streams_secret`, in either direction.
|
|
21
|
+
# * the purpose is `"recover"`, so a subscribe token can never be presented as
|
|
22
|
+
# a recovery capability (and vice versa).
|
|
23
|
+
# * the claims name the exact streams that may be replayed, so a recovery
|
|
24
|
+
# token never widens access. Mint it from the same authorization decision
|
|
25
|
+
# that produced the subscription, and keep the lifetime short.
|
|
26
|
+
#
|
|
27
|
+
# Recovery is opt-in on both sides: nothing here is used unless the
|
|
28
|
+
# application asks for a token *and* the relay runs with `broker_enabled`.
|
|
29
|
+
# An unchanged ActionCable or Turbo client never sees any of it.
|
|
30
|
+
#
|
|
31
|
+
# Two things the token does **not** do:
|
|
32
|
+
#
|
|
33
|
+
# * It does not make history exist. Only publications that pass through the
|
|
34
|
+
# relay are retained, which means `config.publish_via = :http`. The
|
|
35
|
+
# default `:postgres` path emits `pg_notify` from this process and never
|
|
36
|
+
# reaches the broker, so nothing broadcast that way can be replayed - the
|
|
37
|
+
# relay has no Ruby-side switch for this, and this module cannot detect
|
|
38
|
+
# it; it is a deployment decision.
|
|
39
|
+
# * It does not authorize by itself. The relay re-checks on every resume
|
|
40
|
+
# that the connection is identified, that the token is still valid, and
|
|
41
|
+
# that the connection holds a confirmed subscription to each stream it
|
|
42
|
+
# replays: `streams` must be the logical names the channels `stream_from`,
|
|
43
|
+
# and the browser must subscribe to those channels before it creates the
|
|
44
|
+
# recovery subscription.
|
|
45
|
+
module Recovery
|
|
46
|
+
CLAIMS_VERSION = 1
|
|
47
|
+
PURPOSE = "recover"
|
|
48
|
+
SEPARATOR = "--"
|
|
49
|
+
DIGEST = "SHA256"
|
|
50
|
+
|
|
51
|
+
# Same bound the server enforces (`MAX_RECOVERY_STREAMS`).
|
|
52
|
+
MAX_STREAMS = 64
|
|
53
|
+
|
|
54
|
+
# Default token lifetime, in seconds. Deliberately short: a recovery token
|
|
55
|
+
# is used at reconnect time, not held for a session's lifetime.
|
|
56
|
+
DEFAULT_EXPIRES_IN = 900
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Issue a recovery capability.
|
|
60
|
+
#
|
|
61
|
+
# @param session_id [String] an identity *reference* the application
|
|
62
|
+
# recognizes. Never a cookie, never sensitive connection state: it is
|
|
63
|
+
# stored in the relay's session row.
|
|
64
|
+
# @param streams [Array<String>] the logical streams this token may
|
|
65
|
+
# replay. Exactly the streams the caller already authorized.
|
|
66
|
+
# @param expires_in [Integer, nil] seconds; defaults to
|
|
67
|
+
# {DEFAULT_EXPIRES_IN}
|
|
68
|
+
# @return [String] the token for a
|
|
69
|
+
# `{"channel":"Orbitcast::RecoveryChannel","token":"..."}` identifier
|
|
70
|
+
def recovery_token(session_id:, streams:, expires_in: nil, config: Orbitcast.config)
|
|
71
|
+
payload = claims(session_id: session_id, streams: streams,
|
|
72
|
+
expires_in: expires_in, config: config)
|
|
73
|
+
encoded = Base64.strict_encode64(JSON.generate(payload))
|
|
74
|
+
|
|
75
|
+
"#{encoded}#{SEPARATOR}#{signature(encoded, config.recovery_secret!)}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The claim set, in the fixed key order the server's fixtures pin:
|
|
79
|
+
# version, namespace, session_id, streams, purpose, expires_at.
|
|
80
|
+
def claims(session_id:, streams:, expires_in: nil, config: Orbitcast.config, now: Time.now)
|
|
81
|
+
ttl = expires_in.nil? ? DEFAULT_EXPIRES_IN : Integer(expires_in)
|
|
82
|
+
raise ConfigurationError, "expires_in must be greater than zero" unless ttl.positive?
|
|
83
|
+
|
|
84
|
+
namespace = config.namespace
|
|
85
|
+
raise ConfigurationError, "namespace is required to sign recovery tokens" if namespace.nil?
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
version: CLAIMS_VERSION,
|
|
89
|
+
namespace: namespace,
|
|
90
|
+
session_id: validated_session_id(session_id),
|
|
91
|
+
streams: validated_streams(streams),
|
|
92
|
+
purpose: PURPOSE,
|
|
93
|
+
expires_at: now.to_i + ttl
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Exposed for tests and for `orbitcast doctor`; the server owns the
|
|
98
|
+
# authoritative verifier.
|
|
99
|
+
def signature(encoded, key)
|
|
100
|
+
OpenSSL::HMAC.hexdigest(DIGEST, key, encoded)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def validated_session_id(session_id)
|
|
106
|
+
unless session_id.is_a?(String)
|
|
107
|
+
raise ArgumentError, "session_id must be a String, got #{session_id.class}"
|
|
108
|
+
end
|
|
109
|
+
raise ArgumentError, "session_id must not be empty" if session_id.empty?
|
|
110
|
+
|
|
111
|
+
session_id
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A token grants exactly what it names, so an empty list is refused here
|
|
115
|
+
# rather than minted into a token the server would reject anyway.
|
|
116
|
+
def validated_streams(streams)
|
|
117
|
+
unless streams.is_a?(Array)
|
|
118
|
+
raise ArgumentError, "streams must be an Array of stream names, got #{streams.class}"
|
|
119
|
+
end
|
|
120
|
+
raise ArgumentError, "streams must not be empty" if streams.empty?
|
|
121
|
+
|
|
122
|
+
if streams.length > MAX_STREAMS
|
|
123
|
+
raise ArgumentError, "streams must name at most #{MAX_STREAMS} streams"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
streams.map do |stream|
|
|
127
|
+
unless stream.is_a?(String)
|
|
128
|
+
raise ArgumentError, "stream must be a String, got #{stream.class}"
|
|
129
|
+
end
|
|
130
|
+
raise ArgumentError, "stream must not be empty" if stream.empty?
|
|
131
|
+
|
|
132
|
+
stream
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The dedicated recovery key.
|
|
139
|
+
#
|
|
140
|
+
# Added here rather than in `Configuration`'s attribute allowlist so that
|
|
141
|
+
# recovery stays a separate, opt-in concern: set it explicitly
|
|
142
|
+
# (`Orbitcast.config.recovery_secret = ...`), and export it to the relay as
|
|
143
|
+
# `ORBITCAST_RECOVERY_SECRET_BASE64`.
|
|
144
|
+
class Configuration
|
|
145
|
+
# `recovery_secret` reader/writer live in configuration.rb with the other
|
|
146
|
+
# secrets (shared normalizer); only the bang helper is recovery-specific.
|
|
147
|
+
def recovery_secret!
|
|
148
|
+
return recovery_secret if recovery_secret
|
|
149
|
+
|
|
150
|
+
raise ConfigurationError,
|
|
151
|
+
"recovery_secret is not configured; set config.orbitcast.recovery_secret " \
|
|
152
|
+
"(exported to the relay as ORBITCAST_RECOVERY_SECRET_BASE64) before signing " \
|
|
153
|
+
"recovery tokens"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class << self
|
|
158
|
+
# Issue a recovery capability for `streams`. The result goes into an
|
|
159
|
+
# ordinary ActionCable identifier:
|
|
160
|
+
# {"channel":"Orbitcast::RecoveryChannel","token":"..."}.
|
|
161
|
+
def recovery_token(session_id:, streams:, expires_in: nil)
|
|
162
|
+
Recovery.recovery_token(session_id: session_id, streams: streams,
|
|
163
|
+
expires_in: expires_in, config: config)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "protocol"
|
|
7
|
+
|
|
8
|
+
module Orbitcast
|
|
9
|
+
# Declared state serialization.
|
|
10
|
+
#
|
|
11
|
+
# Rails-dispatch requests are stateless: the relay holds the declared
|
|
12
|
+
# connection and channel state between calls and hands it back on the next
|
|
13
|
+
# one. That only works if what crosses the wire is *data*, so this codec
|
|
14
|
+
# supports exactly two things:
|
|
15
|
+
#
|
|
16
|
+
# * JSON primitives (and arrays/objects of them), and
|
|
17
|
+
# * records, tagged explicitly as `{"_gid": "gid://app/User/7"}`.
|
|
18
|
+
#
|
|
19
|
+
# There is no Marshal, no YAML, no arbitrary instance variables, and no
|
|
20
|
+
# implicit capture of anything: state is declared with `orbitcast_state`, or
|
|
21
|
+
# it does not survive the request. Ordinary memoization stays per-call.
|
|
22
|
+
#
|
|
23
|
+
# class ApplicationCable::Connection < Orbitcast::Connection
|
|
24
|
+
# identified_by :current_user
|
|
25
|
+
# orbitcast_state :locale, :cart
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# A `_gid` whose record was deleted between two requests restores as **nil**,
|
|
29
|
+
# not an exception: the relay cannot retry a callback that has already run,
|
|
30
|
+
# and a channel that requires the record must check for it. The same goes for
|
|
31
|
+
# a reference the application is no longer allowed to locate — `GlobalID`
|
|
32
|
+
# locators raise, and a raised locator is a missing record here.
|
|
33
|
+
module StateCodec
|
|
34
|
+
# The tag that makes a record reference explicit. A plain Hash carrying this
|
|
35
|
+
# key cannot be encoded, because it would be indistinguishable from one.
|
|
36
|
+
GID_KEY = "_gid"
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# @return [Object] a JSON-ready value
|
|
40
|
+
# @raise [ProtocolError] for anything that is not declared-state material
|
|
41
|
+
def dump(value)
|
|
42
|
+
case value
|
|
43
|
+
when nil, true, false, String, Integer then value
|
|
44
|
+
when Float
|
|
45
|
+
unless value.finite?
|
|
46
|
+
raise Orbitcast::ProtocolError.new("declared state cannot hold #{value}", field: "state")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
value
|
|
50
|
+
when Symbol then value.to_s
|
|
51
|
+
when Array then value.map { |entry| dump(entry) }
|
|
52
|
+
when Hash then dump_hash(value)
|
|
53
|
+
else
|
|
54
|
+
dump_record(value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Object] the restored value; a missing record restores as nil
|
|
59
|
+
def load(value)
|
|
60
|
+
case value
|
|
61
|
+
when Array then value.map { |entry| load(entry) }
|
|
62
|
+
when Hash
|
|
63
|
+
gid = value[GID_KEY]
|
|
64
|
+
gid.is_a?(String) && value.size == 1 ? locate(gid) : value.transform_values { |entry| load(entry) }
|
|
65
|
+
else
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Dump a whole declared-state map, validating it against the wire limits
|
|
71
|
+
# before it can reach a reply.
|
|
72
|
+
def dump_map(hash)
|
|
73
|
+
map = hash.each_with_object({}) { |(key, value), out| out[key.to_s] = dump(value) }
|
|
74
|
+
Protocol::V1.validate_state_map!("state", map)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def load_map(hash)
|
|
78
|
+
return {} unless hash.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_s] = load(value) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def dump_hash(hash)
|
|
86
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
87
|
+
key = key.to_s
|
|
88
|
+
if key == GID_KEY
|
|
89
|
+
raise Orbitcast::ProtocolError.new(
|
|
90
|
+
"declared state cannot hold a raw #{GID_KEY.inspect} key; it is reserved for " \
|
|
91
|
+
"record references", field: "state"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
out[key] = dump(value)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dump_record(value)
|
|
100
|
+
unless value.respond_to?(:to_global_id)
|
|
101
|
+
raise Orbitcast::ProtocolError.new(
|
|
102
|
+
"declared state holds #{value.class}, which is neither a JSON value nor a " \
|
|
103
|
+
"GlobalID-identified record. Declared state is serialized, not captured: store an " \
|
|
104
|
+
"id, or include GlobalID::Identification in the model.", field: "state"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{ GID_KEY => value.to_global_id.to_s }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A deleted (or no longer locatable) record is nil, by design.
|
|
112
|
+
def locate(gid)
|
|
113
|
+
return nil unless defined?(::GlobalID)
|
|
114
|
+
|
|
115
|
+
::GlobalID::Locator.locate(gid)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# `orbitcast_state :cursor, :user` on a Connection or a Channel.
|
|
122
|
+
#
|
|
123
|
+
# Declaring a key defines an accessor for it and nothing else: the value is
|
|
124
|
+
# dumped after every operation and restored before the next one.
|
|
125
|
+
#
|
|
126
|
+
# The accessors are **private**. `cursor` and `self.cursor = value` work
|
|
127
|
+
# from inside the channel, exactly as an attribute should; what cannot
|
|
128
|
+
# happen is a client performing `{"action":"cursor=","..."}` and writing
|
|
129
|
+
# declared state directly — ActionCable's action allowlist is every public
|
|
130
|
+
# method a channel adds.
|
|
131
|
+
module Declaration
|
|
132
|
+
def orbitcast_state(*names)
|
|
133
|
+
names = names.flatten.map(&:to_sym)
|
|
134
|
+
names.each do |name|
|
|
135
|
+
send(:attr_accessor, name)
|
|
136
|
+
send(:private, name, :"#{name}=")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@orbitcast_declared_state_keys = (declared_state_keys + names).uniq.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Declarations inherit: a base class may declare state every channel
|
|
143
|
+
# keeps, and a subclass adds its own.
|
|
144
|
+
def orbitcast_state_keys
|
|
145
|
+
inherited =
|
|
146
|
+
superclass.respond_to?(:orbitcast_state_keys) ? superclass.orbitcast_state_keys : []
|
|
147
|
+
|
|
148
|
+
(inherited + declared_state_keys).uniq.freeze
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def declared_state_keys
|
|
154
|
+
@orbitcast_declared_state_keys ||= [].freeze
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The per-instance half of the declaration.
|
|
159
|
+
module Instance
|
|
160
|
+
# @return [Hash] the declared state, JSON-ready. Keys whose accessor was
|
|
161
|
+
# never written are omitted rather than emitted as null.
|
|
162
|
+
def orbitcast_dump_state
|
|
163
|
+
self.class.orbitcast_state_keys.each_with_object({}) do |name, out|
|
|
164
|
+
ivar = :"@#{name}"
|
|
165
|
+
next unless instance_variable_defined?(ivar)
|
|
166
|
+
|
|
167
|
+
out[name.to_s] = StateCodec.dump(instance_variable_get(ivar))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param map [Hash] the state the relay held for this session
|
|
172
|
+
def orbitcast_restore_state(map)
|
|
173
|
+
return self unless map.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
self.class.orbitcast_state_keys.each do |name|
|
|
176
|
+
key = name.to_s
|
|
177
|
+
next unless map.key?(key)
|
|
178
|
+
|
|
179
|
+
instance_variable_set(:"@#{name}", StateCodec.load(map[key]))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
self
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
|
|
9
|
+
module Orbitcast
|
|
10
|
+
# Orbitcast signed-stream profile.
|
|
11
|
+
#
|
|
12
|
+
# base64(JSON(claims)) + "--" + hex(HMAC-SHA256(key, base64_part))
|
|
13
|
+
#
|
|
14
|
+
# The HMAC covers the *encoded* bytes, so a verifier can authenticate the
|
|
15
|
+
# token before it parses anything. Claims always carry every
|
|
16
|
+
# authorization-bearing field; a verifier that finds one missing must fail
|
|
17
|
+
# closed rather than widen access.
|
|
18
|
+
#
|
|
19
|
+
# A signed name is a *read* capability. Whisper (client-originated fanout
|
|
20
|
+
# on the stream) is granted only by the optional `whisper: true` claim,
|
|
21
|
+
# emitted only when asked for so plain tokens keep their exact bytes; the
|
|
22
|
+
# relay never grants it to Turbo, CableReady, or public streams
|
|
23
|
+
#
|
|
24
|
+
# This is not Turbo's profile. `Turbo::StreamsChannel.signed_stream_name`
|
|
25
|
+
# signs a bare stream string with Turbo's own verifier key and is parsed
|
|
26
|
+
# separately by the server.
|
|
27
|
+
module Streams
|
|
28
|
+
CLAIMS_VERSION = 1
|
|
29
|
+
PURPOSE = "subscribe"
|
|
30
|
+
SEPARATOR = "--"
|
|
31
|
+
DIGEST = "SHA256"
|
|
32
|
+
|
|
33
|
+
# The relay refuses a stream longer than this (UTF-8 bytes) on every path:
|
|
34
|
+
# signed-name claims, Turbo payloads, publications. Mirrored here so the
|
|
35
|
+
# producer fails before signing or publishing something undeliverable.
|
|
36
|
+
MAX_STREAM_BYTES = 1024
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# @param stream [String] logical stream name
|
|
40
|
+
# @param expires_in [Integer, nil] seconds; defaults to
|
|
41
|
+
# `config.stream_expires_in`
|
|
42
|
+
# @param whisper [Boolean] also grant whisper on the stream
|
|
43
|
+
# @return [String] signed stream name for a channel identifier
|
|
44
|
+
def signed_stream_name(stream, expires_in: nil, whisper: false, config: Orbitcast.config)
|
|
45
|
+
claims = claims(stream, expires_in: expires_in, whisper: whisper, config: config)
|
|
46
|
+
encoded = Base64.strict_encode64(JSON.generate(claims))
|
|
47
|
+
|
|
48
|
+
"#{encoded}#{SEPARATOR}#{signature(encoded, config.streams_secret!)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The claims, in the fixed key order the fixtures pin: version,
|
|
52
|
+
# namespace, stream, purpose, expires_at, then `whisper` only when true.
|
|
53
|
+
def claims(stream, expires_in: nil, whisper: false, config: Orbitcast.config, now: Time.now)
|
|
54
|
+
stream = validated_stream(stream)
|
|
55
|
+
ttl = expires_in.nil? ? config.stream_expires_in : Integer(expires_in)
|
|
56
|
+
raise ConfigurationError, "expires_in must be greater than zero" unless ttl.positive?
|
|
57
|
+
|
|
58
|
+
namespace = config.namespace
|
|
59
|
+
raise ConfigurationError, "namespace is required to sign stream names" if namespace.nil?
|
|
60
|
+
|
|
61
|
+
unless [true, false].include?(whisper)
|
|
62
|
+
raise ArgumentError, "whisper must be true or false, got #{whisper.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
claims = {
|
|
66
|
+
version: CLAIMS_VERSION,
|
|
67
|
+
namespace: namespace,
|
|
68
|
+
stream: stream,
|
|
69
|
+
purpose: PURPOSE,
|
|
70
|
+
expires_at: now.to_i + ttl
|
|
71
|
+
}
|
|
72
|
+
claims[:whisper] = true if whisper
|
|
73
|
+
claims
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Exposed for tests and for `orbitcast doctor`; the server owns the
|
|
77
|
+
# authoritative verifier.
|
|
78
|
+
def signature(encoded, key)
|
|
79
|
+
OpenSSL::HMAC.hexdigest(DIGEST, key, encoded)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The one stream-name guard, shared with the publisher
|
|
83
|
+
# (`Orbitcast::Publisher::Postgres`): a stream name is the routing key of
|
|
84
|
+
# both a signed token and a notice envelope, so the two paths must reject
|
|
85
|
+
# exactly the same values.
|
|
86
|
+
#
|
|
87
|
+
# @param stream [String] logical stream name
|
|
88
|
+
# @return [String] the validated name
|
|
89
|
+
# @raise [ArgumentError] when it is not a String, is empty, is not valid
|
|
90
|
+
# UTF-8, or exceeds {MAX_STREAM_BYTES}
|
|
91
|
+
def validated_stream(stream)
|
|
92
|
+
unless stream.is_a?(String)
|
|
93
|
+
raise ArgumentError, "stream must be a String, got #{stream.class}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
stream = stream.to_s
|
|
97
|
+
raise ArgumentError, "stream must not be empty" if stream.empty?
|
|
98
|
+
|
|
99
|
+
unless stream.valid_encoding?
|
|
100
|
+
raise ArgumentError, "stream is not valid #{stream.encoding}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
unless stream.encoding == Encoding::UTF_8
|
|
104
|
+
begin
|
|
105
|
+
stream = stream.encode(Encoding::UTF_8)
|
|
106
|
+
rescue EncodingError => e
|
|
107
|
+
raise ArgumentError, "stream must be UTF-8 text (#{e.message})"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
if stream.bytesize > MAX_STREAM_BYTES
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"stream is #{stream.bytesize} bytes; the maximum is #{MAX_STREAM_BYTES} " \
|
|
113
|
+
"bytes of UTF-8 (the relay refuses longer streams)"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
stream
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class << self
|
|
122
|
+
# Issue a read capability for `stream` (plus whisper when `whisper:
|
|
123
|
+
# true`). The result goes into an ordinary ActionCable identifier:
|
|
124
|
+
# {"channel":"Orbitcast::StreamsChannel","signed_stream_name":"..."}.
|
|
125
|
+
def signed_stream_name(stream, expires_in: nil, whisper: false)
|
|
126
|
+
Streams.signed_stream_name(stream, expires_in: expires_in, whisper: whisper, config: config)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orbitcast
|
|
4
|
+
# Release version.
|
|
5
|
+
#
|
|
6
|
+
# `Cargo.toml` is the release authority; release-please keeps this constant in
|
|
7
|
+
# lockstep through its extra-file updater. The trailing annotation below is the
|
|
8
|
+
# updater marker -- do not reformat the line or the bump will silently no-op.
|
|
9
|
+
VERSION = "0.3.1" # x-release-please-version
|
|
10
|
+
end
|
data/lib/orbitcast.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "orbitcast/version"
|
|
4
|
+
require_relative "orbitcast/error"
|
|
5
|
+
require_relative "orbitcast/configuration"
|
|
6
|
+
require_relative "orbitcast/protocol"
|
|
7
|
+
require_relative "orbitcast/state_codec"
|
|
8
|
+
require_relative "orbitcast/streams"
|
|
9
|
+
require_relative "orbitcast/recovery"
|
|
10
|
+
|
|
11
|
+
# Orbitcast: Rails real-time stack backed by the Orbitcast Rust relay.
|
|
12
|
+
#
|
|
13
|
+
# Loading this file must not require Rails, ActionCable, or pg. Rails-touching
|
|
14
|
+
# code is loaded lazily:
|
|
15
|
+
#
|
|
16
|
+
# * `Orbitcast::Engine` only when `Rails::Engine` is already defined
|
|
17
|
+
# * `ActionCable::SubscriptionAdapter::Orbitcast` by ActionCable's own adapter
|
|
18
|
+
# lookup
|
|
19
|
+
# * `pg` by `Orbitcast::Publisher::Postgres` when it opens a connection
|
|
20
|
+
#
|
|
21
|
+
# Constant and file names follow the Zeitwerk convention so an application can
|
|
22
|
+
# reference `Orbitcast::Publisher::Postgres` without a classic-loader fallback.
|
|
23
|
+
module Orbitcast
|
|
24
|
+
# Rails-dispatch surface. These need ActionCable, so they load on first
|
|
25
|
+
# reference rather than at require time: a signed-mode
|
|
26
|
+
# application, the executable wrapper, and the plain unit suite never touch
|
|
27
|
+
# them.
|
|
28
|
+
autoload :Channel, "orbitcast/channel"
|
|
29
|
+
autoload :ChannelRegistry, "orbitcast/channel_registry"
|
|
30
|
+
autoload :Connection, "orbitcast/connection"
|
|
31
|
+
autoload :Dispatcher, "orbitcast/dispatcher"
|
|
32
|
+
autoload :RailsEndpoint, "orbitcast/rails_endpoint"
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Global configuration. Applications normally write through the engine
|
|
36
|
+
# (`config.orbitcast.*`) or `cable.yml`; `configure` is the direct path.
|
|
37
|
+
def config
|
|
38
|
+
@config ||= Configuration.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configure
|
|
42
|
+
yield config if block_given?
|
|
43
|
+
config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Test/boot helper: drop the memoized configuration.
|
|
47
|
+
def reset_config!
|
|
48
|
+
@config = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def version
|
|
52
|
+
VERSION
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
require_relative "orbitcast/binary"
|
|
58
|
+
require_relative "orbitcast/publisher/postgres"
|
|
59
|
+
require_relative "orbitcast/control"
|
|
60
|
+
|
|
61
|
+
# Rails integration, only when a host application already loaded Rails.
|
|
62
|
+
#
|
|
63
|
+
# The Rails-dispatch execution bases (`Orbitcast::Connection`,
|
|
64
|
+
# `Orbitcast::Channel`, `Orbitcast::Dispatcher`, `Orbitcast::RailsEndpoint`)
|
|
65
|
+
# need ActionCable and are NOT loaded here: the Engine requires the endpoint in
|
|
66
|
+
# dispatch mode, and an application's `ApplicationCable` classes require the
|
|
67
|
+
# bases themselves. A signed-mode application loads none of it.
|
|
68
|
+
require_relative "orbitcast/engine" if defined?(::Rails::Engine)
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.3.1",
|
|
3
|
+
"target": "x86_64-unknown-linux-gnu",
|
|
4
|
+
"features": [
|
|
5
|
+
"postgres"
|
|
6
|
+
],
|
|
7
|
+
"envelope_versions": [
|
|
8
|
+
1
|
|
9
|
+
],
|
|
10
|
+
"rust_toolchain": "1.99",
|
|
11
|
+
"commit": "b9d10e789b0530850e7ce4e7788111ce9d012a2f",
|
|
12
|
+
"glibc_floor": "2.34",
|
|
13
|
+
"gem_platform": "x86_64-linux-gnu"
|
|
14
|
+
}
|