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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -0
  3. data/README.md +134 -102
  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 +3 -3
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +2 -7
  10. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  11. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +8 -7
  12. data/app/controllers/kiosk/server/oauth_token_controller.rb +4 -1
  13. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  14. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  15. data/app/models/kiosk/checkout_mandate.rb +0 -2
  16. data/app/models/kiosk/settlement.rb +0 -2
  17. data/app/models/kiosk/visit.rb +0 -2
  18. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  19. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  20. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  21. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  22. data/lib/generators/kiosk/install/install_generator.rb +25 -64
  23. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  24. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  25. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  26. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  27. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -14
  28. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  29. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  30. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  31. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  32. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  33. data/lib/kiosk/payable.rb +1 -1
  34. data/lib/kiosk/server/account_binding.rb +14 -5
  35. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  36. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +1 -1
  37. data/lib/kiosk/server/agent_rotation.rb +1 -1
  38. data/lib/kiosk/server/argument_decoder.rb +2 -2
  39. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  40. data/lib/kiosk/server/auth_toll.rb +5 -5
  41. data/lib/kiosk/server/client.rb +1 -1
  42. data/lib/kiosk/server/client_assertion.rb +3 -2
  43. data/lib/kiosk/server/client_registration.rb +6 -2
  44. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  45. data/lib/kiosk/server/configuration_extension.rb +15 -38
  46. data/lib/kiosk/server/current_request.rb +1 -1
  47. data/lib/kiosk/server/device_authorization.rb +1 -1
  48. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  49. data/lib/kiosk/server/device_code_grant.rb +2 -2
  50. data/lib/kiosk/server/dpop.rb +6 -1
  51. data/lib/kiosk/server/engine.rb +2 -2
  52. data/lib/kiosk/server/errors.rb +21 -7
  53. data/lib/kiosk/server/event_stores.rb +2 -2
  54. data/lib/kiosk/server/events.rb +4 -3
  55. data/lib/kiosk/server/events_connection.rb +23 -5
  56. data/lib/kiosk/server/events_replay.rb +79 -0
  57. data/lib/kiosk/server/executor/repeat.rb +1 -1
  58. data/lib/kiosk/server/executor/spending_cap.rb +8 -66
  59. data/lib/kiosk/server/executor.rb +10 -10
  60. data/lib/kiosk/server/failure_log.rb +3 -3
  61. data/lib/kiosk/server/handler_mixin.rb +9 -9
  62. data/lib/kiosk/server/handler_registrations.rb +1 -1
  63. data/lib/kiosk/server/handler_serving.rb +11 -11
  64. data/lib/kiosk/server/headers.rb +3 -3
  65. data/lib/kiosk/server/identity_resolution.rb +1 -1
  66. data/lib/kiosk/server/kyc.rb +5 -7
  67. data/lib/kiosk/server/kyc_verifier.rb +26 -27
  68. data/lib/kiosk/server/linked_assistants.rb +21 -16
  69. data/lib/kiosk/server/money.rb +41 -0
  70. data/lib/kiosk/server/oauth_answers.rb +1 -0
  71. data/lib/kiosk/server/oauth_error.rb +3 -0
  72. data/lib/kiosk/server/open_api.rb +9 -9
  73. data/lib/kiosk/server/page.rb +1 -1
  74. data/lib/kiosk/server/payment_claim.rb +3 -3
  75. data/lib/kiosk/server/pow_gate.rb +12 -33
  76. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  77. data/lib/kiosk/server/receipts.rb +4 -3
  78. data/lib/kiosk/server/reputation_factors.rb +12 -5
  79. data/lib/kiosk/server/request_validation.rb +2 -1
  80. data/lib/kiosk/server/result.rb +1 -1
  81. data/lib/kiosk/server/revocation_stores.rb +1 -1
  82. data/lib/kiosk/server/routing.rb +1 -1
  83. data/lib/kiosk/server/schemas/auth.schema.json +2 -2
  84. data/lib/kiosk/server/schemas/binding.schema.json +3 -3
  85. data/lib/kiosk/server/schemas/kyc.schema.json +22 -3
  86. data/lib/kiosk/server/schemas/mandates.schema.json +5 -5
  87. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  88. data/lib/kiosk/server/session_context.rb +2 -2
  89. data/lib/kiosk/server/tolled_calls.rb +46 -0
  90. data/lib/kiosk/server/verb_wire.rb +3 -3
  91. data/lib/kiosk/server/version.rb +1 -1
  92. data/lib/kiosk/server/well_known.rb +14 -11
  93. data/lib/kiosk/server/wire_rendering.rb +1 -1
  94. data/lib/kiosk/server.rb +6 -2
  95. metadata +9 -8
  96. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  97. data/lib/kiosk/server/schema_definitions.rb +0 -320
  98. data/lib/kiosk/server/settled_spend.rb +0 -21
  99. data/lib/kiosk/server/wrong_door.rb +0 -42
