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,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# POST <endpoint>/kyc/callback — the KYC provider reports an approval.
|
|
6
|
+
# Authenticated by the open request, its nonce and the signed attestation.
|
|
7
|
+
class KycCallbackController < WireController
|
|
8
|
+
def create
|
|
9
|
+
Kyc.served!
|
|
10
|
+
Kyc.callback(parse_body!)
|
|
11
|
+
render json: { ok: true }, status: :ok
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# POST <endpoint>/oauth/device_authorization (RFC 8628 §3.1): an assistant
|
|
8
|
+
# opens the claim half of account binding with `client_id` and `public_key`.
|
|
9
|
+
class OauthDeviceAuthorizationController < ::ActionController::API
|
|
10
|
+
include BindingModuleGate
|
|
11
|
+
|
|
12
|
+
prepend_before_action :refuse_unserved_binding
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
problem = request_problem
|
|
16
|
+
return render_oauth_error(:invalid_request, problem, status: 400) if problem
|
|
17
|
+
|
|
18
|
+
result = DeviceCodeGrant.start(client_id: params[:client_id].to_s, public_key_pem: params[:public_key].to_s)
|
|
19
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
20
|
+
render json: device_authorization(result)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The first reason this request is refused, or nil.
|
|
26
|
+
def request_problem
|
|
27
|
+
return 'client_id parameter required' if params[:client_id].to_s.empty?
|
|
28
|
+
return 'public_key parameter required' if params[:public_key].to_s.empty?
|
|
29
|
+
|
|
30
|
+
key_problem(params[:public_key].to_s) || role_problem
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def key_problem(public_key)
|
|
34
|
+
PopVerifier.load_public_key(public_key.strip)
|
|
35
|
+
nil
|
|
36
|
+
rescue Errors::BadRequest => e
|
|
37
|
+
e.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Refused, not ignored: the role is the approving human's, and this
|
|
41
|
+
# request is unauthenticated.
|
|
42
|
+
def role_problem
|
|
43
|
+
offending = %i[role scope].find { |name| params.key?(name) }
|
|
44
|
+
return unless offending
|
|
45
|
+
|
|
46
|
+
"#{offending} is not accepted here — an assistant does not choose its own role. " \
|
|
47
|
+
"The role of a bound assistant is the approving account holder's own role, " \
|
|
48
|
+
"read from this provider's identity system when they approve at the verify page."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def device_authorization(result)
|
|
52
|
+
{
|
|
53
|
+
device_code: result[:device_code],
|
|
54
|
+
user_code: result[:user_code],
|
|
55
|
+
verification_uri: verification_uri,
|
|
56
|
+
verification_uri_complete: "#{verification_uri}?user_code=#{result[:user_code]}",
|
|
57
|
+
expires_in: result[:expires_in],
|
|
58
|
+
interval: result[:interval]
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def verification_uri
|
|
63
|
+
mount = Kiosk.configuration.mount_path
|
|
64
|
+
"#{request.base_url}#{mount}/oauth/device/verify"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_oauth_error(code, description, status:)
|
|
68
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
69
|
+
render json: { error: code.to_s, error_description: description }, status: status
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# POST <endpoint>/oauth/token: the assistant polls the claim ceremony
|
|
8
|
+
# (RFC 8628 §3.4). Only the device_code grant is served.
|
|
9
|
+
class OauthTokenController < ::ActionController::API
|
|
10
|
+
include BindingModuleGate
|
|
11
|
+
include AuthController::Answers
|
|
12
|
+
include AuthController::Proofs
|
|
13
|
+
|
|
14
|
+
prepend_before_action :refuse_unserved_binding
|
|
15
|
+
|
|
16
|
+
UNSUPPORTED_GRANT = 'only the device_code grant is served here — ' \
|
|
17
|
+
'kiosk-pop (POST /auth/login) is the token-refresh path'
|
|
18
|
+
|
|
19
|
+
def create
|
|
20
|
+
case params[:grant_type].to_s
|
|
21
|
+
when DeviceCodeGrant::GRANT_TYPE then handle_device_code_grant
|
|
22
|
+
when '' then render_oauth_error(:invalid_request, 'grant_type required', status: 400)
|
|
23
|
+
else render_oauth_error(:unsupported_grant_type, UNSUPPORTED_GRANT, status: 400)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The auth toll answers with its own problem documents (§10); every other refusal is OAuth's.
|
|
30
|
+
def handle_device_code_grant
|
|
31
|
+
result = DeviceCodeGrant.exchange(device_code: params[:device_code].to_s, signed: params[:signed], pow: proofs)
|
|
32
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
33
|
+
return render json: token_body(result) if result[:ok]
|
|
34
|
+
|
|
35
|
+
render_grant_failure(result)
|
|
36
|
+
rescue Errors::Base => e
|
|
37
|
+
render_error(e)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# RFC 6749 §5.2: invalid_client — the possession proof failed —
|
|
41
|
+
# is the one error the server SHOULD signal with 401.
|
|
42
|
+
def render_grant_failure(result)
|
|
43
|
+
status = result[:error] == 'invalid_client' ? 401 : 400
|
|
44
|
+
render json: { error: result[:error], error_description: result[:description] }, status: status
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def token_body(result)
|
|
48
|
+
body = { access_token: result[:access_token], token_type: result[:token_type], expires_in: result[:expires_in] }
|
|
49
|
+
body[:scope] = result[:scope] if result[:scope]
|
|
50
|
+
body
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_oauth_error(code, description, status:)
|
|
54
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
55
|
+
render json: { error: code.to_s, error_description: description }, status: status
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# `GET <endpoint>/openapi.json`: public, untolled, cached like `/schema`.
|
|
8
|
+
class OpenApiController < VerbController
|
|
9
|
+
# `?v=` is the origin's document version; the ETag is this document's bytes.
|
|
10
|
+
def show
|
|
11
|
+
render_public_document(
|
|
12
|
+
OpenApi.json(base_url: request.base_url),
|
|
13
|
+
version: SchemaDocument.digest,
|
|
14
|
+
etag: OpenApi.etag(base_url: request.base_url),
|
|
15
|
+
content_type: OpenApi::CONTENT_TYPE
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# GET <endpoint>/payment_setup/return — the page a payment provider sends
|
|
6
|
+
# the human's browser back to. `POST <endpoint>/payment_setup` itself is an
|
|
7
|
+
# ordinary verb, served by {VerbController}.
|
|
8
|
+
class PaymentSetupController < WireController
|
|
9
|
+
READY = ['Payment set up', 'Your assistant can now pay on your behalf. You can close this tab.'].freeze
|
|
10
|
+
UNCONFIRMED = ['Back from payment setup',
|
|
11
|
+
'Your assistant will check whether the setup is complete. You can close this tab.'].freeze
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
PaymentSetup.served!
|
|
15
|
+
title, text = PaymentSetup.returned(request.query_parameters) ? READY : UNCONFIRMED
|
|
16
|
+
render body: page(title, text), content_type: 'text/html'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def page(title, text)
|
|
22
|
+
<<~HTML
|
|
23
|
+
<!DOCTYPE html><html><head><meta charset="utf-8"><title>#{title}</title></head>
|
|
24
|
+
<body style="font-family:system-ui,sans-serif;text-align:center;padding:64px">
|
|
25
|
+
<h1>#{title}</h1><p>#{text}</p>
|
|
26
|
+
</body></html>
|
|
27
|
+
HTML
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# The verbs the engine itself answers, `POST <endpoint>/payment_setup` and
|
|
8
|
+
# `POST <endpoint>/request_kyc`, and every verb route that no controller
|
|
9
|
+
# action can answer: 404 for an unknown name, 405 for a name of the other
|
|
10
|
+
# kind. An operator's own verb goes straight to the action that declares
|
|
11
|
+
# it ({Routing::VerbRoute}).
|
|
12
|
+
#
|
|
13
|
+
# Gates, in order: identity (401), the verb (404/405), the arguments
|
|
14
|
+
# (400), the toll (402).
|
|
15
|
+
class VerbController < WireController
|
|
16
|
+
# GET <endpoint>/<query-name>
|
|
17
|
+
def show
|
|
18
|
+
serve(:query)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST <endpoint>/<action-name>
|
|
22
|
+
def create
|
|
23
|
+
serve(:run)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def serve(command)
|
|
29
|
+
name = params[:kiosk_verb].to_s
|
|
30
|
+
identity = resolve_identity!
|
|
31
|
+
PaymentSetup.served! if name == PaymentSetup::NAME
|
|
32
|
+
Kyc.served! if name == Kyc::NAME
|
|
33
|
+
descriptor = descriptor_for!(command, name)
|
|
34
|
+
args = arguments_for(command, name, descriptor)
|
|
35
|
+
|
|
36
|
+
execute_wire(command: command, args: args, identity: identity, name: name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 404 verb_not_found for an unknown name; 405 with `Allow:` for a known
|
|
40
|
+
# name called with the other kind's method.
|
|
41
|
+
def descriptor_for!(command, name)
|
|
42
|
+
registry, other = command == :query ? [Queries, Actions] : [Actions, Queries]
|
|
43
|
+
return registry.describe(name) if registry.known.include?(name)
|
|
44
|
+
raise wrong_method(command, name) if other.known.include?(name)
|
|
45
|
+
|
|
46
|
+
# Neither: the registry's own 404 names what is registered.
|
|
47
|
+
registry.describe(name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wrong_method(command, name)
|
|
51
|
+
wanted = command == :query ? 'POST' : 'GET'
|
|
52
|
+
Errors::MethodNotAllowed.new(
|
|
53
|
+
command == :query ? "#{name.inspect} is an action, not a query" : "#{name.inspect} is a query, not an action",
|
|
54
|
+
allow: wanted,
|
|
55
|
+
hint: "call #{wanted} #{Kiosk.configuration.mount_path}/#{name} instead — " \
|
|
56
|
+
'queries are GET, actions are POST.'
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
require 'action_dispatch/http/parameters'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# The reserved `GET <endpoint>/schema` (public) and `POST <endpoint>/pay`,
|
|
9
|
+
# and the base every wire controller inherits its seams from.
|
|
10
|
+
# Wire response (JSON): success is the handler's payload VERBATIM; an error
|
|
11
|
+
# is an RFC 9457 problem document.
|
|
12
|
+
class WireController < ::ActionController::API
|
|
13
|
+
include VerbWire
|
|
14
|
+
|
|
15
|
+
rescue_from Errors::Base, with: :render_wire_error
|
|
16
|
+
|
|
17
|
+
# Touching `params` parses the body before any Kiosk code runs.
|
|
18
|
+
rescue_from ::ActionDispatch::Http::Parameters::ParseError do
|
|
19
|
+
render_wire_error(Errors.malformed_json(hint: BODY_HINT))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# No identity, no toll. A stale `?v=` still gets the current catalog,
|
|
23
|
+
# with the short TTL.
|
|
24
|
+
def schema
|
|
25
|
+
render_public_document(
|
|
26
|
+
SchemaDocument.json, version: SchemaDocument.digest, etag: SchemaDocument.etag
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def pay
|
|
31
|
+
identity = resolve_identity!
|
|
32
|
+
body = parse_body!
|
|
33
|
+
RequestValidation.validate_body!(body, exchange: 'POST <endpoint>/pay')
|
|
34
|
+
|
|
35
|
+
execute_wire(command: :pay, args: body, identity: identity, name: 'pay')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def execute_wire(**) = render_result(call_wire(**))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
# The signed cart the engine records when it settles a `pay`: the line items
|
|
5
|
+
# the assistant agreed to pay for. Read-only for the operator.
|
|
6
|
+
class CartMandate < Server::ApplicationRecord
|
|
7
|
+
def self.table_name = "#{Kiosk.configuration.schema}.cart_mandates"
|
|
8
|
+
|
|
9
|
+
has_many :settlements, class_name: 'Kiosk::Settlement', dependent: nil, inverse_of: :cart_mandate
|
|
10
|
+
|
|
11
|
+
# Carts with a line item carrying these attributes, by jsonb containment:
|
|
12
|
+
# `referencing(order_id: id)` matches `[{"order_id": id, "qty": 2}]`. The
|
|
13
|
+
# values are quoted by the adapter, never interpolated.
|
|
14
|
+
scope :referencing, lambda { |**attributes|
|
|
15
|
+
where(Arel::Nodes::InfixOperation.new(
|
|
16
|
+
'@>', arel_table[:line_items],
|
|
17
|
+
Arel::Nodes.build_quoted([attributes.transform_values(&:to_s)].to_json)
|
|
18
|
+
))
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
# The receipt the engine writes after a successful `pay` capture.
|
|
5
|
+
class Settlement < Server::ApplicationRecord
|
|
6
|
+
include Kiosk::Owned
|
|
7
|
+
|
|
8
|
+
def self.table_name = "#{Kiosk.configuration.schema}.settlements"
|
|
9
|
+
|
|
10
|
+
belongs_to :cart_mandate, class_name: 'Kiosk::CartMandate', inverse_of: :settlements
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
# A visit the operator fulfilled: a table honoured, an appointment kept. Its
|
|
5
|
+
# value counts toward the customer's recent spend, so an operator that takes
|
|
6
|
+
# no payment can still lower the proof-of-work toll for a returning customer.
|
|
7
|
+
class Visit < Server::ApplicationRecord
|
|
8
|
+
def self.table_name = "#{Kiosk.configuration.schema}.visits"
|
|
9
|
+
|
|
10
|
+
# Call it when the visit happens, with what the visit was worth.
|
|
11
|
+
def self.record!(user_id:, value_cents:) = create!(user_id:, value_cents:)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<%# «Link an assistant» page (link flow — Kiosk extension).
|
|
2
|
+
Minimal on purpose — override by shipping
|
|
3
|
+
app/views/kiosk/server/assistants/show.html.erb in the host app. %>
|
|
4
|
+
<main>
|
|
5
|
+
<h1>Linked assistant accounts</h1>
|
|
6
|
+
|
|
7
|
+
<% if @notice %><p role="status"><%= @notice %></p><% end %>
|
|
8
|
+
<% if @error %><p role="alert"><%= @error %></p><% end %>
|
|
9
|
+
|
|
10
|
+
<% if @link_code %>
|
|
11
|
+
<section>
|
|
12
|
+
<h2>Your link code</h2>
|
|
13
|
+
<p>
|
|
14
|
+
Paste this into your assistant's chat. It works once and expires in
|
|
15
|
+
<%= @expires_in %> seconds — it is shown only now.
|
|
16
|
+
</p>
|
|
17
|
+
<p><code><%= @link_code %></code></p>
|
|
18
|
+
</section>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<section>
|
|
22
|
+
<h2>Link a new assistant</h2>
|
|
23
|
+
<p>
|
|
24
|
+
Mint a code, hand it to your assistant; the assistant redeems it with
|
|
25
|
+
its own key and is then bound to your account.
|
|
26
|
+
</p>
|
|
27
|
+
<form action="<%= @page_path %>/link" method="post">
|
|
28
|
+
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
|
29
|
+
<button type="submit">Mint a link code</button>
|
|
30
|
+
</form>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<section>
|
|
34
|
+
<h2>Currently linked</h2>
|
|
35
|
+
<% unless @spending_cap_enforced %>
|
|
36
|
+
<p role="note">
|
|
37
|
+
<strong>Note:</strong> this origin configures no <code>spending_cap</code>
|
|
38
|
+
seam, so a cap saved below is recorded against the assistant but nothing
|
|
39
|
+
reads it when a charge is settled. To make it bind, set
|
|
40
|
+
<code>c.spending_cap = Kiosk::Server::ColumnSpendingCap.new</code> in the
|
|
41
|
+
Kiosk initializer.
|
|
42
|
+
</p>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% if @assistants.empty? %>
|
|
45
|
+
<p>No assistant accounts are linked yet.</p>
|
|
46
|
+
<% else %>
|
|
47
|
+
<ul>
|
|
48
|
+
<% @assistants.each do |assistant| %>
|
|
49
|
+
<li>
|
|
50
|
+
<strong><%= assistant[:human_label].to_s.empty? ? "(unnamed)" : assistant[:human_label] %></strong>
|
|
51
|
+
<code><%= assistant[:fingerprint] %></code>
|
|
52
|
+
— linked <%= assistant[:created_at] %>
|
|
53
|
+
<p>
|
|
54
|
+
<%
|
|
55
|
+
spent = assistant[:spent_cents].map { |currency, cents| "#{cents} cents in #{currency.upcase}" }
|
|
56
|
+
%>
|
|
57
|
+
spent: <%= spent.empty? ? "0 cents" : spent.join(", ") %> —
|
|
58
|
+
<%
|
|
59
|
+
cap = assistant[:spending_cap_cents]
|
|
60
|
+
cap_label = if cap.nil?
|
|
61
|
+
"cap: none"
|
|
62
|
+
elsif cap.zero?
|
|
63
|
+
"cap: disabled"
|
|
64
|
+
else
|
|
65
|
+
"cap: #{cap} cents in #{assistant[:spending_cap_currency].upcase}"
|
|
66
|
+
end
|
|
67
|
+
%>
|
|
68
|
+
<%= cap_label %>
|
|
69
|
+
</p>
|
|
70
|
+
<form action="<%= @page_path %>/update" method="post">
|
|
71
|
+
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
|
72
|
+
<input type="hidden" name="agent_id" value="<%= assistant[:agent_id] %>">
|
|
73
|
+
<label>Label
|
|
74
|
+
<input type="text" name="human_label" value="<%= assistant[:human_label] %>">
|
|
75
|
+
</label>
|
|
76
|
+
<label>Spending cap (cents, blank = no cap)
|
|
77
|
+
<input type="number" name="spending_cap_cents" min="0" value="<%= assistant[:spending_cap_cents] %>">
|
|
78
|
+
</label>
|
|
79
|
+
<label>Cap currency (ISO 4217 code; the assistant cannot pay in another currency)
|
|
80
|
+
<input type="text" name="spending_cap_currency" maxlength="3" value="<%= assistant[:spending_cap_currency]&.upcase %>">
|
|
81
|
+
</label>
|
|
82
|
+
<button type="submit">Save</button>
|
|
83
|
+
</form>
|
|
84
|
+
<form action="<%= @page_path %>/unlink" method="post">
|
|
85
|
+
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
|
86
|
+
<input type="hidden" name="agent_id" value="<%= assistant[:agent_id] %>">
|
|
87
|
+
<button type="submit">Unlink</button>
|
|
88
|
+
</form>
|
|
89
|
+
</li>
|
|
90
|
+
<% end %>
|
|
91
|
+
</ul>
|
|
92
|
+
<% end %>
|
|
93
|
+
</section>
|
|
94
|
+
</main>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# Claim-ceremony decision confirmation. Override by shipping
|
|
2
|
+
app/views/kiosk/server/device_verify/decided.html.erb in the host app. %>
|
|
3
|
+
<main>
|
|
4
|
+
<% if @decision == :approved %>
|
|
5
|
+
<h1>Approved</h1>
|
|
6
|
+
<p>
|
|
7
|
+
The assistant is now cleared to link to your account — you can return
|
|
8
|
+
to it. You can unlink it at any time from your linked-assistants page.
|
|
9
|
+
</p>
|
|
10
|
+
<% else %>
|
|
11
|
+
<h1>Denied</h1>
|
|
12
|
+
<p>The request was denied. Nothing was linked to your account.</p>
|
|
13
|
+
<% end %>
|
|
14
|
+
</main>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<%# Claim-ceremony verify page. Minimal on purpose — override
|
|
2
|
+
by shipping app/views/kiosk/server/device_verify/show.html.erb in the
|
|
3
|
+
host app. %>
|
|
4
|
+
<main>
|
|
5
|
+
<h1>Link an assistant to your account</h1>
|
|
6
|
+
|
|
7
|
+
<% if @error %>
|
|
8
|
+
<p role="alert"><%= @error %></p>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<% if @authorization %>
|
|
12
|
+
<p>
|
|
13
|
+
An assistant is asking to be linked to <strong>your</strong> account.
|
|
14
|
+
Approve only if you started this yourself and the fingerprint below
|
|
15
|
+
matches the one your assistant shows you.
|
|
16
|
+
</p>
|
|
17
|
+
<%# The access line is a CONSENT field, not decoration. What the
|
|
18
|
+
assistant ends up able to do is decided by the role stamped here, off
|
|
19
|
+
the signed-in human's own identity — so the human approving the link
|
|
20
|
+
is shown the same value the token will carry. It is not something the
|
|
21
|
+
assistant asked for; the request that opened this ceremony is
|
|
22
|
+
unauthenticated and refuses to carry a role at all. %>
|
|
23
|
+
<dl>
|
|
24
|
+
<dt>Key fingerprint</dt>
|
|
25
|
+
<dd><code><%= @fingerprint %></code></dd>
|
|
26
|
+
<dt>Requested at</dt>
|
|
27
|
+
<dd><%= @authorization.created_at.utc %></dd>
|
|
28
|
+
<dt>Access you are handing it</dt>
|
|
29
|
+
<dd>
|
|
30
|
+
<% if @role %>
|
|
31
|
+
It will act as you, with your own <code><%= @role %></code> access at
|
|
32
|
+
this site — the assistant does not get to ask for any other.
|
|
33
|
+
<% else %>
|
|
34
|
+
It will act as you. This site gives your account no separate access
|
|
35
|
+
level, so the assistant gets the same access you have.
|
|
36
|
+
<% end %>
|
|
37
|
+
</dd>
|
|
38
|
+
</dl>
|
|
39
|
+
<form action="<%= request.path %>" method="post">
|
|
40
|
+
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
|
41
|
+
<input type="hidden" name="user_code" value="<%= @user_code %>">
|
|
42
|
+
<button type="submit" name="decision" value="approve">Approve</button>
|
|
43
|
+
<button type="submit" name="decision" value="deny">Deny</button>
|
|
44
|
+
</form>
|
|
45
|
+
<% else %>
|
|
46
|
+
<p>Enter the code your assistant shows you (looks like <code>WDJB-MJHT</code>).</p>
|
|
47
|
+
<form action="<%= request.path %>" method="get">
|
|
48
|
+
<label for="user_code">Code</label>
|
|
49
|
+
<input type="text" id="user_code" name="user_code" autocomplete="off" autofocus>
|
|
50
|
+
<button type="submit">Continue</button>
|
|
51
|
+
</form>
|
|
52
|
+
<% end %>
|
|
53
|
+
</main>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/base'
|
|
5
|
+
require 'rails/generators/migration'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module Generators
|
|
9
|
+
# `bin/rails g kiosk:install`: writes the Kiosk initializer, the wire routes
|
|
10
|
+
# and the migrations, with the DDL of {Kiosk::Server::SchemaDefinitions} written into each.
|
|
11
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
12
|
+
include ::Rails::Generators::Migration
|
|
13
|
+
|
|
14
|
+
source_root File.expand_path('templates', __dir__)
|
|
15
|
+
|
|
16
|
+
desc 'Generate the Kiosk initializer and its canonical base migrations. ' \
|
|
17
|
+
'Draws the engine mount, so the origin answers on the wire.'
|
|
18
|
+
|
|
19
|
+
class_option :user_table, type: :string, default: 'users',
|
|
20
|
+
desc: "Provider's user table name"
|
|
21
|
+
class_option :user_id_type, type: :string, default: 'uuid',
|
|
22
|
+
desc: 'User-id column type: uuid | bigint | integer | text'
|
|
23
|
+
class_option :schema, type: :string, default: 'kiosk',
|
|
24
|
+
desc: 'Postgres schema name for Kiosk helpers and tables'
|
|
25
|
+
class_option :guc_namespace, type: :string, default: 'app',
|
|
26
|
+
desc: 'GUC namespace prefix used in the session GUC names'
|
|
27
|
+
|
|
28
|
+
# Ascending timestamps within one run, so the migrations sort in order.
|
|
29
|
+
@migration_counter = 0
|
|
30
|
+
class << self
|
|
31
|
+
def next_migration_number(_dirname)
|
|
32
|
+
@migration_counter ||= 0
|
|
33
|
+
number = Time.now.utc.strftime('%Y%m%d%H%M%S').to_i + @migration_counter
|
|
34
|
+
@migration_counter += 1
|
|
35
|
+
format('%014d', number)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
no_commands do
|
|
40
|
+
# One SchemaDefinitions method's DDL, indented for the migration's heredoc.
|
|
41
|
+
def ddl(name, **)
|
|
42
|
+
Kiosk::Server::SchemaDefinitions.public_send(:"#{name}_sql", schema: options[:schema], **)
|
|
43
|
+
.gsub(/^(?=.)/, ' ')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def user_id_type = options[:user_id_type].to_sym
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_initializer
|
|
50
|
+
template 'initializer.rb.tt', 'config/initializers/kiosk.rb'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# schema.rb dumps only the search path's schemas unless told otherwise;
|
|
54
|
+
# public goes first because the kiosk tables reference the user table.
|
|
55
|
+
def dump_kiosk_schema
|
|
56
|
+
application %(config.active_record.dump_schemas = "public,#{options[:schema]}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The engine mount, and a place for the operator's verbs under it.
|
|
60
|
+
def draw_wire_routes
|
|
61
|
+
route <<~RUBY
|
|
62
|
+
kiosk do
|
|
63
|
+
# queries :catalog
|
|
64
|
+
# actions :place_order
|
|
65
|
+
end
|
|
66
|
+
RUBY
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_schema_migration
|
|
70
|
+
migration_template 'create_kiosk_schema.rb.tt',
|
|
71
|
+
'db/migrate/create_kiosk_schema.rb'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_identity_tables_migration
|
|
75
|
+
migration_template 'create_kiosk_identity_tables.rb.tt',
|
|
76
|
+
'db/migrate/create_kiosk_identity_tables.rb'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def create_device_authorizations_migration
|
|
80
|
+
migration_template 'create_kiosk_device_authorizations.rb.tt',
|
|
81
|
+
'db/migrate/create_kiosk_device_authorizations.rb'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create_mandates_migration
|
|
85
|
+
migration_template 'create_kiosk_mandates.rb.tt',
|
|
86
|
+
'db/migrate/create_kiosk_mandates.rb'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def create_kyc_attributes_migration
|
|
90
|
+
migration_template 'create_kiosk_kyc_attributes.rb.tt',
|
|
91
|
+
'db/migrate/create_kiosk_kyc_attributes.rb'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def create_events_migration
|
|
95
|
+
migration_template 'create_kiosk_events.rb.tt',
|
|
96
|
+
'db/migrate/create_kiosk_events.rb'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_pow_spent_migration
|
|
100
|
+
migration_template 'create_kiosk_pow_spent.rb.tt',
|
|
101
|
+
'db/migrate/create_kiosk_pow_spent.rb'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def create_revocations_migration
|
|
105
|
+
migration_template 'create_kiosk_revocations.rb.tt',
|
|
106
|
+
'db/migrate/create_kiosk_revocations.rb'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def create_auth_challenges_migration
|
|
110
|
+
migration_template 'create_kiosk_auth_challenges.rb.tt',
|
|
111
|
+
'db/migrate/create_kiosk_auth_challenges.rb'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_visits_migration
|
|
115
|
+
migration_template 'create_kiosk_visits.rb.tt',
|
|
116
|
+
'db/migrate/create_kiosk_visits.rb'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create_refunds_migration
|
|
120
|
+
migration_template 'create_kiosk_refunds.rb.tt',
|
|
121
|
+
'db/migrate/create_kiosk_refunds.rb'
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|