openui-rails 0.1.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/AUDIT.md +102 -0
  3. data/CHANGELOG.md +71 -0
  4. data/CODE_OF_CONDUCT.md +37 -0
  5. data/CONFIGURATION.md +79 -0
  6. data/CONTRIBUTING.md +66 -0
  7. data/LICENSE +22 -0
  8. data/README.md +471 -0
  9. data/RELEASING.md +90 -0
  10. data/SECURITY.md +48 -0
  11. data/THIRD_PARTY_NOTICES.md +45 -0
  12. data/UPGRADING.md +26 -0
  13. data/app/controllers/openui/rails/actions_controller.rb +77 -0
  14. data/app/controllers/openui/rails/application_controller.rb +10 -0
  15. data/app/javascript/openui/rails/openui_controller.js +347 -0
  16. data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
  17. data/config/routes.rb +5 -0
  18. data/lib/generators/openui/install/install_generator.rb +62 -0
  19. data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
  20. data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
  21. data/lib/openui/rails/actions/dispatcher.rb +194 -0
  22. data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
  23. data/lib/openui/rails/actions/invocation.rb +16 -0
  24. data/lib/openui/rails/actions/registry.rb +113 -0
  25. data/lib/openui/rails/actions/schema.rb +115 -0
  26. data/lib/openui/rails/actions/submission.rb +102 -0
  27. data/lib/openui/rails/actions.rb +18 -0
  28. data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
  29. data/lib/openui/rails/configuration.rb +282 -0
  30. data/lib/openui/rails/engine.rb +46 -0
  31. data/lib/openui/rails/errors.rb +41 -0
  32. data/lib/openui/rails/json_value.rb +33 -0
  33. data/lib/openui/rails/library_manifest.rb +234 -0
  34. data/lib/openui/rails/message.rb +139 -0
  35. data/lib/openui/rails/prompt_builder.rb +115 -0
  36. data/lib/openui/rails/runtime_asset.rb +65 -0
  37. data/lib/openui/rails/runtime_package.rb +74 -0
  38. data/lib/openui/rails/stream/accumulator.rb +86 -0
  39. data/lib/openui/rails/stream/bridge.rb +193 -0
  40. data/lib/openui/rails/stream/broadcaster.rb +106 -0
  41. data/lib/openui/rails/stream/envelope.rb +159 -0
  42. data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
  43. data/lib/openui/rails/stream/transports/memory.rb +33 -0
  44. data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
  45. data/lib/openui/rails/stream/transports.rb +41 -0
  46. data/lib/openui/rails/surface.rb +150 -0
  47. data/lib/openui/rails/version.rb +5 -0
  48. data/lib/openui/rails/view_helper.rb +56 -0
  49. data/lib/openui/rails.rb +33 -0
  50. data/lib/tasks/openui.rake +16 -0
  51. metadata +125 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Actions
