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.
- checksums.yaml +7 -0
- data/AUDIT.md +102 -0
- data/CHANGELOG.md +71 -0
- data/CODE_OF_CONDUCT.md +37 -0
- data/CONFIGURATION.md +79 -0
- data/CONTRIBUTING.md +66 -0
- data/LICENSE +22 -0
- data/README.md +471 -0
- data/RELEASING.md +90 -0
- data/SECURITY.md +48 -0
- data/THIRD_PARTY_NOTICES.md +45 -0
- data/UPGRADING.md +26 -0
- data/app/controllers/openui/rails/actions_controller.rb +77 -0
- data/app/controllers/openui/rails/application_controller.rb +10 -0
- data/app/javascript/openui/rails/openui_controller.js +347 -0
- data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
- data/config/routes.rb +5 -0
- data/lib/generators/openui/install/install_generator.rb +62 -0
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
- data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
- data/lib/openui/rails/actions/dispatcher.rb +194 -0
- data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
- data/lib/openui/rails/actions/invocation.rb +16 -0
- data/lib/openui/rails/actions/registry.rb +113 -0
- data/lib/openui/rails/actions/schema.rb +115 -0
- data/lib/openui/rails/actions/submission.rb +102 -0
- data/lib/openui/rails/actions.rb +18 -0
- data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
- data/lib/openui/rails/configuration.rb +282 -0
- data/lib/openui/rails/engine.rb +46 -0
- data/lib/openui/rails/errors.rb +41 -0
- data/lib/openui/rails/json_value.rb +33 -0
- data/lib/openui/rails/library_manifest.rb +234 -0
- data/lib/openui/rails/message.rb +139 -0
- data/lib/openui/rails/prompt_builder.rb +115 -0
- data/lib/openui/rails/runtime_asset.rb +65 -0
- data/lib/openui/rails/runtime_package.rb +74 -0
- data/lib/openui/rails/stream/accumulator.rb +86 -0
- data/lib/openui/rails/stream/bridge.rb +193 -0
- data/lib/openui/rails/stream/broadcaster.rb +106 -0
- data/lib/openui/rails/stream/envelope.rb +159 -0
- data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
- data/lib/openui/rails/stream/transports/memory.rb +33 -0
- data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
- data/lib/openui/rails/stream/transports.rb +41 -0
- data/lib/openui/rails/surface.rb +150 -0
- data/lib/openui/rails/version.rb +5 -0
- data/lib/openui/rails/view_helper.rb +56 -0
- data/lib/openui/rails.rb +33 -0
- data/lib/tasks/openui.rake +16 -0
- 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
|