@@ -8,7 +8,7 @@ module Kiosk
8
8
  # cannot each build their own store (a double-spent proof of work).
9
9
  LAZY_STORE_MUTEX = Mutex.new
10
10
 
11
- # The discovery settings: where the wire is mounted and what it advertises.
11
+ # The discovery settings: where the protocol is mounted and what it advertises.
12
12
  module Discovery
13
13
  # Default `/kiosk`. Discovery advertises `endpoint = origin + mount_path`.
14
14
  attr_writer :mount_path
@@ -30,13 +30,18 @@ module Kiosk
30
30
  @owner ||= {}
31
31
  end
32
32
 
33
- # The skill this origin was built against. Discovery emits the `skill`
34
- # block only when `skill_sha256` is set.
33
+ # The skill cut of this kiosk-server release. Each release gives the cut
34
+ # of its own version. Do not set it.
35
35
  attr_writer :skill_url
36
36
 
37
37
  def skill_url
38
- @skill_url ||= 'https://kiosk.tech/skill-v0.6.1.md'
38
+ @skill_url ||= 'https://kiosk.tech/skill-v0.6.28.md'
39
39
  end
40
+
41
+ # The SHA-256 of the file at `skill_url`, as hex. Default nil: discovery
42
+ # then advertises no `skill` block. Make it with
43
+ # `curl -fsS <skill_url> | shasum -a 256`. Kiosk::TestHelpers::SkillPin
44
+ # fails when an update of kiosk-server changes `skill_url`.
40
45
  attr_accessor :skill_sha256
41
46
 
42
47
  private
@@ -107,41 +112,15 @@ module Kiosk
107
112
  def kyc_claims
108
113
  @kyc_claims || []
109
114
  end
110
-
111
- # Must match the `iss` of submitted KYC attestations.
112
- attr_writer :kyc_issuer
113
-
114
- def kyc_issuer
115
- @kyc_issuer
116
- end
117
-
118
- # The `aud` a KYC attestation must carry. Default: the origin being served.
119
- attr_writer :kyc_audience
120
-
121
- def kyc_audience
122
- @kyc_audience || Kiosk.current_issuer
123
- end
124
-
125
- # The KYC provider's Ed25519 public key: an OpenSSL::PKey or a PEM string.
126
- def kyc_public_key
127
- @kyc_public_key
128
- end
129
-
130
- def kyc_public_key=(value)
131
- @kyc_public_key = value.is_a?(String) ? OpenSSL::PKey.read(value) : value
132
- return if @kyc_public_key.nil? || Jwk.ed25519?(@kyc_public_key)
133
-
134
- raise ArgumentError, "kyc_public_key must be an Ed25519 key or its PEM string, got #{@kyc_public_key.class}"
135
- end
136
115
  end
137
116
 
138
117
  # The database settings: where Kiosk's tables live and how a request reaches them.
139
118
  module Database
140
- # Postgres schema for Kiosk's own tables and functions. Default 'kiosk'.
119
+ # The Postgres schema of Kiosk's `kiosk_*` tables. Default 'public'.
141
120
  attr_writer :schema
142
121
 
143
122
  def schema
144
- @schema ||= 'kiosk'
123
+ @schema ||= 'public'
145
124
  end
146
125
 
