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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +192 -138
  4. data/app/channels/kiosk_events.rb +30 -17
  5. data/app/controllers/kiosk/server/application_controller.rb +0 -1
  6. data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
  7. data/app/controllers/kiosk/server/auth_controller.rb +21 -65
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
  10. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  11. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  12. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  13. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
  14. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
  16. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  17. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  18. data/app/models/kiosk/checkout_mandate.rb +20 -0
  19. data/app/models/kiosk/settlement.rb +1 -3
  20. data/app/models/kiosk/visit.rb +0 -2
  21. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  22. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  23. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  24. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  25. data/lib/generators/kiosk/install/install_generator.rb +25 -69
  26. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  27. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  28. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  29. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  30. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
  31. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  32. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  33. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  34. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  35. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  36. data/lib/kiosk/payable.rb +19 -25
  37. data/lib/kiosk/server/account_binding.rb +32 -62
  38. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  39. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  40. data/lib/kiosk/server/agent_rotation.rb +62 -0
  41. data/lib/kiosk/server/argument_decoder.rb +2 -2
  42. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  43. data/lib/kiosk/server/assistant_key.rb +86 -0
  44. data/lib/kiosk/server/auth_toll.rb +6 -6
  45. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  46. data/lib/kiosk/server/checkout.rb +57 -0
  47. data/lib/kiosk/server/client.rb +61 -0
  48. data/lib/kiosk/server/client_assertion.rb +154 -0
  49. data/lib/kiosk/server/client_registration.rb +98 -0
  50. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  51. data/lib/kiosk/server/configuration_extension.rb +64 -75
  52. data/lib/kiosk/server/current_request.rb +1 -1
  53. data/lib/kiosk/server/device_authorization.rb +1 -1
  54. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  55. data/lib/kiosk/server/device_code_grant.rb +38 -69
  56. data/lib/kiosk/server/dpop.rb +126 -0
  57. data/lib/kiosk/server/eddsa.rb +38 -0
  58. data/lib/kiosk/server/engine.rb +16 -13
  59. data/lib/kiosk/server/errors.rb +28 -7
  60. data/lib/kiosk/server/event_stores.rb +2 -2
  61. data/lib/kiosk/server/events.rb +4 -3
  62. data/lib/kiosk/server/events_connection.rb +38 -10
  63. data/lib/kiosk/server/events_replay.rb +79 -0
  64. data/lib/kiosk/server/executor/repeat.rb +13 -12
  65. data/lib/kiosk/server/executor/spending_cap.rb +17 -76
  66. data/lib/kiosk/server/executor.rb +86 -105
  67. data/lib/kiosk/server/failure_log.rb +3 -3
  68. data/lib/kiosk/server/handler_mixin.rb +9 -9
  69. data/lib/kiosk/server/handler_registrations.rb +1 -1
  70. data/lib/kiosk/server/handler_serving.rb +11 -11
  71. data/lib/kiosk/server/headers.rb +3 -3
  72. data/lib/kiosk/server/identity_resolution.rb +12 -3
  73. data/lib/kiosk/server/jwk.rb +83 -0
  74. data/lib/kiosk/server/jwks.rb +6 -0
  75. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  76. data/lib/kiosk/server/kyc.rb +5 -7
  77. data/lib/kiosk/server/kyc_verifier.rb +28 -29
  78. data/lib/kiosk/server/link_code.rb +9 -18
  79. data/lib/kiosk/server/linked_assistants.rb +22 -17
  80. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  81. data/lib/kiosk/server/money.rb +41 -0
  82. data/lib/kiosk/server/oauth_answers.rb +31 -0
  83. data/lib/kiosk/server/oauth_error.rb +33 -0
  84. data/lib/kiosk/server/open_api.rb +31 -29
  85. data/lib/kiosk/server/page.rb +1 -1
  86. data/lib/kiosk/server/payment_claim.rb +47 -78
  87. data/lib/kiosk/server/pow_gate.rb +12 -33
  88. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  89. data/lib/kiosk/server/receipts.rb +30 -0
  90. data/lib/kiosk/server/reputation_factors.rb +12 -5
  91. data/lib/kiosk/server/request_validation.rb +3 -3
  92. data/lib/kiosk/server/result.rb +1 -1
  93. data/lib/kiosk/server/revocation_stores.rb +9 -1
  94. data/lib/kiosk/server/routing.rb +1 -1
  95. data/lib/kiosk/server/schemas/auth.schema.json +369 -58
  96. data/lib/kiosk/server/schemas/binding.schema.json +24 -59
  97. data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
  98. data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
  99. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  100. data/lib/kiosk/server/sd_jwt.rb +104 -0
  101. data/lib/kiosk/server/session_context.rb +2 -2
  102. data/lib/kiosk/server/signing_key.rb +18 -52
  103. data/lib/kiosk/server/tolled_calls.rb +46 -0
  104. data/lib/kiosk/server/verb_wire.rb +4 -4
  105. data/lib/kiosk/server/version.rb +1 -1
  106. data/lib/kiosk/server/well_known.rb +272 -205
  107. data/lib/kiosk/server/wire_rendering.rb +2 -2
  108. data/lib/kiosk/server.rb +8 -2
  109. data/lib/kiosk/skill_link.rb +6 -2
  110. data/listen.py +38 -8
  111. metadata +34 -24
  112. data/app/models/kiosk/cart_mandate.rb +0 -21
  113. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  114. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  115. data/lib/kiosk/server/agent_login.rb +0 -49
  116. data/lib/kiosk/server/agent_registration.rb +0 -88
  117. data/lib/kiosk/server/auth_challenge.rb +0 -33
  118. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  119. data/lib/kiosk/server/pop_verifier.rb +0 -87
  120. data/lib/kiosk/server/schema_definitions.rb +0 -351
  121. data/lib/kiosk/server/settled_spend.rb +0 -21
  122. data/lib/kiosk/server/wrong_door.rb +0 -42
