kiosk-server 0.6.1 → 0.6.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/README.md +134 -102
- data/app/channels/kiosk_events.rb +30 -17
- data/app/controllers/kiosk/server/application_controller.rb +0 -1
- data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
- data/app/controllers/kiosk/server/auth_controller.rb +3 -3
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +2 -7
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +8 -7
- data/app/controllers/kiosk/server/oauth_token_controller.rb +4 -1
- data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
- data/app/controllers/kiosk/server/wire_controller.rb +2 -2
- data/app/models/kiosk/checkout_mandate.rb +0 -2
- data/app/models/kiosk/settlement.rb +0 -2
- data/app/models/kiosk/visit.rb +0 -2
- data/app/views/kiosk/server/assistants/show.html.erb +20 -24
- data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
- data/app/views/layouts/kiosk/server/application.html.erb +14 -0
- data/lib/generators/kiosk/install/install_generator.rb +25 -64
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
- data/lib/kiosk/payable.rb +1 -1
- data/lib/kiosk/server/account_binding.rb +14 -5
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +1 -1
- data/lib/kiosk/server/agent_rotation.rb +1 -1
- data/lib/kiosk/server/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/auth_toll.rb +5 -5
- data/lib/kiosk/server/client.rb +1 -1
- data/lib/kiosk/server/client_assertion.rb +3 -2
- data/lib/kiosk/server/client_registration.rb +6 -2
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +15 -38
- data/lib/kiosk/server/current_request.rb +1 -1
- data/lib/kiosk/server/device_authorization.rb +1 -1
- data/lib/kiosk/server/device_authorization_stores.rb +1 -1
- data/lib/kiosk/server/device_code_grant.rb +2 -2
- data/lib/kiosk/server/dpop.rb +6 -1
- data/lib/kiosk/server/engine.rb +2 -2
- data/lib/kiosk/server/errors.rb +21 -7
- data/lib/kiosk/server/event_stores.rb +2 -2
- data/lib/kiosk/server/events.rb +4 -3
- data/lib/kiosk/server/events_connection.rb +23 -5
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +1 -1
- data/lib/kiosk/server/executor/spending_cap.rb +8 -66
- data/lib/kiosk/server/executor.rb +10 -10
- data/lib/kiosk/server/failure_log.rb +3 -3
- data/lib/kiosk/server/handler_mixin.rb +9 -9
- data/lib/kiosk/server/handler_registrations.rb +1 -1
- data/lib/kiosk/server/handler_serving.rb +11 -11
- data/lib/kiosk/server/headers.rb +3 -3
- data/lib/kiosk/server/identity_resolution.rb +1 -1
- data/lib/kiosk/server/kyc.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +26 -27
- data/lib/kiosk/server/linked_assistants.rb +21 -16
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +1 -0
- data/lib/kiosk/server/oauth_error.rb +3 -0
- data/lib/kiosk/server/open_api.rb +9 -9
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +3 -3
- data/lib/kiosk/server/pow_gate.rb +12 -33
- data/lib/kiosk/server/pow_spent_stores.rb +1 -1
- data/lib/kiosk/server/receipts.rb +4 -3
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +2 -1
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +1 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +2 -2
- data/lib/kiosk/server/schemas/binding.schema.json +3 -3
- data/lib/kiosk/server/schemas/kyc.schema.json +22 -3
- data/lib/kiosk/server/schemas/mandates.schema.json +5 -5
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +3 -3
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +14 -11
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +6 -2
- metadata +9 -8
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- data/lib/kiosk/server/schema_definitions.rb +0 -320
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
|
@@ -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
|
|
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
|
|
34
|
-
#
|
|
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.
|
|
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
|
|
119
|
+
# The Postgres schema of Kiosk's `kiosk_*` tables. Default 'public'.
|
|
141
120
|
attr_writer :schema
|
|
142
121
|
|
|
143
122
|
def schema
|
|
144
|
-
@schema ||= '
|
|
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
|
|
256
|
-
# before capture
|
|
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.
|
|
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
|
|
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
|
|
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}".
|
|
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
|
|
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
|
|
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)
|
data/lib/kiosk/server/dpop.rb
CHANGED
|
@@ -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
|
-
|
|
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, ' \
|
data/lib/kiosk/server/engine.rb
CHANGED
|
@@ -29,7 +29,7 @@ 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
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
|
|
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`
|
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)
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -105,14 +114,21 @@ module Kiosk
|
|
|
105
114
|
def kiosk_credential_holds?
|
|
106
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,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:
|
|
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
|
|
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
|
-
# 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?
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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}.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
284
|
-
JOIN %<schema>s.
|
|
285
|
-
LEFT JOIN %<schema>s.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 ")}"
|