kiosk-server 0.6.1 → 0.6.28
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 +4 -4
- data/CHANGELOG.md +138 -0
- data/README.md +134 -102
- data/app/channels/kiosk_events.rb +30 -17
- data/app/controllers/kiosk/server/application_controller.rb +0 -1
- data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
- data/app/controllers/kiosk/server/auth_controller.rb +3 -3
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +2 -7
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +8 -7
- data/app/controllers/kiosk/server/oauth_token_controller.rb +4 -1
- data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
- data/app/controllers/kiosk/server/wire_controller.rb +2 -2
- data/app/models/kiosk/checkout_mandate.rb +0 -2
- data/app/models/kiosk/settlement.rb +0 -2
- data/app/models/kiosk/visit.rb +0 -2
- data/app/views/kiosk/server/assistants/show.html.erb +20 -24
- data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
- data/app/views/layouts/kiosk/server/application.html.erb +14 -0
- data/lib/generators/kiosk/install/install_generator.rb +25 -64
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
- data/lib/kiosk/payable.rb +1 -1
- data/lib/kiosk/server/account_binding.rb +14 -5
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +1 -1
- data/lib/kiosk/server/agent_rotation.rb +1 -1
- data/lib/kiosk/server/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/auth_toll.rb +5 -5
- data/lib/kiosk/server/client.rb +1 -1
- data/lib/kiosk/server/client_assertion.rb +3 -2
- data/lib/kiosk/server/client_registration.rb +6 -2
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +15 -38
- data/lib/kiosk/server/current_request.rb +1 -1
- data/lib/kiosk/server/device_authorization.rb +1 -1
- data/lib/kiosk/server/device_authorization_stores.rb +1 -1
- data/lib/kiosk/server/device_code_grant.rb +2 -2
- data/lib/kiosk/server/dpop.rb +6 -1
- data/lib/kiosk/server/engine.rb +2 -2
- data/lib/kiosk/server/errors.rb +21 -7
- data/lib/kiosk/server/event_stores.rb +2 -2
- data/lib/kiosk/server/events.rb +4 -3
- data/lib/kiosk/server/events_connection.rb +23 -5
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +1 -1
- data/lib/kiosk/server/executor/spending_cap.rb +8 -66
- data/lib/kiosk/server/executor.rb +10 -10
- data/lib/kiosk/server/failure_log.rb +3 -3
- data/lib/kiosk/server/handler_mixin.rb +9 -9
- data/lib/kiosk/server/handler_registrations.rb +1 -1
- data/lib/kiosk/server/handler_serving.rb +11 -11
- data/lib/kiosk/server/headers.rb +3 -3
- data/lib/kiosk/server/identity_resolution.rb +1 -1
- data/lib/kiosk/server/kyc.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +26 -27
- data/lib/kiosk/server/linked_assistants.rb +21 -16
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +1 -0
- data/lib/kiosk/server/oauth_error.rb +3 -0
- data/lib/kiosk/server/open_api.rb +9 -9
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +3 -3
- data/lib/kiosk/server/pow_gate.rb +12 -33
- data/lib/kiosk/server/pow_spent_stores.rb +1 -1
- data/lib/kiosk/server/receipts.rb +4 -3
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +2 -1
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +1 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +2 -2
- data/lib/kiosk/server/schemas/binding.schema.json +3 -3
- data/lib/kiosk/server/schemas/kyc.schema.json +22 -3
- data/lib/kiosk/server/schemas/mandates.schema.json +5 -5
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +3 -3
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +14 -11
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +6 -2
- metadata +9 -8
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- data/lib/kiosk/server/schema_definitions.rb +0 -320
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
|
@@ -5,8 +5,8 @@ require 'action_dispatch'
|
|
|
5
5
|
|
|
6
6
|
module Kiosk
|
|
7
7
|
module Server
|
|
8
|
-
# Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a
|
|
9
|
-
# a method with no pending declaration stays off the
|
|
8
|
+
# Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a verb;
|
|
9
|
+
# a method with no pending declaration stays off the protocol.
|
|
10
10
|
module HandlerMixin
|
|
11
11
|
KINDS = %i[action query].freeze
|
|
12
12
|
|
|
@@ -38,7 +38,7 @@ module Kiosk
|
|
|
38
38
|
base.extend(ClassMethods)
|
|
39
39
|
base.include(InstanceMethods)
|
|
40
40
|
|
|
41
|
-
# The
|
|
41
|
+
# The protocol authenticates by bearer token, never by cookie, so CSRF protection does not apply.
|
|
42
42
|
base.skip_forgery_protection if base.respond_to?(:skip_forgery_protection)
|
|
43
43
|
|
|
44
44
|
# Registered at include time, so an operator's own `rescue_from` declared later wins.
|
|
@@ -80,7 +80,7 @@ module Kiosk
|
|
|
80
80
|
|
|
81
81
|
def bad_topic_name(owner, name)
|
|
82
82
|
"#{owner} declared `topic #{name.inspect}`, which is not a legal Kiosk name. " \
|
|
83
|
-
"A topic name
|
|
83
|
+
"A topic name matches #{NAME_PATTERN.inspect}."
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def reserved_topic(owner, name)
|
|
@@ -300,12 +300,12 @@ module Kiosk
|
|
|
300
300
|
kiosk_declare(method_name, pending)
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
-
#
|
|
303
|
+
# Published name → declaration for the verbs on this class, one per name.
|
|
304
304
|
def kiosk_declarations
|
|
305
305
|
@kiosk_declarations ||= {}
|
|
306
306
|
end
|
|
307
307
|
|
|
308
|
-
#
|
|
308
|
+
# Published name → declaration for the topics on this class.
|
|
309
309
|
def kiosk_topic_declarations
|
|
310
310
|
@kiosk_topic_declarations ||= {}
|
|
311
311
|
end
|
|
@@ -361,7 +361,7 @@ module Kiosk
|
|
|
361
361
|
|
|
362
362
|
private
|
|
363
363
|
|
|
364
|
-
# The acting {Kiosk::Identity}; nil outside a
|
|
364
|
+
# The acting {Kiosk::Identity}; nil outside a protocol request.
|
|
365
365
|
def kiosk_identity = CurrentRequest.identity
|
|
366
366
|
|
|
367
367
|
# Differs from the method name only under `wire_name`.
|
|
@@ -376,8 +376,8 @@ module Kiosk
|
|
|
376
376
|
end
|
|
377
377
|
|
|
378
378
|
# A validation failure answers 400 with the errors' full messages. Any other raise Rails knows
|
|
379
|
-
# a status for maps to its lone
|
|
380
|
-
# never onto the
|
|
379
|
+
# a status for maps to its lone error code, its own message going to the operator's log and
|
|
380
|
+
# never onto the protocol; anything else is re-raised.
|
|
381
381
|
def kiosk_rescue_to_wire(exception)
|
|
382
382
|
raise exception if exception.is_a?(Kiosk::Server::Errors::Base)
|
|
383
383
|
|
|
@@ -18,7 +18,7 @@ module Kiosk
|
|
|
18
18
|
load_handlers_dir
|
|
19
19
|
handlers.select! { |name| name.safe_constantize&.kiosk_register! }
|
|
20
20
|
PaymentSetup.register! if Kiosk.configuration.payment_provider
|
|
21
|
-
Kyc.register! if Kiosk.configuration.kyc_provider
|
|
21
|
+
Kyc.register! if Kiosk.configuration.kyc_provider&.opens_verifications?
|
|
22
22
|
refuse_cross_kind_collisions!
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -5,17 +5,17 @@ require 'json'
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
module Server
|
|
7
7
|
# How a controller that includes {Kiosk::Handler} answers a verb call.
|
|
8
|
-
# {Routing::VerbRoute} sends the
|
|
8
|
+
# {Routing::VerbRoute} sends the protocol request to the action that declares
|
|
9
9
|
# the verb. Around that action this module runs the gates of {VerbWire},
|
|
10
10
|
# the session and the transaction, and writes the action's render as the
|
|
11
|
-
#
|
|
11
|
+
# protocol answer.
|
|
12
12
|
module HandlerServing
|
|
13
13
|
include VerbWire
|
|
14
14
|
|
|
15
|
-
# The Rack env key where {Routing::VerbRoute} puts the
|
|
15
|
+
# The Rack env key where {Routing::VerbRoute} puts the published name it routed.
|
|
16
16
|
VERB_KEY = 'kiosk.verb'
|
|
17
17
|
|
|
18
|
-
# The one header of an action's render that reaches the
|
|
18
|
+
# The one header of an action's render that reaches the response: `Cache-Control`, on a 2xx (§3.7.4).
|
|
19
19
|
PROPAGATED_HEADERS = %w[Cache-Control].freeze
|
|
20
20
|
|
|
21
21
|
private
|
|
@@ -34,7 +34,7 @@ module Kiosk
|
|
|
34
34
|
kiosk_render_refusal(Errors.malformed_json(hint: BODY_HINT))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# The verb's {Result}: the
|
|
37
|
+
# The verb's {Result}: the protocol's gates, then the action inside the session.
|
|
38
38
|
def kiosk_call(declaration, &)
|
|
39
39
|
kind = declaration[:kind]
|
|
40
40
|
name = @_kiosk_wire_name = declaration[:wire_name]
|
|
@@ -55,7 +55,7 @@ module Kiosk
|
|
|
55
55
|
Answer.new(kind, @_kiosk_wire_name).value(status, kiosk_read_body(body), @_kiosk_page)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# The action's `params` are the decoded arguments; the
|
|
58
|
+
# The action's `params` are the decoded arguments; the protocol answers JSON whatever the caller's
|
|
59
59
|
# `Accept`, so an action's template is its `.json` one.
|
|
60
60
|
def kiosk_wire_parameters!(args)
|
|
61
61
|
request.set_header('action_dispatch.request.request_parameters', args.dup)
|
|
@@ -89,7 +89,7 @@ module Kiosk
|
|
|
89
89
|
render_wire_error(error)
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# The action's render was read; the
|
|
92
|
+
# The action's render was read; the protocol answer is written into a fresh response.
|
|
93
93
|
def kiosk_fresh_response!
|
|
94
94
|
set_response!(self.class.make_response!(request))
|
|
95
95
|
@_response_body = nil
|
|
@@ -98,7 +98,7 @@ module Kiosk
|
|
|
98
98
|
# A route drawn straight at a handler action, not through `kiosk do`, answers 404 `verb_not_found`.
|
|
99
99
|
def kiosk_refuse_unrouted
|
|
100
100
|
problem = Errors::VerbNotFound.new(
|
|
101
|
-
'Kiosk handlers are reachable through the Kiosk
|
|
101
|
+
'Kiosk handlers are reachable through the Kiosk HTTP API only',
|
|
102
102
|
hint: "call the verb's own route — " \
|
|
103
103
|
"GET #{Kiosk.configuration.mount_path}/<query-name> or " \
|
|
104
104
|
"POST #{Kiosk.configuration.mount_path}/<action-name>"
|
|
@@ -107,7 +107,7 @@ module Kiosk
|
|
|
107
107
|
render json: problem, status: :not_found, content_type: Errors::PROBLEM_CONTENT_TYPE
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
# An action's render as the verb's answer: its payload, or the
|
|
110
|
+
# An action's render as the verb's answer: its payload, or the Kiosk error it means.
|
|
111
111
|
class Answer
|
|
112
112
|
def initialize(kind, wire_name)
|
|
113
113
|
@kind = kind
|
|
@@ -125,7 +125,7 @@ module Kiosk
|
|
|
125
125
|
|
|
126
126
|
private
|
|
127
127
|
|
|
128
|
-
# 2xx → the rendered JSON; otherwise the
|
|
128
|
+
# 2xx → the rendered JSON; otherwise the Kiosk error it means.
|
|
129
129
|
def decode(status, raw)
|
|
130
130
|
return parse_json(raw) if status >= 200 && status < 300
|
|
131
131
|
|
|
@@ -172,7 +172,7 @@ module Kiosk
|
|
|
172
172
|
raise Errors::ActionFailed.new(
|
|
173
173
|
"#{@kind} #{@wire_name.inspect} rendered a non-JSON body",
|
|
174
174
|
hint: 'a Kiosk handler answers with `render json:` — HTML, redirects and ' \
|
|
175
|
-
'`send_file` have no place
|
|
175
|
+
'`send_file` have no place in a Kiosk response'
|
|
176
176
|
)
|
|
177
177
|
end
|
|
178
178
|
|
data/lib/kiosk/server/headers.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The Kiosk response headers and the cache policy of a
|
|
5
|
+
# The Kiosk response headers and the cache policy of a Kiosk response.
|
|
6
6
|
module Headers
|
|
7
7
|
def self.add_to(headers, server_version: Kiosk::Server::VERSION)
|
|
8
8
|
headers[Kiosk::Protocol::HEADER_SERVER_VERSION] = server_version
|
|
@@ -18,7 +18,7 @@ module Kiosk
|
|
|
18
18
|
# §3.7.1: every one of these changes the answer and none is in the URL.
|
|
19
19
|
WIRE_VARY = %w[Authorization Kiosk-PoW Kiosk-Timezone].freeze
|
|
20
20
|
|
|
21
|
-
# Adds the
|
|
21
|
+
# Adds the protocol `Vary`; forces `no-store` on a 402; defaults to
|
|
22
22
|
# `private, no-store` and refuses a shared-cache directive (§3.7.3).
|
|
23
23
|
def self.add_cache_policy(headers, status:)
|
|
24
24
|
add_wire_vary(headers)
|
|
@@ -56,7 +56,7 @@ module Kiosk
|
|
|
56
56
|
'Sent `private, no-store` instead; send `private, max-age=N` to let the caller cache it.'
|
|
57
57
|
|
|
58
58
|
def self.refuse_shared_cache(value)
|
|
59
|
-
message = '[kiosk-server] refused a shared-cache policy on a
|
|
59
|
+
message = '[kiosk-server] refused a shared-cache policy on a Kiosk response: ' \
|
|
60
60
|
"Cache-Control: #{value.inspect}. #{SHARED_CACHE_RULE}"
|
|
61
61
|
logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
62
62
|
logger ? logger.warn(message) : warn(message)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The identity of a
|
|
5
|
+
# The identity of a protocol or event-stream caller: the access token, read by
|
|
6
6
|
# the configured agent IdP or the bundled one. A cookie session
|
|
7
7
|
# reaches only the human-side pages and the binding endpoints.
|
|
8
8
|
module IdentityResolution
|
data/lib/kiosk/server/kyc.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Kiosk
|
|
|
49
49
|
def claims = Array(Kiosk.configuration.kyc_claims).map(&:to_s)
|
|
50
50
|
|
|
51
51
|
def served!
|
|
52
|
-
return if provider
|
|
52
|
+
return if provider&.opens_verifications?
|
|
53
53
|
|
|
54
54
|
raise Errors::ModuleNotServed.new(
|
|
55
55
|
'this operator does not serve the KYC module',
|
|
@@ -115,8 +115,8 @@ module Kiosk
|
|
|
115
115
|
|
|
116
116
|
# Names only a path this origin serves.
|
|
117
117
|
def gate_hint
|
|
118
|
-
return REQUEST_HINT if provider
|
|
119
|
-
return ATTEST_HINT if
|
|
118
|
+
return REQUEST_HINT if provider&.opens_verifications?
|
|
119
|
+
return ATTEST_HINT if provider
|
|
120
120
|
|
|
121
121
|
CLOSED_HINT
|
|
122
122
|
end
|
|
@@ -148,9 +148,7 @@ module Kiosk
|
|
|
148
148
|
private
|
|
149
149
|
|
|
150
150
|
def open_verification(user_id)
|
|
151
|
-
Kyc.provider.open_verification(subject: user_id, claims: Kyc.claims,
|
|
152
|
-
audience: Kiosk.configuration.kyc_audience.to_s,
|
|
153
|
-
callback_url: Kyc.callback_url)
|
|
151
|
+
Kyc.provider.open_verification(subject: user_id, claims: Kyc.claims, callback_url: Kyc.callback_url)
|
|
154
152
|
rescue Kiosk::KycProviders::Unavailable => e
|
|
155
153
|
FailureLog.report("#{NAME} could not open a verification", e)
|
|
156
154
|
raise Errors::WireError.new(
|
|
@@ -249,7 +247,7 @@ module Kiosk
|
|
|
249
247
|
JSON.generate(attributes), user_id.to_s)
|
|
250
248
|
end
|
|
251
249
|
|
|
252
|
-
def table(name) = connection.quote_table_name("#{Kiosk.configuration.schema}
|
|
250
|
+
def table(name) = connection.quote_table_name("#{Kiosk.configuration.schema}.kiosk_#{name}")
|
|
253
251
|
|
|
254
252
|
def execute(sql, *binds) = connection.exec_query(sql, 'Kiosk KYC', binds).to_a
|
|
255
253
|
|
|
@@ -4,10 +4,10 @@ require 'jwt'
|
|
|
4
4
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
module Server
|
|
7
|
-
# Verifies a KYC attestation JWS against `
|
|
8
|
-
# `sub` and `level: "verified"`
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Verifies a KYC attestation JWS against the configured `kyc_provider`: its
|
|
8
|
+
# key signs it, its `iss` and `aud` match, `sub` and `level: "verified"`
|
|
9
|
+
# match, and the provider accepts it; only attributes that are literally
|
|
10
|
+
# `true` are granted.
|
|
11
11
|
module KycVerifier
|
|
12
12
|
# Named once for the decode and the published hint.
|
|
13
13
|
REQUIRED_CLAIMS = %w[exp iss aud sub].freeze
|
|
@@ -19,17 +19,17 @@ module Kiosk
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
21
21
|
def verify(raw_jws:, subject:)
|
|
22
|
-
|
|
23
|
-
payload
|
|
24
|
-
check_issuer!(payload,
|
|
22
|
+
provider = provider!
|
|
23
|
+
payload = decode(raw_jws, provider)
|
|
24
|
+
check_issuer!(payload, provider)
|
|
25
25
|
# Binds the attestation to this operator.
|
|
26
|
-
if payload[:aud].to_s !=
|
|
26
|
+
if payload[:aud].to_s != provider.audience
|
|
27
27
|
raise Errors::Forbidden.new('KYC attestation audience mismatch',
|
|
28
|
-
hint: audience_hint(payload,
|
|
28
|
+
hint: audience_hint(payload, provider))
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
check_subject!(payload, subject)
|
|
32
|
-
check_provider!(payload,
|
|
32
|
+
check_provider!(payload, provider)
|
|
33
33
|
|
|
34
34
|
payload.merge(attributes: verified_attributes(payload[:attributes]))
|
|
35
35
|
end
|
|
@@ -47,8 +47,8 @@ module Kiosk
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# The verified payload with symbol keys.
|
|
50
|
-
def decode(raw_jws,
|
|
51
|
-
payload, = ::JWT.decode(raw_jws, public_key
|
|
50
|
+
def decode(raw_jws, provider)
|
|
51
|
+
payload, = ::JWT.decode(raw_jws, provider.public_key, true,
|
|
52
52
|
algorithms: [Eddsa::NAME], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
|
|
53
53
|
payload.transform_keys(&:to_sym)
|
|
54
54
|
rescue ::JWT::ExpiredSignature
|
|
@@ -59,30 +59,30 @@ module Kiosk
|
|
|
59
59
|
raise Errors::Forbidden.new('KYC attestation signature invalid', hint: SIGNATURE_HINT)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
# Refused when this origin trusts no KYC
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
return
|
|
62
|
+
# Refused when this origin trusts no KYC provider.
|
|
63
|
+
def provider!
|
|
64
|
+
provider = Kiosk.configuration.kyc_provider
|
|
65
|
+
return provider unless provider.nil?
|
|
66
66
|
|
|
67
67
|
raise Errors::ModuleNotServed.new(
|
|
68
68
|
'this operator does not serve the KYC module',
|
|
69
69
|
hint: 'no KYC attestation is accepted at this origin; retrying will not help. ' \
|
|
70
|
-
'An operator that means to serve KYC sets Kiosk.configuration.
|
|
70
|
+
'An operator that means to serve KYC sets Kiosk.configuration.kyc_provider.'
|
|
71
71
|
)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def check_issuer!(payload,
|
|
75
|
-
return if payload[:iss] ==
|
|
74
|
+
def check_issuer!(payload, provider)
|
|
75
|
+
return if payload[:iss] == provider.issuer
|
|
76
76
|
|
|
77
77
|
raise Errors::Forbidden.new(
|
|
78
78
|
'KYC attestation issuer mismatch',
|
|
79
|
-
hint: "expected #{
|
|
79
|
+
hint: "expected #{provider.issuer.inspect}, got #{payload[:iss].inspect}"
|
|
80
80
|
)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def audience_hint(payload,
|
|
84
|
-
"aud must equal this operator's
|
|
85
|
-
"(expected #{
|
|
83
|
+
def audience_hint(payload, provider)
|
|
84
|
+
"aud must equal the audience this operator's KYC provider mints " \
|
|
85
|
+
"(expected #{provider.audience.inspect}, got #{payload[:aud].inspect})"
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
# Subject and level. A bigint-PK host's subject is an Integer.
|
|
@@ -93,14 +93,13 @@ module Kiosk
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# The provider's own checks ({Kiosk::KycProviders::Base#accepts?}).
|
|
96
|
-
def check_provider!(payload,
|
|
97
|
-
|
|
98
|
-
return if provider.nil? || provider.accepts?(payload.transform_keys(&:to_s))
|
|
96
|
+
def check_provider!(payload, provider)
|
|
97
|
+
return if provider.accepts?(payload.transform_keys(&:to_s))
|
|
99
98
|
|
|
100
99
|
raise Errors::Forbidden, 'the KYC provider does not accept this attestation at this operator'
|
|
101
100
|
end
|
|
102
101
|
|
|
103
|
-
private_class_method :decode, :
|
|
102
|
+
private_class_method :decode, :provider!, :check_issuer!, :audience_hint, :check_subject!, :check_provider!
|
|
104
103
|
end
|
|
105
104
|
end
|
|
106
105
|
end
|
|
@@ -34,7 +34,7 @@ module Kiosk
|
|
|
34
34
|
|
|
35
35
|
def settings_sql(assignments, bind_count)
|
|
36
36
|
<<~SQL.squish
|
|
37
|
-
UPDATE #{Kiosk.configuration.schema}.
|
|
37
|
+
UPDATE #{Kiosk.configuration.schema}.kiosk_agents
|
|
38
38
|
SET #{assignments.join(', ')}
|
|
39
39
|
WHERE id = $#{bind_count - 2}
|
|
40
40
|
AND user_id = $#{bind_count - 1}
|
|
@@ -47,18 +47,16 @@ module Kiosk
|
|
|
47
47
|
|
|
48
48
|
def agents_sql
|
|
49
49
|
<<~SQL.squish
|
|
50
|
-
SELECT id, public_key,
|
|
51
|
-
FROM #{Kiosk.configuration.schema}.
|
|
50
|
+
SELECT id, public_key, bound_at, human_label, spending_cap_cents, spending_cap_currency
|
|
51
|
+
FROM #{Kiosk.configuration.schema}.kiosk_agents
|
|
52
52
|
WHERE user_id = $1 AND issuer = $2 AND revoked_at IS NULL
|
|
53
53
|
ORDER BY created_at
|
|
54
54
|
SQL
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# `{ agent_id => { currency => cents } }`:
|
|
58
|
-
# counts, within `spending_cap_window_days` when set.
|
|
57
|
+
# `{ agent_id => { currency => cents } }`: what each agent spent.
|
|
59
58
|
def spend_by_agent(user_id)
|
|
60
|
-
|
|
61
|
-
tally(run(spend_sql(window_days), 'Kiosk assistant spend', [user_id, Kiosk.current_issuer, *window_days]))
|
|
59
|
+
tally(run(spend_sql, 'Kiosk assistant spend', [user_id, Kiosk.current_issuer]))
|
|
62
60
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
63
61
|
raise unless missing_table?(e)
|
|
64
62
|
|
|
@@ -71,20 +69,27 @@ module Kiosk
|
|
|
71
69
|
end
|
|
72
70
|
end
|
|
73
71
|
|
|
74
|
-
#
|
|
75
|
-
def spend_sql
|
|
72
|
+
# One tally per currency.
|
|
73
|
+
def spend_sql
|
|
76
74
|
schema = Kiosk.configuration.schema
|
|
77
|
-
window = window_days ? 'AND settlements.settled_at >= now() - make_interval(days => $3)' : ''
|
|
78
75
|
<<~SQL.squish
|
|
79
|
-
SELECT settlements.agent_id, lower(btrim(settlements.currency)) AS currency,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
WHERE agents.user_id = $1 AND agents.issuer = $2 AND agents.revoked_at IS NULL #{window}
|
|
76
|
+
SELECT settlements.agent_id, lower(btrim(settlements.currency)) AS currency, SUM(#{net_cents_sql(schema)}) AS cents
|
|
77
|
+
FROM #{schema}.kiosk_settlements settlements
|
|
78
|
+
JOIN #{schema}.kiosk_agents agents ON agents.id = settlements.agent_id
|
|
79
|
+
WHERE agents.user_id = $1 AND agents.issuer = $2 AND agents.revoked_at IS NULL
|
|
84
80
|
GROUP BY 1, 2 ORDER BY 2
|
|
85
81
|
SQL
|
|
86
82
|
end
|
|
87
83
|
|
|
84
|
+
# Each settlement less what was refunded of it, never below zero.
|
|
85
|
+
def net_cents_sql(schema)
|
|
86
|
+
<<~SQL.squish
|
|
87
|
+
GREATEST(settlements.settled_amount_cents - (
|
|
88
|
+
SELECT COALESCE(SUM(r.refunded_amount_cents), 0) FROM #{schema}.kiosk_refunds r WHERE r.settlement_id = settlements.id
|
|
89
|
+
), 0)
|
|
90
|
+
SQL
|
|
91
|
+
end
|
|
92
|
+
|
|
88
93
|
# By class name, so this file does not load `PG`.
|
|
89
94
|
def missing_table?(error)
|
|
90
95
|
error.cause&.class&.name == 'PG::UndefinedTable'
|
|
@@ -95,7 +100,7 @@ module Kiosk
|
|
|
95
100
|
{
|
|
96
101
|
agent_id: row.fetch('id'),
|
|
97
102
|
fingerprint: fingerprint(row.fetch('public_key')),
|
|
98
|
-
|
|
103
|
+
bound_at: row.fetch('bound_at', nil),
|
|
99
104
|
human_label: row.fetch('human_label', nil),
|
|
100
105
|
spending_cap_cents: cap&.to_i,
|
|
101
106
|
spending_cap_currency: row.fetch('spending_cap_currency', nil),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# An amount in minor units (cents) as a person reads and types it: in the
|
|
6
|
+
# currency's major unit, with the ISO 4217 number of decimals.
|
|
7
|
+
module Money
|
|
8
|
+
# ISO 4217 currencies whose minor unit is not 1/100. Every other code has two decimals.
|
|
9
|
+
DECIMALS = {
|
|
10
|
+
'bif' => 0, 'clp' => 0, 'djf' => 0, 'gnf' => 0, 'isk' => 0, 'jpy' => 0, 'kmf' => 0,
|
|
11
|
+
'krw' => 0, 'pyg' => 0, 'rwf' => 0, 'ugx' => 0, 'uyi' => 0, 'vnd' => 0, 'vuv' => 0,
|
|
12
|
+
'xaf' => 0, 'xof' => 0, 'xpf' => 0,
|
|
13
|
+
'bhd' => 3, 'iqd' => 3, 'jod' => 3, 'kwd' => 3, 'lyd' => 3, 'omr' => 3, 'tnd' => 3,
|
|
14
|
+
'clf' => 4, 'uyw' => 4
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# `5000, 'eur'` → `"50.00 EUR"`.
|
|
20
|
+
def format(cents, currency) = "#{amount(cents, currency)} #{currency.upcase}"
|
|
21
|
+
|
|
22
|
+
# `5000, 'eur'` → `"50.00"`.
|
|
23
|
+
def amount(cents, currency)
|
|
24
|
+
decimals = decimals(currency)
|
|
25
|
+
whole, fraction = cents.divmod(10**decimals)
|
|
26
|
+
decimals.zero? ? whole.to_s : "#{whole}.#{fraction.to_s.rjust(decimals, '0')}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `"50", 'eur'` → `5000`. Nil for text that is not an amount with at most the currency's decimals.
|
|
30
|
+
def parse(text, currency)
|
|
31
|
+
decimals = decimals(currency)
|
|
32
|
+
fraction = decimals.zero? ? '' : "(?:\\.(\\d{1,#{decimals}}))?"
|
|
33
|
+
match = /\A(\d+)#{fraction}\z/.match(text.strip) or return nil
|
|
34
|
+
|
|
35
|
+
(match[1].to_i * (10**decimals)) + match[2].to_s.ljust(decimals, '0').to_i
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decimals(currency) = DECIMALS.fetch(currency.downcase, 2)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -24,6 +24,9 @@ module Kiosk
|
|
|
24
24
|
# RFC 6749 Section 5.2: only a failed client authentication is a 401.
|
|
25
25
|
def status = code == 'invalid_client' ? 401 : 400
|
|
26
26
|
|
|
27
|
+
# RFC 9110 Section 11.6.1: a 401 names its challenge, here the client assertion.
|
|
28
|
+
def response_headers = status == 401 ? { 'WWW-Authenticate' => Errors::ClientAssertionRefused.challenge } : {}
|
|
29
|
+
|
|
27
30
|
def to_h = { error: code, error_description: message }
|
|
28
31
|
end
|
|
29
32
|
end
|
|
@@ -41,7 +41,7 @@ module Kiosk
|
|
|
41
41
|
served as `application/problem+json`; branch on its `code` member, not
|
|
42
42
|
on the HTTP status alone.
|
|
43
43
|
|
|
44
|
-
`limit` and `cursor` are reserved parameter names this
|
|
44
|
+
`limit` and `cursor` are reserved parameter names this protocol always
|
|
45
45
|
accepts on a query. A query may declare `limit` to bound its page
|
|
46
46
|
size; no verb declares `cursor`. The two names drive the cursor
|
|
47
47
|
pagination of the specification's Section 8.4. A
|
|
@@ -58,14 +58,14 @@ module Kiosk
|
|
|
58
58
|
}.freeze
|
|
59
59
|
|
|
60
60
|
TAGS = [
|
|
61
|
-
{ name: '
|
|
61
|
+
{ name: 'reserved', description: "The protocol's own reserved endpoints." },
|
|
62
62
|
{ name: 'queries', description: 'Reads. GET, arguments in the query string.' },
|
|
63
63
|
{ name: 'actions', description: 'Writes. POST, arguments in a JSON body.' }
|
|
64
64
|
].freeze
|
|
65
65
|
|
|
66
66
|
def self.build(base_url:, config: Kiosk.configuration)
|
|
67
67
|
endpoint = base_url.to_s.chomp('/') + config.mount_path
|
|
68
|
-
schemas, paths =
|
|
68
|
+
schemas, paths = Reserved.surface(Array(config.capabilities).map(&:to_s))
|
|
69
69
|
Verbs.add_entries(schemas, paths)
|
|
70
70
|
document(endpoint, config, paths, schemas)
|
|
71
71
|
end
|
|
@@ -79,7 +79,7 @@ module Kiosk
|
|
|
79
79
|
security: [{ dpopAuth: [] }],
|
|
80
80
|
tags: TAGS,
|
|
81
81
|
paths: paths,
|
|
82
|
-
components:
|
|
82
|
+
components: Reserved.components(schemas)
|
|
83
83
|
}
|
|
84
84
|
end
|
|
85
85
|
private_class_method :document
|
|
@@ -210,7 +210,7 @@ module Kiosk
|
|
|
210
210
|
content: Refs.json_content("#{descriptor[:name]}.response")
|
|
211
211
|
}
|
|
212
212
|
ok[:headers] = Parameters::PAGINATION_HEADERS.keys.index_with { |h| { '$ref': "#/components/headers/#{h}" } } if kind == :query
|
|
213
|
-
{ '200' => ok }.merge(
|
|
213
|
+
{ '200' => ok }.merge(Reserved.problem_refs)
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
# A query's input is a component only when a parameter points into it.
|
|
@@ -282,7 +282,7 @@ module Kiosk
|
|
|
282
282
|
|
|
283
283
|
# The protocol's own endpoints (§8.3, §11.3), declared rather than derived,
|
|
284
284
|
# and the problem responses every operation shares.
|
|
285
|
-
module
|
|
285
|
+
module Reserved
|
|
286
286
|
SCHEMA_DESCRIPTION = "This origin's catalog: every query and action it publishes, " \
|
|
287
287
|
'with their descriptions and schemas. THE canonical surface ' \
|
|
288
288
|
'description — this OpenAPI document is derived from it. ' \
|
|
@@ -322,7 +322,7 @@ module Kiosk
|
|
|
322
322
|
def schema_operation
|
|
323
323
|
{
|
|
324
324
|
operationId: 'schema',
|
|
325
|
-
tags: ['
|
|
325
|
+
tags: ['reserved'],
|
|
326
326
|
description: SCHEMA_DESCRIPTION,
|
|
327
327
|
# Public: opts out of the global `dpopAuth`.
|
|
328
328
|
security: [],
|
|
@@ -333,7 +333,7 @@ module Kiosk
|
|
|
333
333
|
def pay_operation
|
|
334
334
|
{
|
|
335
335
|
operationId: 'pay',
|
|
336
|
-
tags: ['
|
|
336
|
+
tags: ['reserved'],
|
|
337
337
|
description: PAY_DESCRIPTION,
|
|
338
338
|
requestBody: { required: true, content: Refs.json_content('pay.request') },
|
|
339
339
|
responses: ok_responses('The settlement receipt.', 'pay.response')
|
|
@@ -371,7 +371,7 @@ module Kiosk
|
|
|
371
371
|
PROBLEM = {
|
|
372
372
|
type: 'object',
|
|
373
373
|
title: 'Problem document (RFC 9457)',
|
|
374
|
-
description: 'Every error
|
|
374
|
+
description: 'Every error of this protocol. Branch on `code`.',
|
|
375
375
|
properties: {
|
|
376
376
|
type: {
|
|
377
377
|
type: 'string', format: 'uri',
|
data/lib/kiosk/server/page.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Kiosk
|
|
|
4
4
|
module Server
|
|
5
5
|
# What a query handler returns to paginate: this page's `rows`, an opaque
|
|
6
6
|
# `next_cursor` (nil on the last page) and an optional `total` matching
|
|
7
|
-
# count. The
|
|
7
|
+
# count. The protocol sends the last two as `Link` and `X-Total-Count` headers.
|
|
8
8
|
Page = Data.define(:rows, :next_cursor, :total) do
|
|
9
9
|
def initialize(rows:, next_cursor: nil, total: nil)
|
|
10
10
|
if !next_cursor.nil? && next_cursor.to_s.empty?
|
|
@@ -177,8 +177,8 @@ module Kiosk
|
|
|
177
177
|
end
|
|
178
178
|
|
|
179
179
|
INSERT_SQL = <<~SQL.squish
|
|
180
|
-
INSERT INTO "%<schema>s".
|
|
181
|
-
SELECT id, $1, $2, $3 FROM "%<schema>s".
|
|
180
|
+
INSERT INTO "%<schema>s".kiosk_refunds (settlement_id, psp_reference, refunded_amount_cents, refunded_at)
|
|
181
|
+
SELECT id, $1, $2, $3 FROM "%<schema>s".kiosk_settlements WHERE psp_reference = $4
|
|
182
182
|
ON CONFLICT (psp_reference) DO NOTHING
|
|
183
183
|
SQL
|
|
184
184
|
end
|
|
@@ -188,7 +188,7 @@ module Kiosk
|
|
|
188
188
|
module AfterPayment
|
|
189
189
|
# The executor calls this once, after it records the settlement of
|
|
190
190
|
# `checkout_mandate`, so `c.after_payment` reads the row's `settlement`. A
|
|
191
|
-
# failure here never surfaces
|
|
191
|
+
# failure here never surfaces in the response: the log carries it.
|
|
192
192
|
def settled(checkout_mandate)
|
|
193
193
|
id = Lines.reference(checkout_mandate.line_items, @reference)
|
|
194
194
|
Kiosk.configuration.after_payment&.call(id)
|