kiosk-server 0.5.20 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +192 -138
  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 +21 -65
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
  10. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  11. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  12. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  13. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
  14. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
  16. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  17. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  18. data/app/models/kiosk/checkout_mandate.rb +20 -0
  19. data/app/models/kiosk/settlement.rb +1 -3
  20. data/app/models/kiosk/visit.rb +0 -2
  21. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  22. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  23. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  24. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  25. data/lib/generators/kiosk/install/install_generator.rb +25 -69
  26. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  27. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  28. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  29. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  30. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
  31. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  32. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  33. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  34. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  35. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  36. data/lib/kiosk/payable.rb +19 -25
  37. data/lib/kiosk/server/account_binding.rb +32 -62
  38. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  39. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  40. data/lib/kiosk/server/agent_rotation.rb +62 -0
  41. data/lib/kiosk/server/argument_decoder.rb +2 -2
  42. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  43. data/lib/kiosk/server/assistant_key.rb +86 -0
  44. data/lib/kiosk/server/auth_toll.rb +6 -6
  45. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  46. data/lib/kiosk/server/checkout.rb +57 -0
  47. data/lib/kiosk/server/client.rb +61 -0
  48. data/lib/kiosk/server/client_assertion.rb +154 -0
  49. data/lib/kiosk/server/client_registration.rb +98 -0
  50. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  51. data/lib/kiosk/server/configuration_extension.rb +64 -75
  52. data/lib/kiosk/server/current_request.rb +1 -1
  53. data/lib/kiosk/server/device_authorization.rb +1 -1
  54. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  55. data/lib/kiosk/server/device_code_grant.rb +38 -69
  56. data/lib/kiosk/server/dpop.rb +126 -0
  57. data/lib/kiosk/server/eddsa.rb +38 -0
  58. data/lib/kiosk/server/engine.rb +16 -13
  59. data/lib/kiosk/server/errors.rb +28 -7
  60. data/lib/kiosk/server/event_stores.rb +2 -2
  61. data/lib/kiosk/server/events.rb +4 -3
  62. data/lib/kiosk/server/events_connection.rb +38 -10
  63. data/lib/kiosk/server/events_replay.rb +79 -0
  64. data/lib/kiosk/server/executor/repeat.rb +13 -12
  65. data/lib/kiosk/server/executor/spending_cap.rb +17 -76
  66. data/lib/kiosk/server/executor.rb +86 -105
  67. data/lib/kiosk/server/failure_log.rb +3 -3
  68. data/lib/kiosk/server/handler_mixin.rb +9 -9
  69. data/lib/kiosk/server/handler_registrations.rb +1 -1
  70. data/lib/kiosk/server/handler_serving.rb +11 -11
  71. data/lib/kiosk/server/headers.rb +3 -3
  72. data/lib/kiosk/server/identity_resolution.rb +12 -3
  73. data/lib/kiosk/server/jwk.rb +83 -0
  74. data/lib/kiosk/server/jwks.rb +6 -0
  75. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  76. data/lib/kiosk/server/kyc.rb +5 -7
  77. data/lib/kiosk/server/kyc_verifier.rb +28 -29
  78. data/lib/kiosk/server/link_code.rb +9 -18
  79. data/lib/kiosk/server/linked_assistants.rb +22 -17
  80. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  81. data/lib/kiosk/server/money.rb +41 -0
  82. data/lib/kiosk/server/oauth_answers.rb +31 -0
  83. data/lib/kiosk/server/oauth_error.rb +33 -0
  84. data/lib/kiosk/server/open_api.rb +31 -29
  85. data/lib/kiosk/server/page.rb +1 -1
  86. data/lib/kiosk/server/payment_claim.rb +47 -78
  87. data/lib/kiosk/server/pow_gate.rb +12 -33
  88. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  89. data/lib/kiosk/server/receipts.rb +30 -0
  90. data/lib/kiosk/server/reputation_factors.rb +12 -5
  91. data/lib/kiosk/server/request_validation.rb +3 -3
  92. data/lib/kiosk/server/result.rb +1 -1
  93. data/lib/kiosk/server/revocation_stores.rb +9 -1
  94. data/lib/kiosk/server/routing.rb +1 -1
  95. data/lib/kiosk/server/schemas/auth.schema.json +369 -58
  96. data/lib/kiosk/server/schemas/binding.schema.json +24 -59
  97. data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
  98. data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
  99. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  100. data/lib/kiosk/server/sd_jwt.rb +104 -0
  101. data/lib/kiosk/server/session_context.rb +2 -2
  102. data/lib/kiosk/server/signing_key.rb +18 -52
  103. data/lib/kiosk/server/tolled_calls.rb +46 -0
  104. data/lib/kiosk/server/verb_wire.rb +4 -4
  105. data/lib/kiosk/server/version.rb +1 -1
  106. data/lib/kiosk/server/well_known.rb +272 -205
  107. data/lib/kiosk/server/wire_rendering.rb +2 -2
  108. data/lib/kiosk/server.rb +8 -2
  109. data/lib/kiosk/skill_link.rb +6 -2
  110. data/listen.py +38 -8
  111. metadata +34 -24
  112. data/app/models/kiosk/cart_mandate.rb +0 -21
  113. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  114. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  115. data/lib/kiosk/server/agent_login.rb +0 -49
  116. data/lib/kiosk/server/agent_registration.rb +0 -88
  117. data/lib/kiosk/server/auth_challenge.rb +0 -33
  118. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  119. data/lib/kiosk/server/pop_verifier.rb +0 -87
  120. data/lib/kiosk/server/schema_definitions.rb +0 -351
  121. data/lib/kiosk/server/settled_spend.rb +0 -21
  122. data/lib/kiosk/server/wrong_door.rb +0 -42
