solid_objects 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +261 -31
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +116 -23
  9. data/docs/authorization.md +53 -3
  10. data/docs/benchmarks.md +6 -2
  11. data/docs/correctness.md +45 -1
  12. data/docs/database-schema.md +18 -5
  13. data/docs/development.md +40 -0
  14. data/docs/fit.md +10 -2
  15. data/docs/migrating-existing-state.md +8 -1
  16. data/docs/operations.md +74 -36
  17. data/docs/realtime.md +106 -12
  18. data/docs/roadmap.md +14 -7
  19. data/docs/security.md +25 -3
  20. data/docs/state-migrations.md +2 -0
  21. data/examples/application/README.md +5 -4
  22. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  23. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  24. data/examples/application/config/initializers/solid_objects.rb +9 -3
  25. data/lib/generators/solid_objects/templates/solid_objects.rb +33 -2
  26. data/lib/solid_objects/activation.rb +33 -4
  27. data/lib/solid_objects/actor.rb +58 -6
  28. data/lib/solid_objects/actor_channel.rb +73 -5
  29. data/lib/solid_objects/actor_definition.rb +2 -0
  30. data/lib/solid_objects/actor_snapshot.rb +35 -10
  31. data/lib/solid_objects/actor_view.rb +103 -10
  32. data/lib/solid_objects/application_write_guard.rb +24 -0
  33. data/lib/solid_objects/caller_process.rb +28 -0
  34. data/lib/solid_objects/cli.rb +44 -5
  35. data/lib/solid_objects/client.rb +98 -5
  36. data/lib/solid_objects/commit_action_registry.rb +42 -0
  37. data/lib/solid_objects/component_path_resolver.rb +27 -0
  38. data/lib/solid_objects/component_registration.rb +117 -0
  39. data/lib/solid_objects/component_renderer.rb +82 -0
  40. data/lib/solid_objects/component_subscriptions.rb +109 -0
  41. data/lib/solid_objects/component_token.rb +139 -0
  42. data/lib/solid_objects/component_view.rb +67 -0
  43. data/lib/solid_objects/configuration.rb +39 -1
  44. data/lib/solid_objects/database_adapter.rb +28 -1
  45. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  46. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  47. data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
  48. data/lib/solid_objects/errors.rb +156 -0
  49. data/lib/solid_objects/executor.rb +65 -4
  50. data/lib/solid_objects/instance_pruner.rb +97 -0
  51. data/lib/solid_objects/message_pruner.rb +97 -0
  52. data/lib/solid_objects/message_reference.rb +9 -0
  53. data/lib/solid_objects/process_pruner.rb +49 -0
  54. data/lib/solid_objects/reference.rb +5 -0
  55. data/lib/solid_objects/state_snapshot.rb +41 -0
  56. data/lib/solid_objects/stream_token.rb +32 -13
  57. data/lib/solid_objects/sync_deadline.rb +57 -0
  58. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  59. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  60. data/lib/solid_objects/test_helper.rb +78 -0
  61. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  62. data/lib/solid_objects/version.rb +1 -1
  63. data/lib/solid_objects/worker.rb +1 -1
  64. data/lib/solid_objects.rb +40 -0
  65. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  66. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  67. data/sig/generated/lib/solid_objects/actor.rbs +29 -0
  68. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  69. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  70. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  71. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  72. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  73. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  74. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  75. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  76. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  77. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  78. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  79. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  80. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  81. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  82. data/sig/generated/lib/solid_objects/configuration.rbs +35 -7
  83. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  84. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  85. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  86. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
  87. data/sig/generated/lib/solid_objects/errors.rbs +130 -0
  88. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  89. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  90. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  91. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  92. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  93. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  94. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  95. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  96. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  97. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  98. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  99. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  100. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  101. data/sig/generated/lib/solid_objects.rbs +12 -0
  102. metadata +34 -1
@@ -50,16 +50,57 @@ module SolidObjects
50
50
  arguments,
51
51
  authorization_context:
52
52
  )