147
126
  # Prefix of the session GUCs a request sets, e.g. `app.user_id`. Default 'app'.
@@ -252,16 +231,14 @@ module Kiosk
252
231
  # `account`, a `user_model` record, and its tokens are revoked.
253
232
  attr_accessor :assistant_unlinked
254
233
 
255
- # Optional `(agent_id:) → { amount_cents:, currency: } | nil`, checked
256
- # before capture; 0 blocks payments, and so does a checkout in another currency.
234
+ # Optional `(agent_id:) → { amount_cents:, currency: } | nil`: the most
235
+ # one `pay` may charge, checked before capture. 0 blocks payments, and so
236
+ # does a checkout in another currency.
257
237
  # {Kiosk::Server::ColumnSpendingCap} reads
258
238
  # `agents.spending_cap_cents`, the column edited by the manage-assistants
259
239
  # page, and `agents.spending_cap_currency`.
260
240
  attr_accessor :spending_cap
261
241
 
262
- # Days of settled spend summed against the cap. Default nil: all time.
263
- attr_accessor :spending_cap_window_days
264
-
265
242
  # `call(id, lines) → cents | String`: the payable row's price, or why the checkout is refused.
266
243
  attr_accessor :checkout_price_checker
267
244
 
@@ -310,7 +287,7 @@ module Kiosk
310
287
  LAZY_STORE_MUTEX.synchronize { @pow_spent_store ||= Kiosk::Server::PowSpentStores::ActiveRecord.new }
311
288
  end
312
289
 
313
- # Seconds an access token lives. One paid proof-of-work toll lasts as long. Default 3600.
290
+ # Seconds an access token lives. Default 3600.
314
291
  attr_writer :access_token_ttl
315
292
 
316
293
  def access_token_ttl
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The wire request being dispatched, visible to its handler for the duration
5
+ # The protocol request being dispatched, visible to its handler for the duration
6
6
  # of one block. Fiber-local: a handler that hands work to another thread must
7
7
  # pass what it needs.
8
8
  module CurrentRequest
@@ -5,7 +5,7 @@ require 'digest'
5
5
 
6
6
  module Kiosk
7
7
  module Server
8
- # One account-binding request on the RFC 8628 device-grant wire: a `:claim`
8
+ # One account-binding request on the RFC 8628 device-grant endpoints: a `:claim`
9
9
  # the agent opens and a human approves, or a `:link` a signed-in human
10
10
  # creates already approved. Only SHA-256 digests of its two codes are kept.
11
11
  #
@@ -147,7 +147,7 @@ module Kiosk
147
147
  private
148
148
 
149
149
  def connection = ::ActiveRecord::Base.lease_connection
150
- def table = %("#{Kiosk.configuration.schema}".device_authorizations)
150
+ def table = %("#{Kiosk.configuration.schema}".kiosk_device_authorizations)
151
151
 
152
152
  def insert_sql
153
153
  placeholders = (1..COLUMNS.size).map { |i| "$#{i}" }.join(', ')
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The claim ceremony on the RFC 8628 device-grant wire: binds a registered
5
+ # The claim ceremony on the RFC 8628 device-grant endpoints: binds a registered
6
6
  # client to an existing human account once the human approves.
7
7
  #
8
8
  # .start — POST /oauth/device_authorization
@@ -42,7 +42,7 @@ module Kiosk
42
42
  expires_in: expires_in, interval: DEFAULT_POLL_INTERVAL, da: da }
43
43
  end
44
44
 
45
- # The first poll of each device code value pays the auth toll; later polls
45
+ # The first poll of each device code value is tolled; later polls
46
46
  # of that value are free until any code could have expired. The store is
47
47
  # not read first, so an unknown code costs the same work as a live one.
48
48
  def pay_toll!(device_code, pow, now: Time.current)
@@ -23,7 +23,12 @@ module Kiosk
23
23
  module_function
24
24
 
25
25
  # The `WWW-Authenticate` value of a 401 on a request that needs a token.
