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
@@ -5,8 +5,8 @@ require 'action_dispatch'
5
5
 
6
6
  module Kiosk
7
7
  module Server
8
- # Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a wire verb;
9
- # a method with no pending declaration stays off the wire.
8
+ # Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a verb;
9
+ # a method with no pending declaration stays off the protocol.
10
10
  module HandlerMixin
11
11
  KINDS = %i[action query].freeze
12
12
 
@@ -38,7 +38,7 @@ module Kiosk
38
38
  base.extend(ClassMethods)
39
39
  base.include(InstanceMethods)
40
40
 
41
- # The wire authenticates by bearer token, never by cookie, so CSRF protection does not apply.
41
+ # The protocol authenticates by bearer token, never by cookie, so CSRF protection does not apply.
42
42
  base.skip_forgery_protection if base.respond_to?(:skip_forgery_protection)
43
43
 
44
44
  # Registered at include time, so an operator's own `rescue_from` declared later wins.
@@ -80,7 +80,7 @@ module Kiosk
80
80
 
81
81
  def bad_topic_name(owner, name)
82
82
  "#{owner} declared `topic #{name.inspect}`, which is not a legal Kiosk name. " \
83
- "A topic name is a wire name: #{NAME_PATTERN.inspect}."
83
+ "A topic name matches #{NAME_PATTERN.inspect}."
84
84
  end
85
85
 
86
86
  def reserved_topic(owner, name)
@@ -300,12 +300,12 @@ module Kiosk
300
300
  kiosk_declare(method_name, pending)
301
301
  end
302
302
 
303
- # Wire name → declaration for the verbs on this class, one per name.
303
+ # Published name → declaration for the verbs on this class, one per name.
304
304
  def kiosk_declarations
305
305
  @kiosk_declarations ||= {}
306
306
  end
307
307
 
308
- # Wire name → declaration for the topics on this class.
308
+ # Published name → declaration for the topics on this class.
309
309
  def kiosk_topic_declarations
310
310
  @kiosk_topic_declarations ||= {}
311
311
  end
@@ -361,7 +361,7 @@ module Kiosk
361
361
 
362
362
  private
363
363
 
364
- # The acting {Kiosk::Identity}; nil outside a wire request.
364
+ # The acting {Kiosk::Identity}; nil outside a protocol request.
365
365
  def kiosk_identity = CurrentRequest.identity
366
366
 
367
367
  # Differs from the method name only under `wire_name`.
@@ -376,8 +376,8 @@ module Kiosk
376
376
  end
377
377
 
378
378
  # A validation failure answers 400 with the errors' full messages. Any other raise Rails knows
379
- # a status for maps to its lone wire code, its own message going to the operator's log and
380
- # never onto the wire; anything else is re-raised.
379
+ # a status for maps to its lone error code, its own message going to the operator's log and
380
+ # never onto the protocol; anything else is re-raised.
381
381
  def kiosk_rescue_to_wire(exception)
382
382
  raise exception if exception.is_a?(Kiosk::Server::Errors::Base)
383
383
 
@@ -18,7 +18,7 @@ module Kiosk
18
18
  load_handlers_dir
19
19
  handlers.select! { |name| name.safe_constantize&.kiosk_register! }
20
20
  PaymentSetup.register! if Kiosk.configuration.payment_provider
21
- Kyc.register! if Kiosk.configuration.kyc_provider
21
+ Kyc.register! if Kiosk.configuration.kyc_provider&.opens_verifications?
22
22
  refuse_cross_kind_collisions!
23
23
  end
24
24
 
@@ -5,17 +5,17 @@ require 'json'
5
5
  module Kiosk
6
6
  module Server
7
7
  # How a controller that includes {Kiosk::Handler} answers a verb call.
8
- # {Routing::VerbRoute} sends the wire request to the action that declares
8
+ # {Routing::VerbRoute} sends the protocol request to the action that declares
9
9
  # the verb. Around that action this module runs the gates of {VerbWire},
