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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- 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
|