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
@@ -9,6 +9,12 @@ module Kiosk
9
9
  def build(keys:)
10
10
  { keys: Array(keys).map(&:to_jwk) }
11
11
  end
12
+
13
+ # The public key of the JWK in `jwks` whose `kid` is `kid`, or nil.
14
+ def find(jwks, kid)
15
+ jwk = Array(jwks[:keys] || jwks['keys']).find { |k| (k[:kid] || k['kid']) == kid }
16
+ jwk && Jwk.import(jwk)
17
+ end
12
18
  end
13
19
  end
14
20
  end
@@ -5,16 +5,12 @@ require 'securerandom'
5
5
 
6
6
  module Kiosk
7
7
  module Server
8
- # Issues and verifies the RS256 access tokens of the bundled kiosk-pop IdP.
8
+ # Issues and verifies the EdDSA access tokens of the bundled agent IdP.
9
9
  module JwtIssuer
10
- ALGORITHM = 'RS256'
10
+ ALGORITHM = Eddsa::NAME
11
11
  DEFAULT_EXPIRES_IN = 3600
12
12
  DEFAULT_LEEWAY = 60
13
13
 
14
- # A `kid` not in the JWKS: the signature cannot be checked.
15
- UNKNOWN_KID = ->(e) { e.message.include?('public key for kid') || e.message.include?('Could not find public key') }
16
- private_constant :UNKNOWN_KID
17
-
18
14
  class Error < StandardError; end
19
15
  class SignatureError < Error; end
20
16
  class ExpiredError < Error; end
@@ -33,7 +29,7 @@ module Kiosk
33
29
  raise ArgumentError, 'signing_key must carry a private key for issuance' unless signing_key.private?
34
30
 
35
31
  payload = registered_claims(claims, issuer: issuer, audience: audience, issued_at: now.to_i, expires_in: expires_in)
36
- ::JWT.encode(payload, signing_key.rsa, ALGORITHM, { kid: signing_key.kid, typ: 'JWT' })
32
+ ::JWT.encode(payload, signing_key.key, ALGORITHM, { kid: signing_key.kid, typ: 'JWT' })
37
33
  end
38
34
 
39
35
  def verify(token:, jwks:, audience: nil, issuer: nil, leeway: DEFAULT_LEEWAY)
@@ -58,9 +54,9 @@ module Kiosk
58
54
  def decode(token, jwks, audience:, issuer:, leeway:)
59
55
  decoded, = ::JWT.decode(
60
56
  token, nil, true,
61
- jwks: jwks, algorithms: [ALGORITHM], aud: audience, iss: issuer,
57
+ algorithms: [ALGORITHM], aud: audience, iss: issuer,
62
58
  verify_aud: !audience.nil?, verify_iss: !issuer.nil?, leeway: leeway
63
- )
59
+ ) { |header| Jwks.find(jwks, header['kid']) || raise(SignatureError, 'no key of the JWKS has this kid') }
64
60
  symbolize(decoded)
65
61
  rescue ::JWT::DecodeError => e
66
62
  raise error_class_for(e), e.message
@@ -70,7 +66,7 @@ module Kiosk
70
66
  case error
71
67
  when ::JWT::ExpiredSignature then ExpiredError
72
68
  when ::JWT::InvalidAudError then AudienceError
73
- when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm, UNKNOWN_KID then SignatureError
69
+ when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm then SignatureError
74
70
  else InvalidError
75
71
  end
76
72
  end
@@ -88,9 +84,7 @@ module Kiosk
88
84
  end
89
85
  end
90
86
 
91
- def symbolize(hash)
92
- hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
93
- end
87
+ def symbolize(hash) = hash.deep_symbolize_keys
94
88
 
95
89
  private_class_method :registered_claims, :decode, :error_class_for
96
90
  end
@@ -49,7 +49,7 @@ module Kiosk
49
49
  def claims = Array(Kiosk.configuration.kyc_claims).map(&:to_s)