10
10
  # the session and the transaction, and writes the action's render as the
11
- # wire answer.
11
+ # protocol answer.
12
12
  module HandlerServing
13
13
  include VerbWire
14
14
 
15
- # The Rack env key where {Routing::VerbRoute} puts the wire name it routed.
15
+ # The Rack env key where {Routing::VerbRoute} puts the published name it routed.
16
16
  VERB_KEY = 'kiosk.verb'
17
17
 
18
- # The one header of an action's render that reaches the wire: `Cache-Control`, on a 2xx (§3.7.4).
18
+ # The one header of an action's render that reaches the response: `Cache-Control`, on a 2xx (§3.7.4).
19
19
  PROPAGATED_HEADERS = %w[Cache-Control].freeze
20
20
 
21
21
  private
@@ -34,7 +34,7 @@ module Kiosk
34
34
  kiosk_render_refusal(Errors.malformed_json(hint: BODY_HINT))
35
35
  end
36
36
 
37
- # The verb's {Result}: the wire's gates, then the action inside the session.
37
+ # The verb's {Result}: the protocol's gates, then the action inside the session.
38
38
  def kiosk_call(declaration, &)
39
39
  kind = declaration[:kind]
40
40
  name = @_kiosk_wire_name = declaration[:wire_name]
@@ -55,7 +55,7 @@ module Kiosk
55
55
  Answer.new(kind, @_kiosk_wire_name).value(status, kiosk_read_body(body), @_kiosk_page)
56
56
  end
57
57
 
58
- # The action's `params` are the decoded arguments; the wire answers JSON whatever the caller's
58
+ # The action's `params` are the decoded arguments; the protocol answers JSON whatever the caller's
59
59
  # `Accept`, so an action's template is its `.json` one.
60
60
  def kiosk_wire_parameters!(args)
61
61
  request.set_header('action_dispatch.request.request_parameters', args.dup)
@@ -89,7 +89,7 @@ module Kiosk
89
89
  render_wire_error(error)
90
90
  end
91
91
 
92
- # The action's render was read; the wire answer is written into a fresh response.
92
+ # The action's render was read; the protocol answer is written into a fresh response.
93
93
  def kiosk_fresh_response!
94
94
  set_response!(self.class.make_response!(request))
95
95
  @_response_body = nil
@@ -98,7 +98,7 @@ module Kiosk
98
98
  # A route drawn straight at a handler action, not through `kiosk do`, answers 404 `verb_not_found`.
99
99
  def kiosk_refuse_unrouted
