kiosk-server 0.6.1 → 0.6.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -0
  3. data/README.md +134 -102
  4. data/app/channels/kiosk_events.rb +30 -17
  5. data/app/controllers/kiosk/server/application_controller.rb +0 -1
  6. data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
  7. data/app/controllers/kiosk/server/auth_controller.rb +3 -3
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +2 -7
  10. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  11. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +8 -7
  12. data/app/controllers/kiosk/server/oauth_token_controller.rb +4 -1
  13. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  14. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  15. data/app/models/kiosk/checkout_mandate.rb +0 -2
  16. data/app/models/kiosk/settlement.rb +0 -2
  17. data/app/models/kiosk/visit.rb +0 -2
  18. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  19. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  20. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  21. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  22. data/lib/generators/kiosk/install/install_generator.rb +25 -64
  23. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  24. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  25. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  26. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  27. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -14
  28. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  29. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  30. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  31. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  32. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  33. data/lib/kiosk/payable.rb +1 -1
  34. data/lib/kiosk/server/account_binding.rb +14 -5
  35. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  36. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +1 -1
  37. data/lib/kiosk/server/agent_rotation.rb +1 -1
  38. data/lib/kiosk/server/argument_decoder.rb +2 -2
  39. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  40. data/lib/kiosk/server/auth_toll.rb +5 -5
  41. data/lib/kiosk/server/client.rb +1 -1
  42. data/lib/kiosk/server/client_assertion.rb +3 -2
  43. data/lib/kiosk/server/client_registration.rb +6 -2
  44. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  45. data/lib/kiosk/server/configuration_extension.rb +15 -38
  46. data/lib/kiosk/server/current_request.rb +1 -1
  47. data/lib/kiosk/server/device_authorization.rb +1 -1
  48. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  49. data/lib/kiosk/server/device_code_grant.rb +2 -2
  50. data/lib/kiosk/server/dpop.rb +6 -1
  51. data/lib/kiosk/server/engine.rb +2 -2
  52. data/lib/kiosk/server/errors.rb +21 -7
  53. data/lib/kiosk/server/event_stores.rb +2 -2
  54. data/lib/kiosk/server/events.rb +4 -3
  55. data/lib/kiosk/server/events_connection.rb +23 -5
  56. data/lib/kiosk/server/events_replay.rb +79 -0
  57. data/lib/kiosk/server/executor/repeat.rb +1 -1
  58. data/lib/kiosk/server/executor/spending_cap.rb +8 -66
  59. data/lib/kiosk/server/executor.rb +10 -10
  60. data/lib/kiosk/server/failure_log.rb +3 -3
  61. data/lib/kiosk/server/handler_mixin.rb +9 -9
  62. data/lib/kiosk/server/handler_registrations.rb +1 -1
  63. data/lib/kiosk/server/handler_serving.rb +11 -11
  64. data/lib/kiosk/server/headers.rb +3 -3
  65. data/lib/kiosk/server/identity_resolution.rb +1 -1
  66. data/lib/kiosk/server/kyc.rb +5 -7
  67. data/lib/kiosk/server/kyc_verifier.rb +26 -27
  68. data/lib/kiosk/server/linked_assistants.rb +21 -16
  69. data/lib/kiosk/server/money.rb +41 -0
  70. data/lib/kiosk/server/oauth_answers.rb +1 -0
  71. data/lib/kiosk/server/oauth_error.rb +3 -0
  72. data/lib/kiosk/server/open_api.rb +9 -9
  73. data/lib/kiosk/server/page.rb +1 -1
  74. data/lib/kiosk/server/payment_claim.rb +3 -3
  75. data/lib/kiosk/server/pow_gate.rb +12 -33
  76. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  77. data/lib/kiosk/server/receipts.rb +4 -3
  78. data/lib/kiosk/server/reputation_factors.rb +12 -5
  79. data/lib/kiosk/server/request_validation.rb +2 -1
  80. data/lib/kiosk/server/result.rb +1 -1
  81. data/lib/kiosk/server/revocation_stores.rb +1 -1
  82. data/lib/kiosk/server/routing.rb +1 -1
  83. data/lib/kiosk/server/schemas/auth.schema.json +2 -2
  84. data/lib/kiosk/server/schemas/binding.schema.json +3 -3
  85. data/lib/kiosk/server/schemas/kyc.schema.json +22 -3
  86. data/lib/kiosk/server/schemas/mandates.schema.json +5 -5
  87. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  88. data/lib/kiosk/server/session_context.rb +2 -2
  89. data/lib/kiosk/server/tolled_calls.rb +46 -0
  90. data/lib/kiosk/server/verb_wire.rb +3 -3
  91. data/lib/kiosk/server/version.rb +1 -1
  92. data/lib/kiosk/server/well_known.rb +14 -11
  93. data/lib/kiosk/server/wire_rendering.rb +1 -1
  94. data/lib/kiosk/server.rb +6 -2
  95. metadata +9 -8
  96. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  97. data/lib/kiosk/server/schema_definitions.rb +0 -320
  98. data/lib/kiosk/server/settled_spend.rb +0 -21
  99. data/lib/kiosk/server/wrong_door.rb +0 -42