@@ -4,55 +4,59 @@ require 'action_controller'
4
4
 
5
5
  module Kiosk
6
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.
7
+ # POST <endpoint>/oauth/token (RFC 6749 Section 3.2): a registered client
8
+ # authenticates with a client assertion (RFC 7523) and a DPoP proof of its
9
+ # registered key (RFC 9449), and gets an access token bound to that key.
10
+ # The client_credentials grant is always served; the device-code grant
11
+ # (RFC 8628 Section 3.4) only where the origin serves account binding.
9
12
  class OauthTokenController < ::ActionController::API
10
- include BindingModuleGate
11
13
  include AuthController::Answers
12
14
  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'
15
+ include OauthAnswers
18
16
 
19
17
  def create
20
18
  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)
19
+ when Client::CLIENT_CREDENTIALS then render_token(authenticated_client)
20
+ when Client::DEVICE_CODE then device_code_grant
21
+ when '' then raise OauthError.new(:invalid_request, 'grant_type required')
22
+ else raise OauthError.new(:unsupported_grant_type, unsupported_grant)
24
23
  end
24
+ rescue OauthError => e
25
+ render_oauth_error(e)
26
+ rescue Errors::Base => e
27
+ render_error(e)
25
28
  end
26
29
 
27
30
  private
28
31
 
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]
32
+ # The toll comes before the client authenticates and before the code is read.
33
+ def device_code_grant
34
+ device_code = params[:device_code].to_s
35
+ raise OauthError.new(:unsupported_grant_type, unsupported_grant) unless Kiosk.configuration.serve_account_binding
36
+ raise OauthError.new(:invalid_request, 'device_code parameter required') if device_code.empty?
34
37
 
35
- render_grant_failure(result)
36
- rescue Errors::Base => e
37
- render_error(e)
38
+ DeviceCodeGrant.pay_toll!(device_code, proofs)
39
+ render_token(DeviceCodeGrant.exchange(device_code: device_code, client: authenticated_client))
38
40
  end
39
41
 
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
42
+ def unsupported_grant = "the grant types served here are #{Client.grant_types.join(', ')}"
46
43
 
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
44
+ # The client assertion, then the DPoP proof of the registered key.
45
+ def authenticated_client
46
+ client = authenticate_client!
47
+ Dpop.verify_token_request!(request.headers['DPoP'], jkt: client.thumbprint, url: "#{Kiosk.current_issuer}#{request.path}")
48
+ client
51
49
  end
52
50
 
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
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.
55
+ def render_token(client)
56
+ token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: client.id, role: client.role)
57
+ body = { access_token: token, token_type: Dpop::SCHEME, expires_in: Kiosk.configuration.access_token_ttl }
58
+ body[:scope] = client.role if client.role
59
+ render_oauth(body)
56
60
  end
57
61
  end
58
62
  end
@@ -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
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ # The checkout mandate the engine records when it settles a `pay`: the line
5
+ # items of the operator's checkout that the assistant agreed to pay for.
6
+ # Read-only for the operator.
7
+ class CheckoutMandate < Server::ApplicationRecord
8
+ has_many :settlements, class_name: 'Kiosk::Settlement', dependent: nil, inverse_of: :checkout_mandate
9
+
10
+ # Checkout mandates with a line item carrying these attributes, by jsonb
11
+ # containment: `referencing(order_id: id)` matches `[{"order_id": id, "qty": 2}]`.
12
+ # The values are quoted by the adapter, never interpolated.
13
+ scope :referencing, lambda { |**attributes|
14
+ where(Arel::Nodes::InfixOperation.new(
15
+ '@>', arel_table[:line_items],
16
+ Arel::Nodes.build_quoted([attributes.transform_values(&:to_s)].to_json)
17
+ ))
18
+ }
19
+ end
20
+ end
@@ -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
- belongs_to :cart_mandate, class_name: 'Kiosk::CartMandate', inverse_of: :settlements
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
+
46
+ # The type of `users.id`, which every `user_id` column takes.
47
+ def user_id_type = ":#{options[:user_id_type]}"
45
48
 