6
+ # The single authorization and execution path for every write-ish call,
7
+ # whether it originated from a component `onAction` or a browser
8
+ # `toolProvider` `Query()`/`Mutation()`. It fails closed: with no
9
+ # authorizer configured, an action is denied.
10
+ #
11
+ # Order of checks (all before any side effect):
12
+ # 1. resolve + validate the submission
13
+ # 2. action is registered (allowlist)
14
+ # 3. args match the schema
15
+ # 4. confirmation when the action is destructive
16
+ # 5. surface revision is current (stale protection)
17
+ # 6. authorizer allows it
18
+ # 7. idempotency claim (duplicates never execute twice)
19
+ class Dispatcher
20
+ # Outcome returned after an action executes or replays.
21
+ Result = Struct.new(:action, :surface_id, :version, :value, :replayed, keyword_init: true) do
22
+ def replayed? = replayed == true
23
+
24
+ def to_h
25
+ { action: action, surface_id: surface_id, version: version, value: value, replayed: replayed? }
26
+ end
27
+ end
28
+
29
+ # Build a dispatcher from the process-wide configuration.
30
+ #
31
+ # @param configuration [Configuration]
32
+ # @return [Dispatcher]
33
+ def self.from_configuration(configuration = OpenUI::Rails.configuration)
34
+ new(
35
+ registry: configuration.action_registry,
36
+ idempotency_store: configuration.idempotency_store || Idempotency::MemoryStore.new,
37
+ idempotency_ttl: configuration.idempotency_ttl,
38
+ authorize_action: configuration.authorize_action,
39
+ surface_resolver: configuration.surface_resolver,
40
+ idempotency_scope: configuration.idempotency_scope
41
+ )
42
+ end
43
+
44
+ # Build a dispatcher with explicit action and replay dependencies.
45
+ #
46
+ # @param registry [Registry]
47
+ #
48
+ # @param idempotency_store [#claim, #record]
49
+ #
50
+ # @param idempotency_ttl [Numeric]
51
+ #
52
+ # @param authorize_action [#call, nil]
53
+ #
54
+ # @param surface_resolver [#call, nil]
55
+ #
56
+ # @param idempotency_scope [#call, nil]
57
+ #
58
+ # @return [Dispatcher]
59
+ def initialize(registry:, idempotency_store:, idempotency_ttl:, authorize_action: nil, surface_resolver: nil,
60
+ idempotency_scope: nil)
61
+ @registry = registry
62
+ @idempotency_store = idempotency_store
63
+ @idempotency_ttl = idempotency_ttl
64
+ @authorize_action = authorize_action
65
+ @surface_resolver = surface_resolver
66
+ @idempotency_scope = idempotency_scope
67
+ end
68
+
69
+ # @param payload [Hash, Submission]
70
+ #
71
+ # @param actor [Object, nil] authenticated host actor
72
+ #
73
+ # @param surface [Object, nil] already-resolved host surface
74
+ #
75
+ # @param tenant [Object, nil] trusted host tenant identity
76
+ #
77
+ # @param context [Hash] host-supplied request context
78
+ #
79
+ # @return [Result]
80
+ #
81
+ # @raise [OpenUI::Rails::Error] on any failed gate, before side effects
82
+ def call(payload, actor: nil, surface: nil, tenant: nil, context: {})
83
+ submission = Submission.parse(payload)
84
+ definition = @registry.fetch(submission.action)
85
+
86
+ definition.schema.validate!(submission.args)
87
+ check_confirmation!(definition, submission)
88
+
89
+ surface ||= resolve_surface(submission.surface_id, actor, context)
90
+ check_version!(submission, surface)
91
+
92
+ invocation = Invocation.new(
93
+ action: definition.name,
94
+ args: submission.args,
95
+ actor: actor,
96
+ surface: surface,
97
+ tenant: tenant,
98
+ context: context,
99
+ submission: submission
100
+ )
101
+
102
+ authorize!(definition, invocation)
103
+
104
+ replayed, stored, key, token = claim_idempotency(submission, invocation)
105
+ return result(submission, stored, replayed: true) if replayed
106
+
107
+ value = definition.handler.call(invocation)
108
+ @idempotency_store.record(key, value, claim_token: token)
109
+ result(submission, value, replayed: false)
110
+ end
111
+
112
+ private
113
+
114
+ def check_confirmation!(definition, submission)
115
+ return if !definition.confirmation || submission.confirmed?
116
+
117
+ raise InvalidActionError, "action #{definition.name} requires explicit confirmation"
118
+ end
119
+
120
+ def resolve_surface(surface_id, actor, context)
121
+ return nil if @surface_resolver.nil?
122
+
123
+ surface = @surface_resolver.call(surface_id, actor: actor, context: context)
124
+ return surface unless surface.nil?
125
+
126
+ raise UnauthorizedActionError, "unknown or inaccessible surface"
127
+ end
128
+
129
+ def check_version!(submission, surface)
130
+ if surface.respond_to?(:id) && surface.id.to_s != submission.surface_id
131
+ raise UnauthorizedActionError, "resolved surface does not match the submission"
132
+ end
133
+ return if surface.nil? || !surface.respond_to?(:version)
134
+ return if surface.version == submission.version
135
+
136
+ raise StaleSurfaceError,
137
+ "surface #{submission.surface_id} is at version #{surface.version}, " \
138
+ "action submitted for #{submission.version}"
139
+ end
140
+
141
+ def authorize!(definition, invocation)
142
+ authorizer = definition.authorize || @authorize_action
143
+ if authorizer.nil?
144
+ raise UnauthorizedActionError, "no authorizer configured for action #{definition.name}"
145
+ end
146
+
147
+ return if authorizer.call(invocation)
148
+
149
+ raise UnauthorizedActionError, "action #{definition.name} is not permitted"
150
+ end
151
+
152
+ def claim_idempotency(submission, invocation)
153
+ scope = if @idempotency_scope
154
+ @idempotency_scope.call(invocation)
155
+ else
156
+ [ identity(invocation.tenant), identity(invocation.actor) ]
157
+ end
158
+ key = Digest::SHA256.hexdigest(JSON.generate([ scope, submission.idempotency_key ]))
159
+ digest = Digest::SHA256.hexdigest(JSON.generate(submission.to_h.except(:idempotency_key)))
160
+ status, stored, stored_digest, token = @idempotency_store.claim(key, ttl: @idempotency_ttl, digest: digest)
161
+
162
+ return [ false, nil, key, token ] if status == :claimed
163
+
164
+ if stored_digest != digest
165
+ raise ReplayError, "idempotency key reused with different arguments"
166
+ end
167
+
168
+ raise ReplayError, "duplicate submission is still in progress" if status == :pending
169
+ raise ConfigurationError, "invalid idempotency store status #{status.inspect}" unless status == :replay
170
+
171
+ [ true, stored ]
172
+ end
173
+
174
+ def identity(record)
175
+ return nil if record.nil?
176
+ return [ record.class.name, record ] if record.is_a?(String) || record.is_a?(Symbol) || record.is_a?(Numeric)
177
+ return [ record.class.name, record.id.to_s ] if record.respond_to?(:id) && !record.id.nil?
178
+
179
+ raise ConfigurationError, "actor/tenant needs a stable id; configure idempotency_scope for other identities"
180
+ end
181
+
182
+ def result(submission, value, replayed:)
183
+ Result.new(
184
+ action: submission.action,
185
+ surface_id: submission.surface_id,
186
+ version: submission.version,
187
+ value: value,
188
+ replayed: replayed
189
+ )
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OpenUI
6
+ module Rails
7
+ module Actions
8
+ module Idempotency
9
+ # Default, single-process idempotency store. Hosts that run multiple
10
+ # processes should inject a shared store (e.g. Redis or the database)
11
+ # that implements the same contract.
12
+ #
13
+ # Contract:
14
+ # claim(key, ttl:, digest:) -> [status, result, digest, claim_token]
15
+ # status is :claimed, :pending or :replay (even for a nil result).
16
+ # record(key, result, claim_token:) ignores an expired/replaced claim.
17
+ class MemoryStore
18
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
19
+ @entries = {}
20
+ @mutex = Mutex.new
21
+ @clock = clock
22
+ end
23
+
24
+ def claim(key, ttl:, digest: nil)
25
+ raise ConfigurationError, "idempotency ttl must be positive" unless ttl.is_a?(Numeric) && ttl.finite? && ttl > 0
26
+
27
+ @mutex.synchronize do
28
+ now = @clock.call
29
+ purge(now)
30
+
31
+ if (entry = @entries[key])
32
+ return [ entry[:completed] ? :replay : :pending, entry[:result], entry[:digest], entry[:token] ]
33
+ end
34
+
35
+ token = SecureRandom.hex(16)
36
+ @entries[key] = { digest: digest, result: nil, completed: false, token: token, expires_at: now + ttl }
37
+ [ :claimed, nil, digest, token ]
38
+ end
39
+ end
40
+
41
+ def record(key, result, claim_token:)
42
+ @mutex.synchronize do
43
+ entry = @entries[key]
44
+ if entry && entry[:token] == claim_token && entry[:expires_at] > @clock.call
45
+ entry[:result] = result
46
+ entry[:completed] = true
47
+ end
48
+ end
49
+ end
50
+
51
+ def size
52
+ @mutex.synchronize { @entries.size }
53
+ end
54
+
55
+ private
56
+
57
+ def purge(now)
58
+ @entries.reject! { |_key, entry| entry[:expires_at] <= now }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Actions
6
+ # Everything an authorizer or handler needs to decide and act, without
7
+ # handing them the raw client payload.
8
+ Invocation = Struct.new(
9
+ :action, :args, :actor, :surface, :tenant, :context, :submission,
10
+ keyword_init: true
11
+ ) do
12
+ def message_id = context[:message_id]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Actions
6
+ # The allowlist of actions a model-generated surface may invoke. Only
7
+ # handlers registered here are reachable: there is no reflection from a
8
+ # model-provided name to a Ruby method.
9
+ class Registry
10
+ # Immutable definition stored by the registry and passed to dispatch.
11
+ Definition = Struct.new(:name, :handler, :authorize, :schema, :confirmation, :description, keyword_init: true)
12
+
13
+ # Pattern accepted for action names sent over the wire.
14
+ NAME_PATTERN = /\A[a-zA-Z][a-zA-Z0-9_.:-]{0,63}\z/
15
+
16
+ # Create an empty action allowlist.
17
+ #
18
+ # @return [Registry]
19
+ def initialize
20
+ @definitions = {}
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # Register a callable action.
25
+ #
26
+ # @param name [String, Symbol] the wire name the surface emits
27
+ #
28
+ # @param handler [#call] receives an Invocation, performs the work
29
+ #
30
+ # @param authorize [#call, nil] receives an Invocation, returns truthy
31
+ # when allowed. Required at dispatch time (fail closed).
32
+ #
33
+ # @param schema [Schema, Hash, #call, nil] arg validation
34
+ #
35
+ # @param confirmation [Boolean] require `confirmed: true` on the payload
36
+ #
37
+ # @param description [String, nil] text describing the action
38
+ #
39
+ # @return [Definition]
40
+ def register(name, handler: nil, authorize: nil, schema: nil, confirmation: false, description: nil, &block)
41
+ handler ||= block
42
+ raise ConfigurationError, "handler is required for action #{name.inspect}" unless handler.respond_to?(:call)
43
+ unless authorize.nil? || authorize.respond_to?(:call)
44
+ raise ConfigurationError, "authorize must respond to #call"
45
+ end
46
+
47
+ definition = Definition.new(
48
+ name: normalize_name(name).dup.freeze,
49
+ handler: handler,
50
+ authorize: authorize,
51
+ schema: Schema.from(schema),
52
+ confirmation: confirmation ? true : false,
53
+ description: description
54
+ ).freeze
55
+
56
+ @mutex.synchronize { @definitions[definition.name] = definition }
57
+ definition
58
+ end
59
+
60
+ # Find a registered action or raise when it is not allowlisted.
61
+ #
62
+ # @param name [String, Symbol]
63
+ #
64
+ # @return [Definition]
65
+ #
66
+ # @raise [UnknownActionError] when no action has this name
67
+ def fetch(name)
68
+ definition = @mutex.synchronize { @definitions[normalize_name(name)] }
69
+ raise UnknownActionError, "unknown action #{name.inspect}" if definition.nil?
70
+
71
+ definition
72
+ rescue ConfigurationError => error
73
+ raise InvalidActionError, error.message
74
+ end
75
+
76
+ # Whether an action name is present in the allowlist.
77
+ #
78
+ # @param name [String, Symbol]
79
+ #
80
+ # @return [Boolean]
81
+ def registered?(name)
82
+ @mutex.synchronize { @definitions.key?(normalize_name(name)) }
83
+ end
84
+
85
+ # Return the registered action names.
86
+ #
87
+ # @return [Array<String>]
88
+ def names
89
+ @mutex.synchronize { @definitions.keys }
90
+ end
91
+
92
+ # Remove all registered actions.
93
+ #
94
+ # @return [self]
95
+ def clear!
96
+ @mutex.synchronize { @definitions.clear }
97
+ self
98
+ end
99
+
100
+ private
101
+
102
+ def normalize_name(name)
103
+ name = name.to_s
104
+ unless name.match?(NAME_PATTERN)
105
+ raise ConfigurationError, "invalid action name #{name.inspect}"
106
+ end
107
+
108
+ name
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Actions
6
+ # Validates the `args` of an action submission against a small, explicit
7
+ # contract. It is intentionally not a general-purpose schema language: it
8
+ # only ensures the shape a host handler relies on, so a malformed payload
9
+ # is rejected before any side effect.
10
+ class Schema
11
+ # Wraps a host predicate. Returning false (or raising) rejects the args.
12
+ class Callable
13
+ def initialize(callable)
14
+ @callable = callable
15
+ end
16
+
17
+ def validate!(args)
18
+ raise InvalidActionError, "action arguments must be an object" unless args.is_a?(Hash)
19
+ raise InvalidActionError, "action arguments failed validation" unless @callable.call(args)
20
+
21
+ args
22
+ end
23
+ end
24
+
25
+ # A schema that accepts any JSON object.
26
+ def self.any
27
+ new(required: {}, optional: {}, allow_extra: true)
28
+ end
29
+
30
+ def self.define(required: {}, optional: {}, allow_extra: false)
31
+ new(required: required || {}, optional: optional || {}, allow_extra: allow_extra)
32
+ end
33
+
34
+ # Build a schema from a Hash of required types, an existing Schema or a
35
+ # callable predicate.
36
+ def self.from(value)
37
+ case value
38
+ when nil then any
39
+ when Schema, Callable then value
40
+ when Hash then new(required: value, allow_extra: false)
41
+ else
42
+ unless value.respond_to?(:call)
43
+ raise ConfigurationError, "schema must be a Hash, Schema, or respond to #call"
44
+ end
45
+
46
+ Callable.new(value)
47
+ end
48
+ end
49
+
50
+ attr_reader :required, :optional
51
+
52
+ def initialize(required: {}, optional: {}, allow_extra: false)
53
+ @required = required
54
+ @optional = optional
55
+ @allow_extra = allow_extra
56
+ end
57
+
58
+ def allow_extra? = @allow_extra
59
+
60
+ def validate!(args)
61
+ raise InvalidActionError, "action arguments must be an object" unless args.is_a?(Hash)
62
+
63
+ values = normalize(args)
64
+ validate_required(values)
65
+ validate_optional(values)
66
+ validate_extras(values.keys) unless allow_extra?
67
+ args
68
+ end
69
+
70
+ private
71
+
72
+ def normalize(args)
73
+ args.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
74
+ end
75
+
76
+ def validate_required(values)
77
+ required.each do |key, expected|
78
+ name = key.to_s
79
+ raise InvalidActionError, "missing required argument #{name}" unless values.key?(name)
80
+ next if type_matches?(values[name], expected)
81
+
82
+ raise InvalidActionError, "argument #{name} has an invalid value"
83
+ end
84
+ end
85
+
86
+ def validate_optional(values)
87
+ optional.each do |key, expected|
88
+ name = key.to_s
89
+ next unless values.key?(name)
90
+ next if type_matches?(values[name], expected)
91
+
92
+ raise InvalidActionError, "argument #{name} has an invalid value"
93
+ end
94
+ end
95
+
96
+ def validate_extras(keys)
97
+ allowed = (required.keys + optional.keys).map(&:to_s)
98
+ extra = keys - allowed
99
+ return if extra.empty?
100
+
101
+ raise InvalidActionError, "unexpected arguments: #{extra.join(", ")}"
102
+ end
103
+
104
+ def type_matches?(value, expected)
105
+ case expected
106
+ when Array then expected.any? { |candidate| type_matches?(value, candidate) }
107
+ when Regexp then value.is_a?(String) && expected.match?(value)
108
+ when Module then value.is_a?(expected)
109
+ else expected.respond_to?(:call) ? expected.call(value) == true : false
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module OpenUI
7
+ module Rails
8
+ module Actions
9
+ # Validated, immutable view of the payload emitted by a component
10
+ # `onAction` or a browser `toolProvider` `Query()`/`Mutation()` call:
11
+ #
12
+ # { action:, args:, surface_id:, version:, idempotency_key: }
13
+ #
14
+ # `confirmed` is the Gem's explicit-confirmation flag for destructive
15
+ # actions. Unknown keys are ignored; known keys are validated strictly.
16
+ class Submission
17
+ MAX_IDEMPOTENCY_KEY_BYTES = 200
18
+
19
+ attr_reader :action, :args, :surface_id, :version, :idempotency_key, :confirmed
20
+
21
+ def self.parse(payload)
22
+ return payload if payload.is_a?(Submission)
23
+
24
+ raise InvalidActionError, "action payload must be an object" unless payload.is_a?(Hash)
25
+
26
+ values = payload.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
27
+
28
+ new(
29
+ action: values["action"],
30
+ args: values.fetch("args", {}),
31
+ surface_id: values["surface_id"] || values["surfaceId"],
32
+ version: values["version"],
33
+ idempotency_key: values["idempotency_key"] || values["idempotencyKey"],
34
+ confirmed: values["confirmed"] == true
35
+ )
36
+ end
37
+
38
+ def initialize(action:, surface_id:, version:, idempotency_key:, args: {}, confirmed: false)
39
+ @action = validate_string(action, "action")
40
+ @surface_id = validate_string(surface_id, "surface_id")
41
+ @idempotency_key = validate_idempotency_key(idempotency_key)
42
+ @version = validate_version(version)
43
+ @args = validate_args(args)
44
+ @confirmed = confirmed == true
45
+ @args_digest = Digest::SHA256.hexdigest(JSON.generate(@args)).freeze
46
+ freeze
47
+ end
48
+
49
+ def confirmed? = confirmed
50
+
51
+ # Stable digest of the args, used for idempotency conflict detection.
52
+ attr_reader :args_digest
53
+
54
+ def to_h
55
+ {
56
+ action: action,
57
+ args: args,
58
+ surface_id: surface_id,
59
+ version: version,
60
+ idempotency_key: idempotency_key,
61
+ confirmed: confirmed
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def validate_string(value, name)
68
+ string = value.to_s
69
+ raise InvalidActionError, "#{name} is required" if string.empty?
70
+ raise InvalidActionError, "#{name} must be a scalar" unless value.is_a?(String) || value.is_a?(Symbol)
71
+
72
+ string.dup.freeze
73
+ end
74
+
75
+ def validate_idempotency_key(value)
76
+ key = validate_string(value, "idempotency_key")
77
+ if key.bytesize > MAX_IDEMPOTENCY_KEY_BYTES
78
+ raise InvalidActionError, "idempotency_key exceeds #{MAX_IDEMPOTENCY_KEY_BYTES} bytes"
79
+ end
80
+
81
+ key
82
+ end
83
+
84
+ def validate_version(value)
85
+ unless value.is_a?(Integer) && value >= 0
86
+ raise InvalidActionError, "version must be a non-negative Integer"
87
+ end
88
+
89
+ value
90
+ end
91
+
92
+ def validate_args(value)
93
+ raise InvalidActionError, "args must be an object" unless value.is_a?(Hash)
94
+
95
+ JsonValue.copy(value)
96
+ rescue ArgumentError => error
97
+ raise InvalidActionError, error.message
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openui/rails/actions/schema"
4
+ require "openui/rails/actions/registry"
5
+ require "openui/rails/actions/submission"
6
+ require "openui/rails/actions/invocation"
7
+ require "openui/rails/actions/idempotency/memory_store"
8
+ require "openui/rails/actions/dispatcher"
9
+
10
+ module OpenUI
11
+ module Rails
12
+ # Server-authoritative action loop: an allowlist registry, arg validation,
13
+ # a host authorizer, stale-surface protection and idempotency, shared by
14
+ # both component actions and browser tool calls.
15
+ module Actions
16
+ end
17
+ end
18
+ end