kiosk-server 0.5.20 → 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 +196 -0
- data/README.md +192 -138
- 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 +21 -65
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
- 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 +20 -0
- data/app/models/kiosk/settlement.rb +1 -3
- 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 -69
- 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 -15
- 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 +19 -25
- data/lib/kiosk/server/account_binding.rb +32 -62
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +6 -6
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +154 -0
- data/lib/kiosk/server/client_registration.rb +98 -0
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +64 -75
- 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 +38 -69
- data/lib/kiosk/server/dpop.rb +126 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +16 -13
- data/lib/kiosk/server/errors.rb +28 -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 +38 -10
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +17 -76
- data/lib/kiosk/server/executor.rb +86 -105
- 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 +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +28 -29
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +22 -17
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +31 -0
- data/lib/kiosk/server/oauth_error.rb +33 -0
- data/lib/kiosk/server/open_api.rb +31 -29
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +47 -78
- 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 +30 -0
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +3 -3
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +9 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +369 -58
- data/lib/kiosk/server/schemas/binding.schema.json +24 -59
- data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
- data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +4 -4
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +272 -205
- data/lib/kiosk/server/wire_rendering.rb +2 -2
- data/lib/kiosk/server.rb +8 -2
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +34 -24
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
- data/lib/kiosk/server/schema_definitions.rb +0 -351
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
data/lib/kiosk/server/engine.rb
CHANGED
|
@@ -29,11 +29,11 @@ module Kiosk
|
|
|
29
29
|
Kiosk::Server::IssuerMiddleware
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Credential-bearing
|
|
32
|
+
# Credential-bearing protocol fields, kept out of the `Parameters:` log line.
|
|
33
33
|
# Whole-key matches, so an operator's own `promo_code` is left alone.
|
|
34
34
|
FILTERED_PARAMETERS = %w[
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
client_assertion new_client_assertion code device_code kyc_jws
|
|
36
|
+
checkout_mandate payment_mandate
|
|
37
37
|
].map { |field| /\A#{field}\z/ }.freeze
|
|
38
38
|
|
|
39
39
|
initializer 'kiosk-server.filter_parameters' do |app|
|
|
@@ -61,7 +61,7 @@ module Kiosk
|
|
|
61
61
|
::Rails.logger ? ::Rails.logger.warn(message) : warn(message)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# A wrong `issuer` refuses every
|
|
64
|
+
# A wrong `issuer` refuses every client assertion with "client assertion audience mismatch".
|
|
65
65
|
# Warned, not raised: an operator seeds before the public origin exists.
|
|
66
66
|
LOOPBACK_ISSUER =
|
|
67
67
|
%r{\A(?:https?://)?(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?/?\z}i
|
|
@@ -69,8 +69,8 @@ module Kiosk
|
|
|
69
69
|
# The boot-time configuration checks. Each answers a message, or nil when the configuration is sound.
|
|
70
70
|
module BootChecks
|
|
71
71
|
ISSUER_UNSET =
|
|
72
|
-
'[kiosk-server] `c.issuer` is not set, so every
|
|
73
|
-
'with "
|
|
72
|
+
'[kiosk-server] `c.issuer` is not set, so every client assertion is refused ' \
|
|
73
|
+
'with "client assertion audience mismatch". Set it to the origin assistants dial: ' \
|
|
74
74
|
'Kiosk.configure { |c| c.issuer = "https://api.example.com" }.'
|
|
75
75
|
|
|
76
76
|
ISSUER_LOOPBACK =
|
|
@@ -144,14 +144,18 @@ module Kiosk
|
|
|
144
144
|
get '/agents.txt', to: 'kiosk/server/discovery#agents_txt'
|
|
145
145
|
get '/agents.json', to: 'kiosk/server/discovery#agents_json'
|
|
146
146
|
get '/auth.md', to: 'kiosk/server/discovery#auth_md'
|
|
147
|
-
get '/.well-known/agent-configuration', to: 'kiosk/server/discovery#agent_configuration'
|
|
148
147
|
get '/.well-known/kiosk.json', to: 'kiosk/server/discovery#kiosk_json'
|
|
149
148
|
get '/.well-known/api-catalog', to: 'kiosk/server/discovery#api_catalog'
|
|
149
|
+
get '/.well-known/oauth-authorization-server', to: 'kiosk/server/discovery#oauth_authorization_server'
|
|
150
|
+
get '/.well-known/agent-skills/index.json', to: 'kiosk/server/discovery#agent_skills_index'
|
|
151
|
+
get '/.well-known/ard.json', to: 'kiosk/server/discovery#ard'
|
|
152
|
+
get '/.well-known/ai-catalog.json', to: 'kiosk/server/discovery#ard'
|
|
153
|
+
get '/.well-known/oauth-protected-resource', to: 'kiosk/server/discovery#oauth_protected_resource'
|
|
150
154
|
end
|
|
151
155
|
end
|
|
152
156
|
|
|
153
157
|
routes do
|
|
154
|
-
# The
|
|
158
|
+
# The protocol's reserved endpoints. The mount is drawn first in the host's
|
|
155
159
|
# routes, so these win by first-match. Optional modules are drawn
|
|
156
160
|
# unconditionally and answer `module_not_served` (501) when not configured.
|
|
157
161
|
# `schema` resolves no identity — it is PUBLIC, like `openapi.json`
|
|
@@ -165,10 +169,10 @@ module Kiosk
|
|
|
165
169
|
# `events` is also in {HandlerMixin::RESERVED_NAMES}; keep the two in step.
|
|
166
170
|
mount Kiosk::Server::EventsCable::RACK_APP => 'events', internal: true, anchor: true, as: :kiosk_events
|
|
167
171
|
|
|
168
|
-
|
|
169
|
-
post '
|
|
170
|
-
post '
|
|
171
|
-
post 'auth/
|
|
172
|
+
# RFC 7591 registration and the token endpoint (RFC 6749) are core.
|
|
173
|
+
post 'oauth/register', to: 'oauth_registration#create'
|
|
174
|
+
post 'oauth/token', to: 'oauth_token#create'
|
|
175
|
+
post 'auth/rotate', to: 'auth#rotate'
|
|
172
176
|
|
|
173
177
|
# JWKS sits under the mount, not the origin root.
|
|
174
178
|
get '.well-known/jwks.json', to: 'jwks#show'
|
|
@@ -183,7 +187,6 @@ module Kiosk
|
|
|
183
187
|
|
|
184
188
|
# RFC 8628 device flow.
|
|
185
189
|
post 'oauth/device_authorization', to: 'oauth_device_authorization#create'
|
|
186
|
-
post 'oauth/token', to: 'oauth_token#create'
|
|
187
190
|
get 'oauth/device/verify', to: 'device_verify#show'
|
|
188
191
|
post 'oauth/device/verify', to: 'device_verify#create'
|
|
189
192
|
|
data/lib/kiosk/server/errors.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The
|
|
5
|
+
# The Kiosk error contract: the closed `code` vocabulary and the problem
|
|
6
6
|
# document (RFC 9457) every refusal is rendered as.
|
|
7
7
|
module Errors
|
|
8
|
-
# The
|
|
8
|
+
# The protocol's seventeen refusal codes and their HTTP status (spec §9). Adding one is a spec change first.
|
|
9
9
|
CODES = {
|
|
10
10
|
'bad_request' => 400,
|
|
11
11
|
'unauthenticated' => 401,
|
|
@@ -119,7 +119,7 @@ module Kiosk
|
|
|
119
119
|
end
|
|
120
120
|
|
|
121
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
|
|
122
|
+
# The exception's own text goes to the operator's log, never to the protocol.
|
|
123
123
|
# One entry per {STATUS_CODES} value.
|
|
124
124
|
module Rescued
|
|
125
125
|
DETAILS = {
|
|
@@ -156,7 +156,7 @@ module Kiosk
|
|
|
156
156
|
RESCUED_DETAILS = Rescued::DETAILS
|
|
157
157
|
RESCUED_HINTS = Rescued::HINTS
|
|
158
158
|
|
|
159
|
-
# @return [Hash] `{code:, message:, hint:}` of the
|
|
159
|
+
# @return [Hash] `{code:, message:, hint:}` of the Kiosk error a rescued exception becomes
|
|
160
160
|
def self.rescued_wire(code, verb: nil) = Rescued.wire(code, verb)
|
|
161
161
|
|
|
162
162
|
class Base < StandardError
|
|
@@ -191,7 +191,7 @@ module Kiosk
|
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
-
# A
|
|
194
|
+
# A Kiosk error named by its code rather than its class. `extra:` adds extension members;
|
|
195
195
|
# the members the code and message decide stay theirs, and `instance` is never emitted (§9).
|
|
196
196
|
class WireError < Base
|
|
197
197
|
DECIDED = %i[type title status detail instance code hint].freeze
|
|
@@ -200,7 +200,7 @@ module Kiosk
|
|
|
200
200
|
code = code.to_s
|
|
201
201
|
unless CODES.key?(code)
|
|
202
202
|
raise ArgumentError,
|
|
203
|
-
"unknown
|
|
203
|
+
"unknown error code #{code.inspect} — the vocabulary is Errors::CODES, closed by the spec"
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
super(message, hint: hint)
|
|
@@ -231,6 +231,27 @@ module Kiosk
|
|
|
231
231
|
HTTP_STATUS = 401
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
+
# The 401 of a request that needs an access token: it names the DPoP scheme (RFC 9449 §7.1).
|
|
235
|
+
class AccessTokenRefused < Unauthenticated
|
|
236
|
+
def initialize(message = 'a valid access token with its DPoP proof is required', hint: Dpop.hint) = super
|
|
237
|
+
|
|
238
|
+
def response_headers = { 'WWW-Authenticate' => Dpop.challenge }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The 401 of a refused client assertion (RFC 7523). No registered scheme names one.
|
|
242
|
+
class ClientAssertionRefused < Unauthenticated
|
|
243
|
+
def self.challenge = %(Kiosk-Client-Assertion realm="#{Kiosk.current_issuer}")
|
|
244
|
+
|
|
245
|
+
def response_headers = { 'WWW-Authenticate' => self.class.challenge }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The 401 of a binding path or engine page that the provider's own sign-in session authenticates.
|
|
249
|
+
class SessionRequired < Unauthenticated
|
|
250
|
+
def self.challenge = %(Kiosk-Session realm="#{Kiosk.current_issuer}")
|
|
251
|
+
|
|
252
|
+
def response_headers = { 'WWW-Authenticate' => self.class.challenge }
|
|
253
|
+
end
|
|
254
|
+
|
|
234
255
|
class Forbidden < Base
|
|
235
256
|
CODE = 'forbidden'
|
|
236
257
|
HTTP_STATUS = 403
|
|
@@ -273,7 +294,7 @@ module Kiosk
|
|
|
273
294
|
HTTP_STATUS = 403
|
|
274
295
|
end
|
|
275
296
|
|
|
276
|
-
# The acting assistant's spending cap
|
|
297
|
+
# The checkout is above the acting assistant's spending cap, or in another currency; checked before capture.
|
|
277
298
|
class SpendingCapExceeded < Base
|
|
278
299
|
CODE = 'spending_cap_exceeded'
|
|
279
300
|
HTTP_STATUS = 403
|
|
@@ -83,7 +83,7 @@ module Kiosk
|
|
|
83
83
|
SQL
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
# The
|
|
86
|
+
# The protocol's five event members, string-keyed, as {EventStore} gives them.
|
|
87
87
|
def to_event(row)
|
|
88
88
|
{
|
|
89
89
|
'id' => row['id'].to_i,
|
|
@@ -114,7 +114,7 @@ module Kiosk
|
|
|
114
114
|
prune! if due
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
def table = %("#{Kiosk.configuration.schema}".
|
|
117
|
+
def table = %("#{Kiosk.configuration.schema}".kiosk_events)
|
|
118
118
|
|
|
119
119
|
# `connection` raises under `permanent_connection_checkout = :disallowed`.
|
|
120
120
|
def connection = ::ActiveRecord::Base.lease_connection
|
data/lib/kiosk/server/events.rb
CHANGED
|
@@ -28,11 +28,12 @@ module Kiosk
|
|
|
28
28
|
|
|
29
29
|
def known = registry.keys.sort
|
|
30
30
|
|
|
31
|
-
# Published beside `queries` and `actions`; `subject_reachable` is the operator's rule, not
|
|
31
|
+
# Published beside `queries` and `actions`; `subject_reachable` is the operator's rule, not protocol.
|
|
32
32
|
def catalog = known.map { |name| catalog_entry(registry[name]) }
|
|
33
33
|
|
|
34
34
|
# Appends one event to the tail of every identity in `identity_scope`. `reach` authorises
|
|
35
|
-
# subscribing; `identity_scope` names who is told about this transition.
|
|
35
|
+
# subscribing; `identity_scope` names who is told about this transition. The id is taken
|
|
36
|
+
# here and seen at commit, so call it as the last step of the transaction (spec Section 8.5.5).
|
|
36
37
|
# @param identity_scope [Array<String>] user_ids; empty writes nothing
|
|
37
38
|
# @return [Integer, nil] the id of the last append, nil for an empty scope
|
|
38
39
|
def emit(topic:, data:, identity_scope:, subject: nil, occurred_at: nil)
|
|
@@ -70,7 +71,7 @@ module Kiosk
|
|
|
70
71
|
|
|
71
72
|
def already_declared(name)
|
|
72
73
|
"topic #{name.inspect} is already declared on this origin. A topic name is one " \
|
|
73
|
-
'
|
|
74
|
+
'published name and one payload shape; declare it once, on the controller that owns ' \
|
|
74
75
|
'the transition it reports.'
|
|
75
76
|
end
|
|
76
77
|
|
|
@@ -61,6 +61,15 @@ module Kiosk
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Spec Section 3, point 6: the 101 answer carries the version headers. The
|
|
65
|
+
# WebSocket driver writes it past every middleware, and Action Cable gives
|
|
66
|
+
# no other way to set its headers than the driver it keeps.
|
|
67
|
+
def initialize(...)
|
|
68
|
+
super
|
|
69
|
+
driver = websocket.send(:websocket)&.instance_variable_get(:@driver)
|
|
70
|
+
Headers.build.each { |name, value| driver.set_header(name, value) } if driver
|
|
71
|
+
end
|
|
72
|
+
|
|
64
73
|
def connect
|
|
65
74
|
identity = resolve_identity || reject_unauthorized_connection
|
|
66
75
|
@kiosk_identity = identity
|
|
@@ -103,16 +112,23 @@ module Kiosk
|
|
|
103
112
|
|
|
104
113
|
# Closes with `token_expired` or `revoked` once the credential no longer resolves (§8.5.6).
|
|
105
114
|
def kiosk_credential_holds?
|
|
106
|
-
return true if
|
|
115
|
+
return true if token_holds?
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
else
|
|
111
|
-
close(reason: 'revoked', reconnect: false)
|
|
112
|
-
end
|
|
117
|
+
expired = kiosk_credential_expired?
|
|
118
|
+
close(reason: expired ? 'token_expired' : 'revoked', reconnect: expired)
|
|
113
119
|
false
|
|
114
120
|
end
|
|
115
121
|
|
|
122
|
+
# A subscription the upgrade URL declares joins the URL's replay group;
|
|
123
|
+
# one a `subscribe` frame declares replays alone (§8.5.5).
|
|
124
|
+
def kiosk_replay_member(floor, &)
|
|
125
|
+
member = @kiosk_url_replay&.join(floor, &)
|
|
126
|
+
return member if member
|
|
127
|
+
|
|
128
|
+
group = EventsReplay.new(kiosk_identity_key)
|
|
129
|
+
group.join(floor, &).tap { group.close }
|
|
130
|
+
end
|
|
131
|
+
|
|
116
132
|
private
|
|
117
133
|
|
|
118
134
|
def reauthorise!
|
|
@@ -151,18 +167,30 @@ module Kiosk
|
|
|
151
167
|
end
|
|
152
168
|
|
|
153
169
|
def auto_subscribe!
|
|
170
|
+
@kiosk_url_replay = EventsReplay.new(kiosk_identity_key)
|
|
154
171
|
UrlSubscriptions.identifiers(request.query_string).each do |identifier|
|
|
155
172
|
subscriptions.execute_command('command' => 'subscribe', 'identifier' => identifier)
|
|
156
173
|
end
|
|
174
|
+
@kiosk_url_replay.close
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The token and the DPoP proof of the upgrade request.
|
|
178
|
+
def resolve_identity
|
|
179
|
+
under_issuer { Kiosk::Server::IdentityResolution.resolve(request) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# §8.5.6: the proof was checked once, at the upgrade, and is past its
|
|
183
|
+
# 60-second window by now; a re-check reads the token alone.
|
|
184
|
+
def token_holds?
|
|
185
|
+
under_issuer { Kiosk::Server::IdentityResolution.agent_idp.verify(request) }
|
|
157
186
|
end
|
|
158
187
|
|
|
159
188
|
# Under the upgrade's own issuer: this runs outside {IssuerMiddleware}.
|
|
160
189
|
# A raising IdP refuses the upgrade rather than failing the socket.
|
|
161
|
-
def
|
|
162
|
-
|
|
163
|
-
Kiosk.with_issuer(issuer) { Kiosk::Server::IdentityResolution.resolve(request) }
|
|
190
|
+
def under_issuer(&)
|
|
191
|
+
Kiosk.with_issuer(Kiosk.configuration.issuer_for(request.base_url), &)
|
|
164
192
|
rescue StandardError => e
|
|
165
|
-
logger&.error("[kiosk] events connection identity resolution failed: #{e.class}")
|
|
193
|
+
logger&.error("[kiosk] events connection identity resolution failed: #{e.class}: #{e.message}")
|
|
166
194
|
nil
|
|
167
195
|
end
|
|
168
196
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Delivery in id order (spec Section 8.5.5). The subscriptions one upgrade
|
|
6
|
+
# URL declares form one group; a `subscribe` frame forms a group of one.
|
|
7
|
+
# When every member is confirmed, the group replays the members' committed
|
|
8
|
+
# tail in one ascending pass. Until then it holds their live events, and
|
|
9
|
+
# after the replay it sends those it did not replay, in id order. An event
|
|
10
|
+
# that commits after a higher id is outside this order (the same section).
|
|
11
|
+
class EventsReplay
|
|
12
|
+
# One subscription in the group. `floor` is the cursor it replays after.
|
|
13
|
+
Member = Struct.new(:group, :floor, :deliver) do
|
|
14
|
+
delegate :confirmed, to: :group
|
|
15
|
+
|
|
16
|
+
def live(event) = group.live(self, event)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(identity_key)
|
|
20
|
+
@identity_key = identity_key
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@members = []
|
|
23
|
+
@confirmed = 0
|
|
24
|
+
@open = true
|
|
25
|
+
@held = []
|
|
26
|
+
@replayed = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Adds a member while the group is open; nil after {#close}.
|
|
30
|
+
# `deliver` transmits an event when the subscription wants it.
|
|
31
|
+
def join(floor, &deliver)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
next unless @open
|
|
34
|
+
|
|
35
|
+
Member.new(self, floor, deliver).tap { |member| @members << member }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# No member joins after this.
|
|
40
|
+
def close = settle { @open = false }
|
|
41
|
+
|
|
42
|
+
def confirmed = settle { @confirmed += 1 }
|
|
43
|
+
|
|
44
|
+
def live(member, event)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@replayed ? member.deliver.call(event) : @held << [member, event]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def settle
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
yield
|
|
55
|
+
replay! if !@open && !@replayed && @members.any? && @confirmed == @members.size
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def replay!
|
|
60
|
+
tail = Kiosk.configuration.event_store.since(@identity_key, @members.map(&:floor).min)
|
|
61
|
+
tail.each { |event| offer(event) }
|
|
62
|
+
release_held(tail.to_set { |event| event['id'] })
|
|
63
|
+
@replayed = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A held event the replay already offered this member is not sent again.
|
|
67
|
+
def release_held(replayed)
|
|
68
|
+
@held.sort_by { |_, event| event['id'] }.each do |member, event|
|
|
69
|
+
member.deliver.call(event) unless replayed.include?(event['id']) && event['id'] > member.floor
|
|
70
|
+
end
|
|
71
|
+
@held = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def offer(event)
|
|
75
|
+
@members.each { |member| member.deliver.call(event) if event['id'] > member.floor }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -3,38 +3,39 @@
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
5
|
class Executor
|
|
6
|
-
# A `pay` whose
|
|
6
|
+
# A `pay` whose mandates, or another live pair for the same checkout, came before (§11.6).
|
|
7
7
|
module Repeat
|
|
8
8
|
private
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
# declined: the
|
|
10
|
+
# The identical pair, presented before. Settled: its settlement. Neither
|
|
11
|
+
# settled nor declined: the pair, to capture again. Anything else is a conflict.
|
|
12
12
|
def repeat(chain)
|
|
13
13
|
row = recorded_chain(chain)
|
|
14
14
|
raise Errors::Conflict.new('mandate already processed', hint: Pay::CONFLICT_HINT) if row.nil? || row['declined_at']
|
|
15
|
-
return stored_settlement(row) if row['settlement_id']
|
|
15
|
+
return stored_settlement(chain, row) if row['settlement_id']
|
|
16
16
|
|
|
17
|
-
chain.
|
|
18
|
-
chain.repeat
|
|
17
|
+
chain.checkout_row = row.fetch('checkout_row')
|
|
18
|
+
chain.repeat = true
|
|
19
19
|
chain
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def recorded_chain(chain)
|
|
23
|
-
return nil if chain.
|
|
23
|
+
return nil if chain.checkout.nil? || chain.payment.nil?
|
|
24
24
|
|
|
25
25
|
row = nil
|
|
26
26
|
SessionContext.open(connection: connection, identity: identity) { row = chain_record(chain) }
|
|
27
27
|
row
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def stored_settlement(row)
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
def stored_settlement(chain, row)
|
|
31
|
+
settled = { psp_reference: row.fetch('psp_reference'), settled_amount_cents: row.fetch('settled_amount_cents').to_i,
|
|
32
|
+
network_confirmation_id: row['network_confirmation_id'] }
|
|
33
|
+
settlement_result(chain, row.fetch('settlement_id'), settled, row.fetch('currency'))
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
# A refusal under which no money moved:
|
|
36
|
+
# A refusal under which no money moved: a new pair can pay the checkout.
|
|
36
37
|
def decline(chain)
|
|
37
|
-
SessionContext.open(connection: connection, identity: identity) {
|
|
38
|
+
SessionContext.open(connection: connection, identity: identity) { decline_checkout(chain.checkout_row) }
|
|
38
39
|
end
|
|
39
40
|
end
|
|
40
41
|
end
|
|
@@ -3,98 +3,39 @@
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
5
|
class Executor
|
|
6
|
-
# The assistant's spending cap, checked
|
|
6
|
+
# The assistant's spending cap: the most one `pay` may charge, checked before capture.
|
|
7
7
|
module SpendingCap
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
# A nil seam or a nil cap means uncapped. A cap allows no payment in
|
|
11
|
-
# another currency.
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
# is a repeat and adds nothing.
|
|
15
|
-
def enforce_spending_cap!(cart)
|
|
11
|
+
# another currency. A checkout mandate already recorded is a repeat: the
|
|
12
|
+
# first `pay` of that checkout passed this check.
|
|
13
|
+
def enforce_spending_cap!(checkout)
|
|
16
14
|
cap = Kiosk.configuration.spending_cap&.call(agent_id: identity.agent_id)
|
|
17
|
-
return if cap.nil?
|
|
15
|
+
return if cap.nil? || checkout_recorded?(checkout)
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
refuse_other_currency!(cap.fetch(:currency), cart)
|
|
23
|
-
refuse_over_cap!(cap.fetch(:amount_cents).to_i, cart)
|
|
17
|
+
refuse_other_currency!(cap.fetch(:currency), checkout)
|
|
18
|
+
refuse_over_cap!(cap.fetch(:amount_cents).to_i, checkout)
|
|
24
19
|
end
|
|
25
20
|
|
|
26
|
-
def refuse_other_currency!(cap_currency,
|
|
21
|
+
def refuse_other_currency!(cap_currency, checkout)
|
|
27
22
|
cap_code = MandateVerifier.canonical_currency(cap_currency)
|
|
28
|
-
|
|
29
|
-
return if cap_code == cart_code
|
|
23
|
+
return if cap_code == checkout.currency
|
|
30
24
|
|
|
31
25
|
raise Errors::SpendingCapExceeded.new('assistant spending cap is in another currency',
|
|
32
|
-
hint: "the cap is in #{cap_code.upcase}; this charge is in #{
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def refuse_over_cap!(cap, cart)
|
|
36
|
-
window_days = Kiosk.configuration.spending_cap_window_days
|
|
37
|
-
spent = spent_in_window(cart, window_days)
|
|
38
|
-
return if spent + cart.total_amount_cents.to_i <= cap
|
|
39
|
-
|
|
40
|
-
raise Errors::SpendingCapExceeded.new('assistant spending cap exceeded', hint: cap_hint(cap, spent, window_days, cart))
|
|
26
|
+
hint: "the cap is in #{cap_code.upcase}; this charge is in #{checkout.currency.upcase}")
|
|
41
27
|
end
|
|
42
28
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def spent_in_window(cart, window_days)
|
|
46
|
-
binds = spent_binds(identity.agent_id, cart.currency, window_days)
|
|
47
|
-
sql = "SELECT (#{settled_sql(window_days)}) + (#{in_flight_sql}) AS total"
|
|
48
|
-
connection.exec_query(sql, 'Kiosk spent total', binds).to_a.first.fetch('total').to_i
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def cap_hint(cap, spent, window_days, cart)
|
|
52
|
-
window_note = window_days ? " in the last #{window_days.to_i} day(s)" : ''
|
|
53
|
-
"cap #{cap} cents; #{spent} already settled or in flight#{window_note}; " \
|
|
54
|
-
"this charge is #{cart.total_amount_cents.to_i}"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Settled cents for this agent in one currency (see SettledSpend),
|
|
58
|
-
# case-folded on both sides so `eur` and `EUR` are one tally. No window
|
|
59
|
-
# means no predicate; the day count is a bind.
|
|
60
|
-
def settled_sql(window_days)
|
|
61
|
-
schema = Kiosk.configuration.schema
|
|
62
|
-
window = window_days ? 'AND settled_at >= now() - make_interval(days => $3)' : ''
|
|
63
|
-
<<~SQL.squish
|
|
64
|
-
SELECT COALESCE(SUM(#{SettledSpend.net_cents_sql(schema)}), 0) AS total
|
|
65
|
-
FROM #{schema}.settlements
|
|
66
|
-
WHERE agent_id = $1 AND lower(btrim(currency)) = $2 #{window}
|
|
67
|
-
SQL
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Carts of this agent whose mandate is live, that have no settlement and
|
|
71
|
-
# were not declined: a capture may still run for each.
|
|
72
|
-
def in_flight_sql
|
|
73
|
-
schema = Kiosk.configuration.schema
|
|
74
|
-
<<~SQL.squish
|
|
75
|
-
SELECT COALESCE(SUM(c.total_amount_cents), 0) AS total
|
|
76
|
-
FROM #{schema}.cart_mandates c
|
|
77
|
-
WHERE c.agent_id = $1 AND lower(btrim(c.currency)) = $2 AND c.expires_at > now()
|
|
78
|
-
AND c.declined_at IS NULL
|
|
79
|
-
AND NOT EXISTS (SELECT 1 FROM #{schema}.settlements s WHERE s.cart_mandate_id = c.id)
|
|
80
|
-
SQL
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# The agent, the canonical currency and, with a window, its day count.
|
|
84
|
-
def spent_binds(agent_id, currency, window_days)
|
|
85
|
-
binds = [agent_id, MandateVerifier.canonical_currency(currency)]
|
|
86
|
-
window_days ? binds << window_days.to_i : binds
|
|
87
|
-
end
|
|
29
|
+
def refuse_over_cap!(cap, checkout)
|
|
30
|
+
return if checkout.total_amount_cents <= cap
|
|
88
31
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
connection.exec_query(sql, 'Kiosk cart recorded', [cart.user_id, cart.id]).any?
|
|
32
|
+
raise Errors::SpendingCapExceeded.new('assistant spending cap exceeded',
|
|
33
|
+
hint: "one payment may charge #{cap} cents at most; this charge is #{checkout.total_amount_cents} cents")
|
|
92
34
|
end
|
|
93
35
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
connection.exec_query('
|
|
97
|
-
'Kiosk spending cap lock', [agent_id.to_s])
|
|
36
|
+
def checkout_recorded?(checkout)
|
|
37
|
+
sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.kiosk_checkout_mandates WHERE user_id = $1 AND reference = $2"
|
|
38
|
+
connection.exec_query(sql, 'Kiosk checkout mandate recorded', [checkout.user_id, checkout.reference]).any?
|
|
98
39
|
end
|
|
99
40
|
end
|
|
100
41
|
end
|