50
50
 
51
51
  def served!
52
- return if provider
52
+ return if provider&.opens_verifications?
53
53
 
54
54
  raise Errors::ModuleNotServed.new(
55
55
  'this operator does not serve the KYC module',
@@ -115,8 +115,8 @@ module Kiosk
115
115
 
116
116
  # Names only a path this origin serves.
117
117
  def gate_hint
118
- return REQUEST_HINT if provider
119
- return ATTEST_HINT if Kiosk.configuration.kyc_public_key
118
+ return REQUEST_HINT if provider&.opens_verifications?
119
+ return ATTEST_HINT if provider
120
120
 
121
121
  CLOSED_HINT
122
122
  end
@@ -148,9 +148,7 @@ module Kiosk
148
148
  private
149
149
 
150
150
  def open_verification(user_id)
151
- Kyc.provider.open_verification(subject: user_id, claims: Kyc.claims,
152
- audience: Kiosk.configuration.kyc_audience.to_s,
153
- callback_url: Kyc.callback_url)
151
+ Kyc.provider.open_verification(subject: user_id, claims: Kyc.claims, callback_url: Kyc.callback_url)
154
152
  rescue Kiosk::KycProviders::Unavailable => e
155
153
  FailureLog.report("#{NAME} could not open a verification", e)
156
154
  raise Errors::WireError.new(
@@ -249,7 +247,7 @@ module Kiosk
249
247
  JSON.generate(attributes), user_id.to_s)
250
248
  end
251
249
 
252
- def table(name) = connection.quote_table_name("#{Kiosk.configuration.schema}.#{name}")
250
+ def table(name) = connection.quote_table_name("#{Kiosk.configuration.schema}.kiosk_#{name}")
253
251
 
254
252
  def execute(sql, *binds) = connection.exec_query(sql, 'Kiosk KYC', binds).to_a
255
253
 
@@ -4,14 +4,14 @@ require 'jwt'
4
4
 
5
5
  module Kiosk
6
6
  module Server
7
- # Verifies a KYC attestation JWS against `kyc_public_key`: `iss`, `aud`,
8
- # `sub` and `level: "verified"` must match, and the configured
9
- # `kyc_provider` must accept it; only attributes
10
- # that are literally `true` are granted.
7
+ # Verifies a KYC attestation JWS against the configured `kyc_provider`: its
8
+ # key signs it, its `iss` and `aud` match, `sub` and `level: "verified"`
9
+ # match, and the provider accepts it; only attributes that are literally
10
+ # `true` are granted.
11
11
  module KycVerifier
12
12
  # Named once for the decode and the published hint.
13
13
  REQUIRED_CLAIMS = %w[exp iss aud sub].freeze
14
- SIGNATURE_HINT = 'an attestation is a compact RS256 JWS signed by the issuer this origin ' \
14
+ SIGNATURE_HINT = 'an attestation is a compact EdDSA JWS signed by the issuer this origin ' \
15
15
  'is configured to trust'
16
16
  SUBJECT_HINT = "sub must name the principal: the caller's at POST <endpoint>/agents/kyc, " \
17
17
  "the open verification's at the callback"
@@ -19,17 +19,17 @@ module Kiosk
19
19
  module_function
20
20
 
21
21
  def verify(raw_jws:, subject:)
22
- config = Kiosk.configuration
23
- payload = decode(raw_jws, config)
24
- check_issuer!(payload, config)
22
+ provider = provider!
23
+ payload = decode(raw_jws, provider)
24
+ check_issuer!(payload, provider)
25
25
  # Binds the attestation to this operator.
26
- if payload[:aud].to_s != config.kyc_audience.to_s
26
+ if payload[:aud].to_s != provider.audience
27
27
  raise Errors::Forbidden.new('KYC attestation audience mismatch',
28
- hint: audience_hint(payload, config))
28
+ hint: audience_hint(payload, provider))
29
29
  end
