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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. 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