26
- def challenge = %(#{SCHEME} algs="#{AssistantKey.algorithms.join(' ')}")
26
+ # `resource_metadata` names the protected-resource metadata (RFC 9728
27
+ # Section 5.1).
28
+ def challenge
29
+ %(#{SCHEME} algs="#{AssistantKey.algorithms.join(' ')}", ) +
30
+ %(resource_metadata="#{Kiosk.current_issuer}/.well-known/oauth-protected-resource")
31
+ end
27
32
 
28
33
  def hint
29
34
  'send `Authorization: DPoP <access_token>` and one `DPoP` header: a JWS with typ dpop+jwt, ' \
@@ -29,7 +29,7 @@ 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
35
  client_assertion new_client_assertion code device_code kyc_jws
@@ -155,7 +155,7 @@ module Kiosk
155
155
  end
156
156
 
157
157
  routes do
158
- # 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
159
159
  # routes, so these win by first-match. Optional modules are drawn
160
160
  # unconditionally and answer `module_not_served` (501) when not configured.
161
161
  # `schema` resolves no identity — it is PUBLIC, like `openapi.json`
@@ -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)
@@ -238,6 +238,20 @@ module Kiosk
238
238
  def response_headers = { 'WWW-Authenticate' => Dpop.challenge }
239
239
  end
240
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
+
241
255
  class Forbidden < Base
242
256
  CODE = 'forbidden'
243
257
  HTTP_STATUS = 403
@@ -280,7 +294,7 @@ module Kiosk
280
294
  HTTP_STATUS = 403
281
295
  end
282
296
 
283
- # 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.
284
298
  class SpendingCapExceeded < Base
285
299
  CODE = 'spending_cap_exceeded'
286
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
@@ -105,14 +114,21 @@ module Kiosk
105
114
  def kiosk_credential_holds?
106
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,9 +167,11 @@ 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
157
175
  end
158
176
 
159
177
  # The token and the DPoP proof of the upgrade request.
@@ -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
@@ -33,7 +33,7 @@ module Kiosk
33
33
  settlement_result(chain, row.fetch('settlement_id'), settled, row.fetch('currency'))
34
34
  end
35
35
 
36
- # A refusal under which no money moved: the checkout stops counting against the spending cap.
36
+ # A refusal under which no money moved: a new pair can pay the checkout.
37
37
  def decline(chain)
38
38
  SessionContext.open(connection: connection, identity: identity) { decline_checkout(chain.checkout_row) }
39
39
  end
@@ -3,21 +3,16 @@
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 checkout
12
- # mandate, under a per-assistant lock, so two concurrent pays see each
13
- # other: the second counts the first's checkout as in flight. A checkout
14
- # mandate already recorded is a repeat and adds nothing.
11
+ # another currency. A checkout mandate already recorded is a repeat: the
12
+ # first `pay` of that checkout passed this check.
15
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
18
-
19
- lock_assistant_payments!(identity.agent_id)
20
- return if checkout_recorded?(checkout)
15
+ return if cap.nil? || checkout_recorded?(checkout)
21
16
 
22
17
  refuse_other_currency!(cap.fetch(:currency), checkout)
23
18
  refuse_over_cap!(cap.fetch(:amount_cents).to_i, checkout)
@@ -32,69 +27,16 @@ module Kiosk
32
27
  end
33
28
 
34
29
  def refuse_over_cap!(cap, checkout)
35
- window_days = Kiosk.configuration.spending_cap_window_days
36
- spent = spent_in_window(checkout, window_days)
37
- return if spent + checkout.total_amount_cents <= cap
38
-
39
- raise Errors::SpendingCapExceeded.new('assistant spending cap exceeded', hint: cap_hint(cap, spent, window_days, checkout))
40
- end
41
-
42
- # One tally per currency: cents are not fungible across currencies. One
43
- # statement, so a settlement committed between two reads is counted once.
44
- def spent_in_window(checkout, window_days)
45
- binds = spent_binds(identity.agent_id, checkout.currency, window_days)
46
- sql = "SELECT (#{settled_sql(window_days)}) + (#{in_flight_sql}) AS total"
47
- connection.exec_query(sql, 'Kiosk spent total', binds).to_a.first.fetch('total').to_i
48
- end
49
-
50
- def cap_hint(cap, spent, window_days, checkout)
51
- window_note = window_days ? " in the last #{window_days.to_i} day(s)" : ''
52
- "cap #{cap} cents; #{spent} already settled or in flight#{window_note}; " \
53
- "this charge is #{checkout.total_amount_cents}"
54
- end
30
+ return if checkout.total_amount_cents <= cap
55
31
 
56
- # Settled cents for this agent in one currency (see SettledSpend),
57
- # case-folded on both sides so `eur` and `EUR` are one tally. No window
58
- # means no predicate; the day count is a bind.
59
- def settled_sql(window_days)
60
- schema = Kiosk.configuration.schema
61
- window = window_days ? 'AND settled_at >= now() - make_interval(days => $3)' : ''
62
- <<~SQL.squish
63
- SELECT COALESCE(SUM(#{SettledSpend.net_cents_sql(schema)}), 0) AS total
64
- FROM #{schema}.settlements
65
- WHERE agent_id = $1 AND lower(btrim(currency)) = $2 #{window}
66
- SQL
67
- end
68
-
69
- # Checkout mandates of this agent that are live, have no settlement and
70
- # were not declined: a capture may still run for each.
71
- def in_flight_sql
72
- schema = Kiosk.configuration.schema
73
- <<~SQL.squish
74
- SELECT COALESCE(SUM(c.total_amount_cents), 0) AS total
75
- FROM #{schema}.checkout_mandates c
76
- WHERE c.agent_id = $1 AND lower(btrim(c.currency)) = $2 AND c.expires_at > now()
77
- AND c.declined_at IS NULL
78
- AND NOT EXISTS (SELECT 1 FROM #{schema}.settlements s WHERE s.checkout_mandate_id = c.id)
79
- SQL
80
- end
81
-
82
- # The agent, the canonical currency and, with a window, its day count.
83
- def spent_binds(agent_id, currency, window_days)
84
- binds = [agent_id, MandateVerifier.canonical_currency(currency)]
85
- window_days ? binds << window_days.to_i : binds
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")
86
34
  end
87
35
 
88
36
  def checkout_recorded?(checkout)
89
- sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.checkout_mandates WHERE user_id = $1 AND reference = $2"
37
+ sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.kiosk_checkout_mandates WHERE user_id = $1 AND reference = $2"
90
38
  connection.exec_query(sql, 'Kiosk checkout mandate recorded', [checkout.user_id, checkout.reference]).any?
91
39
  end
92
-
93
- # Held to the end of the transaction; an advisory lock needs no grant on any table.
94
- def lock_assistant_payments!(agent_id)
95
- connection.exec_query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
96
- 'Kiosk spending cap lock', [agent_id.to_s])
97
- end
98
40
  end
99
41
  end
100
42
  end
@@ -4,7 +4,7 @@ module Kiosk
4
4
  module Server
5
5
  # Runs one call for an identity inside a {SessionContext}; returns a {Result} or raises {Errors::Base}.
6
6
  class Executor
7
- # The coarse kinds of call shared by the toll, the policy and this dispatcher; not wire paths.
7
+ # The coarse kinds of call shared by the toll, the policy and this dispatcher; not HTTP paths.
8
8
  VERBS = %i[query run pay].freeze
9
9
 
10
10
  # Kinds with an irreversible external effect (a PSP capture) that manage their own transactions.
@@ -121,7 +121,7 @@ module Kiosk
121
121
  result
122
122
  end
123
123
 
124
- # The handler's message goes to the operator's log, never onto the wire.
124
+ # The handler's message goes to the operator's log, never onto the protocol.
125
125
  def report_handler_failure(kind, name, error)
126
126
  FailureLog.report("#{kind} #{name.inspect} raised #{error.class}", error)
127
127
  end
@@ -220,7 +220,7 @@ module Kiosk
220
220
  end
221
221
 
222
222
  # Phase 2: the irreversible capture. A refusal under which no money moved
223
- # declines the checkout mandate, so it stops counting against the spending cap.
223
+ # declines the checkout mandate, so a new pair can pay that checkout.
224
224
  def capture(provider, chain)
225
225
  charge(provider, chain)
226
226
  rescue Kiosk::PaymentProviders::SetupRequired
@@ -280,9 +280,9 @@ module Kiosk
280
280
  SELECT c.id AS checkout_row, c.declined_at,
281
281
  s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency,
282
282
  s.network_confirmation_id
283
- FROM %<schema>s.checkout_mandates c
284
- JOIN %<schema>s.payment_mandates p ON p.checkout_mandate_id = c.id
285
- LEFT JOIN %<schema>s.settlements s ON s.checkout_mandate_id = c.id
283
+ FROM %<schema>s.kiosk_checkout_mandates c
284
+ JOIN %<schema>s.kiosk_payment_mandates p ON p.checkout_mandate_id = c.id
285
+ LEFT JOIN %<schema>s.kiosk_settlements s ON s.checkout_mandate_id = c.id
286
286
  WHERE c.user_id = $1 AND p.user_id = $1
287
287
  AND c.reference = $2
288
288
  AND c.sd_jwt = $3
@@ -292,7 +292,7 @@ module Kiosk
292
292
 
293
293
  # `line_items` arrives as JSON text; the cast stores a jsonb array rather than a string.
294
294
  CHECKOUT_INSERT_SQL = <<~SQL.squish
295
- INSERT INTO %<schema>s.checkout_mandates
295
+ INSERT INTO %<schema>s.kiosk_checkout_mandates
296
296
  (reference, checkout_hash, user_id, agent_id, issuer, line_items,
297
297
  total_amount_cents, currency, expires_at, created_at, sd_jwt)
298
298
  VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, now(), $10)
@@ -300,7 +300,7 @@ module Kiosk
300
300
  SQL
301
301
 
302
302
  PAYMENT_INSERT_SQL = <<~SQL.squish
303
- INSERT INTO %<schema>s.payment_mandates
303
+ INSERT INTO %<schema>s.kiosk_payment_mandates
304
304
  (reference, checkout_mandate_id, user_id, agent_id, issuer,
305
305
  payment_instrument, amount_cents, currency, expires_at, created_at, sd_jwt)
306
306
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10)
@@ -309,7 +309,7 @@ module Kiosk
309
309
 
310
310
  # Nobody signs a settlement; `UNIQUE (checkout_mandate_id)` keeps it single.
311
311
  SETTLEMENT_INSERT_SQL = <<~SQL.squish
312
- INSERT INTO %<schema>s.settlements
312
+ INSERT INTO %<schema>s.kiosk_settlements
313
313
  (checkout_mandate_id, user_id, agent_id, issuer, psp_reference,
314
314
  settled_amount_cents, currency, network_confirmation_id, settled_at)
315
315
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
@@ -317,7 +317,7 @@ module Kiosk
317
317
  SQL
318
318
 
319
319
  DECLINE_SQL = <<~SQL.squish
320
- UPDATE %<schema>s.checkout_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
320
+ UPDATE %<schema>s.kiosk_checkout_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
321
321
  SQL
322
322
 
323
323
  private
@@ -2,18 +2,18 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # Logs the exception whose message a refusal kept off the wire: Rails' logger, else `warn`.
5
+ # Logs the exception whose message a refusal kept out of the response: Rails' logger, else `warn`.
6
6
  # A raising logger is swallowed so one failure does not become two.
7
7
  module FailureLog
8
8
  # Enough frames to reach the operator's own code without flooding the log.
9
9
  BACKTRACE_FRAMES = 20
10
10
 
11
- # One string to grep for refusals that kept their message off the wire.
11
+ # One string to grep for refusals that kept their message off the protocol.
12
12
  PREFIX = '[kiosk-server]'
13
13
 
14
14
  module_function
15
15
 
16
- # `summary` is the caller's own words, the half that also reaches the wire.
16
+ # `summary` is the caller's own words, the half that also reaches the protocol.
17
17
  def report(summary, error)
18
18
  line = "#{PREFIX} #{summary}: #{error.message}\n " \
19
19
  "#{Array(error.backtrace).first(BACKTRACE_FRAMES).join("\n ")}"