30
30
 
31
31
  check_subject!(payload, subject)
32
- check_provider!(payload, config)
32
+ check_provider!(payload, provider)
33
33
 
34
34
  payload.merge(attributes: verified_attributes(payload[:attributes]))
35
35
  end
@@ -47,9 +47,9 @@ module Kiosk
47
47
  end
48
48
 
49
49
  # The verified payload with symbol keys.
50
- def decode(raw_jws, config)
51
- payload, = ::JWT.decode(raw_jws, public_key!(config), true,
52
- algorithms: ['RS256'], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
50
+ def decode(raw_jws, provider)
51
+ payload, = ::JWT.decode(raw_jws, provider.public_key, true,
52
+ algorithms: [Eddsa::NAME], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
53
53
  payload.transform_keys(&:to_sym)
54
54
  rescue ::JWT::ExpiredSignature
55
55
  raise Errors::Forbidden, 'KYC attestation expired'
@@ -59,30 +59,30 @@ module Kiosk
59
59
  raise Errors::Forbidden.new('KYC attestation signature invalid', hint: SIGNATURE_HINT)
60
60
  end
61
61
 
62
- # Refused when this origin trusts no KYC issuer.
63
- def public_key!(config)
64
- key = config.kyc_public_key
65
- return key unless key.nil?
62
+ # Refused when this origin trusts no KYC provider.
63
+ def provider!
64
+ provider = Kiosk.configuration.kyc_provider
65
+ return provider unless provider.nil?
66
66
 
67
67
  raise Errors::ModuleNotServed.new(
68
68
  'this operator does not serve the KYC module',
69
69
  hint: 'no KYC attestation is accepted at this origin; retrying will not help. ' \
70
- 'An operator that means to serve KYC sets Kiosk.configuration.kyc_public_key.'
70
+ 'An operator that means to serve KYC sets Kiosk.configuration.kyc_provider.'
71
71
  )
72
72
  end
73
73
 
74
- def check_issuer!(payload, config)
75
- return if payload[:iss] == config.kyc_issuer
74
+ def check_issuer!(payload, provider)
75
+ return if payload[:iss] == provider.issuer
76
76
 
77
77
  raise Errors::Forbidden.new(
78
78
  'KYC attestation issuer mismatch',
79
- hint: "expected #{config.kyc_issuer.inspect}, got #{payload[:iss].inspect}"
79
+ hint: "expected #{provider.issuer.inspect}, got #{payload[:iss].inspect}"
80
80
  )
81
81
  end
82
82
 
83
- def audience_hint(payload, config)
84
- "aud must equal this operator's kyc_audience " \
85
- "(expected #{config.kyc_audience.inspect}, got #{payload[:aud].inspect})"
83
+ def audience_hint(payload, provider)
84
+ "aud must equal the audience this operator's KYC provider mints " \
85
+ "(expected #{provider.audience.inspect}, got #{payload[:aud].inspect})"
86
86
  end
87
87
 
88
88
  # Subject and level. A bigint-PK host's subject is an Integer.
@@ -93,14 +93,13 @@ module Kiosk
93
93
  end
94
94
 
95
95
  # The provider's own checks ({Kiosk::KycProviders::Base#accepts?}).
96
- def check_provider!(payload, config)
97
- provider = config.kyc_provider
98
- return if provider.nil? || provider.accepts?(payload.transform_keys(&:to_s))
96
+ def check_provider!(payload, provider)
97
+ return if provider.accepts?(payload.transform_keys(&:to_s))
99
98
 
100
99
  raise Errors::Forbidden, 'the KYC provider does not accept this attestation at this operator'
101
100
  end
102
101
 
103
- private_class_method :decode, :public_key!, :check_issuer!, :audience_hint, :check_subject!, :check_provider!
102
+ private_class_method :decode, :provider!, :check_issuer!, :audience_hint, :check_subject!, :check_provider!
104
103
  end
105
104
  end
106
105
  end
@@ -4,7 +4,7 @@ module Kiosk
4
4
  module Server
5
5
  # Account binding started by the human: the signed-in holder mints a
6
6
  # single-use link code ({.mint}, POST /auth/link) and their assistant
7
- # redeems it with its key, a possession proof and the auth toll ({.redeem}, POST /auth/claim).
7
+ # redeems it with a client assertion and the auth toll ({.redeem}, POST /auth/claim).
8
8
  module LinkCode
9
9
  CLIENT_ID = 'kiosk-link'
10
10
 
@@ -31,37 +31,28 @@ module Kiosk
31
31
  end
32
32
 
33
33
  # The toll comes before the lookup, so an unknown code costs the same work as a live one.
34
- def redeem(code:, public_key_pem:, signed:, pow: nil, # rubocop:disable Metrics/ParameterLists -- the claim controller and the specs pass these by name
34
+ #
35
+ # @return [Client] the client under the account of the human who minted the code
36
+ # @raise [ClientAssertion::Refused] the client assertion is refused
37
+ def redeem(code:, assertion_type:, assertion:, pow: nil, # rubocop:disable Metrics/ParameterLists -- the claim controller and the specs pass these by name
35
38
  store: Kiosk.configuration.device_authorization_store,
36
39
  now: Time.current)
37
40
  raise Errors::BadRequest, 'code required' if code.to_s.empty?
38
41
 
39
- pem = public_key_pem.to_s.strip
40
- PopVerifier.load_public_key(pem)
41
- AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s, public_key: pem }, pow: pow)
42
+ AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s }, pow: pow)
42
43
  da = live_link(store, code.to_s, now)