100
100
  problem = Errors::VerbNotFound.new(
101
- 'Kiosk handlers are reachable through the Kiosk wire only',
101
+ 'Kiosk handlers are reachable through the Kiosk HTTP API only',
102
102
  hint: "call the verb's own route — " \
103
103
  "GET #{Kiosk.configuration.mount_path}/<query-name> or " \
104
104
  "POST #{Kiosk.configuration.mount_path}/<action-name>"
@@ -107,7 +107,7 @@ module Kiosk
107
107
  render json: problem, status: :not_found, content_type: Errors::PROBLEM_CONTENT_TYPE
108
108
  end
109
109
 
110
- # An action's render as the verb's answer: its payload, or the wire error it means.
110
+ # An action's render as the verb's answer: its payload, or the Kiosk error it means.
111
111
  class Answer
112
112
  def initialize(kind, wire_name)
113
113
  @kind = kind
@@ -125,7 +125,7 @@ module Kiosk
125
125
 
126
126
  private
127
127
 
128
- # 2xx → the rendered JSON; otherwise the wire error it means.
128
+ # 2xx → the rendered JSON; otherwise the Kiosk error it means.
129
129
  def decode(status, raw)
130
130
  return parse_json(raw) if status >= 200 && status < 300
131
131
 
@@ -172,7 +172,7 @@ module Kiosk
172
172
  raise Errors::ActionFailed.new(
173
173
  "#{@kind} #{@wire_name.inspect} rendered a non-JSON body",
174
174
  hint: 'a Kiosk handler answers with `render json:` — HTML, redirects and ' \
175
- '`send_file` have no place on the wire'
175
+ '`send_file` have no place in a Kiosk response'
176
176
  )
177
177
  end
178
178
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The Kiosk response headers and the cache policy of a wire response.
5
+ # The Kiosk response headers and the cache policy of a Kiosk response.
6
6
  module Headers
7
7
  def self.add_to(headers, server_version: Kiosk::Server::VERSION)
8
8
  headers[Kiosk::Protocol::HEADER_SERVER_VERSION] = server_version
@@ -18,7 +18,7 @@ module Kiosk
18
18
  # §3.7.1: every one of these changes the answer and none is in the URL.
19
19
  WIRE_VARY = %w[Authorization Kiosk-PoW Kiosk-Timezone].freeze
20
20
 
21
- # Adds the wire `Vary`; forces `no-store` on a 402; defaults to
21
+ # Adds the protocol `Vary`; forces `no-store` on a 402; defaults to
22
22
  # `private, no-store` and refuses a shared-cache directive (§3.7.3).
23
23
  def self.add_cache_policy(headers, status:)
24
24
  add_wire_vary(headers)
@@ -56,7 +56,7 @@ module Kiosk
56
56
  'Sent `private, no-store` instead; send `private, max-age=N` to let the caller cache it.'
57
57
 
58
58
  def self.refuse_shared_cache(value)
59
- message = '[kiosk-server] refused a shared-cache policy on a wire response: ' \
59
+ message = '[kiosk-server] refused a shared-cache policy on a Kiosk response: ' \
60
60
  "Cache-Control: #{value.inspect}. #{SHARED_CACHE_RULE}"
61
61
  logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
62
62
  logger ? logger.warn(message) : warn(message)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The identity of a wire or event-stream caller: the access token, read by
5
+ # The identity of a protocol or event-stream caller: the access token, read by
6
6
  # the configured agent IdP or the bundled one. A cookie session
7
7
  # reaches only the human-side pages and the binding endpoints.
8
8
  module IdentityResolution
@@ -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,10 +4,10 @@ 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
@@ -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,8 +47,8 @@ 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,
50
+ def decode(raw_jws, provider)
51
+ payload, = ::JWT.decode(raw_jws, provider.public_key, true,
52
52
  algorithms: [Eddsa::NAME], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
53
53
  payload.transform_keys(&:to_sym)
54
54
  rescue ::JWT::ExpiredSignature
@@ -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
@@ -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),
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # An amount in minor units (cents) as a person reads and types it: in the
6
+ # currency's major unit, with the ISO 4217 number of decimals.
7
+ module Money
8
+ # ISO 4217 currencies whose minor unit is not 1/100. Every other code has two decimals.
9
+ DECIMALS = {
10
+ 'bif' => 0, 'clp' => 0, 'djf' => 0, 'gnf' => 0, 'isk' => 0, 'jpy' => 0, 'kmf' => 0,
11
+ 'krw' => 0, 'pyg' => 0, 'rwf' => 0, 'ugx' => 0, 'uyi' => 0, 'vnd' => 0, 'vuv' => 0,
12
+ 'xaf' => 0, 'xof' => 0, 'xpf' => 0,
13
+ 'bhd' => 3, 'iqd' => 3, 'jod' => 3, 'kwd' => 3, 'lyd' => 3, 'omr' => 3, 'tnd' => 3,
14
+ 'clf' => 4, 'uyw' => 4
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ # `5000, 'eur'` → `"50.00 EUR"`.
20
+ def format(cents, currency) = "#{amount(cents, currency)} #{currency.upcase}"
21
+
22
+ # `5000, 'eur'` → `"50.00"`.
23
+ def amount(cents, currency)
24
+ decimals = decimals(currency)
25
+ whole, fraction = cents.divmod(10**decimals)
26
+ decimals.zero? ? whole.to_s : "#{whole}.#{fraction.to_s.rjust(decimals, '0')}"
27
+ end
28
+
29
+ # `"50", 'eur'` → `5000`. Nil for text that is not an amount with at most the currency's decimals.
30
+ def parse(text, currency)
31
+ decimals = decimals(currency)
32
+ fraction = decimals.zero? ? '' : "(?:\\.(\\d{1,#{decimals}}))?"
33
+ match = /\A(\d+)#{fraction}\z/.match(text.strip) or return nil
34
+
35
+ (match[1].to_i * (10**decimals)) + match[2].to_s.ljust(decimals, '0').to_i
36
+ end
37
+
38
+ def decimals(currency) = DECIMALS.fetch(currency.downcase, 2)
39
+ end
40
+ end
41
+ end
@@ -14,6 +14,7 @@ module Kiosk
14
14
  end
15
15
 
16
16
  def render_oauth_error(error)
17
+ error.response_headers.each { |name, value| response.set_header(name, value) }
17
18
  render_oauth(error.to_h, status: error.status)
18
19
  end
19
20
 
@@ -24,6 +24,9 @@ module Kiosk
24
24
  # RFC 6749 Section 5.2: only a failed client authentication is a 401.
25
25
  def status = code == 'invalid_client' ? 401 : 400
26
26
 
27
+ # RFC 9110 Section 11.6.1: a 401 names its challenge, here the client assertion.
28
+ def response_headers = status == 401 ? { 'WWW-Authenticate' => Errors::ClientAssertionRefused.challenge } : {}
29
+
27
30
  def to_h = { error: code, error_description: message }
28
31
  end
29
32
  end
@@ -41,7 +41,7 @@ module Kiosk
41
41
  served as `application/problem+json`; branch on its `code` member, not
42
42
  on the HTTP status alone.
43
43
 
44
- `limit` and `cursor` are reserved parameter names this wire always
44
+ `limit` and `cursor` are reserved parameter names this protocol always
45
45
  accepts on a query. A query may declare `limit` to bound its page
46
46
  size; no verb declares `cursor`. The two names drive the cursor
47
47
  pagination of the specification's Section 8.4. A
@@ -58,14 +58,14 @@ module Kiosk
58
58
  }.freeze
59
59
 
60
60
  TAGS = [
61
- { name: 'wire', description: "The protocol's own reserved endpoints." },
61
+ { name: 'reserved', description: "The protocol's own reserved endpoints." },
62
62
  { name: 'queries', description: 'Reads. GET, arguments in the query string.' },
63
63
  { name: 'actions', description: 'Writes. POST, arguments in a JSON body.' }
64
64
  ].freeze
65
65
 
66
66
  def self.build(base_url:, config: Kiosk.configuration)
67
67
  endpoint = base_url.to_s.chomp('/') + config.mount_path
68
- schemas, paths = Wire.surface(Array(config.capabilities).map(&:to_s))
68
+ schemas, paths = Reserved.surface(Array(config.capabilities).map(&:to_s))
69
69
  Verbs.add_entries(schemas, paths)
70
70
  document(endpoint, config, paths, schemas)
71
71
  end
@@ -79,7 +79,7 @@ module Kiosk
79
79
  security: [{ dpopAuth: [] }],
80
80
  tags: TAGS,
81
81
  paths: paths,
82
- components: Wire.components(schemas)
82
+ components: Reserved.components(schemas)
83
83
  }
