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,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Rails engine. `kiosk` in config/routes.rb mounts it at the configured mount_path,
4
+ # drawing the protocol plane under the mount and the discovery documents at the root.
5
+ # `rails` first: rails/engine needs its core_ext.
6
+ require 'rails'
7
+ require 'rails/engine'
8
+
9
+ module Kiosk
10
+ module Server
11
+ class Engine < ::Rails::Engine
12
+ isolate_namespace Kiosk::Server
13
+
14
+ # True when this engine is mounted anywhere in +route_set+.
15
+ def self.mounted_in?(route_set)
16
+ route_set.routes.any? do |route|
17
+ app = route.app
18
+ app.respond_to?(:app) && (app.app == self || app.app.is_a?(self))
19
+ end
20
+ end
21
+
22
+ # Outside ShowExceptions, so routing 404s and unhandled 500s carry the
23
+ # version headers too (§3.6); below HostAuthorization/SSL, which answer
24
+ # before the application is reached.
25
+ initializer 'kiosk-server.middleware' do |app|
26
+ app.middleware.insert_before ::ActionDispatch::ShowExceptions,
27
+ Kiosk::Server::HeadersMiddleware
28
+ app.middleware.insert_before ::ActionDispatch::ShowExceptions,
29
+ Kiosk::Server::IssuerMiddleware
30
+ end
31
+
32
+ # Credential-bearing wire fields, kept out of the `Parameters:` log line.
33
+ # Whole-key matches, so an operator's own `promo_code` is left alone.
34
+ FILTERED_PARAMETERS = %w[
35
+ signed code device_code kyc_jws
36
+ intent_mandate_jws cart_mandate_jws payment_mandate_jws
37
+ ].map { |field| /\A#{field}\z/ }.freeze
38
+
39
+ initializer 'kiosk-server.filter_parameters' do |app|
40
+ app.config.filter_parameters += FILTERED_PARAMETERS
41
+ end
42
+
43
+ # Rebuilds the registries at boot and after every development reload.
44
+ config.to_prepare do
45
+ Kiosk::Server::SchemaSlots.reset!
46
+ Kiosk::Server::HandlerRegistrations.reload!
47
+ Kiosk::Server::SchemaDocument.reset!
48
+ Kiosk::Server::OpenApi.reset!
49
+ end
50
+
51
+ # Derives the `schema` catalog after eager loading, in every environment.
52
+ config.after_initialize do
53
+ Kiosk::Server::SchemaDocument.derive!
54
+ end
55
+
56
+ config.after_initialize do
57
+ next unless Kiosk::Server::Actions.known.empty? && Kiosk::Server::Queries.known.empty?
58
+
59
+ message = '[kiosk-server] no queries or actions are registered. Put handler controllers ' \
60
+ "(classes including Kiosk::Handler) in #{Kiosk::Server::HandlerRegistrations::HANDLERS_DIR}."
61
+ ::Rails.logger ? ::Rails.logger.warn(message) : warn(message)
62
+ end
63
+
64
+ # A wrong `issuer` refuses every proof with "proof audience mismatch".
65
+ # Warned, not raised: an operator seeds before the public origin exists.
66
+ LOOPBACK_ISSUER =
67
+ %r{\A(?:https?://)?(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?/?\z}i
68
+
69
+ # The boot-time configuration checks. Each answers a message, or nil when the configuration is sound.
70
+ module BootChecks
71
+ ISSUER_UNSET =
72
+ '[kiosk-server] `c.issuer` is not set, so every authenticated request is refused ' \
73
+ 'with "proof audience mismatch". Set it to the origin assistants dial: ' \
74
+ 'Kiosk.configure { |c| c.issuer = "https://api.example.com" }.'
75
+
76
+ ISSUER_LOOPBACK =
77
+ '[kiosk-server] The served origin %<origin>s is a loopback origin; no assistant can ' \
78
+ 'reach it outside development and test. Set `c.issuer` and `c.additional_origins` ' \
79
+ 'to public origins.'
80
+
81
+ DEFAULT_ROLE =
82
+ 'An assistant admitted without a role would get a token with no role claim. ' \
83
+ 'Set the least-privileged declared role as the default ' \
84
+ '(Kiosk.configure { |c| c.registration_role = :%<first>s }), or declare no roles (`c.roles = []`).'
85
+
86
+ def issuer_warning(config:, local:)
87
+ issuer = config.issuer.to_s.strip
88
+ return ISSUER_UNSET if issuer.empty?
89
+ return nil if local
90
+
91
+ loopback = [issuer, *config.additional_origins].map { |o| o.to_s.strip }
92
+ .find { |o| LOOPBACK_ISSUER.match?(o) }
93
+ format(ISSUER_LOOPBACK, origin: loopback.inspect) if loopback
94
+ end
95
+
96
+ # An origin that declares roles must configure a `registration_role` among
97
+ # them; an origin with no roles needs none.
98
+ def default_role_configuration_error(config:)
99
+ declared = Array(config.roles).map(&:to_s).reject(&:empty?)
100
+ return nil if declared.empty?
101
+
102
+ configured = config.registration_role.to_s.strip
103
+ return nil if declared.include?(configured)
104
+
105
+ "#{default_role_head(config, declared, configured)} #{format(DEFAULT_ROLE, first: declared.first)}"
106
+ end
107
+
108
+ private
109
+
110
+ def default_role_head(config, declared, configured)
111
+ roles = declared.join(', ')
112
+ return "[kiosk-server] this origin declares roles (#{roles}) and configures no `registration_role`." if configured.empty?
113
+
114
+ "[kiosk-server] this origin declares roles (#{roles}) and its " \
115
+ "`registration_role` is #{config.registration_role.inspect}, which is not one of them."
116
+ end
117
+ end
118
+ extend BootChecks
119
+
120
+ config.after_initialize do
121
+ message = Kiosk::Server::Engine.issuer_warning(
122
+ config: Kiosk.configuration, local: ::Rails.env.local?
123
+ )
124
+ next unless message
125
+
126
+ ::Rails.logger ? ::Rails.logger.warn(message) : warn(message)
127
+ end
128
+
129
+ config.after_initialize do
130
+ message = Kiosk::Server::Engine.default_role_configuration_error(config: Kiosk.configuration)
131
+ raise Kiosk::Server::Errors::ConfigurationError, message if message
132
+ end
133
+
134
+ # A production origin that serves events needs a shared cable adapter; this raises without one.
135
+ config.after_initialize do
136
+ Kiosk::Server::EventsCable.cable_config if ::Rails.env.production? && Kiosk::Server::Events.known.any?
137
+ end
138
+
139
+ # `routes.append` runs after config/routes.rb is drawn, so the mount is visible here.
140
+ initializer 'kiosk-server.root_discovery_routes' do |app|
141
+ app.routes.append do
142
+ next unless Kiosk::Server::Engine.mounted_in?(app.routes)
143
+
144
+ get '/agents.txt', to: 'kiosk/server/discovery#agents_txt'
145
+ get '/agents.json', to: 'kiosk/server/discovery#agents_json'
146
+ get '/auth.md', to: 'kiosk/server/discovery#auth_md'
147
+ get '/.well-known/agent-configuration', to: 'kiosk/server/discovery#agent_configuration'
148
+ get '/.well-known/kiosk.json', to: 'kiosk/server/discovery#kiosk_json'
149
+ get '/.well-known/api-catalog', to: 'kiosk/server/discovery#api_catalog'
150
+ end
151
+ end
152
+
153
+ routes do
154
+ # The wire's reserved endpoints. The mount is drawn first in the host's
155
+ # routes, so these win by first-match. Optional modules are drawn
156
+ # unconditionally and answer `module_not_served` (501) when not configured.
157
+ # `schema` resolves no identity — it is PUBLIC, like `openapi.json`
158
+ get 'schema', to: 'wire#schema'
159
+ post 'pay', to: 'wire#pay'
160
+
161
+ post 'payment_setup', to: 'verb#create', defaults: { kiosk_verb: 'payment_setup' }
162
+ get 'payment_setup/return', to: 'payment_setup#show'
163
+
164
+ # Our own cable app, so a host's Action Cable config is never touched.
165
+ # `events` is also in {HandlerMixin::RESERVED_NAMES}; keep the two in step.
166
+ mount Kiosk::Server::EventsCable::RACK_APP => 'events', internal: true, anchor: true, as: :kiosk_events
167
+
168
+ get 'auth/challenge', to: 'auth#challenge'
169
+ post 'auth/register', to: 'auth#register'
170
+ post 'auth/login', to: 'auth#login'
171
+ post 'auth/revoke', to: 'auth#revoke'
172
+
173
+ # JWKS sits under the mount, not the origin root.
174
+ get '.well-known/jwks.json', to: 'jwks#show'
175
+
176
+ # Drawn here so the literal `.json` path wins over the verb routes.
177
+ get 'openapi.json', to: 'open_api#show'
178
+
179
+ post 'agents/kyc', to: 'kyc_attestation#create'
180
+
181
+ post 'request_kyc', to: 'verb#create', defaults: { kiosk_verb: 'request_kyc' }
182
+ post 'kyc/callback', to: 'kyc_callback#create'
183
+
184
+ # RFC 8628 device flow.
185
+ post 'oauth/device_authorization', to: 'oauth_device_authorization#create'
186
+ post 'oauth/token', to: 'oauth_token#create'
187
+ get 'oauth/device/verify', to: 'device_verify#show'
188
+ post 'oauth/device/verify', to: 'device_verify#create'
189
+
190
+ post 'auth/link', to: 'auth#link'
191
+ post 'auth/claim', to: 'auth#claim'
192
+ post 'auth/unlink', to: 'auth#unlink'
193
+
194
+ get 'auth/assistants', to: 'assistants#show'
195
+ post 'auth/assistants/link', to: 'assistants#link'
196
+ post 'auth/assistants/update', to: 'assistants#update'
197
+ post 'auth/assistants/unlink', to: 'assistants#unlink'
198
+
199
+ # The operator routes its own verbs with `kiosk do … end` in config/routes.rb.
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The wire error contract: the closed `code` vocabulary and the problem
6
+ # document (RFC 9457) every refusal is rendered as.
7
+ module Errors
8
+ # The wire's seventeen refusal codes and their HTTP status (spec §9). Adding one is a spec change first.
9
+ CODES = {
10
+ 'bad_request' => 400,
11
+ 'unauthenticated' => 401,
12
+ 'pow_required' => 402,
13
+ 'payment_setup_required' => 402,
14
+ 'payment_failed' => 402,
15
+ 'forbidden' => 403,
16
+ 'rls_denied' => 403,
17
+ 'spending_cap_exceeded' => 403,
18
+ 'kyc_required' => 403,
19
+ 'verb_not_found' => 404,
20
+ 'not_found' => 404,
21
+ 'method_not_allowed' => 405,
22
+ 'conflict' => 409,
23
+ 'quota_exceeded' => 429,
24
+ 'action_failed' => 500,
25
+ 'internal_error' => 500,
26
+ 'module_not_served' => 501
27
+ }.freeze
28
+
29
+ # RFC 9457 `title`: constant per code; the incident goes in `detail`.
30
+ TITLES = {
31
+ 'bad_request' => 'Malformed request',
32
+ 'unauthenticated' => 'Not authenticated',
33
+ 'pow_required' => 'Proof-of-work required',
34
+ 'payment_setup_required' => 'Payment setup required',
35
+ 'payment_failed' => 'Payment failed',
36
+ 'forbidden' => 'Forbidden',
37
+ 'rls_denied' => 'Row-level security denied the statement',
38
+ 'spending_cap_exceeded' => 'Spending cap exceeded',
39
+ 'kyc_required' => 'KYC attestation required',
40
+ 'verb_not_found' => 'No such verb',
41
+ 'not_found' => 'Not found',
42
+ 'method_not_allowed' => 'Method not allowed',
43
+ 'conflict' => 'State conflict',
44
+ 'quota_exceeded' => 'Quota exceeded',
45
+ 'action_failed' => 'Action failed',
46
+ 'internal_error' => 'Internal error',
47
+ 'module_not_served' => 'Module not served'
48
+ }.freeze
49
+
50
+ # An identifier, not a page: clients branch on `code`, never on this URI.
51
+ PROBLEM_TYPE_BASE = 'https://kiosk.tech/problems/'
52
+
53
+ PROBLEM_CONTENT_TYPE = 'application/problem+json'
54
+
55
+ def self.problem_type(code) = "#{PROBLEM_TYPE_BASE}#{code}"
56
+
57
+ def self.problem_title(code) = TITLES.fetch(code, code)
58
+
59
+ # The code a bare status (rendered, or from Rails' `rescue_responses`) maps to.
60
+ # 402 and 500 are absent: several codes share each, so a handler names the code.
61
+ # 501 is absent: `module_not_served` is raised by name, never inferred.
62
+ # 404 is `not_found`: `verb_not_found` is raised by the registry before any handler runs.
63
+ STATUS_CODES = {
64
+ 400 => 'bad_request',
65
+ 401 => 'unauthenticated',
66
+ 403 => 'forbidden',
67
+ 404 => 'not_found',
68
+ 405 => 'method_not_allowed',
69
+ 409 => 'conflict',
70
+ 422 => 'bad_request',
71
+ 429 => 'quota_exceeded'
72
+ }.freeze
73
+
74
+ # The hint for a verb name nothing registered: the names that are.
75
+ module UnknownName
76
+ MAX_NAMES = 20
77
+ PLURALS = { 'query' => 'queries', 'action' => 'actions' }.freeze
78
+
79
+ def self.hint(name, verb, names)
80
+ "unknown #{verb} '#{name}'. #{available(verb, names)} Call GET .../schema for the full catalog."
81
+ end
82
+
83
+ def self.available(verb, names)
84
+ return "No #{PLURALS.fetch(verb, "#{verb}s")} are registered." if names.empty?
85
+
86
+ listed = names.first(MAX_NAMES).join(', ')
87
+ listed += ', …' if names.size > MAX_NAMES
88
+ "Available: #{listed}."
89
+ end
90
+ end
91
+
92
+ # Errors.unknown_name_hint("listings", "query", %w[browse_listings listing_detail])
93
+ # # => "unknown query 'listings'. Available: browse_listings, listing_detail. " \
94
+ # # "Call GET .../schema for the full catalog."
95
+ def self.unknown_name_hint(name, verb, names) = UnknownName.hint(name, verb, names)
96
+
97
+ # The two malformed-request sentences are built here and nowhere else;
98
+ # an exception's or a parser's own message never reaches `detail`.
99
+
100
+ # @param err [KeyError, #to_s] the rescued `KeyError`, or the field name
101
+ def self.missing_field(err, hint: nil)
102
+ name = err.is_a?(::KeyError) ? key_of(err) : err
103
+ return BadRequest.new("missing field: #{name}", hint: hint) if name
104
+
105
+ # A hand-raised KeyError carries no key.
106
+ BadRequest.new('request is missing a required field', hint: hint)
107
+ end
108
+
109
+ def self.key_of(err)
110
+ err.key.to_s
111
+ rescue ::ArgumentError
112
+ nil
113
+ end
114
+
115
+ MALFORMED_JSON_HINT = 'the request body must be a single well-formed JSON object'
116
+
117
+ def self.malformed_json(hint: MALFORMED_JSON_HINT)
118
+ BadRequest.new('invalid JSON body', hint: hint)
119
+ end
120
+
121
+ # What a Rails-native raise answers with: our sentence for the decided code.
122
+ # The exception's own text goes to the operator's log, never to the wire.
123
+ # One entry per {STATUS_CODES} value.
124
+ module Rescued
125
+ DETAILS = {
126
+ 'bad_request' => 'rejected the request as malformed',
127
+ 'unauthenticated' => 'requires a credential this request did not carry',
128
+ 'forbidden' => 'refused the request',
129
+ 'not_found' => 'found no such record',
130
+ 'method_not_allowed' => 'does not accept the request as sent',
131
+ 'conflict' => 'refused the request as conflicting with current state',
132
+ 'quota_exceeded' => 'refused the request because a quota is exhausted'
133
+ }.freeze
134
+
135
+ HINTS = {
136
+ 'bad_request' => "check the arguments against this verb's input_schema — " \
137
+ 'GET .../schema publishes it.',
138
+ 'unauthenticated' => 'present a valid access token for this origin and retry.',
139
+ 'forbidden' => 'this principal may not make this call; retrying it unchanged ' \
140
+ 'will be refused again.',
141
+ 'not_found' => 'an argument names something this origin does not have; ' \
142
+ 're-read it from a query before retrying.',
143
+ 'method_not_allowed' => 'a query is GET .../<query-name> and an action is ' \
144
+ 'POST .../<action-name>; GET .../schema says which each verb is.',
145
+ 'conflict' => 'the state moved under you; re-read it with a query and retry ' \
146
+ 'with what it says.',
147
+ 'quota_exceeded' => 'the operator sets this quota; wait before retrying.'
148
+ }.freeze
149
+
150
+ def self.wire(code, verb)
151
+ subject = verb.nil? || verb.to_s.empty? ? 'this verb' : "verb #{verb.to_s.inspect}"
152
+ { code: code, message: "#{subject} #{DETAILS.fetch(code)}", hint: HINTS.fetch(code) }
153
+ end
154
+ end
155
+
156
+ RESCUED_DETAILS = Rescued::DETAILS
157
+ RESCUED_HINTS = Rescued::HINTS
158
+
159
+ # @return [Hash] `{code:, message:, hint:}` of the wire error a rescued exception becomes
160
+ def self.rescued_wire(code, verb: nil) = Rescued.wire(code, verb)
161
+
162
+ class Base < StandardError
163
+ CODE = 'internal_error'
164
+ HTTP_STATUS = 500
165
+
166
+ attr_reader :hint
167
+
168
+ def initialize(message = nil, hint: nil)
169
+ super(message)
170
+ @hint = hint
171
+ end
172
+
173
+ def code = self.class.const_get(:CODE)
174
+ def http_status = self.class.const_get(:HTTP_STATUS)
175
+
176
+ # Problem-document members beyond `code`/`message`/`hint`.
177
+ def extensions = {}
178
+
179
+ def response_headers = {}
180
+
181
+ # `code` is a top-level extension member (RFC 9457 §3.2): the one field a client branches on.
182
+ def to_problem
183
+ {
184
+ type: Errors.problem_type(code),
185
+ title: Errors.problem_title(code),
186
+ status: http_status,
187
+ detail: message,
188
+ code: code,
189
+ hint: hint
190
+ }.merge(extensions).compact
191
+ end
192
+ end
193
+
194
+ # A wire error named by its code rather than its class. `extra:` adds extension members;
195
+ # the members the code and message decide stay theirs, and `instance` is never emitted (§9).
196
+ class WireError < Base
197
+ DECIDED = %i[type title status detail instance code hint].freeze
198
+
199
+ def initialize(message = nil, code:, hint: nil, extra: nil)
200
+ code = code.to_s
201
+ unless CODES.key?(code)
202
+ raise ArgumentError,
203
+ "unknown wire code #{code.inspect} — the vocabulary is Errors::CODES, closed by the spec"
204
+ end
205
+
206
+ super(message, hint: hint)
207
+ @wire_code = code
208
+ @extra = extensions_for(code, extra || {})
209
+ end
210
+
211
+ def code = @wire_code
212
+ def http_status = CODES.fetch(@wire_code)
213
+ def extensions = @extra
214
+
215
+ private
216
+
217
+ # `challenges` appears only on `pow_required` (§9).
218
+ def extensions_for(code, extra)
219
+ extra = extra.except(*DECIDED)
220
+ code == 'pow_required' ? extra : extra.except(:challenges)
221
+ end
222
+ end
223
+
224
+ class BadRequest < Base
225
+ CODE = 'bad_request'
226
+ HTTP_STATUS = 400
227
+ end
228
+
229
+ class Unauthenticated < Base
230
+ CODE = 'unauthenticated'
231
+ HTTP_STATUS = 401
232
+ end
233
+
234
+ class Forbidden < Base
235
+ CODE = 'forbidden'
236
+ HTTP_STATUS = 403
237
+ end
238
+
239
+ # An argument names something absent (§9.1 rule 2); an unknown verb is {VerbNotFound}.
240
+ class NotFound < Base
241
+ CODE = 'not_found'
242
+ HTTP_STATUS = 404
243
+ end
244
+
245
+ # No verb by that name is registered; the hint lists the ones that are.
246
+ class VerbNotFound < Base
247
+ CODE = 'verb_not_found'
248
+ HTTP_STATUS = 404
249
+ end
250
+
251
+ # The verb exists but not for this HTTP method.
252
+ class MethodNotAllowed < Base
253
+ CODE = 'method_not_allowed'
254
+ HTTP_STATUS = 405
255
+
256
+ attr_reader :allow
257
+
258
+ def initialize(message = nil, allow:, hint: nil)
259
+ super(message, hint: hint)
260
+ @allow = allow.to_s
261
+ end
262
+
263
+ def response_headers = { 'Allow' => allow }
264
+ end
265
+
266
+ class Conflict < Base
267
+ CODE = 'conflict'
268
+ HTTP_STATUS = 409
269
+ end
270
+
271
+ class RLSDenied < Base
272
+ CODE = 'rls_denied'
273
+ HTTP_STATUS = 403
274
+ end
275
+
276
+ # The acting assistant's spending cap would be exceeded; checked before capture.
277
+ class SpendingCapExceeded < Base
278
+ CODE = 'spending_cap_exceeded'
279
+ HTTP_STATUS = 403
280
+ end
281
+
282
+ class KycRequired < Base
283
+ CODE = 'kyc_required'
284
+ HTTP_STATUS = 403
285
+ end
286
+
287
+ # The operator's handler raised; `internal_error` is the platform's own failure.
288
+ class ActionFailed < Base
289
+ CODE = 'action_failed'
290
+ HTTP_STATUS = 500
291
+ end
292
+
293
+ class PaymentSetupRequired < Base
294
+ CODE = 'payment_setup_required'
295
+ HTTP_STATUS = 402
296
+
297
+ def initialize(message = 'payment setup required',
298
+ hint: 'call payment_setup to obtain a card setup link')
299
+ super
300
+ end
301
+ end
302
+
303
+ # The charge did not settle. The adapter supplies a human-safe message, never the PSP's.
304
+ class PaymentFailed < Base
305
+ CODE = 'payment_failed'
306
+ HTTP_STATUS = 402
307
+
308
+ def initialize(message = 'payment failed',
309
+ hint: 'the charge did not settle; verify via my_orders before retrying')
310
+ super
311
+ end
312
+ end
313
+
314
+ class PowRequired < Base
315
+ CODE = 'pow_required'
316
+ HTTP_STATUS = 402
317
+
318
+ attr_reader :challenges
319
+
320
+ def initialize(challenges:)
321
+ super('proof-of-work required')
322
+ @challenges = challenges
323
+ end
324
+
325
+ def extensions = { challenges: challenges }
326
+ end
327
+
328
+ # This origin does not serve the optional module the path reaches (§6, §11, §12).
329
+ # `message` names the module.
330
+ class ModuleNotServed < Base
331
+ CODE = 'module_not_served'
332
+ HTTP_STATUS = 501
333
+ end
334
+
335
+ # Raised when an optional feature is called, not at boot.
336
+ class ConfigurationError < StandardError; end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ module EventStores
6
+ # The default event store: the `<schema>.events` table, shared by every process on the database.
7
+ class ActiveRecord
8
+ # The published retention floor: at least this much history per identity.
9
+ DEFAULT_RETENTION_HOURS = 24
10
+
11
+ # Seconds between opportunistic retention sweeps.
12
+ DEFAULT_PRUNE_INTERVAL = 300
13
+
14
+ # Retention is by time only; a row-count ceiling would become the real
15
+ # retention on a busy subject.
16
+ def initialize(retention_hours: DEFAULT_RETENTION_HOURS,
17
+ prune_interval: DEFAULT_PRUNE_INTERVAL)
18
+ @retention_hours = retention_hours
19
+ @prune_interval = prune_interval
20
+ @last_prune_at = 0
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # `event` is string-keyed, without "id"; returns the id the sequence assigned.
25
+ def append(identity_key, event)
26
+ prune_if_due!
27
+
28
+ binds = [identity_key.to_s, event['topic'], event['subject'],
29
+ event['occurred_at'], JSON.generate(event['data'])]
30
+ rows = connection.exec_query(insert_sql, 'Kiosk events append', binds).to_a
31
+ rows.first['id'].to_i
32
+ end
33
+
34
+ # This identity's events after the cursor `id`, ascending.
35
+ def since(identity_key, id)
36
+ sql = <<~SQL.squish
37
+ SELECT id, topic, subject, occurred_at, data
38
+ FROM #{table}
39
+ WHERE identity_key = $1 AND id > $2
40
+ ORDER BY id ASC
41
+ SQL
42
+ connection.exec_query(sql, 'Kiosk events since', [identity_key.to_s, id.to_i])
43
+ .to_a.map { |row| to_event(row) }
44
+ end
45
+
46
+ # The origin's maximum id, 0 when empty.
47
+ def head
48
+ row = connection.exec_query(
49
+ %(SELECT COALESCE(MAX(id), 0) AS head FROM #{table}), 'Kiosk events head'
50
+ ).to_a.first
51
+ row['head'].to_i
52
+ end
53
+
54
+ # True when rows after the caller's cursor were swept; a cursor at or
55
+ # past the newest row missed nothing.
56
+ def truncated?(_identity_key, id)
57
+ row = connection.exec_query(
58
+ %(SELECT COALESCE(MIN(id), 0) AS floor, COALESCE(MAX(id), 0) AS head FROM #{table}),
59
+ 'Kiosk events floor'
60
+ ).to_a.first
61
+ return false if row['head'].to_i <= id.to_i
62
+
63
+ row['floor'].to_i > id.to_i + 1
64
+ end
65
+
66
+ # Deletes everything older than the retention window; {#append} calls
67
+ # it at most once per +prune_interval+ per process.
68
+ def prune!
69
+ connection.exec_query(
70
+ %(DELETE FROM #{table} WHERE created_at < now() - ($1 || ' hours')::interval),
71
+ 'Kiosk events prune', [@retention_hours.to_s]
72
+ )
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def insert_sql
79
+ <<~SQL.squish
80
+ INSERT INTO #{table} (identity_key, topic, subject, occurred_at, data)
81
+ VALUES ($1, $2, $3, $4, $5)
82
+ RETURNING id
83
+ SQL
84
+ end
85
+
86
+ # The wire's five event members, string-keyed, as {EventStore} gives them.
87
+ def to_event(row)
88
+ {
89
+ 'id' => row['id'].to_i,
90
+ 'topic' => row['topic'],
91
+ 'subject' => row['subject'],
92
+ 'occurred_at' => as_iso8601(row['occurred_at']),
93
+ 'data' => row['data'].is_a?(String) ? JSON.parse(row['data']) : row['data']
94
+ }
95
+ end
96
+
97
+ # The adapter may return a Time or a String.
98
+ def as_iso8601(value)
99
+ return value.utc.strftime('%Y-%m-%dT%H:%M:%SZ') if value.respond_to?(:utc)
100
+
101
+ Time.parse(value.to_s).utc.strftime('%Y-%m-%dT%H:%M:%SZ')
102
+ end
103
+
104
+ def prune_if_due!
105
+ now = Time.now.to_i
106
+ due = @mutex.synchronize do
107
+ if now - @last_prune_at >= @prune_interval
108
+ @last_prune_at = now
109
+ true
110
+ else
111
+ false
112
+ end
113
+ end
114
+ prune! if due
115
+ end
116
+
117
+ def table = %("#{Kiosk.configuration.schema}".events)
118
+
119
+ # `connection` raises under `permanent_connection_checkout = :disallowed`.
120
+ def connection = ::ActiveRecord::Base.lease_connection
121
+ end
122
+ end
123
+ end
124
+ end