46
- def user_id_type = options[:user_id_type].to_sym
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,59 +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_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'
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
122
78
  end
123
79
  end
124
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
@@ -1,16 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 002 — Kiosk identity tables: agents, agent_tokens, agent_mappings.
3
+ # Kiosk's identity tables: the registered assistants, the tolled calls of
4
+ # each account since its last proof, and the assistant accounts that
5
+ # registration opened, with the account each one merged into at its first binding.
4
6
  class CreateKioskIdentityTables < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
5
- def up
6
- execute <<~'SQL'
7
- <%= ddl(:identity_tables, user_id_type: user_id_type, user_table: options[:user_table]) %>
8
- SQL
9
- end
7
+ def change
8
+ <% unless public_schema? -%>
9
+ create_schema "<%= options[:schema] %>", if_not_exists: true
10
+
11
+ <% end -%>
12
+ create_table <%= table :agents %>, id: :uuid do |t|
13
+ t.references :user, type: <%= user_id_type %>, null: false, foreign_key: <%= users_key %>,
14
+ index: { where: 'revoked_at IS NULL' }
15
+ t.text :allowed_roles, array: true, null: false, default: []
16
+ t.text :public_key
17
+ t.text :human_label
18
+ # The most one payment may charge. NULL: no cap. 0: no payments. A cap has its currency.
19
+ t.bigint :spending_cap_cents
20
+ t.text :spending_cap_currency
21
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
22
+ # The time of the latest binding.
23
+ t.timestamptz :bound_at
24
+ t.timestamptz :revoked_at
25
+ t.text :issuer, null: false
26
+ t.check_constraint "spending_cap_currency ~ '^[a-z]{3}$'", name: 'kiosk_agents_spending_cap_currency_code'
27
+ t.check_constraint '(spending_cap_cents IS NULL) = (spending_cap_currency IS NULL)',
28
+ name: 'kiosk_agents_spending_cap_currency_set'
29
+ # One live row per public key on an origin, enforced by the database; a revoked key can register again.
30
+ t.index %i[issuer public_key], unique: true, where: 'revoked_at IS NULL'
31
+ end
32
+
33
+ create_table <%= table :tolled_calls %>, primary_key: :user_id, id: <%= user_id_type %>, default: nil do |t|
34
+ t.bigint :calls_since_proof, null: false, default: 0
35
+ t.foreign_key :<%= options[:user_table] %>, column: :user_id, on_delete: :cascade
36
+ end
10
37
 
11
- def down
12
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".agent_mappings)
13
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".agent_tokens)
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".agents)
38
+ create_table <%= table :assistant_accounts %>, primary_key: :user_id, id: <%= user_id_type %>, default: nil do |t|
39
+ t.column :merged_into, <%= user_id_type %>, index: { where: 'merged_into IS NOT NULL' }
40
+ t.timestamptz :merged_at
41
+ t.foreign_key :<%= options[:user_table] %>, column: :user_id, on_delete: :cascade
42
+ t.foreign_key :<%= options[:user_table] %>, column: :merged_into, on_delete: :cascade
43
+ end
15
44
  end
16
45
  end
@@ -1,20 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 006 — create kiosk.kyc_attributes.
4
- # ONE ROW per NAMED ANONYMIZED boolean attribute a valid KYC attestation
5
- # granted an agent (e.g. age_over_18, licence_a). Only the NAMES are stored —
6
- # never the DOB, licence number, or any underlying document.
7
- # There is no value column: the grant IS the row, so every gate is an EXISTS
8
- # and no reader has to decide which spelling of `true` counts.
9
- # Additive/opt-in: needed only if an Action gates on specific KYC attributes.
3
+ # The KYC grants and the open verifications, keyed on the person. A grant is
4
+ # one row per anonymized attribute a valid attestation granted (`age_over_18`):
5
+ # only the name, never the document or the value behind it.
10
6
  class CreateKioskKycAttributes < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
11
- def up
12
- execute <<~'SQL'
13
- <%= ddl(:kyc_attributes, user_id_type: user_id_type, user_table: options[:user_table]) %>
14
- SQL
15
- end
7
+ def change
8
+ create_table <%= table :kyc_attributes %>, primary_key: %i[user_id name] do |t|
9
+ t.references :user, type: <%= user_id_type %>, null: false, index: false, foreign_key: <%= users_key %>
10
+ t.text :name, null: false
11
+ t.timestamptz :granted_at, null: false, default: -> { 'now()' }
12
+ end
16
13
 
17
- def down
18
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".kyc_attributes)
14
+ create_table <%= table :kyc_requests %>, id: :text do |t|
15
+ t.references :user, type: <%= user_id_type %>, null: false, index: false, foreign_key: <%= users_key %>
16
+ t.text :nonce, null: false
17
+ t.timestamptz :approved_at
18
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
19
+ t.index %i[user_id created_at]
20
+ end
19
21
  end
20
22
  end