84
84
  end
85
85
  private_class_method :document
@@ -210,7 +210,7 @@ module Kiosk
210
210
  content: Refs.json_content("#{descriptor[:name]}.response")
211
211
  }
212
212
  ok[:headers] = Parameters::PAGINATION_HEADERS.keys.index_with { |h| { '$ref': "#/components/headers/#{h}" } } if kind == :query
213
- { '200' => ok }.merge(Wire.problem_refs)
213
+ { '200' => ok }.merge(Reserved.problem_refs)
214
214
  end
215
215
 
216
216
  # A query's input is a component only when a parameter points into it.
@@ -282,7 +282,7 @@ module Kiosk
282
282
 
283
283
  # The protocol's own endpoints (§8.3, §11.3), declared rather than derived,
284
284
  # and the problem responses every operation shares.
285
- module Wire
285
+ module Reserved
286
286
  SCHEMA_DESCRIPTION = "This origin's catalog: every query and action it publishes, " \
287
287
  'with their descriptions and schemas. THE canonical surface ' \
288
288
  'description — this OpenAPI document is derived from it. ' \
@@ -322,7 +322,7 @@ module Kiosk
322
322
  def schema_operation
323
323
  {
324
324
  operationId: 'schema',
325
- tags: ['wire'],
325
+ tags: ['reserved'],
326
326
  description: SCHEMA_DESCRIPTION,
327
327
  # Public: opts out of the global `dpopAuth`.
328
328
  security: [],
@@ -333,7 +333,7 @@ module Kiosk
333
333
  def pay_operation
334
334
  {
335
335
  operationId: 'pay',
336
- tags: ['wire'],
336
+ tags: ['reserved'],
337
337
  description: PAY_DESCRIPTION,
338
338
  requestBody: { required: true, content: Refs.json_content('pay.request') },
339
339
  responses: ok_responses('The settlement receipt.', 'pay.response')
@@ -371,7 +371,7 @@ module Kiosk
371
371
  PROBLEM = {
372
372
  type: 'object',
373
373
  title: 'Problem document (RFC 9457)',
374
- description: 'Every error on this wire. Branch on `code`.',
374
+ description: 'Every error of this protocol. Branch on `code`.',
375
375
  properties: {
376
376
  type: {
377
377
  type: 'string', format: 'uri',
@@ -4,7 +4,7 @@ module Kiosk
4
4
  module Server
5
5
  # What a query handler returns to paginate: this page's `rows`, an opaque
6
6
  # `next_cursor` (nil on the last page) and an optional `total` matching
7
- # count. The wire sends the last two as `Link` and `X-Total-Count` headers.
7
+ # count. The protocol sends the last two as `Link` and `X-Total-Count` headers.
8
8
  Page = Data.define(:rows, :next_cursor, :total) do
9
9
  def initialize(rows:, next_cursor: nil, total: nil)
10
10
  if !next_cursor.nil? && next_cursor.to_s.empty?
@@ -177,8 +177,8 @@ module Kiosk
177
177
  end
178
178
 
179
179
  INSERT_SQL = <<~SQL.squish
180
- INSERT INTO "%<schema>s".refunds (settlement_id, psp_reference, refunded_amount_cents, refunded_at)
181
- SELECT id, $1, $2, $3 FROM "%<schema>s".settlements WHERE psp_reference = $4
180
+ INSERT INTO "%<schema>s".kiosk_refunds (settlement_id, psp_reference, refunded_amount_cents, refunded_at)
181
+ SELECT id, $1, $2, $3 FROM "%<schema>s".kiosk_settlements WHERE psp_reference = $4
182
182
  ON CONFLICT (psp_reference) DO NOTHING
183
183
  SQL
184
184
  end
@@ -188,7 +188,7 @@ module Kiosk
188
188
  module AfterPayment
189
189
  # The executor calls this once, after it records the settlement of
190
190
  # `checkout_mandate`, so `c.after_payment` reads the row's `settlement`. A
191
- # failure here never surfaces on the wire: the log carries it.
191
+ # failure here never surfaces in the response: the log carries it.
192
192
  def settled(checkout_mandate)
193
193
  id = Lines.reference(checkout_mandate.line_items, @reference)
194
194
  Kiosk.configuration.after_payment&.call(id)