43
44
 
44
- # The proof comes before the consume, so a failed one leaves the code live.
45
- prove_possession!(pem, signed)
45
+ # The client authenticates before the consume, so a refused assertion leaves the code live.
46
+ client = ClientAssertion.authenticate!(assertion_type, assertion)
46
47
  # The atomic consume, not `consumed?` below, decides single use under a race.
47
48
  raise Errors::Conflict, 'link code already used' if store.claim_consume(da, now: now).nil?
48
49
 
49
- bind(pem, da)
50
+ AccountBinding.bind!(agent_id: client.id, user_id: da.user_id, requested_role: da.requested_role)
50
51
  end
51
52
 
52
53
  class << self
53
54
  private
54
55
 
55
- def prove_possession!(pem, signed)
56
- payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
57
- AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
58
- end
59
-
60
- def bind(pem, link)
61
- result = AccountBinding.bind!(public_key_pem: pem, user_id: link.user_id, requested_role: link.requested_role)
62
- { agent_id: result[:agent_id], user_id: result[:user_id], access_token: result[:access_token] }
63
- end
64
-
65
56
  def live_link(store, code, now)
66
57
  da = store.lookup_by_device_code_hash(DeviceAuthorization.hash_device_code(code))
67
58
  unless da&.link?
@@ -34,7 +34,7 @@ module Kiosk
34
34
 
35
35
  def settings_sql(assignments, bind_count)
36
36
  <<~SQL.squish
37
- UPDATE #{Kiosk.configuration.schema}.agents
37
+ UPDATE #{Kiosk.configuration.schema}.kiosk_agents
38
38
  SET #{assignments.join(', ')}
39
39
  WHERE id = $#{bind_count - 2}
40
40
  AND user_id = $#{bind_count - 1}
@@ -47,18 +47,16 @@ module Kiosk
47
47
 
48
48
  def agents_sql
49
49
  <<~SQL.squish
50
- SELECT id, public_key, created_at, human_label, spending_cap_cents, spending_cap_currency
51
- FROM #{Kiosk.configuration.schema}.agents
50
+ SELECT id, public_key, bound_at, human_label, spending_cap_cents, spending_cap_currency
51
+ FROM #{Kiosk.configuration.schema}.kiosk_agents
52
52
  WHERE user_id = $1 AND issuer = $2 AND revoked_at IS NULL
