kiosk-server 0.5.20
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/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- metadata +349 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
require 'action_dispatch'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a wire verb;
|
|
9
|
+
# a method with no pending declaration stays off the wire.
|
|
10
|
+
module HandlerMixin
|
|
11
|
+
KINDS = %i[action query].freeze
|
|
12
|
+
|
|
13
|
+
# The four reaches of §7.2; the first is the default.
|
|
14
|
+
REACHES = %i[principal published consented role].freeze
|
|
15
|
+
|
|
16
|
+
# The strictest reach, so an undeclared verb is never widened.
|
|
17
|
+
DEFAULT_REACH = :principal
|
|
18
|
+
|
|
19
|
+
# §8.1: a verb name is one path segment.
|
|
20
|
+
NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
|
|
21
|
+
|
|
22
|
+
# First path segments the engine draws under the mount; keep in step with its routes.
|
|
23
|
+
RESERVED_NAMES = %w[agents auth events kyc oauth pay payment_setup request_kyc schema].freeze
|
|
24
|
+
|
|
25
|
+
# The declaration members a verb registry entry takes.
|
|
26
|
+
REGISTERED_KEYS = %i[reach description input_schema output_schema example_params example_row].freeze
|
|
27
|
+
|
|
28
|
+
# Required on every verb (§8.3); a declaration missing either is refused at class load.
|
|
29
|
+
REQUIRED_DECLARATIONS = %i[input_schema output_schema].freeze
|
|
30
|
+
|
|
31
|
+
# Installs the mixin. Called from Kiosk::Handler.included.
|
|
32
|
+
def self.install(base)
|
|
33
|
+
raise ArgumentError, Refusal::NOT_A_CONTROLLER unless base.is_a?(Class) && base <= ::ActionController::Metal
|
|
34
|
+
|
|
35
|
+
# An operator's base class and its subclass may both include the mixin.
|
|
36
|
+
return if base.respond_to?(:kiosk_declarations)
|
|
37
|
+
|
|
38
|
+
base.extend(ClassMethods)
|
|
39
|
+
base.include(InstanceMethods)
|
|
40
|
+
|
|
41
|
+
# The wire authenticates by bearer token, never by cookie, so CSRF protection does not apply.
|
|
42
|
+
base.skip_forgery_protection if base.respond_to?(:skip_forgery_protection)
|
|
43
|
+
|
|
44
|
+
# Registered at include time, so an operator's own `rescue_from` declared later wins.
|
|
45
|
+
base.rescue_from(StandardError, with: :kiosk_rescue_to_wire) if base.respond_to?(:rescue_from)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolved lazily so this file can be required before the registries.
|
|
49
|
+
def self.registry_for(kind)
|
|
50
|
+
kind == :action ? Actions : Queries
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The sentences a refused declaration raises with.
|
|
54
|
+
module Refusal
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
NOT_A_CONTROLLER =
|
|
58
|
+
'include Kiosk::Handler into a controller class — each verb route reaches ' \
|
|
59
|
+
'a controller action, which needs an ActionController subclass. ' \
|
|
60
|
+
'Pick the base class yourself (ApplicationController, ActionController::API, …); ' \
|
|
61
|
+
'Kiosk does not impose one.'
|
|
62
|
+
|
|
63
|
+
def mount_path = Kiosk.configuration.mount_path
|
|
64
|
+
|
|
65
|
+
def bad_kind(owner, value)
|
|
66
|
+
"#{owner} declared `kind #{value.inspect}`, which is not a Kiosk verb kind. " \
|
|
67
|
+
"It is :query (reached by GET #{mount_path}/<name>) or " \
|
|
68
|
+
":action (POST #{mount_path}/<name>)."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def bad_reach(owner, value)
|
|
72
|
+
"#{owner} declared `reach #{value.inspect}`, which is not a Kiosk verb reach. " \
|
|
73
|
+
"It is :principal (the default — only the calling principal's own rows, or rows " \
|
|
74
|
+
'that belong to no principal), :published (the operator publishes owner-carrying ' \
|
|
75
|
+
'rows to everyone, by intent), :consented (a principal shared them, and the ' \
|
|
76
|
+
'operator can point at the artefact that says so) or :role (the reach follows the ' \
|
|
77
|
+
"caller's operator-assigned role claim). A verb that touches nobody else's rows " \
|
|
78
|
+
'declares nothing at all.'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def bad_topic_name(owner, name)
|
|
82
|
+
"#{owner} declared `topic #{name.inspect}`, which is not a legal Kiosk name. " \
|
|
83
|
+
"A topic name is a wire name: #{NAME_PATTERN.inspect}."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def reserved_topic(owner, name)
|
|
87
|
+
"#{owner} declared `topic #{name.inspect}`, but that name is reserved by the " \
|
|
88
|
+
"engine itself: #{RESERVED_NAMES.join(', ')}. Give the topic a name of its own."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The §8.1/§8.3 name rules and required fields, first failure first; nil when none fails.
|
|
92
|
+
# `clash` is a declaration of the same name earlier in this class body.
|
|
93
|
+
def declaration(where, name, declaration, clash)
|
|
94
|
+
return missing_kind(where, name) if declaration[:kind].nil?
|
|
95
|
+
return name_clash(where, name, declaration, clash) if clash
|
|
96
|
+
return illegal_name(where, name) unless NAME_PATTERN.match?(name)
|
|
97
|
+
return reserved_name(where, name) if RESERVED_NAMES.include?(name)
|
|
98
|
+
|
|
99
|
+
missing = REQUIRED_DECLARATIONS.reject { |key| declaration.key?(key) }
|
|
100
|
+
return missing_schemas(where, name, missing) unless missing.empty?
|
|
101
|
+
|
|
102
|
+
reserved_parameter(where, name, ArgumentDecoder.fetch(declaration[:input_schema], :properties))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# §8.1 item 6: no verb declares `cursor`; a verb that declares `limit` declares an integer.
|
|
106
|
+
def reserved_parameter(where, name, properties)
|
|
107
|
+
return declared_cursor(where, name) if ArgumentDecoder.fetch(properties, :cursor)
|
|
108
|
+
|
|
109
|
+
limit = ArgumentDecoder.fetch(properties, :limit)
|
|
110
|
+
declared_limit(where, name, limit) unless limit.nil? || ArgumentDecoder.declared_type(limit) == 'integer'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def declared_cursor(where, name)
|
|
114
|
+
"#{where} declares `cursor` in the input_schema of #{name.inspect}. `cursor` is reserved: " \
|
|
115
|
+
'the engine accepts it on every query, and it carries the token of your `next` link. ' \
|
|
116
|
+
'Remove `cursor` from the input_schema.'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def declared_limit(where, name, limit)
|
|
120
|
+
"#{where} declares `limit` in the input_schema of #{name.inspect} as #{limit.inspect}. " \
|
|
121
|
+
'`limit` is the page size, an integer. Declare it `{ type: "integer", maximum: <your largest page> }`, ' \
|
|
122
|
+
'or remove it from the input_schema.'
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def missing_kind(where, name)
|
|
126
|
+
"#{where} declares the Kiosk verb #{name.inspect} without a `kind`. " \
|
|
127
|
+
"Add `kind :query` (GET #{mount_path}/#{name}) or `kind :action` (POST #{mount_path}/#{name})."
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# One name declared twice in one class body; the cross-class case is {HandlerRegistrations}.
|
|
131
|
+
def name_clash(where, name, declaration, clash)
|
|
132
|
+
return same_name(where, name, clash) if clash[:kind] == declaration[:kind]
|
|
133
|
+
|
|
134
|
+
"#{where} declares #{name.inspect} as a#{'n' if declaration[:kind] == :action} " \
|
|
135
|
+
"#{declaration[:kind]}, but ##{clash[:method_name]} on this class already " \
|
|
136
|
+
"declares it as a#{'n' if clash[:kind] == :action} #{clash[:kind]}. " \
|
|
137
|
+
'Rename one, or give it a `wire_name` of its own.'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def same_name(where, name, clash)
|
|
141
|
+
"#{where} declares the Kiosk verb #{name.inspect}, which ##{clash[:method_name]} " \
|
|
142
|
+
'on this class already declares. Rename one of the methods, or give one of them a ' \
|
|
143
|
+
'`wire_name` of its own.'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def illegal_name(where, name)
|
|
147
|
+
"#{where} declares the Kiosk verb #{name.inspect}, which is not a legal verb " \
|
|
148
|
+
"name (#{NAME_PATTERN.source}). Rename the method, or give it a legal `wire_name`."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def reserved_name(where, name)
|
|
152
|
+
"#{where} declares the Kiosk verb #{name.inspect}, which the engine reserves " \
|
|
153
|
+
"(#{RESERVED_NAMES.join(', ')}). Give it a `wire_name` of its own."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def missing_schemas(where, name, missing)
|
|
157
|
+
"#{where} declares the Kiosk verb #{name.inspect} without #{missing.join(' and ')}. " \
|
|
158
|
+
'Declare both; a verb that takes nothing declares ' \
|
|
159
|
+
'`input_schema type: "object", additionalProperties: false, properties: {}`.'
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Evaluates a `topic` block, keeping its declarations out of the verb macros' pending state.
|
|
164
|
+
class TopicDeclaration
|
|
165
|
+
attr_reader :reach_value, :description_value, :payload_schema_value,
|
|
166
|
+
:subject_reachable_value
|
|
167
|
+
|
|
168
|
+
def examples_value = (@examples_value ||= [])
|
|
169
|
+
|
|
170
|
+
def reach(value)
|
|
171
|
+
unless REACHES.include?(value)
|
|
172
|
+
raise ArgumentError,
|
|
173
|
+
"a topic declared `reach #{value.inspect}`, which is not a Kiosk reach. " \
|
|
174
|
+
"It is :principal (the default — only the calling principal's own events), " \
|
|
175
|
+
':published (the operator publishes to everyone, by intent), :consented (a ' \
|
|
176
|
+
'principal shared the subject, and the operator can point at the artefact ' \
|
|
177
|
+
"that says so) or :role (the reach follows the caller's operator-assigned " \
|
|
178
|
+
'role claim).'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@reach_value = value
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def description(text)
|
|
185
|
+
declared << :description
|
|
186
|
+
@description_value = text
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def payload_schema(schema = nil, **kwargs)
|
|
190
|
+
declared << :payload_schema
|
|
191
|
+
@payload_schema_value = schema || kwargs
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# One example `data` per call; each must satisfy `payload_schema`.
|
|
195
|
+
def example(data) = examples_value << data
|
|
196
|
+
|
|
197
|
+
# `(subject, identity) -> Boolean`; runs outside any request, so it cannot read {CurrentRequest}.
|
|
198
|
+
def subject_reachable(callable) = @subject_reachable_value = callable
|
|
199
|
+
|
|
200
|
+
# What {Events.register} takes for the topic `name`.
|
|
201
|
+
def registration(name)
|
|
202
|
+
{ name: name, reach: reach_value || :principal, description: description_value,
|
|
203
|
+
payload_schema: payload_schema_value, examples: examples_value,
|
|
204
|
+
subject_reachable: subject_reachable_value }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Each macro must be called; `description nil` is a valid answer (§8.5.1).
|
|
208
|
+
def validate!(owner:, name:)
|
|
209
|
+
missing = Events::REQUIRED - declared
|
|
210
|
+
unless missing.empty?
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"#{owner} declared `topic #{name.inspect}` without " \
|
|
213
|
+
"#{missing.join(' and ')}. A topic carries `description` for semantics and " \
|
|
214
|
+
'`payload_schema` for shape; a subscriber with neither has to receive a ' \
|
|
215
|
+
'message to find out what it is.'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
validate_examples!(owner: owner, name: name)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def declared = (@declared ||= [])
|
|
224
|
+
|
|
225
|
+
def validate_examples!(owner:, name:)
|
|
226
|
+
return if examples_value.empty?
|
|
227
|
+
|
|
228
|
+
require 'json_schemer'
|
|
229
|
+
schema = JSONSchemer.schema(RequestValidation.normalize(payload_schema_value))
|
|
230
|
+
examples_value.each do |data|
|
|
231
|
+
errors = schema.validate(RequestValidation.normalize(data)).map { it['error'] }
|
|
232
|
+
next if errors.empty?
|
|
233
|
+
|
|
234
|
+
raise ArgumentError,
|
|
235
|
+
"#{owner} declared `topic #{name.inspect}` with an example that does not " \
|
|
236
|
+
"satisfy its payload_schema: #{errors.join('; ')}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
module ClassMethods
|
|
242
|
+
# Which HTTP method reaches the next-defined method; one controller may declare both.
|
|
243
|
+
def kind(value)
|
|
244
|
+
raise ArgumentError, Refusal.bad_kind(self, value) unless KINDS.include?(value)
|
|
245
|
+
|
|
246
|
+
kiosk_pending[:kind] = value
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Whose rows the next verb may touch (§7.2); the default is :principal.
|
|
250
|
+
def reach(value)
|
|
251
|
+
raise ArgumentError, Refusal.bad_reach(self, value) unless REACHES.include?(value)
|
|
252
|
+
|
|
253
|
+
kiosk_pending[:reach] = value
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def description(text)
|
|
257
|
+
kiosk_pending[:description] = text
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def input_schema(schema = nil, **kwargs)
|
|
261
|
+
kiosk_pending[:input_schema] = schema || kwargs
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def output_schema(schema = nil, **kwargs)
|
|
265
|
+
kiosk_pending[:output_schema] = schema || kwargs
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def example_params(example = nil, **kwargs)
|
|
269
|
+
kiosk_pending[:example_params] = example || kwargs
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def example_row(example = nil, **kwargs)
|
|
273
|
+
kiosk_pending[:example_row] = example || kwargs
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def wire_name(name)
|
|
277
|
+
kiosk_pending[:wire_name] = name.to_s
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Declares an event topic; the block holds `reach`, `description`, `payload_schema`,
|
|
281
|
+
# `example` and `subject_reachable`.
|
|
282
|
+
def topic(name, &block)
|
|
283
|
+
name = name.to_s
|
|
284
|
+
raise ArgumentError, Refusal.bad_topic_name(self, name) unless name.match?(NAME_PATTERN)
|
|
285
|
+
raise ArgumentError, Refusal.reserved_topic(self, name) if RESERVED_NAMES.include?(name)
|
|
286
|
+
|
|
287
|
+
declaration = TopicDeclaration.new
|
|
288
|
+
declaration.instance_eval(&block) if block
|
|
289
|
+
declaration.validate!(owner: self, name: name)
|
|
290
|
+
kiosk_topic_declarations[name] = declaration.registration(name)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# `super` first: AbstractController::Base also hooks method_added.
|
|
294
|
+
def method_added(method_name)
|
|
295
|
+
super
|
|
296
|
+
pending = @kiosk_pending
|
|
297
|
+
return if pending.blank?
|
|
298
|
+
|
|
299
|
+
@kiosk_pending = nil
|
|
300
|
+
kiosk_declare(method_name, pending)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Wire name → declaration for the verbs on this class, one per name.
|
|
304
|
+
def kiosk_declarations
|
|
305
|
+
@kiosk_declarations ||= {}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Wire name → declaration for the topics on this class.
|
|
309
|
+
def kiosk_topic_declarations
|
|
310
|
+
@kiosk_topic_declarations ||= {}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Runs as the class body is read; call it directly only to restore registrations after a test reset.
|
|
314
|
+
def kiosk_register!
|
|
315
|
+
kiosk_declarations.each_value { |declaration| kiosk_register_one(declaration) }
|
|
316
|
+
kiosk_topic_declarations.each_value { |declaration| Events.register(**declaration) }
|
|
317
|
+
self
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
def kiosk_pending
|
|
323
|
+
@kiosk_pending ||= {}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def kiosk_declare(method_name, pending)
|
|
327
|
+
declaration = pending.merge(
|
|
328
|
+
method_name: method_name.to_s,
|
|
329
|
+
wire_name: (pending[:wire_name] || method_name).to_s,
|
|
330
|
+
reach: pending[:reach] || HandlerMixin::DEFAULT_REACH
|
|
331
|
+
)
|
|
332
|
+
kiosk_refuse_bad_declaration!(declaration)
|
|
333
|
+
kiosk_declarations[declaration[:wire_name]] = declaration
|
|
334
|
+
kiosk_register_one(declaration)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The §8.1/§8.3 name rules and required fields, refused at class load.
|
|
338
|
+
def kiosk_refuse_bad_declaration!(declaration)
|
|
339
|
+
name = declaration[:wire_name]
|
|
340
|
+
where = "#{self}##{declaration[:method_name]}"
|
|
341
|
+
refusal = Refusal.declaration(where, name, declaration, kiosk_declarations[name])
|
|
342
|
+
raise ArgumentError, refusal if refusal
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def kiosk_register_one(declaration)
|
|
346
|
+
handler = ControllerVerb.new(
|
|
347
|
+
controller: self,
|
|
348
|
+
method_name: declaration[:method_name],
|
|
349
|
+
wire_name: declaration[:wire_name],
|
|
350
|
+
kind: declaration[:kind]
|
|
351
|
+
)
|
|
352
|
+
HandlerMixin.registry_for(declaration[:kind]).declare(
|
|
353
|
+
declaration[:wire_name], handler, **REGISTERED_KEYS.index_with { |key| declaration[key] }
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Handler-side helpers, private so none is mistaken for a controller action.
|
|
359
|
+
module InstanceMethods
|
|
360
|
+
include HandlerServing
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
# The acting {Kiosk::Identity}; nil outside a wire request.
|
|
365
|
+
def kiosk_identity = CurrentRequest.identity
|
|
366
|
+
|
|
367
|
+
# Differs from the method name only under `wire_name`.
|
|
368
|
+
def kiosk_wire_name = @_kiosk_wire_name
|
|
369
|
+
|
|
370
|
+
# One page of rows; a nil `next_cursor` is the last page. `total` counts every matching row and
|
|
371
|
+
# nil omits `X-Total-Count`. The body stays the bare rows; the page facts leave as headers.
|
|
372
|
+
# Without `rows` the action's own template renders them.
|
|
373
|
+
def render_kiosk_page(rows = nil, next_cursor: nil, total: nil)
|
|
374
|
+
@_kiosk_page = { next_cursor: next_cursor, total: total }
|
|
375
|
+
rows.nil? ? render : render(json: rows)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# A validation failure answers 400 with the errors' full messages. Any other raise Rails knows
|
|
379
|
+
# a status for maps to its lone wire code, its own message going to the operator's log and
|
|
380
|
+
# never onto the wire; anything else is re-raised.
|
|
381
|
+
def kiosk_rescue_to_wire(exception)
|
|
382
|
+
raise exception if exception.is_a?(Kiosk::Server::Errors::Base)
|
|
383
|
+
|
|
384
|
+
invalid = kiosk_invalid_model(exception)
|
|
385
|
+
raise Kiosk::Server::Errors::BadRequest, invalid.errors.full_messages.join('; ') if invalid
|
|
386
|
+
|
|
387
|
+
code = kiosk_rescued_code(exception)
|
|
388
|
+
raise exception if code.nil?
|
|
389
|
+
|
|
390
|
+
kiosk_render_rescued(exception, code)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def kiosk_rescued_code(exception)
|
|
394
|
+
status = ::ActionDispatch::ExceptionWrapper.rescue_responses[exception.class.name]
|
|
395
|
+
Kiosk::Server::Errors::STATUS_CODES[::Rack::Utils.status_code(status)]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def kiosk_render_rescued(exception, code)
|
|
399
|
+
Kiosk::Server::FailureLog.report(
|
|
400
|
+
"verb #{kiosk_wire_name.inspect} answered #{code} for #{exception.class}", exception
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Raised inside the rescue, so the exception reaches the audit sink as `cause`.
|
|
404
|
+
wire = Kiosk::Server::Errors.rescued_wire(code, verb: kiosk_wire_name)
|
|
405
|
+
raise Kiosk::Server::Errors::WireError.new(wire[:message], code: code, hint: wire[:hint])
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# A failed model validation is an argument outside its domain: 400, in the model's own words.
|
|
409
|
+
def kiosk_invalid_model(exception)
|
|
410
|
+
case exception
|
|
411
|
+
when ::ActiveModel::ValidationError then exception.model
|
|
412
|
+
when ::ActiveRecord::RecordInvalid then exception.record
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Rebuilds the verb and topic registries from every class that includes
|
|
6
|
+
# Kiosk::Handler. The engine calls {reload!} from `to_prepare`, so a verb
|
|
7
|
+
# added, edited or removed in development is served after the next reload.
|
|
8
|
+
module HandlerRegistrations
|
|
9
|
+
HANDLERS_DIR = 'app/controllers/kiosk'
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def add(handler)
|
|
13
|
+
handlers << handler.name if handler.name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def reload!
|
|
17
|
+
clear!
|
|
18
|
+
load_handlers_dir
|
|
19
|
+
handlers.select! { |name| name.safe_constantize&.kiosk_register! }
|
|
20
|
+
PaymentSetup.register! if Kiosk.configuration.payment_provider
|
|
21
|
+
Kyc.register! if Kiosk.configuration.kyc_provider
|
|
22
|
+
refuse_cross_kind_collisions!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def clear!
|
|
26
|
+
[Actions, Queries, Events].each do |registry|
|
|
27
|
+
registry.known.each { |name| registry.unregister(name) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handlers
|
|
32
|
+
@handlers ||= Set.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def load_handlers_dir
|
|
38
|
+
return unless defined?(::Rails) && ::Rails.application
|
|
39
|
+
|
|
40
|
+
dir = ::Rails.root.join(HANDLERS_DIR)
|
|
41
|
+
::Rails.autoloaders.main.eager_load_dir(dir) if dir.directory?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refuse_cross_kind_collisions!
|
|
45
|
+
both = Actions.known & Queries.known
|
|
46
|
+
return if both.empty?
|
|
47
|
+
|
|
48
|
+
raise Errors::ConfigurationError,
|
|
49
|
+
"#{both.sort.join(', ')} #{both.one? ? 'is' : 'are'} declared as BOTH a query and " \
|
|
50
|
+
"an action. GET #{Kiosk.configuration.mount_path}/<name> is a query and " \
|
|
51
|
+
"POST #{Kiosk.configuration.mount_path}/<name> is an action, so one name cannot be " \
|
|
52
|
+
'both. Rename one of them, or give it a `wire_name` of its own.'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# How a controller that includes {Kiosk::Handler} answers a verb call.
|
|
8
|
+
# {Routing::VerbRoute} sends the wire request to the action that declares
|
|
9
|
+
# the verb. Around that action this module runs the gates of {VerbWire},
|
|
10
|
+
# the session and the transaction, and writes the action's render as the
|
|
11
|
+
# wire answer.
|
|
12
|
+
module HandlerServing
|
|
13
|
+
include VerbWire
|
|
14
|
+
|
|
15
|
+
# The Rack env key where {Routing::VerbRoute} puts the wire name it routed.
|
|
16
|
+
VERB_KEY = 'kiosk.verb'
|
|
17
|
+
|
|
18
|
+
# The one header of an action's render that reaches the wire: `Cache-Control`, on a 2xx (§3.7.4).
|
|
19
|
+
PROPAGATED_HEADERS = %w[Cache-Control].freeze
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Outside the operator's callbacks and `rescue_from`, which all run inside `super`.
|
|
24
|
+
def process_action(*)
|
|
25
|
+
declaration = self.class.kiosk_declarations[request.get_header(VERB_KEY)]
|
|
26
|
+
return kiosk_refuse_unrouted unless declaration && declaration[:method_name] == action_name
|
|
27
|
+
|
|
28
|
+
# Parses the parameters first, so a malformed body is refused before the identity.
|
|
29
|
+
request.parameters
|
|
30
|
+
kiosk_render_answer(kiosk_call(declaration) { super })
|
|
31
|
+
rescue Errors::Base => e
|
|
32
|
+
kiosk_render_refusal(e)
|
|
33
|
+
rescue ::ActionDispatch::Http::Parameters::ParseError
|
|
34
|
+
kiosk_render_refusal(Errors.malformed_json(hint: BODY_HINT))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The verb's {Result}: the wire's gates, then the action inside the session.
|
|
38
|
+
def kiosk_call(declaration, &)
|
|
39
|
+
kind = declaration[:kind]
|
|
40
|
+
name = @_kiosk_wire_name = declaration[:wire_name]
|
|
41
|
+
command = kind == :query ? :query : :run
|
|
42
|
+
identity = resolve_identity!
|
|
43
|
+
args = arguments_for(command, name, HandlerMixin.registry_for(kind).describe(name))
|
|
44
|
+
handler = ->(handler_args) { kiosk_answer(kind, handler_args, &) }
|
|
45
|
+
call_wire(command: command, args: args, identity: identity, name: name, handler: handler)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs the action with the decoded arguments as its parameters, and reads its render as the answer.
|
|
49
|
+
def kiosk_answer(kind, args)
|
|
50
|
+
kiosk_wire_parameters!(args)
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
status, headers, body = response.to_a
|
|
54
|
+
@_kiosk_headers = kiosk_propagated_headers(status, headers)
|
|
55
|
+
Answer.new(kind, @_kiosk_wire_name).value(status, kiosk_read_body(body), @_kiosk_page)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The action's `params` are the decoded arguments; the wire answers JSON whatever the caller's
|
|
59
|
+
# `Accept`, so an action's template is its `.json` one.
|
|
60
|
+
def kiosk_wire_parameters!(args)
|
|
61
|
+
request.set_header('action_dispatch.request.request_parameters', args.dup)
|
|
62
|
+
request.set_header('action_dispatch.request.query_parameters', {})
|
|
63
|
+
request.delete_header('action_dispatch.request.parameters')
|
|
64
|
+
request.set_header('action_dispatch.request.formats', [Mime[:json]])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def kiosk_render_answer(result)
|
|
68
|
+
kiosk_fresh_response!
|
|
69
|
+
@_kiosk_headers.each { |header, value| response.headers[header] = value }
|
|
70
|
+
render_result(result)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def kiosk_propagated_headers(status, headers)
|
|
74
|
+
return {} unless (200...300).cover?(status)
|
|
75
|
+
|
|
76
|
+
PROPAGATED_HEADERS.index_with { headers[it].to_s }.reject { |_, value| value.empty? }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def kiosk_read_body(body)
|
|
80
|
+
raw = +''
|
|
81
|
+
body.each { |chunk| raw << chunk }
|
|
82
|
+
raw
|
|
83
|
+
ensure
|
|
84
|
+
body.close if body.respond_to?(:close)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def kiosk_render_refusal(error)
|
|
88
|
+
kiosk_fresh_response!
|
|
89
|
+
render_wire_error(error)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The action's render was read; the wire answer is written into a fresh response.
|
|
93
|
+
def kiosk_fresh_response!
|
|
94
|
+
set_response!(self.class.make_response!(request))
|
|
95
|
+
@_response_body = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A route drawn straight at a handler action, not through `kiosk do`, answers 404 `verb_not_found`.
|
|
99
|
+
def kiosk_refuse_unrouted
|
|
100
|
+
problem = Errors::VerbNotFound.new(
|
|
101
|
+
'Kiosk handlers are reachable through the Kiosk wire only',
|
|
102
|
+
hint: "call the verb's own route — " \
|
|
103
|
+
"GET #{Kiosk.configuration.mount_path}/<query-name> or " \
|
|
104
|
+
"POST #{Kiosk.configuration.mount_path}/<action-name>"
|
|
105
|
+
).to_problem
|
|
106
|
+
|
|
107
|
+
render json: problem, status: :not_found, content_type: Errors::PROBLEM_CONTENT_TYPE
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# An action's render as the verb's answer: its payload, or the wire error it means.
|
|
111
|
+
class Answer
|
|
112
|
+
def initialize(kind, wire_name)
|
|
113
|
+
@kind = kind
|
|
114
|
+
@wire_name = wire_name
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A query answers its rows, or a {Page} when `render_kiosk_page` recorded the page facts.
|
|
118
|
+
def value(status, raw, page)
|
|
119
|
+
payload = decode(status, raw)
|
|
120
|
+
return payload unless @kind == :query
|
|
121
|
+
|
|
122
|
+
rows!(payload)
|
|
123
|
+
page ? Page.new(rows: payload, **page) : payload
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# 2xx → the rendered JSON; otherwise the wire error it means.
|
|
129
|
+
def decode(status, raw)
|
|
130
|
+
return parse_json(raw) if status >= 200 && status < 300
|
|
131
|
+
|
|
132
|
+
parsed = begin
|
|
133
|
+
parse_json(raw)
|
|
134
|
+
rescue Errors::Base
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
raise wire_error(status, parsed)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A query answers an array of rows, paginated or not (§8.2).
|
|
141
|
+
def rows!(payload)
|
|
142
|
+
return if payload.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
raise Errors::ActionFailed.new(
|
|
145
|
+
"query #{@wire_name.inspect} answered something that is not an array of rows",
|
|
146
|
+
hint: 'A query renders a JSON array of rows, paginated or not.'
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A body code travels only when it is in the vocabulary and matches the status; otherwise
|
|
151
|
+
# the status's lone code decides, and with none it is `action_failed`.
|
|
152
|
+
def wire_error(status, parsed)
|
|
153
|
+
error_obj = parsed.is_a?(Hash) && parsed['error'].is_a?(Hash) ? parsed['error'] : nil
|
|
154
|
+
code = code_for(error_obj, status)
|
|
155
|
+
return Errors::ActionFailed.new(error_message(parsed, status), hint: error_hint(parsed)) if code.nil?
|
|
156
|
+
|
|
157
|
+
extra = error_obj&.except('code', 'message', 'hint')&.transform_keys(&:to_sym)
|
|
158
|
+
Errors::WireError.new(error_message(parsed, status),
|
|
159
|
+
code: code, hint: error_hint(parsed), extra: extra)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def code_for(error_obj, status)
|
|
163
|
+
rendered = error_obj && error_obj['code'].to_s
|
|
164
|
+
rendered && Errors::CODES[rendered] == status ? rendered : Errors::STATUS_CODES[status]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_json(raw)
|
|
168
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
169
|
+
|
|
170
|
+
JSON.parse(raw)
|
|
171
|
+
rescue JSON::ParserError
|
|
172
|
+
raise Errors::ActionFailed.new(
|
|
173
|
+
"#{@kind} #{@wire_name.inspect} rendered a non-JSON body",
|
|
174
|
+
hint: 'a Kiosk handler answers with `render json:` — HTML, redirects and ' \
|
|
175
|
+
'`send_file` have no place on the wire'
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The operator's own message reaches the agent when the body carries one.
|
|
180
|
+
def error_message(parsed, status)
|
|
181
|
+
from_body = parsed.is_a?(Hash) ? (parsed['error'] || parsed['message']) : nil
|
|
182
|
+
from_body = from_body['message'] if from_body.is_a?(Hash)
|
|
183
|
+
return from_body.to_s unless from_body.nil? || from_body.to_s.empty?
|
|
184
|
+
|
|
185
|
+
"#{@kind} #{@wire_name.inspect} answered #{status}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def error_hint(parsed)
|
|
189
|
+
return nil unless parsed.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
hint = parsed['hint'] || (parsed['error'].is_a?(Hash) ? parsed['error']['hint'] : nil)
|
|
192
|
+
hint&.to_s
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|