kiosk-server 0.5.20

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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The topic registry, shaped like {Queries} and {Actions}. A topic takes
6
+ # `reach` from the verbs' four values; `subject_reachable` takes the
7
+ # subject and identity, because it is re-run on a socket with no request.
8
+ module Events
9
+ # Declared on every topic, as `output_schema` is on every verb.
10
+ REQUIRED = %i[description payload_schema].freeze
11
+
12
+ class << self
13
+ # rubocop:disable-next Metrics/ParameterLists -- each keyword is one member of the published topic declaration
14
+ def register(name:, reach:, description:, payload_schema:, subject_reachable:, examples: [])
15
+ name = name.to_s
16
+ declaration = { name:, reach:, description:, payload_schema:, examples:, subject_reachable: }.freeze
17
+
18
+ # The same declaration again (a reload) is a no-op.
19
+ return if registry[name] == declaration
20
+ raise ArgumentError, already_declared(name) if registry.key?(name)
21
+
22
+ registry[name] = declaration
23
+ end
24
+
25
+ def unregister(name) = registry.delete(name.to_s)
26
+
27
+ def fetch(name) = registry[name.to_s]
28
+
29
+ def known = registry.keys.sort
30
+
31
+ # Published beside `queries` and `actions`; `subject_reachable` is the operator's rule, not wire.
32
+ def catalog = known.map { |name| catalog_entry(registry[name]) }
33
+
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.
36
+ # @param identity_scope [Array<String>] user_ids; empty writes nothing
37
+ # @return [Integer, nil] the id of the last append, nil for an empty scope
38
+ def emit(topic:, data:, identity_scope:, subject: nil, occurred_at: nil)
39
+ name = topic.to_s
40
+ raise ArgumentError, undeclared(name) unless fetch(name)
41
+
42
+ event = {
43
+ 'topic' => name,
44
+ 'subject' => subject,
45
+ 'occurred_at' => (occurred_at || Time.current).utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
46
+ 'data' => data
47
+ }
48
+ Array(identity_scope).map { |identity_key| append(identity_key, event) }.last
49
+ end
50
+
51
+ def reset! = registry.clear
52
+
53
+ private
54
+
55
+ def registry = (@registry ||= {})
56
+
57
+ def catalog_entry(declaration)
58
+ entry = { name: declaration[:name], description: declaration[:description],
59
+ reach: declaration[:reach].to_s, payload_schema: declaration[:payload_schema] }
60
+ entry[:examples] = declaration[:examples] if declaration[:examples].any?
61
+ entry
62
+ end
63
+
64
+ # Append before broadcast, so every pushed id can be resumed from.
65
+ def append(identity_key, event)
66
+ id = Kiosk.configuration.event_store.append(identity_key, event)
67
+ EventsCable.broadcast(identity_key, event.merge('id' => id))
68
+ id
69
+ end
70
+
71
+ def already_declared(name)
72
+ "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
+ 'the transition it reports.'
75
+ end
76
+
77
+ def undeclared(name)
78
+ "#{name.inspect} is not a declared topic on this origin. Declare it with " \
79
+ "`topic #{name.to_sym.inspect} do … end` on the controller that owns the " \
80
+ 'transition it reports; known topics: ' \
81
+ "#{known.empty? ? '(none)' : known.join(', ')}."
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_cable'
4
+ require 'json'
5
+ require 'rack'
6
+
7
+ module Kiosk
8
+ module Server
9
+ # The engine's own Action Cable server, so the host's connection class and forgery setting stay
10
+ # the host's. One stream per (identity, topic); the channel filters by subject.
11
+ module EventsCable
12
+ STREAM_PREFIX = 'kiosk:events'
13
+
14
+ # A lambda, so the server is built on first request, once the host's
15
+ # `config/cable.yml` can be read.
16
+ RACK_APP = ->(env) { Kiosk::Server::EventsCable.serve(env) }
17
+
18
+ UNSERVED_DETAIL = 'this operator does not serve the events module'
19
+ UNSERVED_HINT = "`events` is absent from this origin's capabilities and it publishes no " \
20
+ 'events_url; there is nothing to subscribe to here'
21
+
22
+ CABLE_YML_MISSING = '[kiosk-server] this origin serves events and has no config/cable.yml. ' \
23
+ 'Add one with a production adapter that every process shares, ' \
24
+ 'for example `adapter: solid_cable`.'
25
+ CABLE_ADAPTER_MISSING = '[kiosk-server] config/cable.yml names no adapter for %<env>s. ' \
26
+ 'Set one that every process shares, for example `adapter: solid_cable`.'
27
+
28
+ class << self
29
+ # An origin that declares no topic answers `501 module_not_served`
30
+ # (spec Sections 8.5.3, 16.1 item 9), before any credential is read.
31
+ def serve(env)
32
+ return unserved_module if Kiosk::Server::Events.known.empty?
33
+
34
+ server.call(env)
35
+ end
36
+
37
+ def server
38
+ @server ||= ::ActionCable::Server::Base.new(config: configuration)
39
+ end
40
+
41
+ def stream_name(identity_key, topic)
42
+ "#{STREAM_PREFIX}:#{identity_key}:#{topic}"
43
+ end
44
+
45
+ # Called by {Events.emit} after the append, so the event carries its id.
46
+ def broadcast(identity_key, event)
47
+ server.broadcast(stream_name(identity_key, event['topic']), event)
48
+ end
49
+
50
+ # The host's `config/cable.yml`. Outside production a host without one gets
51
+ # `async`, which serves one process only. In production this raises.
52
+ def cable_config
53
+ return host_cable_config if rails_app_with_cable_yml?
54
+ raise Errors::ConfigurationError, CABLE_YML_MISSING if production?
55
+
56
+ { 'adapter' => 'async' }
57
+ end
58
+
59
+ def configuration
60
+ @configuration ||= ::ActionCable::Server::Configuration.new.tap do |config|
61
+ config.connection_class = -> { Kiosk::Server::EventsConnection }
62
+ config.cable = cable_config
63
+ config.logger = resolved_logger
64
+ # Authorised by the `Authorization` header, which a page cannot attach cross-origin.
65
+ config.disable_request_forgery_protection = true
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def unserved_module
72
+ error = Errors::ModuleNotServed.new(UNSERVED_DETAIL, hint: UNSERVED_HINT)
73
+ headers = ::Rack::Headers.new
74
+ headers['content-type'] = Errors::PROBLEM_CONTENT_TYPE
75
+ Headers.add_to(headers)
76
+ Headers.add_cache_policy(headers, status: error.http_status)
77
+ [error.http_status, headers, [::JSON.generate(error.to_problem)]]
78
+ end
79
+
80
+ def rails_app_with_cable_yml?
81
+ defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application &&
82
+ ::Rails.root&.join('config/cable.yml')&.exist?
83
+ end
84
+
85
+ def host_cable_config
86
+ config = ::Rails.application.config_for(:cable).to_h.transform_keys(&:to_s)
87
+ return config if config['adapter']
88
+
89
+ raise Errors::ConfigurationError, format(CABLE_ADAPTER_MISSING, env: ::Rails.env)
90
+ end
91
+
92
+ def production? = defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.production?
93
+
94
+ def resolved_logger
95
+ return ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
96
+
97
+ ::Logger.new(IO::NULL)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_cable'
4
+ require 'json'
5
+ require 'rack'
6
+
7
+ module Kiosk
8
+ module Server
9
+ # The Action Cable connection behind `<endpoint>/events`, authenticated from the
10
+ # `Authorization` header, never the query string, which access logs record.
11
+ class EventsConnection < ::ActionCable::Connection::Base
12
+ # A String: Action Cable serialises every `identified_by` value.
13
+ identified_by :kiosk_identity_key
14
+
15
+ attr_reader :kiosk_identity
16
+
17
+ # Action Cable beats every 3 seconds; forwarding one in ten keeps a client from drowning in them.
18
+ BEATS_PER_PING = 10
19
+
20
+ # The one channel a frame here may name; see {KioskEvents}.
21
+ CHANNEL = 'KioskEvents'
22
+
23
+ # A subscriber never publishes (spec Section 8.5.5), so no `message`.
24
+ COMMANDS = %w[subscribe unsubscribe].freeze
25
+
26
+ # §8.5.6: the credential is re-checked at least every 60 seconds, on every socket.
27
+ class_attribute :reauthorise_every, default: 30
28
+
29
+ # The `subscribe` identifiers that the upgrade URL's `topic` and `since` declare.
30
+ module UrlSubscriptions
31
+ module_function
32
+
33
+ def identifiers(query_string)
34
+ query = ::Rack::Utils.parse_query(query_string)
35
+ since = since(query['since'])
36
+ topics(query['topic']).map do |topic, subject|
37
+ identifier = { 'channel' => CHANNEL, 'topic' => topic }
38
+ identifier['subject'] = subject if subject
39
+ identifier['since'] = since if since
40
+ ::JSON.generate(identifier)
41
+ end
42
+ end
43
+
44
+ # `?topic=todo&topic=delivery` or `?topic=todo,delivery`, a subject after
45
+ # a colon (a topic name cannot hold one). `Rack::Utils.parse_query`
46
+ # because Rails keeps only the last of a repeated key.
47
+ def topics(raw)
48
+ Array(raw).flat_map { |value| value.to_s.split(',') }
49
+ .map(&:strip).reject(&:empty?)
50
+ .map { |value| value.split(':', 2) }
51
+ .map { |topic, subject| [topic, (subject unless subject.to_s.empty?)] }
52
+ end
53
+
54
+ # Digits become an Integer; anything else passes through for
55
+ # {KioskEvents} to refuse.
56
+ def since(value)
57
+ value = value.last if value.is_a?(Array)
58
+ return nil if value.blank?
59
+
60
+ value.match?(/\A\d+\z/) ? value.to_i : value
61
+ end
62
+ end
63
+
64
+ def connect
65
+ identity = resolve_identity || reject_unauthorized_connection
66
+ @kiosk_identity = identity
67
+ self.kiosk_identity_key = identity.user_id.to_s
68
+ end
69
+
70
+ # Subscriptions declared in the URL (§8.5.4) become `subscribe` commands.
71
+ def handle_open
72
+ super
73
+ return unless @kiosk_identity
74
+
75
+ @reauthorisation = server.event_loop.timer(reauthorise_every) { send_async(:reauthorise!) }
76
+ auto_subscribe!
77
+ end
78
+
79
+ def handle_close
80
+ @reauthorisation&.shutdown
81
+ super
82
+ end
83
+
84
+ def beat
85
+ @beats = (@beats || 0) + 1
86
+ super if (@beats % BEATS_PER_PING).zero?
87
+ end
88
+
89
+ # Every frame is answered (§8.5.4, §8.5.7); a repeated `subscribe` is confirmed again, not replayed.
90
+ def dispatch_websocket_message(websocket_message)
91
+ frame = parse_object(websocket_message) || {}
92
+ command = frame['command']
93
+ identifier = frame['identifier']
94
+
95
+ return close_invalid_request unless COMMANDS.include?(command) && identifier.is_a?(::String)
96
+
97
+ live = subscriptions.identifiers.include?(identifier)
98
+ return transmit_about(identifier, :confirmation) if command == 'subscribe' && live
99
+ return transmit_about(identifier, :rejection) unless actionable?(command, identifier, live)
100
+
101
+ super
102
+ end
103
+
104
+ # Closes with `token_expired` or `revoked` once the credential no longer resolves (§8.5.6).
105
+ def kiosk_credential_holds?
106
+ return true if resolve_identity
107
+
108
+ if kiosk_credential_expired?
109
+ close(reason: 'token_expired', reconnect: true)
110
+ else
111
+ close(reason: 'revoked', reconnect: false)
112
+ end
113
+ false
114
+ end
115
+
116
+ private
117
+
118
+ def reauthorise!
119
+ kiosk_credential_holds?
120
+ rescue StandardError => e
121
+ logger.error("[kiosk] events re-authorisation failed: #{e.class}: #{e.message}")
122
+ end
123
+
124
+ def kiosk_credential_expired?
125
+ exp = kiosk_identity.claims&.dig(:exp)
126
+ !exp.nil? && Time.now.to_i >= exp.to_i
127
+ end
128
+
129
+ # Nil for anything that is not a JSON object.
130
+ def parse_object(document)
131
+ parsed = ::JSON.parse(document)
132
+ parsed if parsed.is_a?(::Hash)
133
+ rescue ::JSON::ParserError
134
+ nil
135
+ end
136
+
137
+ def close_invalid_request
138
+ close(reason: ::ActionCable::INTERNAL[:disconnect_reasons][:invalid_request], reconnect: false)
139
+ end
140
+
141
+ def transmit_about(identifier, type)
142
+ transmit(identifier: identifier, type: ::ActionCable::INTERNAL[:message_types][type])
143
+ end
144
+
145
+ # An `unsubscribe` must name a live subscription by its exact string; a
146
+ # `subscribe` must name this channel, and {KioskEvents} judges the rest.
147
+ def actionable?(command, identifier, live)
148
+ return live if command == 'unsubscribe'
149
+
150
+ parse_object(identifier)&.dig('channel') == CHANNEL
151
+ end
152
+
153
+ def auto_subscribe!
154
+ UrlSubscriptions.identifiers(request.query_string).each do |identifier|
155
+ subscriptions.execute_command('command' => 'subscribe', 'identifier' => identifier)
156
+ end
157
+ end
158
+
159
+ # Under the upgrade's own issuer: this runs outside {IssuerMiddleware}.
160
+ # A raising IdP refuses the upgrade rather than failing the socket.
161
+ def resolve_identity
162
+ issuer = Kiosk.configuration.issuer_for(request.base_url)
163
+ Kiosk.with_issuer(issuer) { Kiosk::Server::IdentityResolution.resolve(request) }
164
+ rescue StandardError => e
165
+ logger&.error("[kiosk] events connection identity resolution failed: #{e.class}")
166
+ nil
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ class Executor
6
+ # A `pay` whose chain was presented before (§11.6).
7
+ module Repeat
8
+ private
9
+
10
+ # A chain presented before. Settled: its settlement. Neither settled nor
11
+ # declined: the chain, to capture again. Anything else is a conflict.
12
+ def repeat(chain)
13
+ row = recorded_chain(chain)
14
+ raise Errors::Conflict.new('mandate already processed', hint: Pay::CONFLICT_HINT) if row.nil? || row['declined_at']
15
+ return stored_settlement(row) if row['settlement_id']
16
+
17
+ chain.cart_row = row.fetch('cart_row')
18
+ chain.repeat = true
19
+ chain
20
+ end
21
+
22
+ def recorded_chain(chain)
23
+ return nil if chain.intent.nil? || chain.cart.nil? || chain.payment.nil?
24
+
25
+ row = nil
26
+ SessionContext.open(connection: connection, identity: identity) { row = chain_record(chain) }
27
+ row
28
+ end
29
+
30
+ def stored_settlement(row)
31
+ settlement_result(row.fetch('settlement_id'), row.fetch('psp_reference'),
32
+ row.fetch('settled_amount_cents').to_i, row.fetch('currency'))
33
+ end
34
+
35
+ # A refusal under which no money moved: the cart stops counting against the spending cap.
36
+ def decline(chain)
37
+ SessionContext.open(connection: connection, identity: identity) { decline_cart(chain.cart_row) }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ class Executor
6
+ # The assistant's spending cap, checked at pay time.
7
+ module SpendingCap
8
+ private
9
+
10
+ # A nil seam or a nil cap means uncapped. A cap allows no payment in
11
+ # another currency. Runs in the transaction that persists the cart,
12
+ # under a per-assistant lock, so two concurrent pays see each other: the
13
+ # second counts the first's cart as in flight. A cart already recorded
14
+ # is a repeat and adds nothing.
15
+ def enforce_spending_cap!(cart)
16
+ 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 cart_recorded?(cart)
21
+
22
+ refuse_other_currency!(cap.fetch(:currency), cart)
23
+ refuse_over_cap!(cap.fetch(:amount_cents).to_i, cart)
24
+ end
25
+
26
+ def refuse_other_currency!(cap_currency, cart)
27
+ cap_code = MandateVerifier.canonical_currency(cap_currency)
28
+ cart_code = MandateVerifier.canonical_currency(cart.currency)
29
+ return if cap_code == cart_code
30
+
31
+ raise Errors::SpendingCapExceeded.new('assistant spending cap is in another currency',
32
+ hint: "the cap is in #{cap_code.upcase}; this charge is in #{cart_code.upcase}")
33
+ end
34
+
35
+ def refuse_over_cap!(cap, cart)
36
+ window_days = Kiosk.configuration.spending_cap_window_days
37
+ spent = spent_in_window(cart, window_days)
38
+ return if spent + cart.total_amount_cents.to_i <= cap
39
+
40
+ raise Errors::SpendingCapExceeded.new('assistant spending cap exceeded', hint: cap_hint(cap, spent, window_days, cart))
41
+ end
42
+
43
+ # One tally per currency: cents are not fungible across currencies. One
44
+ # statement, so a settlement committed between two reads is counted once.
45
+ def spent_in_window(cart, window_days)
46
+ binds = spent_binds(identity.agent_id, cart.currency, window_days)
47
+ sql = "SELECT (#{settled_sql(window_days)}) + (#{in_flight_sql}) AS total"
48
+ connection.exec_query(sql, 'Kiosk spent total', binds).to_a.first.fetch('total').to_i
49
+ end
50
+
51
+ def cap_hint(cap, spent, window_days, cart)
52
+ window_note = window_days ? " in the last #{window_days.to_i} day(s)" : ''
53
+ "cap #{cap} cents; #{spent} already settled or in flight#{window_note}; " \
54
+ "this charge is #{cart.total_amount_cents.to_i}"
55
+ end
56
+
57
+ # Settled cents for this agent in one currency (see SettledSpend),
58
+ # case-folded on both sides so `eur` and `EUR` are one tally. No window
59
+ # means no predicate; the day count is a bind.
60
+ def settled_sql(window_days)
61
+ schema = Kiosk.configuration.schema
62
+ window = window_days ? 'AND settled_at >= now() - make_interval(days => $3)' : ''
63
+ <<~SQL.squish
64
+ SELECT COALESCE(SUM(#{SettledSpend.net_cents_sql(schema)}), 0) AS total
65
+ FROM #{schema}.settlements
66
+ WHERE agent_id = $1 AND lower(btrim(currency)) = $2 #{window}
67
+ SQL
68
+ end
69
+
70
+ # Carts of this agent whose mandate is live, that have no settlement and
71
+ # were not declined: a capture may still run for each.
72
+ def in_flight_sql
73
+ schema = Kiosk.configuration.schema
74
+ <<~SQL.squish
75
+ SELECT COALESCE(SUM(c.total_amount_cents), 0) AS total
76
+ FROM #{schema}.cart_mandates c
77
+ WHERE c.agent_id = $1 AND lower(btrim(c.currency)) = $2 AND c.expires_at > now()
78
+ AND c.declined_at IS NULL
79
+ AND NOT EXISTS (SELECT 1 FROM #{schema}.settlements s WHERE s.cart_mandate_id = c.id)
80
+ SQL
81
+ end
82
+
83
+ # The agent, the canonical currency and, with a window, its day count.
84
+ def spent_binds(agent_id, currency, window_days)
85
+ binds = [agent_id, MandateVerifier.canonical_currency(currency)]
86
+ window_days ? binds << window_days.to_i : binds
87
+ end
88
+
89
+ def cart_recorded?(cart)
90
+ sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.cart_mandates WHERE user_id = $1 AND mandate_id = $2"
91
+ connection.exec_query(sql, 'Kiosk cart recorded', [cart.user_id, cart.id]).any?
92
+ end
93
+
94
+ # Held to the end of the transaction; an advisory lock needs no grant on any table.
95
+ def lock_assistant_payments!(agent_id)
96
+ connection.exec_query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
97
+ 'Kiosk spending cap lock', [agent_id.to_s])
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end