orbitcast 0.3.1-arm64-darwin

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/arm64-darwin/bin/orbitcast +0 -0
  40. data/vendor/arm64-darwin/manifest.json +14 -0
  41. metadata +130 -0
@@ -0,0 +1,636 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "ipaddr"
5
+
6
+ require_relative "error"
7
+
8
+ module Orbitcast
9
+ # Validated Orbitcast settings.
10
+ #
11
+ # Configuration is deliberately stricter than the tolerant wire envelopes:
12
+ # unknown keys, unsupported modes, and malformed values raise immediately
13
+ # instead of being defaulted into something permissive. Writers validate at the
14
+ # boundary; `validate!` only checks cross-field requirements.
15
+ class Configuration
16
+ PUBLISH_VIA = %i[postgres http].freeze
17
+
18
+ # `:signed` — the relay authorizes subscriptions from signed stream names
19
+ # and runs no application Ruby.
20
+ # `:dispatch` — the relay calls back into Rails for connection
21
+ # authentication, channel authorization, and actions.
22
+ MODES = %i[signed dispatch].freeze
23
+
24
+ # How `Publisher::Postgres` reaches the cable database.
25
+ # `:pool` — a bounded, fork-safe dedicated `pg` pool for `database_url`.
26
+ # `:active_record` — the application's Active Record connection, so a
27
+ # broadcast joins the surrounding transaction and rolls back with it.
28
+ CONNECTIONS = %i[pool active_record].freeze
29
+
30
+ # Every key `assign` accepts. Also the allowlist used by the installer and
31
+ # by `cable.yml` parsing.
32
+ ATTRIBUTES = %i[
33
+ database_url
34
+ namespace
35
+ publish_via
36
+ mode
37
+ streams_secret
38
+ turbo_secret
39
+ recovery_secret
40
+ pool_size
41
+ pool_timeout
42
+ stream_expires_in
43
+ overflow_enabled
44
+ overflow_ttl
45
+ max_publication_size
46
+ rpc_token
47
+ rpc_endpoint_url
48
+ rpc_mount_path
49
+ connection_class
50
+ channels
51
+ ingress_url
52
+ ingress_token
53
+ listen_addr
54
+ listener_database_url
55
+ trusted_proxies
56
+ outbound_queue_messages
57
+ outbound_queue_bytes
58
+ streams_previous_secrets
59
+ turbo_previous_secrets
60
+ connection
61
+ ].freeze
62
+
63
+ NAMESPACE_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,63}\z/
64
+ POSTGRES_URL_PATTERN = %r{\Apostgres(?:ql)?://}
65
+ HTTP_URL_PATTERN = %r{\Ahttps?://}
66
+
67
+ # Where the Engine mounts the private RPC endpoint in dispatch mode. The
68
+ # relay appends the operation name to it.
69
+ DEFAULT_RPC_MOUNT_PATH = "/_orbitcast/v1/rails"
70
+
71
+ DEFAULT_CONNECTION_CLASS = "ApplicationCable::Connection"
72
+
73
+ # Where the relay listens when a development or test application does not
74
+ # say otherwise. Production has no default: an operator must choose.
75
+ DEFAULT_DEVELOPMENT_LISTEN_ADDR = "127.0.0.1:28080"
76
+
77
+ # Purpose string for the derived Orbitcast stream key:
78
+ # `Rails.application.key_generator.generate_key(purpose, STREAMS_KEY_SIZE)`
79
+ # with the namespace baked into the purpose, so two namespaces sharing one
80
+ # `secret_key_base` still verify with different keys. Changing it
81
+ # invalidates every outstanding signed name.
82
+ STREAMS_KEY_PURPOSE_FORMAT = "orbitcast/streams/v1/%<namespace>s"
83
+ STREAMS_KEY_SIZE = 32
84
+
85
+ DEFAULT_POOL_SIZE = 5
86
+ DEFAULT_POOL_TIMEOUT = 5.0
87
+ DEFAULT_STREAM_EXPIRES_IN = 3600
88
+
89
+ # Overflow relay defaults; both match the Rust server's defaults, because
90
+ # both sides read the same `orbitcast_payloads` rows.
91
+ DEFAULT_OVERFLOW_TTL = 300
92
+ DEFAULT_MAX_PUBLICATION_SIZE = 1024 * 1024
93
+
94
+ # PostgreSQL's NOTIFY payload limit. A publication limit below it would
95
+ # reject payloads that need no relay at all.
96
+ MIN_MAX_PUBLICATION_SIZE = 8000
97
+
98
+ attr_reader :database_url, :namespace, :publish_via, :mode,
99
+ :streams_secret, :turbo_secret, :recovery_secret,
100
+ :pool_size, :pool_timeout, :stream_expires_in,
101
+ :overflow_enabled, :overflow_ttl, :max_publication_size,
102
+ :rpc_token, :rpc_endpoint_url, :rpc_mount_path,
103
+ :connection_class, :channels,
104
+ :ingress_url, :ingress_token,
105
+ :listen_addr, :listener_database_url, :trusted_proxies,
106
+ :outbound_queue_messages, :outbound_queue_bytes,
107
+ :streams_previous_secrets, :turbo_previous_secrets,
108
+ :connection
109
+
110
+ # The application's `ActiveSupport::KeyGenerator` (set by the Engine) that
111
+ # derives a stream key when none is configured explicitly. Never assigned
112
+ # from cable.yml or `config.orbitcast`.
113
+ attr_accessor :key_generator
114
+
115
+ def initialize
116
+ @database_url = nil
117
+ @namespace = nil
118
+ @publish_via = :postgres
119
+ @mode = :signed
120
+ @streams_secret = nil
121
+ @turbo_secret = nil
122
+ @recovery_secret = nil
123
+ @pool_size = DEFAULT_POOL_SIZE
124
+ @pool_timeout = DEFAULT_POOL_TIMEOUT
125
+ @stream_expires_in = DEFAULT_STREAM_EXPIRES_IN
126
+ # Off by default: the relay requires the orbitcast_payloads migration,
127
+ # and nothing here ever creates it implicitly.
128
+ @overflow_enabled = false
129
+ @overflow_ttl = DEFAULT_OVERFLOW_TTL
130
+ @max_publication_size = DEFAULT_MAX_PUBLICATION_SIZE
131
+ # Rails-dispatch mode. The token is held here in raw form; the launcher
132
+ # exports it to the relay as strict base64.
133
+ @rpc_token = nil
134
+ @rpc_endpoint_url = nil
135
+ @rpc_mount_path = DEFAULT_RPC_MOUNT_PATH
136
+ @connection_class = DEFAULT_CONNECTION_CLASS
137
+ # nil derives the allowlist from app/channels; an Array is the reviewed,
138
+ # explicit form a deployment should ship.
139
+ @channels = nil
140
+ @ingress_url = nil
141
+ @ingress_token = nil
142
+ # Relay transport and tuning. All optional here; the launcher fills the
143
+ # development default and refuses a production boot without a listener.
144
+ @listen_addr = nil
145
+ @listener_database_url = nil
146
+ @trusted_proxies = [].freeze
147
+ @outbound_queue_messages = nil
148
+ @outbound_queue_bytes = nil
149
+ # Verify-only previous keys for rotation.
150
+ @streams_previous_secrets = [].freeze
151
+ @turbo_previous_secrets = [].freeze
152
+ @connection = :pool
153
+ @key_generator = nil
154
+ @derived_streams_secret = nil
155
+ end
156
+
157
+ def dispatch?
158
+ mode == :dispatch
159
+ end
160
+
161
+ def overflow_enabled?
162
+ @overflow_enabled
163
+ end
164
+
165
+ # Bulk assignment from `cable.yml`, `config.orbitcast`, or the installer.
166
+ # Unknown keys are a hard error: a typo must never leave a secret unset.
167
+ def assign(attributes)
168
+ unless attributes.respond_to?(:each_pair)
169
+ raise ConfigurationError, "expected a Hash of Orbitcast settings, got #{attributes.class}"
170
+ end
171
+
172
+ attributes.each_pair do |key, value|
173
+ name = key.to_sym
174
+ # `adapter:` is ActionCable's own selector, not an Orbitcast setting.
175
+ next if name == :adapter
176
+
177
+ unless ATTRIBUTES.include?(name)
178
+ raise ConfigurationError,
179
+ "unknown Orbitcast setting #{name.inspect}; known settings: #{ATTRIBUTES.join(", ")}"
180
+ end
181
+
182
+ public_send(:"#{name}=", value)
183
+ end
184
+
185
+ self
186
+ end
187
+
188
+ def database_url=(value)
189
+ @database_url = optional_string(:database_url, value)
190
+ return if @database_url.nil? || @database_url.match?(POSTGRES_URL_PATTERN)
191
+
192
+ raise ConfigurationError,
193
+ "database_url must be a postgres:// or postgresql:// DSN"
194
+ end
195
+
196
+ def namespace=(value)
197
+ namespace = required_string(:namespace, value)
198
+ unless namespace.match?(NAMESPACE_PATTERN)
199
+ raise ConfigurationError,
200
+ "namespace #{namespace.inspect} must be 1-64 chars of [A-Za-z0-9_.-] and start alphanumeric"
201
+ end
202
+
203
+ @namespace = namespace
204
+ end
205
+
206
+ def publish_via=(value)
207
+ @publish_via = enum_value(:publish_via, value, PUBLISH_VIA)
208
+ end
209
+
210
+ def mode=(value)
211
+ @mode = enum_value(:mode, value, MODES)
212
+ end
213
+
214
+ def streams_secret=(value)
215
+ @streams_secret = secret(:streams_secret, value)
216
+ end
217
+
218
+ # The explicit key when configured; otherwise the key derived from the
219
+ # application's key generator and the namespace (see
220
+ # STREAMS_KEY_PURPOSE_FORMAT). Nil when neither is available.
221
+ def streams_secret
222
+ @streams_secret || derived_streams_secret
223
+ end
224
+
225
+ def streams_secret_derived?
226
+ @streams_secret.nil? && !derived_streams_secret.nil?
227
+ end
228
+
229
+ # The purpose string the derived key is generated under.
230
+ def streams_key_purpose(namespace = self.namespace)
231
+ raise ConfigurationError, "namespace is required to derive the stream key" if namespace.nil?
232
+
233
+ format(STREAMS_KEY_PURPOSE_FORMAT, namespace: namespace)
234
+ end
235
+
236
+ def turbo_secret=(value)
237
+ @turbo_secret = secret(:turbo_secret, value)
238
+ end
239
+
240
+ def recovery_secret=(value)
241
+ @recovery_secret = secret(:recovery_secret, value)
242
+ end
243
+
244
+ def pool_size=(value)
245
+ @pool_size = positive_integer(:pool_size, value)
246
+ end
247
+
248
+ def pool_timeout=(value)
249
+ @pool_timeout = positive_float(:pool_timeout, value)
250
+ end
251
+
252
+ def stream_expires_in=(value)
253
+ @stream_expires_in = positive_integer(:stream_expires_in, value)
254
+ end
255
+
256
+ # Relay oversized publications through `orbitcast_payloads`. Requires the
257
+ # migration on the cable database *and* `overflow_enabled` on the server.
258
+ def overflow_enabled=(value)
259
+ @overflow_enabled = boolean(:overflow_enabled, value)
260
+ end
261
+
262
+ # Relay row lifetime in seconds, measured from the database clock.
263
+ def overflow_ttl=(value)
264
+ @overflow_ttl = positive_integer(:overflow_ttl, value)
265
+ end
266
+
267
+ # Ceiling on one publication's payload, in bytes.
268
+ def max_publication_size=(value)
269
+ size = positive_integer(:max_publication_size, value)
270
+ if size < MIN_MAX_PUBLICATION_SIZE
271
+ raise ConfigurationError,
272
+ "max_publication_size must be at least #{MIN_MAX_PUBLICATION_SIZE} bytes " \
273
+ "(PostgreSQL's NOTIFY payload limit)"
274
+ end
275
+
276
+ @max_publication_size = size
277
+ end
278
+
279
+ # The raw service credential the relay presents as `Authorization: Bearer`.
280
+ #
281
+ # Rails holds the **raw** token; `exported_env` hands the relay the same
282
+ # bytes as strict base64 (`ORBITCAST_RPC_TOKEN_BASE64`), because the relay
283
+ # decodes key material rather than guessing a UTF-8 interpretation. The two
284
+ # are the same secret in two encodings, not two secrets.
285
+ def rpc_token=(value)
286
+ @rpc_token = secret(:rpc_token, value)
287
+ end
288
+
289
+ # Where the relay reaches this application, e.g.
290
+ # `https://app.internal/_orbitcast/v1/rails`. Exported for convenience; an
291
+ # operator may set ORBITCAST_RPC_ENDPOINT_URL on the relay instead.
292
+ def rpc_endpoint_url=(value)
293
+ @rpc_endpoint_url = optional_string(:rpc_endpoint_url, value)
294
+ return if @rpc_endpoint_url.nil? || @rpc_endpoint_url.match?(HTTP_URL_PATTERN)
295
+
296
+ raise ConfigurationError, "rpc_endpoint_url must be an http:// or https:// URL"
297
+ end
298
+
299
+ # Where the Engine mounts the private endpoint inside this application.
300
+ def rpc_mount_path=(value)
301
+ path = required_string(:rpc_mount_path, value)
302
+ unless path.start_with?("/")
303
+ raise ConfigurationError, "rpc_mount_path must start with a slash"
304
+ end
305
+
306
+ @rpc_mount_path = path.chomp("/")
307
+ end
308
+
309
+ def connection_class=(value)
310
+ @connection_class = value.is_a?(Class) ? value : required_string(:connection_class, value)
311
+ end
312
+
313
+ # The channel allowlist: an Array of class names, or :auto to derive it
314
+ # from app/channels. A client-supplied channel name that is not on it is
315
+ # never constantized.
316
+ def channels=(value)
317
+ @channels =
318
+ case value
319
+ when nil then nil
320
+ when :auto, "auto" then :auto
321
+ when Array then value.map { |entry| entry.is_a?(Class) ? entry.name : entry.to_s }.freeze
322
+ else
323
+ raise ConfigurationError,
324
+ "channels must be an Array of channel class names or :auto, got #{value.class}"
325
+ end
326
+ end
327
+
328
+ def ingress_url=(value)
329
+ @ingress_url = optional_string(:ingress_url, value)
330
+ return if @ingress_url.nil? || @ingress_url.match?(HTTP_URL_PATTERN)
331
+
332
+ raise ConfigurationError, "ingress_url must be an http:// or https:// URL"
333
+ end
334
+
335
+ def ingress_token=(value)
336
+ @ingress_token = secret(:ingress_token, value)
337
+ end
338
+
339
+ # `host:port` the relay binds its public listener to.
340
+ def listen_addr=(value)
341
+ addr = optional_string(:listen_addr, value)
342
+ if addr && !addr.match?(/\A\S+:\d{1,5}\z/)
343
+ raise ConfigurationError, "listen_addr must look like host:port, got #{addr.inspect}"
344
+ end
345
+
346
+ @listen_addr = addr
347
+ end
348
+
349
+ # A separate DSN for the relay's long-lived LISTEN session (direct or
350
+ # session-pooled; never PgBouncer transaction pooling). Must address the
351
+ # same cable database as `database_url`.
352
+ def listener_database_url=(value)
353
+ @listener_database_url = optional_string(:listener_database_url, value)
354
+ return if @listener_database_url.nil? || @listener_database_url.match?(POSTGRES_URL_PATTERN)
355
+
356
+ raise ConfigurationError,
357
+ "listener_database_url must be a postgres:// or postgresql:// DSN"
358
+ end
359
+
360
+ # CIDRs whose forwarded scheme/host/client headers the relay trusts.
361
+ def trusted_proxies=(value)
362
+ list = string_list(:trusted_proxies, value)
363
+ list.each do |cidr|
364
+ IPAddr.new(cidr)
365
+ rescue IPAddr::Error
366
+ raise ConfigurationError,
367
+ "trusted_proxies entry #{cidr.inspect} is not an IP address or CIDR (e.g. \"10.0.0.0/8\")"
368
+ end
369
+
370
+ @trusted_proxies = list.freeze
371
+ end
372
+
373
+ def outbound_queue_messages=(value)
374
+ @outbound_queue_messages = value.nil? ? nil : positive_integer(:outbound_queue_messages, value)
375
+ end
376
+
377
+ def outbound_queue_bytes=(value)
378
+ @outbound_queue_bytes = value.nil? ? nil : positive_integer(:outbound_queue_bytes, value)
379
+ end
380
+
381
+ # Retired Orbitcast stream keys that still verify (never sign) during a
382
+ # rotation window. Raw bytes; exported as comma-separated strict base64.
383
+ def streams_previous_secrets=(value)
384
+ @streams_previous_secrets = secret_list(:streams_previous_secrets, value)
385
+ end
386
+
387
+ def turbo_previous_secrets=(value)
388
+ @turbo_previous_secrets = secret_list(:turbo_previous_secrets, value)
389
+ end
390
+
391
+ def connection=(value)
392
+ @connection = enum_value(:connection, value, CONNECTIONS)
393
+ end
394
+
395
+ def active_record_connection?
396
+ connection == :active_record
397
+ end
398
+
399
+ # Cross-field requirements. Called before anything opens a connection or
400
+ # signs a token.
401
+ def validate!
402
+ raise ConfigurationError, "namespace is required" if namespace.nil?
403
+
404
+ if publish_via == :postgres && database_url.nil?
405
+ raise ConfigurationError,
406
+ "database_url is required when publish_via is :postgres"
407
+ end
408
+
409
+ if publish_via == :http && ingress_url.nil?
410
+ raise ConfigurationError,
411
+ "ingress_url is required when publish_via is :http"
412
+ end
413
+
414
+ # Never fall back to an unauthenticated endpoint: a dispatch-mode
415
+ # application without a service credential is a boot-time error, not a
416
+ # silently open callback surface.
417
+ if dispatch? && rpc_token.nil?
418
+ raise ConfigurationError,
419
+ "rpc_token is required when mode is :dispatch; the relay authenticates every " \
420
+ "call with it (ORBITCAST_RPC_TOKEN_BASE64 on the relay side)"
421
+ end
422
+
423
+ self
424
+ end
425
+
426
+ def streams_secret!
427
+ return streams_secret if streams_secret
428
+
429
+ raise ConfigurationError,
430
+ "streams_secret is not configured; set config.orbitcast.streams_secret " \
431
+ "(or ORBITCAST_STREAMS_SECRET) before signing stream names, or let the Engine " \
432
+ "derive one from Rails.application.key_generator (requires a namespace)"
433
+ end
434
+
435
+ # The allowlisted `ORBITCAST_*` values handed to the Rust process.
436
+ #
437
+ # Key material is exported with strict base64 so the server can decode raw
438
+ # key bytes instead of guessing a UTF-8 interpretation. Nothing here is
439
+ # ever passed as an argv element.
440
+ def exported_env
441
+ # `mode` and `publish_via` are Ruby-side concerns (adapter behaviour and
442
+ # token production); the relay reads neither, so they are not exported.
443
+ env = {
444
+ "ORBITCAST_NAMESPACE" => namespace,
445
+ "ORBITCAST_DATABASE_URL" => database_url,
446
+ # Both sides of the relay must agree: the publisher writes the row, the
447
+ # server reads it back and owns expiry cleanup.
448
+ "ORBITCAST_OVERFLOW_ENABLED" => overflow_enabled.to_s,
449
+ "ORBITCAST_OVERFLOW_TTL_MS" => (overflow_ttl * 1000).to_s,
450
+ "ORBITCAST_MAX_PUBLICATION_SIZE" => max_publication_size.to_s
451
+ }
452
+
453
+ env["ORBITCAST_LISTEN_ADDR"] = listen_addr
454
+ env["ORBITCAST_LISTENER_DATABASE_URL"] = listener_database_url
455
+ env["ORBITCAST_TRUSTED_PROXIES"] = trusted_proxies.join(",") unless trusted_proxies.empty?
456
+ env["ORBITCAST_OUTBOUND_QUEUE_MESSAGES"] = outbound_queue_messages&.to_s
457
+ env["ORBITCAST_OUTBOUND_QUEUE_BYTES"] = outbound_queue_bytes&.to_s
458
+
459
+ env["ORBITCAST_STREAMS_SECRET_BASE64"] = Base64.strict_encode64(streams_secret) if streams_secret
460
+ env["ORBITCAST_RECOVERY_SECRET_BASE64"] = Base64.strict_encode64(recovery_secret) if recovery_secret
461
+ unless streams_previous_secrets.empty?
462
+ env["ORBITCAST_STREAMS_PREVIOUS_SECRETS_BASE64"] =
463
+ streams_previous_secrets.map { |key| Base64.strict_encode64(key) }.join(",")
464
+ end
465
+
466
+ # The Turbo profile is enabled exactly when a Turbo key is configured:
467
+ # a relay handed the key without the switch would reject every
468
+ # `turbo_stream_from` subscription, and vice versa.
469
+ if turbo_secret
470
+ env["ORBITCAST_TURBO_STREAMS"] = "true"
471
+ env["ORBITCAST_TURBO_SECRET_BASE64"] = Base64.strict_encode64(turbo_secret)
472
+ unless turbo_previous_secrets.empty?
473
+ env["ORBITCAST_TURBO_PREVIOUS_SECRETS_BASE64"] =
474
+ turbo_previous_secrets.map { |key| Base64.strict_encode64(key) }.join(",")
475
+ end
476
+ end
477
+
478
+ # Rails-dispatch mode: the relay needs the endpoint URL and the same
479
+ # service credential, base64 of the exact raw bytes Rails compares
480
+ # against. Exported only in dispatch mode, so a signed-mode deployment
481
+ # cannot accidentally hand a token to a relay that will not use it.
482
+ if dispatch?
483
+ env["ORBITCAST_RPC_ENDPOINT_URL"] = rpc_endpoint_url
484
+ env["ORBITCAST_RPC_TOKEN_BASE64"] = Base64.strict_encode64(rpc_token) if rpc_token
485
+ end
486
+
487
+ env.compact
488
+ end
489
+
490
+ # Never let a secret reach a log line or an exception message.
491
+ def inspect
492
+ "#<#{self.class.name} namespace=#{namespace.inspect} mode=#{mode.inspect} " \
493
+ "publish_via=#{publish_via.inspect} connection=#{connection.inspect} " \
494
+ "database_url=#{redacted_database_url.inspect} " \
495
+ "listener_database_url=#{redacted_url(listener_database_url).inspect} " \
496
+ "listen_addr=#{listen_addr.inspect} overflow_enabled=#{overflow_enabled.inspect} " \
497
+ "streams_secret=#{streams_secret_derived? ? "[DERIVED]" : redact(streams_secret)} " \
498
+ "turbo_secret=#{redact(turbo_secret)} " \
499
+ "rpc_token=#{redact(rpc_token)} ingress_token=#{redact(ingress_token)}>"
500
+ end
501
+ alias to_s inspect
502
+
503
+ private
504
+
505
+ def redacted_database_url
506
+ redacted_url(database_url)
507
+ end
508
+
509
+ def redacted_url(url)
510
+ return nil if url.nil?
511
+
512
+ url.sub(%r{://([^:/@]+):[^@]*@}, '://\1:[REDACTED]@')
513
+ end
514
+
515
+ # Memoized per namespace: a namespace change after derivation must not
516
+ # keep handing out the previous namespace's key.
517
+ def derived_streams_secret
518
+ return nil if key_generator.nil? || namespace.nil?
519
+
520
+ if @derived_streams_secret.nil? || @derived_streams_secret.first != namespace
521
+ key = key_generator.generate_key(streams_key_purpose(namespace), STREAMS_KEY_SIZE)
522
+ @derived_streams_secret = [namespace, key.to_s.b.freeze]
523
+ end
524
+
525
+ @derived_streams_secret.last
526
+ end
527
+
528
+ def redact(value)
529
+ value.nil? ? "nil" : "[REDACTED]"
530
+ end
531
+
532
+ def optional_string(name, value)
533
+ return nil if value.nil?
534
+
535
+ string = string!(name, value)
536
+ string.empty? ? nil : string
537
+ end
538
+
539
+ def required_string(name, value)
540
+ string = string!(name, value)
541
+ raise ConfigurationError, "#{name} must not be empty" if string.empty?
542
+
543
+ string
544
+ end
545
+
546
+ def string!(name, value)
547
+ unless value.is_a?(String)
548
+ raise ConfigurationError, "#{name} must be a String, got #{value.class}"
549
+ end
550
+
551
+ value
552
+ end
553
+
554
+ def secret(name, value)
555
+ return nil if value.nil?
556
+
557
+ string = string!(name, value)
558
+ raise ConfigurationError, "#{name} must not be empty" if string.empty?
559
+
560
+ string.dup.freeze
561
+ end
562
+
563
+ def secret_list(name, value)
564
+ return [].freeze if value.nil?
565
+
566
+ unless value.is_a?(Array)
567
+ raise ConfigurationError, "#{name} must be an Array of secrets, got #{value.class}"
568
+ end
569
+
570
+ value.map { |entry| secret(name, entry) || raise(ConfigurationError, "#{name} must not contain nil") }
571
+ .freeze
572
+ end
573
+
574
+ def string_list(name, value)
575
+ return [] if value.nil?
576
+
577
+ list = value.is_a?(String) ? value.split(",").map(&:strip) : value
578
+ unless list.is_a?(Array)
579
+ raise ConfigurationError, "#{name} must be an Array of Strings, got #{value.class}"
580
+ end
581
+
582
+ list.map { |entry| required_string(name, entry) }
583
+ end
584
+
585
+ # Strict: a typo must never quietly disable (or enable) a switch.
586
+ def boolean(name, value)
587
+ case value
588
+ when true, false then value
589
+ when "true", "1", 1 then true
590
+ when "false", "0", 0 then false
591
+ else
592
+ raise ConfigurationError,
593
+ "#{name} must be a boolean (true/false), got #{value.inspect}"
594
+ end
595
+ end
596
+
597
+ def enum_value(name, value, allowed)
598
+ symbol =
599
+ case value
600
+ when Symbol then value
601
+ when String then value.to_sym
602
+ else
603
+ raise ConfigurationError, "#{name} must be a Symbol or String, got #{value.class}"
604
+ end
605
+
606
+ unless allowed.include?(symbol)
607
+ raise ConfigurationError,
608
+ "#{name} #{symbol.inspect} is not supported; supported values: #{allowed.map(&:inspect).join(", ")}"
609
+ end
610
+
611
+ symbol
612
+ end
613
+
614
+ def positive_integer(name, value)
615
+ integer = Integer(value)
616
+ raise ConfigurationError, "#{name} must be greater than zero" unless integer.positive?
617
+
618
+ integer
619
+ rescue TypeError, ArgumentError => e
620
+ raise e if e.is_a?(ConfigurationError)
621
+
622
+ raise ConfigurationError, "#{name} must be an Integer, got #{value.inspect}"
623
+ end
624
+
625
+ def positive_float(name, value)
626
+ float = Float(value)
627
+ raise ConfigurationError, "#{name} must be greater than zero" unless float.positive?
628
+
629
+ float
630
+ rescue TypeError, ArgumentError => e
631
+ raise e if e.is_a?(ConfigurationError)
632
+
633
+ raise ConfigurationError, "#{name} must be a Float, got #{value.inspect}"
634
+ end
635
+ end
636
+ end