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,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
|