53
53
  ORDER BY created_at
54
54
  SQL
55
55
  end
56
56
 
57
- # `{ agent_id => { currency => cents } }`: the figure the spending cap
58
- # counts, within `spending_cap_window_days` when set.
57
+ # `{ agent_id => { currency => cents } }`: what each agent spent.
59
58
  def spend_by_agent(user_id)
60
- window_days = Kiosk.configuration.spending_cap_window_days&.to_i
61
- tally(run(spend_sql(window_days), 'Kiosk assistant spend', [user_id, Kiosk.current_issuer, *window_days]))
59
+ tally(run(spend_sql, 'Kiosk assistant spend', [user_id, Kiosk.current_issuer]))
62
60
  rescue ::ActiveRecord::StatementInvalid => e
63
61
  raise unless missing_table?(e)
64
62
 
@@ -71,20 +69,27 @@ module Kiosk
71
69
  end
72
70
  end
73
71
 
74
- # The day count, when set, is the third bind.
75
- def spend_sql(window_days)
72
+ # One tally per currency.
73
+ def spend_sql
76
74
  schema = Kiosk.configuration.schema
77
- window = window_days ? 'AND settlements.settled_at >= now() - make_interval(days => $3)' : ''
78
75
  <<~SQL.squish
79
- SELECT settlements.agent_id, lower(btrim(settlements.currency)) AS currency,
80
- SUM(#{SettledSpend.net_cents_sql(schema)}) AS cents
81
- FROM #{schema}.settlements
82
- JOIN #{schema}.agents ON agents.id = settlements.agent_id
83
- WHERE agents.user_id = $1 AND agents.issuer = $2 AND agents.revoked_at IS NULL #{window}
76
+ SELECT settlements.agent_id, lower(btrim(settlements.currency)) AS currency, SUM(#{net_cents_sql(schema)}) AS cents
77
+ FROM #{schema}.kiosk_settlements settlements
78
+ JOIN #{schema}.kiosk_agents agents ON agents.id = settlements.agent_id
79
+ WHERE agents.user_id = $1 AND agents.issuer = $2 AND agents.revoked_at IS NULL
84
80
  GROUP BY 1, 2 ORDER BY 2
85
81
  SQL
86
82
  end
87
83
 
84
+ # Each settlement less what was refunded of it, never below zero.
85
+ def net_cents_sql(schema)
86
+ <<~SQL.squish
87
+ GREATEST(settlements.settled_amount_cents - (
88
+ SELECT COALESCE(SUM(r.refunded_amount_cents), 0) FROM #{schema}.kiosk_refunds r WHERE r.settlement_id = settlements.id
89
+ ), 0)
90
+ SQL
91
+ end
92
+
88
93
  # By class name, so this file does not load `PG`.
89
94
  def missing_table?(error)
90
95
  error.cause&.class&.name == 'PG::UndefinedTable'
@@ -95,7 +100,7 @@ module Kiosk
95
100
  {
96
101
  agent_id: row.fetch('id'),
97
102
  fingerprint: fingerprint(row.fetch('public_key')),
98
- created_at: row.fetch('created_at'),
103
+ bound_at: row.fetch('bound_at', nil),
99
104
  human_label: row.fetch('human_label', nil),
100
105
  spending_cap_cents: cap&.to_i,
101
106
  spending_cap_currency: row.fetch('spending_cap_currency', nil),
@@ -106,7 +111,7 @@ module Kiosk
106
111
  def fingerprint(pem)
107
112
  return '(no key)' if pem.blank?
108
113
 
109
- SigningKey.from_pem(pem).kid
114
+ AssistantKey.thumbprint(AssistantKey.from_pem(pem))
110
115
  rescue StandardError
111
116
  '(unreadable key)'
112
117
  end