@@ -9,7 +9,6 @@ module Kiosk
9
9
  class ApplicationController < ::ActionController::Base
10
10
  include AccountHolderGate
11
11
  include BindingModuleGate
12
- include WrongDoor
13
12
 
14
13
  prepend_before_action :refuse_unserved_binding
15
14
  end
@@ -8,11 +8,8 @@ module Kiosk
8
8
  class AssistantsController < ApplicationController
9
9
  SIGN_IN_PROMPT = 'Sign in to your account first to manage linked assistants.'
10
10
  SIGN_IN_ALERT = 'Please sign in to manage your linked assistants.'
11
-
12
- WRONG_DOOR_MESSAGE = "this is the account holder's browser page, not the Kiosk wire — " \
13
- 'it needs a signed-in session and a CSRF token from its own form'
14
-
15
- layout false
11
+ CAP_NOT_AN_AMOUNT = 'Write the spending cap as an amount, such as 50 or 12.50.'
12
+ CURRENCY_NOT_A_CODE = 'Write the cap currency as a three-letter currency code, such as EUR.'
16
13
 
17
14
  def show
18
15
  return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
@@ -41,7 +38,7 @@ module Kiosk
41
38
  render_error(e)
42
39
  end
43
40
 
44
- # Edits a bound assistant's label and/or spending cap and its currency (blank cap → unlimited).
41
+ # Edits a bound assistant's label and/or spending cap and its currency (blank cap → no cap).
45
42
  def update
46
43
  return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
47
44
 
@@ -62,8 +59,9 @@ module Kiosk
62
59
 
63
60
  def render_page(status: :ok)
64
61
  @assistants = LinkedAssistants.list(@identity.user_id)
65
- # Without a `config.spending_cap` seam the saved cap binds nothing; the page says so.
66
- @spending_cap_enforced = !Kiosk.configuration.spending_cap.nil?
62
+ # The spend shows where the origin takes payments; the cap, where a `spending_cap` reads it.
63
+ @payments = !Kiosk.configuration.payment_provider.nil?
64
+ @spending_cap = !Kiosk.configuration.spending_cap.nil?
67
65
  @page_path = request.path.sub(%r{/(link|unlink|update)\z}, '')
68
66
  render :show, status: status
69
67
  end
@@ -72,24 +70,25 @@ module Kiosk
72
70
  def settings_changes
73
71
  changes = {}
74
72
  changes['human_label'] = params[:human_label].to_s if params.key?(:human_label)
75
- changes['spending_cap_cents'], changes['spending_cap_currency'] = spending_cap_param if params.key?(:spending_cap_cents)
73
+ changes['spending_cap_cents'], changes['spending_cap_currency'] = spending_cap_param if params.key?(:spending_cap)
76
74
  changes
77
75
  end
78
76
 
79
- # `[cents, currency]`, or `[nil, nil]` for no cap.
77
+ # `[cents, currency]` from the amount the person typed, or `[nil, nil]` for no cap.
80
78
  def spending_cap_param
81
- raw = params[:spending_cap_cents].to_s
79
+ raw = params[:spending_cap].to_s
82
80
  return [nil, nil] if raw.strip.empty?
83
81
 
84
- cents = Integer(raw, exception: false) || raise(Errors::BadRequest, 'spending_cap_cents must be an integer')
85
- [cents, spending_cap_currency_param]
82
+ currency = spending_cap_currency_param
83
+ cents = Money.parse(raw, currency) || raise(Errors::BadRequest, CAP_NOT_AN_AMOUNT)
84
+ [cents, currency]
86
85
  end