@@ -29,11 +29,11 @@ module Kiosk
29
29
  Kiosk::Server::IssuerMiddleware
30
30
  end
31
31
 
32
- # Credential-bearing wire fields, kept out of the `Parameters:` log line.
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
- signed code device_code kyc_jws
36
- intent_mandate_jws cart_mandate_jws payment_mandate_jws
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 proof with "proof audience mismatch".
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 authenticated request is refused ' \
73
- 'with "proof audience mismatch". Set it to the origin assistants dial: ' \
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 wire's reserved endpoints. The mount is drawn first in the host's
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
- 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
+ # 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
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The wire error contract: the closed `code` vocabulary and the problem
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 wire's seventeen refusal codes and their HTTP status (spec §9). Adding one is a spec change first.
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 wire.
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 wire error a rescued exception becomes
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 wire error named by its code rather than its class. `extra:` adds extension members;
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 wire code #{code.inspect} — the vocabulary is Errors::CODES, closed by the spec"
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 would be exceeded; checked before capture.
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 wire's five event members, string-keyed, as {EventStore} gives them.
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}".events)
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
@@ -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 wire.
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
- 'wire name and one payload shape; declare it once, on the controller that owns ' \
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 resolve_identity
115
+ return true if token_holds?
107
116
 
108
- if kiosk_credential_expired?
109
- close(reason: 'token_expired', reconnect: true)
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 resolve_identity
162
- issuer = Kiosk.configuration.issuer_for(request.base_url)
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 chain was presented before (§11.6).
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
- # A chain presented before. Settled: its settlement. Neither settled nor
11
- # declined: the chain, to capture again. Anything else is a conflict.
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.cart_row = row.fetch('cart_row')
18
- chain.repeat = true
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.intent.nil? || chain.cart.nil? || chain.payment.nil?
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
- settlement_result(row.fetch('settlement_id'), row.fetch('psp_reference'),
32
- row.fetch('settled_amount_cents').to_i, row.fetch('currency'))
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: the cart stops counting against the spending cap.
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) { decline_cart(chain.cart_row) }
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 at pay time.
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. Runs in the transaction that persists the cart,
12
- # under a per-assistant lock, so two concurrent pays see each other: the
13
- # second counts the first's cart as in flight. A cart already recorded
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? # this assistant is uncapped
15
+ return if cap.nil? || checkout_recorded?(checkout)
18
16
 
19
- lock_assistant_payments!(identity.agent_id)
20
- return if cart_recorded?(cart)
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, cart)
21
+ def refuse_other_currency!(cap_currency, checkout)
27
22
  cap_code = MandateVerifier.canonical_currency(cap_currency)
28
- cart_code = MandateVerifier.canonical_currency(cart.currency)
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 #{cart_code.upcase}")
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
- # One tally per currency: cents are not fungible across currencies. One
44
- # statement, so a settlement committed between two reads is counted once.
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
- def cart_recorded?(cart)
90
- sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.cart_mandates WHERE user_id = $1 AND mandate_id = $2"
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
- # Held to the end of the transaction; an advisory lock needs no grant on any table.
95
- def lock_assistant_payments!(agent_id)
96
- connection.exec_query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
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