53
- message_reference = mailbox.enqueue(
53
+ reject_sync_inside_transaction!(reference, message_name)
54
+ SyncDeadline.with(timeout:) do
55
+ message_reference = enqueue_sync(
56
+ reference,
57
+ message_name,
58
+ arguments,
59
+ idempotency_key:,
60
+ timeout:
61
+ )
62
+ SynchronousInvocation.new.call(message_reference, timeout:)
63
+ end
64
+ end
65
+
66
+ # @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
67
+ def wait(message_reference, timeout:, authorization_context: nil)
68
+ message = Message.find(message_reference.id)
69
+ validate_message_reference!(message_reference, message)
70
+ reference = Reference.new(
71
+ actor_type: message.actor_type,
72
+ actor_id: message.actor_id
73
+ )
74
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
75
+ query = actor_class.definition.queries.key?(message.message_name.to_sym)
76
+ actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
77
+ unless query || actor_message
78
+ raise UnknownMessage, "unknown message #{message.message_name.inspect}"
79
+ end
80
+ authorize!(
81
+ query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
54
82
  reference,
55
- message_name,
56
- arguments,
57
- kind: "sync",
58
- idempotency_key:
83
+ message.message_name,
84
+ message.arguments,
85
+ authorization_context:
59
86
  )
87
+ reject_sync_inside_transaction!(reference, message.message_name)
60
88
  SynchronousInvocation.new.call(message_reference, timeout:)
61
89
  end
62
90
 
91
+ # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
92
+ def snapshot(reference, authorization_context: nil)
93
+ SolidObjects.registry.fetch(reference.actor_type)
94
+ authorize!(
95
+ SolidObjects.configuration.authorize_query,
96
+ reference,
97
+ "__snapshot__",
98
+ {},
99
+ authorization_context:
100
+ )
101
+ StateSnapshot.new(reference)
102
+ end
103
+
63
104
  # @rbs (Reference, ?authorization_context: untyped) -> bool
64
105
  def destroy(reference, authorization_context: nil)
65
106
  raise ActorCallCycle, "actors cannot synchronously destroy another actor" if Context.current_actor
@@ -93,6 +134,58 @@ module SolidObjects
93
134
 
94
135
  attr_reader :mailbox
95
136
 
137
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
138
+ def enqueue_sync(reference, message_name, arguments, idempotency_key:, timeout:)
139
+ mailbox.enqueue(
140
+ reference,
141
+ message_name,
142
+ arguments,
143
+ kind: "sync",
144
+ idempotency_key:
145
+ )
146
+ rescue DatabaseDeadlineExceeded
147
+ SolidObjects.instrument(
148
+ :"sync.enqueue_timeout",
149
+ actor_type: reference.actor_type,
150
+ actor_id: reference.actor_id,
151
+ message_name: message_name.to_s
152
+ )
153
+ raise SyncEnqueueTimeout.new(
154
+ timeout:,
155
+ actor_type: reference.actor_type,
156
+ actor_id: reference.actor_id,
157
+ message_name: message_name.to_s
158
+ )
159
+ end
160
+
161
+ # @rbs (MessageReference, Message) -> void
162
+ def validate_message_reference!(message_reference, message)
163
+ valid = message_reference.request_id == message.request_id &&
164
+ message_reference.actor_type == message.actor_type &&
165
+ message_reference.actor_id == message.actor_id &&
166
+ message_reference.sequence == message.sequence
167
+ return if valid
168
+
169
+ raise ActorDestroyed, "message reference no longer identifies this invocation"
170
+ end
171
+
172
+ # @rbs (Reference, Symbol | String) -> void
173
+ def reject_sync_inside_transaction!(reference, message_name)
174
+ return unless SolidObjects::Record.connection.transaction_open?
175
+
176
+ SolidObjects.instrument(
177
+ :"sync.transaction_rejected",
178
+ actor_type: reference.actor_type,
179
+ actor_id: reference.actor_id,
180
+ message_name: message_name.to_s
181
+ )
182
+ raise SyncInsideTransaction.new(
183
+ actor_type: reference.actor_type,
184
+ actor_id: reference.actor_id,
185
+ message_name: message_name.to_s
186
+ )
187
+ end
188
+
96
189
  # @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
97
190
  def authorize!(hook, reference, message_name, arguments, authorization_context:)
98
191
  authorized = hook.call(
@@ -0,0 +1,42 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ CommitActionContext = Data.define(
5
+ :message_id,
6
+ :request_id,
7
+ :actor_type,
8
+ :actor_id,
9
+ :activation_generation
10
+ )
11
+
12
+ class CommitActionRegistry
13
+ # @rbs @handlers: Hash[String, Proc]
14
+ # @rbs @mutex: Mutex
15
+
16
+ # @rbs () -> void
17
+ def initialize
18
+ @handlers = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # @rbs (String | Symbol, Proc) -> Proc
23
+ def register(name, handler)
24
+ action_name = name.to_s
25
+ raise ArgumentError, "commit action name cannot be empty" if action_name.empty?
26
+
27
+ mutex.synchronize { handlers[action_name] = handler }
28
+ handler
29
+ end
30
+
31
+ # @rbs (String | Symbol) -> Proc
32
+ def fetch(name)
33
+ handlers.fetch(name.to_s) do
34
+ raise UnknownCommitAction, "no commit action registered for #{name.inspect}"
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :handlers, :mutex
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ComponentPathResolver
5
+ # @rbs (view_context: untyped) -> String
6
+ def call(view_context:)
7
+ unless mounted?
8
+ raise UnsupportedComponentRendering,
9
+ "reactive components require the SolidObjects::Engine to be mounted in the host routes"
10
+ end
11
+
12
+ SolidObjects::Engine.routes.url_helpers.components_path
13
+ end
14
+
15
+ private
16
+
17
+ # @rbs () -> bool
18
+ def mounted?
19
+ return false unless defined?(Rails) && Rails.application
20
+
21
+ Rails.application.routes.routes.any? do |route|
22
+ route.app.respond_to?(:app) &&
23
+ route.app.app.equal?(SolidObjects::Engine)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,117 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "uri"
4
+
5
+ module SolidObjects
6
+ class ComponentRegistration
7
+ # @rbs @reference: Reference
8
+ # @rbs @component_name: String
9
+ # @rbs @dependencies: Array[String]
10
+ # @rbs @instance_id: Integer
11
+ # @rbs @revision: Integer
12
+ # @rbs @refresh_path: String
13
+ # @rbs @token: String
14
+
15
+ attr_reader :reference,
16
+ :component_name,
17
+ :dependencies,
18
+ :instance_id,
19
+ :revision,
20
+ :refresh_path,
21
+ :token
22
+
23
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
24
+ def initialize(
25
+ reference:,
26
+ component_name:,
27
+ dependencies:,
28
+ instance_id:,
29
+ revision:,
30
+ refresh_path:,
31
+ token:
32
+ )
33
+ @reference = reference
34
+ @component_name = component_name
35
+ @dependencies = dependencies.freeze
36
+ @instance_id = instance_id
37
+ @revision = revision
38
+ @refresh_path = refresh_path
39
+ @token = token
40
+ end
41
+
42
+ class << self
43
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
44
+ def issue(reference:, component_name:, dependencies:, snapshot:, refresh_path:)
45
+ token = ComponentToken.generate(
46
+ reference:,
47
+ component_name:,
48
+ dependencies:,
49
+ instance_id: snapshot.instance_id,
50
+ revision: snapshot.revision,
51
+ refresh_path:
52
+ )
53
+ new(
54
+ reference:,
55
+ component_name:,
56
+ dependencies:,
57
+ instance_id: snapshot.instance_id,
58
+ revision: snapshot.revision,
59
+ refresh_path:,
60
+ token:
61
+ )
62
+ end
63
+
64
+ # @rbs (String) -> ComponentRegistration
65
+ def from_token(token)
66
+ payload = ComponentToken.verify(token)
67
+ reference = Reference.new(
68
+ actor_type: payload.fetch("actor_type"),
69
+ actor_id: payload.fetch("actor_id")
70
+ )
71
+ dependencies = payload.fetch("dependencies")
72
+ validate_dependencies!(reference, dependencies)
73
+ new(
74
+ reference:,
75
+ component_name: payload.fetch("component_name"),
76
+ dependencies:,
77
+ instance_id: payload.fetch("instance_id"),
78
+ revision: payload.fetch("revision"),
79
+ refresh_path: payload.fetch("refresh_path"),
80
+ token:
81
+ )
82
+ rescue UnknownActorType, UnknownComponentDependency => error
83
+ raise InvalidComponentToken, error.message
84
+ end
85
+
86
+ private
87
+
88
+ # @rbs (Reference, Array[String]) -> void
89
+ def validate_dependencies!(reference, dependencies)
90
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
91
+ observables = actor_class.definition.observables
92
+ unknown = dependencies.find do |dependency|
93
+ !observables.key?(dependency.to_sym)
94
+ end
95
+ return unless unknown
96
+
97
+ raise UnknownComponentDependency,
98
+ "unknown observable dependency #{unknown.inspect} for #{reference.actor_type}"
99
+ end
100
+ end
101
+
102
+ # @rbs () -> Array[Integer]
103
+ def revision_key
104
+ [ instance_id, revision ]
105
+ end
106
+
107
+ # @rbs (Integer, Integer) -> String
108
+ def refresh_url(instance_id, revision)
109
+ query = URI.encode_www_form(
110
+ token:,
111
+ instance_id:,
112
+ revision:
113
+ )
114
+ "#{refresh_path}?#{query}"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,82 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ComponentRenderer
5
+ # @rbs @snapshot: ActorSnapshot
6
+ # @rbs @component_name: String
7
+ # @rbs @dependencies: Array[String]
8
+ # @rbs @view_context: untyped
9
+ # @rbs @authorization_context: untyped
10
+
11
+ # @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void
12
+ def initialize(
13
+ snapshot:,
14
+ component_name:,
15
+ dependencies:,
16
+ view_context:,
17
+ authorization_context:
18
+ )
19
+ @snapshot = snapshot
20
+ @component_name = component_name
21
+ @dependencies = dependencies
22
+ @view_context = view_context
23
+ @authorization_context = authorization_context
24
+ end
25
+
26
+ # @rbs () -> untyped
27
+ def call
28
+ [ component_name, *dependencies ].uniq.each do |authorization_name|
29
+ authorize_read!(authorization_name)
30
+ end
31
+ actor = ComponentView.new(
32
+ snapshot:,
33
+ dependencies:,
34
+ authorization_context:
35
+ )
36
+ view_context.render(
37
+ partial: default_partial,
38
+ locals: {
39
+ actor:,
40
+ authorization_context:
41
+ }
42
+ )
43
+ rescue ActionView::MissingTemplate
44
+ raise UnknownComponent,
45
+ "unknown component #{component_name.inspect} for #{snapshot.reference.actor_type}"
46
+ rescue ActionView::Template::Error => error
47
+ raise error.cause if error.cause.is_a?(SolidObjects::Error)
48
+
49
+ raise
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :snapshot,
55
+ :component_name,
56
+ :dependencies,
57
+ :view_context,
58
+ :authorization_context
59
+
60
+ # @rbs (String) -> void
61
+ def authorize_read!(observable_name)
62
+ authorized = SolidObjects.configuration.authorize_query.call(
63
+ actor_type: snapshot.reference.actor_type,
64
+ actor_id: snapshot.reference.actor_id,
65
+ message_name: observable_name,
66
+ arguments: {},
67
+ authorization_context:
68
+ )
69
+ return if authorized
70
+
71
+ raise Unauthorized, "actor component query is not authorized"
72
+ end
73
+
74
+ # @rbs () -> String
75
+ def default_partial
76
+ actor_name = snapshot.actor_class.name
77
+ raise InvalidActor, "anonymous actors cannot render reactive components" unless actor_name
78
+
79
+ "actors/#{actor_name.underscore}/#{component_name}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,109 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ComponentSubscriptions
5
+ MAXIMUM_COMPONENTS = 50
6
+ MAXIMUM_SERIALIZED_BYTES = 1_048_576
7
+
8
+ # @rbs @registrations: Array[ComponentRegistration]
9
+ # @rbs @revisions: Hash[String, Array[Integer]]
10
+
11
+ # @rbs (String?, reference: Reference) -> ComponentSubscriptions
12
+ def self.parse(serialized, reference:)
13
+ return new([]) unless serialized
14
+ unless serialized.is_a?(String) &&
15
+ serialized.bytesize <= MAXIMUM_SERIALIZED_BYTES
16
+ raise InvalidComponentToken, "invalid actor component registrations"
17
+ end
18
+
19
+ tokens = JSON.parse(serialized)
20
+ unless tokens.is_a?(Array) &&
21
+ tokens.length <= MAXIMUM_COMPONENTS &&
22
+ tokens.all? { |token| token.is_a?(String) }
23
+ raise InvalidComponentToken, "invalid actor component registrations"
24
+ end
25
+
26
+ registrations = tokens.map do |token|
27
+ ComponentRegistration.from_token(token).tap do |registration|
28
+ validate_identity!(registration, reference)
29
+ end
30
+ end
31
+ if registrations.map(&:component_name).uniq.length != registrations.length
32
+ raise InvalidComponentToken, "duplicate actor component registration"
33
+ end
34
+
35
+ new(registrations)
36
+ end
37
+
38
+ # @rbs (Array[ComponentRegistration]) -> void
39
+ def initialize(registrations)
40
+ @registrations = registrations
41
+ @revisions = registrations.to_h do |registration|
42
+ [ registration.component_name, registration.revision_key ]
43
+ end
44
+ end
45
+
46
+ # @rbs (Hash[String, untyped]) -> Array[String]
47
+ def refreshes_for(invalidation)
48
+ observable_name = invalidation.fetch("observable_name")
49
+ instance_id = invalidation.fetch("instance_id")
50
+ revision = invalidation.fetch("revision")
51
+ registrations.filter_map do |registration|
52
+ next unless registration.dependencies.include?(observable_name)
53
+ next unless newer_revision?(
54
+ registration.component_name,
55
+ instance_id,
56
+ revision
57
+ )
58
+
59
+ refresh(registration, instance_id, revision)
60
+ end
61
+ end
62
+
63
+ # @rbs (ActorSnapshot) -> Array[String]
64
+ def reconnect_refreshes(snapshot)
65
+ registrations.filter_map do |registration|
66
+ next unless newer_revision?(
67
+ registration.component_name,
68
+ snapshot.instance_id,
69
+ snapshot.revision
70
+ )
71
+
72
+ refresh(registration, snapshot.instance_id, snapshot.revision)
73
+ end
74
+ end
75
+
76
+ class << self
77
+ private
78
+
79
+ # @rbs (ComponentRegistration, Reference) -> void
80
+ def validate_identity!(registration, reference)
81
+ return if registration.reference.actor_type == reference.actor_type &&
82
+ registration.reference.actor_id == reference.actor_id
83
+
84
+ raise InvalidComponentToken,
85
+ "actor component identity does not match its subscription"
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :registrations, :revisions
92
+
93
+ # @rbs (ComponentRegistration, Integer, Integer) -> String
94
+ def refresh(registration, instance_id, revision)
95
+ revisions[registration.component_name] = [ instance_id, revision ]
96
+ TurboStreamRenderer.component_refresh(
97
+ registration,
98
+ instance_id,
99
+ revision
100
+ )
101
+ end
102
+
103
+ # @rbs (String, Integer, Integer) -> bool
104
+ def newer_revision?(component_name, instance_id, revision)
105
+ current = revisions.fetch(component_name)
106
+ (current <=> [ instance_id, revision ]) == -1
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,139 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "active_support/message_verifier"
4
+
5
+ module SolidObjects
6
+ module ComponentToken
7
+ PURPOSE = "solid_objects.actor_component"
8
+ MAXIMUM_TOKEN_BYTES = 16_384
9
+ MAXIMUM_DEPENDENCIES = 50
10
+
11
+ module_function
12
+
13
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String
14
+ def generate(
15
+ reference:,
16
+ component_name:,
17
+ dependencies:,
18
+ instance_id:,
19
+ revision:,
20
+ refresh_path:
21
+ )
22
+ payload = {
23
+ "actor_type" => reference.actor_type,
24
+ "actor_id" => reference.actor_id,
25
+ "component_name" => component_name,
26
+ "dependencies" => dependencies,
27
+ "instance_id" => instance_id,
28
+ "revision" => revision,
29
+ "refresh_path" => refresh_path
30
+ }
31
+ validate_payload!(payload)
32
+ verifier.generate(payload, purpose: PURPOSE).tap do |token|
33
+ raise InvalidComponentToken, "component token is too large" if token.bytesize > MAXIMUM_TOKEN_BYTES
34
+ end
35
+ end
36
+
37
+ # @rbs (String) -> Hash[String, untyped]
38
+ def verify(token)
39
+ unless token.is_a?(String) && token.bytesize <= MAXIMUM_TOKEN_BYTES
40
+ raise InvalidComponentToken, "invalid actor component token"
41
+ end
42
+
43
+ payload = verifier.verified(token, purpose: PURPOSE)
44
+ validate_payload!(payload)
45
+ payload
46
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
47
+ raise InvalidComponentToken, "invalid actor component token"
48
+ end
49
+
50
+ # @rbs (Hash[String, untyped]) -> Hash[String, untyped]
51
+ def validate_payload!(payload)
52
+ unless payload.is_a?(Hash) &&
53
+ payload["actor_type"].is_a?(String) &&
54
+ payload["actor_id"].is_a?(String) &&
55
+ payload["component_name"].is_a?(String) &&
56
+ payload["dependencies"].is_a?(Array) &&
57
+ payload["instance_id"].is_a?(Integer) &&
58
+ payload["revision"].is_a?(Integer) &&
59
+ payload["refresh_path"].is_a?(String)
60
+ raise InvalidComponentToken, "invalid actor component token"
61
+ end
62
+
63
+ validate_component_name!(payload.fetch("component_name"))
64
+ validate_dependencies!(payload.fetch("dependencies"))
65
+ validate_revision!(
66
+ payload.fetch("instance_id"),
67
+ payload.fetch("revision")
68
+ )
69
+ validate_refresh_path!(payload.fetch("refresh_path"))
70
+ payload
71
+ end
72
+ private_class_method :validate_payload!
73
+
74
+ # @rbs (String) -> void
75
+ def validate_component_name!(component_name)
76
+ return if component_name.match?(/\A[a-zA-Z0-9_]+\z/)
77
+
78
+ raise InvalidComponentToken, "invalid actor component name"
79
+ end
80
+ private_class_method :validate_component_name!
81
+
82
+ # @rbs (Array[untyped]) -> void
83
+ def validate_dependencies!(dependencies)
84
+ valid = dependencies.any? &&
85
+ dependencies.length <= MAXIMUM_DEPENDENCIES &&
86
+ dependencies.uniq.length == dependencies.length &&
87
+ dependencies.all? { |dependency| dependency.is_a?(String) && dependency.match?(/\A[a-zA-Z0-9_]+\z/) }
88
+ return if valid
89
+
90
+ raise InvalidComponentToken, "invalid actor component dependencies"
91
+ end
92
+ private_class_method :validate_dependencies!
93
+
94
+ # @rbs (Integer, Integer) -> void
95
+ def validate_revision!(instance_id, revision)
96
+ return unless instance_id.negative? || revision.negative?
97
+
98
+ raise InvalidComponentToken, "invalid actor component revision"
99
+ end
100
+ private_class_method :validate_revision!
101
+
102
+ # @rbs (String) -> void
103
+ def validate_refresh_path!(refresh_path)
104
+ valid = refresh_path.bytesize <= 2_048 &&
105
+ refresh_path.start_with?("/") &&
106
+ !refresh_path.start_with?("//") &&
107
+ !refresh_path.include?("\0") &&
108
+ !refresh_path.include?("?") &&
109
+ !refresh_path.include?("#")
110
+ return if valid
111
+
112
+ raise InvalidComponentToken, "invalid actor component refresh path"
113
+ end
114
+ private_class_method :validate_refresh_path!
115
+
116
+ # @rbs () -> ActiveSupport::MessageVerifier
117
+ def verifier
118
+ ActiveSupport::MessageVerifier.new(
119
+ signing_secret,
120
+ digest: "SHA256",
121
+ serializer: JSON
122
+ )
123
+ end
124
+ private_class_method :verifier
125
+
126
+ # @rbs () -> String
127
+ def signing_secret
128
+ configured_secret = SolidObjects.configuration.stream_signing_secret
129
+ return configured_secret if configured_secret
130
+
131
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
132
+ return Rails.application.key_generator.generate_key(PURPOSE, 64)
133
+ end
134
+
135
+ raise ArgumentError, "configure stream_signing_secret outside a Rails application"
136
+ end
137
+ private_class_method :signing_secret
138
+ end
139
+ end