87
86
 
88
87
  def spending_cap_currency_param
89
88
  code = MandateVerifier.canonical_currency(params[:spending_cap_currency])
90
89
  return code if MandateVerifier::CURRENCY_CODE.match?(code)
91
90
 
92
- raise Errors::BadRequest, 'spending_cap_currency must be a three-letter ISO 4217 code, such as EUR'
91
+ raise Errors::BadRequest, CURRENCY_NOT_A_CODE
93
92
  end
94
93
  end
95
94
  end
@@ -17,7 +17,7 @@ module Kiosk
17
17
  render json: payload, status: status
18
18
  end
19
19
 
20
- # The same RFC 9457 problem document as the wire (§9).
20
+ # The same RFC 9457 problem document as the protocol (§9).
21
21
  def render_error(err)
22
22
  add_problem_headers(err)
23
23
  render json: err.to_problem, status: err.http_status,
@@ -32,7 +32,7 @@ module Kiosk
32
32
  response.set_header('WWW-Authenticate', challenge) if challenge
33
33
  end
34
34
 
35
- # RFC 7235 challenge header for the 402s, as on the wire.
35
+ # RFC 7235 challenge header for the 402s, as in the protocol.
36
36
  def www_authenticate_for(err)
37
37
  issuer = Kiosk.current_issuer
38
38
  case err
@@ -119,7 +119,7 @@ module Kiosk
119
119
  def authenticated_account_holder!
120
120
  identity = Kiosk.configuration.user_idp&.verify(request)
121
121
  hint = "sign in to the provider first — this endpoint authenticates via the provider's own session"
122
- raise Errors::Unauthenticated.new('account session required', hint:) unless identity
122
+ raise Errors::SessionRequired.new('account session required', hint:) unless identity
123
123
  raise Errors::BadRequest, 'Content-Type must be application/json' unless request.media_type == 'application/json'
124
124
 
125
125
  identity
@@ -11,14 +11,9 @@ module Kiosk
11
11
 
12
12
  CODE_NOT_RECOGNISED = 'That code was not recognised — it may have expired. Ask the assistant for a fresh one.'
13
13
 
14
- WRONG_DOOR_MESSAGE = "this is the account holder's browser consent page, not the Kiosk wire — " \
15
- 'it needs a signed-in session and a CSRF token from its own form'
16
-
17
14
  # Failed code lookups per session before a 429, against 31^8 possible codes.
18
15
  MAX_CODE_ATTEMPTS = 10
19
16
 
20
- layout false
21
-
22
17
  def show
23
18
  return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
24
19
  return render_attempts_capped if attempts_capped?
@@ -10,15 +10,14 @@ module Kiosk
10
10
  # Rails stamps `Vary: Accept` on a negotiated render; these documents are
11
11
  # one answer for everybody, so a shared cache must keep one copy.
12
12
  after_action :drop_vary
13
+ before_action :allow_cors
13
14
 
14
15
  def agents_txt
15
- allow_cors
16
16
  render plain: WellKnown.agents_txt(base_url: request.base_url),
17
17
  content_type: 'text/plain; charset=utf-8'
18
18
  end
19
19
 
20
20
  def agents_json
21
- allow_cors
22
21
  short_ttl
23
22
  render json: WellKnown.agents_json(base_url: request.base_url)
24
23
  end
@@ -28,17 +27,14 @@ module Kiosk
28
27
  end
29
28
 
30
29
  def oauth_protected_resource
31
- allow_cors
32
30
  render json: WellKnown.oauth_protected_resource(base_url: request.base_url)
33
31
  end
34
32
 
35
33
  def agent_skills_index
36
- allow_cors
37
34
  render_or_not_found WellKnown.agent_skills_index
38
35
  end
39
36
 
40
37
  def ard
41
- allow_cors
42
38
  short_ttl
43
39
  render json: WellKnown.ard(base_url: request.base_url)
44
40
  end
@@ -49,7 +45,6 @@ module Kiosk
49
45
  end
50
46
 
51
47
  def api_catalog
52
- allow_cors
53
48
  short_ttl
54
49
  # RFC 9727 §2: a HEAD request finds the catalog by this header.
