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,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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The base of the engine's models.
6
+ class ApplicationRecord < ::ActiveRecord::Base
7
+ self.abstract_class = true
8
+ end
9
+ end
10
+ 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