55
50
  response.set_header('Link', %(<#{request.base_url}/.well-known/api-catalog>; rel="api-catalog"))
@@ -59,7 +54,6 @@ module Kiosk
59
54
  end
60
55
 
61
56
  def auth_md
62
- allow_cors
63
57
  render plain: WellKnown.auth_md(base_url: request.base_url),
64
58
  content_type: 'text/markdown; charset=utf-8'
65
59
  end
@@ -71,6 +65,7 @@ module Kiosk
71
65
  end
72
66
 
73
67
  # agents.txt v1.0 and AI Catalog require `Access-Control-Allow-Origin: *`.
68
+ # Every document here answers it, so a page on any origin can read it.
74
69
  def allow_cors
75
70
  response.set_header('Access-Control-Allow-Origin', '*')
76
71
  end
@@ -7,7 +7,9 @@ module Kiosk
7
7
  class KycCallbackController < WireController
8
8
  def create
9
9
  Kyc.served!
10
- Kyc.callback(parse_body!)
10
+ body = parse_body!
11
+ RequestValidation.validate_body!(body, exchange: 'POST <endpoint>/kyc/callback')
12
+ Kyc.callback(body)
11
13
  render json: { ok: true }, status: :ok
12
14
  end
13
15
  end
@@ -24,15 +24,16 @@ module Kiosk
24
24
 
25
25
  private
26
26
 
27
- # Refused, not ignored: the role is the approving human's, and the
28
- # assistant does not choose it.
27
+ # A `role` is refused: the role is the approving human's, and the
28
+ # assistant does not choose it. A `scope` is ignored (RFC 6749 Section
29
+ # 3.3): an assistant gets another role only when a human who holds that
30
+ # role binds it to their account, or through the identity provider.
29
31
  def role_problem
30
- offending = %i[role scope].find { |name| params.key?(name) }
31
- return unless offending
32
+ return unless params.key?(:role)
32
33
 
33
- "#{offending} is not accepted here — an assistant does not choose its own role. " \
34
- "The role of a bound assistant is the approving account holder's own role, " \
35
- "read from this provider's identity system when they approve at the verify page."
34
+ 'role is not accepted here: an assistant does not choose its own role. ' \
35
+ 'The role of a bound assistant is the role of the account holder who approves, ' \
36
+ "and this provider's identity system gives it at the verify page."
36
37
  end
37
38
 
38
39
  def device_authorization(result)
@@ -48,7 +48,10 @@ module Kiosk
48
48
  client
49
49
  end
50
50
 
51
- # RFC 6749 Section 5.1. The scope is the role of the client.
51
+ # RFC 6749 Section 5.1. The scope is the role of the client. A requested
52
+ # `scope` is ignored (RFC 6749 Section 3.3): an assistant gets another
53
+ # role only when a human who holds that role binds it to their account,
54
+ # or through the identity provider.
52
55
  def render_token(client)
53
56
  token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: client.id, role: client.role)
54
57
  body = { access_token: token, token_type: Dpop::SCHEME, expires_in: Kiosk.configuration.access_token_ttl }
@@ -18,12 +18,11 @@ module Kiosk
18
18
 
19
19
  private
20
20
 
21
+ # No script and no inline style: a host Content-Security-Policy of `default-src 'self'` holds.
21
22
  def page(title, text)
22
23
  <<~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>
24
+ <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>#{title}</title></head>
25
+ <body><main><h1>#{title}</h1><p>#{text}</p></main></body></html>
27
26
  HTML
28
27
  end
29
28
  end
@@ -6,8 +6,8 @@ require 'action_dispatch/http/parameters'
6
6
  module Kiosk
7
7
  module Server
8
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
9
+ # and the base every WireController inherits its seams from.
10
+ # Response (JSON): success is the handler's payload VERBATIM; an error
11
11
  # is an RFC 9457 problem document.
12
12
  class WireController < ::ActionController::API
13
13
  include VerbWire
@@ -5,8 +5,6 @@ module Kiosk
5
5
  # items of the operator's checkout that the assistant agreed to pay for.
6
6
  # Read-only for the operator.
7
7
  class CheckoutMandate < Server::ApplicationRecord
8
- def self.table_name = "#{Kiosk.configuration.schema}.checkout_mandates"
9
-
10
8
  has_many :settlements, class_name: 'Kiosk::Settlement', dependent: nil, inverse_of: :checkout_mandate
11
9
 
12
10
  # Checkout mandates with a line item carrying these attributes, by jsonb
@@ -5,8 +5,6 @@ module Kiosk
5
5
  class Settlement < Server::ApplicationRecord
6
6
  include Kiosk::Owned
7
7
 
8
- def self.table_name = "#{Kiosk.configuration.schema}.settlements"
9
-
10
8
  belongs_to :checkout_mandate, class_name: 'Kiosk::CheckoutMandate', inverse_of: :settlements
11
9
  end
12
10
  end
@@ -5,8 +5,6 @@ module Kiosk
5
5
  # value counts toward the customer's recent spend, so an operator that takes
6
6
  # no payment can still lower the proof-of-work toll for a returning customer.
7
7
  class Visit < Server::ApplicationRecord
8
- def self.table_name = "#{Kiosk.configuration.schema}.visits"
9
-
10
8
  # Call it when the visit happens, with what the visit was worth.
11
9
  def self.record!(user_id:, value_cents:) = create!(user_id:, value_cents:)
12
10
  end
@@ -1,6 +1,7 @@
1
1
  <%# «Link an assistant» page (link flow — Kiosk extension).
2
2
  Minimal on purpose — override by shipping
3
3
  app/views/kiosk/server/assistants/show.html.erb in the host app. %>
4
+ <% content_for :title, 'Linked assistants' %>
4
5
  <main>
5
6
  <h1>Linked assistant accounts</h1>
6
7
 
@@ -21,26 +22,17 @@
21
22
  <section>
22
23
  <h2>Link a new assistant</h2>
23
24
  <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.
25
+ Get a link code and paste it into your assistant's chat. The assistant
26
+ uses the code once, with its own key, and is then linked to your account.
26
27
  </p>
27
28
  <form action="<%= @page_path %>/link" method="post">
28
29
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
29
- <button type="submit">Mint a link code</button>
30
+ <button type="submit">Get a link code</button>
30
31
  </form>
31
32
  </section>
32
33
 
33
34
  <section>
34
35
  <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
36
  <% if @assistants.empty? %>
45
37
  <p>No assistant accounts are linked yet.</p>
46
38
  <% else %>
@@ -49,36 +41,40 @@
49
41
  <li>
50
42
  <strong><%= assistant[:human_label].to_s.empty? ? "(unnamed)" : assistant[:human_label] %></strong>
51
43
  <code><%= assistant[:fingerprint] %></code>
52
- — linked <%= assistant[:created_at] %>
44
+ <% if assistant[:bound_at] %>— linked <%= assistant[:bound_at] %><% end %>
45
+ <% if @payments %>
53
46
  <p>
54
- <%
55
- spent = assistant[:spent_cents].map { |currency, cents| "#{cents} cents in #{currency.upcase}" }
56
- %>
57
- spent: <%= spent.empty? ? "0 cents" : spent.join(", ") %> —
47
+ <% spent = assistant[:spent_cents].map { |currency, cents| Kiosk::Server::Money.format(cents, currency) } %>
48
+ Spent: <%= spent.empty? ? "nothing" : spent.join(", ") %>
49
+ <% if @spending_cap %>
58
50
  <%
59
51
  cap = assistant[:spending_cap_cents]
60
52
  cap_label = if cap.nil?
61
- "cap: none"
53
+ "no spending cap"
62
54
  elsif cap.zero?
63
- "cap: disabled"
55
+ "no payments allowed"
64
56
  else
65
- "cap: #{cap} cents in #{assistant[:spending_cap_currency].upcase}"
57
+ "spending cap #{Kiosk::Server::Money.format(cap, assistant[:spending_cap_currency])} per payment"
66
58
  end
67
59
  %>
68
- <%= cap_label %>
60
+ — <%= cap_label %>
61
+ <% end %>
69
62
  </p>
63
+ <% end %>
70
64
  <form action="<%= @page_path %>/update" method="post">
71
65
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
72
66
  <input type="hidden" name="agent_id" value="<%= assistant[:agent_id] %>">
73
67
  <label>Label
74
68
  <input type="text" name="human_label" value="<%= assistant[:human_label] %>">
75
69
  </label>
76
- <label>Spending cap (cents, blank = no cap)
77
- <input type="number" name="spending_cap_cents" min="0" value="<%= assistant[:spending_cap_cents] %>">
70
+ <% if @spending_cap %>
71
+ <label>Spending cap per payment (blank: no cap; 0: no payments)
72
+ <input type="text" name="spending_cap" inputmode="decimal" value="<%= assistant[:spending_cap_cents] && Kiosk::Server::Money.amount(assistant[:spending_cap_cents], assistant[:spending_cap_currency]) %>">
78
73
  </label>
79
- <label>Cap currency (ISO 4217 code; the assistant cannot pay in another currency)
74
+ <label>Cap currency (a three-letter code such as EUR; the assistant cannot pay in another currency)
80
75
  <input type="text" name="spending_cap_currency" maxlength="3" value="<%= assistant[:spending_cap_currency]&.upcase %>">
81
76
  </label>
77
+ <% end %>
82
78
  <button type="submit">Save</button>
83
79
  </form>
84
80
  <form action="<%= @page_path %>/unlink" method="post">
@@ -1,5 +1,6 @@
1
1
  <%# Claim-ceremony decision confirmation. Override by shipping
2
2
  app/views/kiosk/server/device_verify/decided.html.erb in the host app. %>
3
+ <% content_for :title, 'Link an assistant' %>
3
4
  <main>
4
5
  <% if @decision == :approved %>
5
6
  <h1>Approved</h1>
@@ -1,6 +1,7 @@
1
1
  <%# Claim-ceremony verify page. Minimal on purpose — override
2
2
  by shipping app/views/kiosk/server/device_verify/show.html.erb in the
3
3
  host app. %>
4
+ <% content_for :title, 'Link an assistant' %>
4
5
  <main>
5
6
  <h1>Link an assistant to your account</h1>
6
7
 
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <%# The document around the account holder's pages. Override by shipping
3
+ app/views/layouts/kiosk/server/application.html.erb in the host app. %>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="UTF-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title><%= yield :title %> — <%= request.host %></title>
9
+ </head>
10
+ <body>
11
+ <%= yield %>
12
+ <footer><a href="/">Back to <%= request.host %></a></footer>
13
+ </body>
14
+ </html>
@@ -6,24 +6,22 @@ require 'rails/generators/migration'
6
6
 
7
7
  module Kiosk
8
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.
9
+ # `bin/rails g kiosk:install`: writes the Kiosk initializer, the Kiosk routes
10
+ # and the migrations of Kiosk's `kiosk_*` tables.
11
11
  class InstallGenerator < ::Rails::Generators::Base
12
12
  include ::Rails::Generators::Migration
13
13
 
14
14
  source_root File.expand_path('templates', __dir__)
15
15
 
16
16
  desc 'Generate the Kiosk initializer and its canonical base migrations. ' \
17
- 'Draws the engine mount, so the origin answers on the wire.'
17
+ 'Draws the engine mount, so the origin answers Kiosk requests.'
18
18
 
19
19
  class_option :user_table, type: :string, default: 'users',
20
20
  desc: "Provider's user table name"
21
- class_option :user_id_type, type: :string, default: 'uuid',
21
+ class_option :user_id_type, type: :string, default: 'uuid', enum: %w[uuid bigint integer text],
22
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'
23
+ class_option :schema, type: :string, default: 'public',
24
+ desc: "Postgres schema of Kiosk's tables"
27
25
 
28
26
  # Ascending timestamps within one run, so the migrations sort in order.
29
27
  @migration_counter = 0
@@ -36,23 +34,30 @@ module Kiosk
36
34
  end
37
35
  end
38
36
 
37
+ MIGRATIONS = %w[identity_tables device_authorizations mandates kyc_attributes events
38
+ pow_spent revocations visits refunds].freeze
39
+
39
40
  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
41
+ def public_schema? = options[:schema] == 'public'
42
+
43
+ # A Kiosk table as a migration names it: `:kiosk_agents`, or `"ops.kiosk_agents"`.
44
+ def table(name) = public_schema? ? ":kiosk_#{name}" : %("#{options[:schema]}.kiosk_#{name}")
45
45
 
46
- def user_id_type = options[:user_id_type].to_sym
46
+ # The type of `users.id`, which every `user_id` column takes.
47
+ def user_id_type = ":#{options[:user_id_type]}"
48
+
49
+ # The foreign key of a `user_id` column: a row goes with its user.
50
+ def users_key = "{ to_table: :#{options[:user_table]}, on_delete: :cascade }"
47
51
  end
48
52
 
49
53
  def create_initializer
50
54
  template 'initializer.rb.tt', 'config/initializers/kiosk.rb'
51
55
  end
52
56
 
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.
57
+ # schema.rb dumps only the search path's schemas unless told otherwise.
55
58
  def dump_kiosk_schema
59
+ return if public_schema?
60
+
56
61
  application %(config.active_record.dump_schemas = "public,#{options[:schema]}")
57
62
  end
58
63
 
@@ -66,54 +71,10 @@ module Kiosk
66
71
  RUBY
67
72
  end
68
73
 
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_visits_migration
110
- migration_template 'create_kiosk_visits.rb.tt',
111
- 'db/migrate/create_kiosk_visits.rb'
112
- end
113
-
114
- def create_refunds_migration
115
- migration_template 'create_kiosk_refunds.rb.tt',
116
- 'db/migrate/create_kiosk_refunds.rb'
74
+ def create_migrations
75
+ MIGRATIONS.each do |name|
76
+ migration_template "create_kiosk_#{name}.rb.tt", "db/migrate/create_kiosk_#{name}.rb"
77
+ end
117
78
  end
118
79
  end
119
80
  end
@@ -1,17 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 004 — kiosk.device_authorizations: the account-binding state
4
- # machine behind the RFC 8628-shaped claim and link ceremonies, in the shape
5
- # DeviceAuthorizationStores::ActiveRecord reads and writes (hashed device and
6
- # user codes, the bound public key, claim vs link).
3
+ # The account-binding requests behind the claim and link ceremonies (RFC 8628
4
+ # shape). The device and user codes are stored hashed. `requested_role` is the
5
+ # role of the approving human, never one a client asks for.
7
6
  class CreateKioskDeviceAuthorizations < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
8
- def up
9
- execute <<~'SQL'
10
- <%= ddl(:device_authorizations, user_id_type: user_id_type) %>
11
- SQL
12
- end
13
-
14
- def down
15
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".device_authorizations)
7
+ def change
8
+ create_table <%= table :device_authorizations %>, id: :uuid do |t|
9
+ t.text :device_code_hash, null: false, index: { unique: true }
10
+ # Unique among pending requests only: a finished request may share a code with an old one.
11
+ t.text :user_code_hash, null: false, index: { unique: true, where: "status = 'pending'" }
12
+ t.text :public_key_pem
13
+ t.text :kind, null: false, default: 'claim'
14
+ t.text :client_id, null: false
15
+ t.text :requested_role
16
+ t.text :status, null: false
17
+ t.column :user_id, <%= user_id_type %>
18
+ t.timestamptz :expires_at, null: false, index: { where: "status IN ('pending', 'approved')" }
19
+ t.timestamptz :consumed_at
20
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
21
+ t.check_constraint "status IN ('pending', 'approved', 'denied', 'consumed', 'expired')",
22
+ name: 'kiosk_device_authorizations_status'
23
+ t.check_constraint "kind IN ('claim', 'link')", name: 'kiosk_device_authorizations_kind'
24
+ end
16
25
  end
17
26
  end
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 007 — create kiosk.events.
4
- # The per-identity EVENT TAIL an operator serving any topic needs. `id` is a
5
- # bigserial, and that is what makes the wire's event id a per-origin monotonic
6
- # integer — one cursor then resumes every subscription on a socket with one
7
- # integer comparison.
8
- # `identity_key` is a user_id, not an agent_id: a human's second assistant must
9
- # read the same tail, and a revoked assistant is stopped at the socket.
10
- # Laid down unconditionally, like kiosk.kyc_attributes: the route is drawn
11
- # whether or not this origin declares a topic, and an empty table costs nothing.
3
+ # The event tail. `id` is the origin's one ascending cursor: a single integer
4
+ # resumes every subscription on a socket. `identity_key` is a user id, so every
5
+ # assistant of one person reads the same tail.
12
6
  class CreateKioskEvents < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
13
- def up
14
- execute <<~'SQL'
15
- <%= ddl(:events) %>
16
- SQL
17
- end
18
-
19
- def down
20
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".events)
7
+ def change
8
+ create_table <%= table :events %> do |t|
9
+ t.text :identity_key, null: false
10
+ t.text :topic, null: false
11
+ t.text :subject
12
+ t.timestamptz :occurred_at, null: false
13
+ t.jsonb :data, null: false
14
+ # The retention sweep reads it.
15
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }, index: true
16
+ # `since` for one subscriber: the cursor scan, in id order.
17
+ t.index %i[identity_key id]
18
+ end
21
19
  end
22
20
  end