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
@@ -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
@@ -1,20 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 005 — kiosk AP2 mandate trail:
4
- # checkout_mandates, payment_mandates, settlements.
5
- # See the Payment (AP2 mandates) section of the spec.
3
+ # The AP2 trail of a `pay`: the checkout mandate, the payment mandate and the
4
+ # settlement. `reference` is the hash of the presented SD-JWT, unique per user.
5
+ # A checkout has one live checkout mandate: `declined_at` records a charge
6
+ # refused with no money moved, and frees the checkout.
6
7
  class CreateKioskMandates < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:mandates, user_id_type: user_id_type) %>
10
- SQL
11
- end
8
+ def change
9
+ create_table <%= table :checkout_mandates %>, id: :uuid do |t|
10
+ t.text :reference, null: false
11
+ t.text :checkout_hash, null: false
12
+ t.column :user_id, <%= user_id_type %>, null: false
13
+ t.uuid :agent_id, null: false
14
+ t.text :issuer, null: false
15
+ t.jsonb :line_items, null: false
16
+ t.bigint :total_amount_cents, null: false
17
+ t.text :currency, null: false
18
+ t.timestamptz :expires_at, null: false
19
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
20
+ t.text :sd_jwt, null: false
21
+ t.timestamptz :declined_at
22
+ t.index %i[user_id reference], unique: true
23
+ t.index %i[user_id checkout_hash], unique: true, where: 'declined_at IS NULL'
24
+ end
25
+
26
+ create_table <%= table :payment_mandates %>, id: :uuid do |t|
27
+ t.text :reference, null: false
28
+ t.references :checkout_mandate, type: :uuid, null: false,
29
+ foreign_key: { to_table: <%= table :checkout_mandates %>, on_delete: :cascade }
30
+ t.column :user_id, <%= user_id_type %>, null: false
31
+ t.uuid :agent_id, null: false
32
+ t.text :issuer, null: false
33
+ t.text :payment_instrument, null: false
34
+ t.bigint :amount_cents, null: false
35
+ t.text :currency, null: false
36
+ t.timestamptz :expires_at
37
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
38
+ t.text :sd_jwt, null: false
39
+ t.index %i[user_id reference], unique: true
40
+ end
12
41
 
13
- def down
14
- # Reverse-FK order: payment_mandates and settlements both reference
15
- # checkout_mandates, so both drop before it.
16
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".payment_mandates)
17
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".settlements)
18
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".checkout_mandates)
42
+ # The server writes a settlement; nobody signs it. One per checkout mandate.
43
+ create_table <%= table :settlements %>, id: :uuid do |t|
44
+ t.references :checkout_mandate, type: :uuid, null: false, index: { unique: true },
45
+ foreign_key: { to_table: <%= table :checkout_mandates %>, on_delete: :cascade }
46
+ t.column :user_id, <%= user_id_type %>, null: false, index: true
47
+ t.uuid :agent_id, null: false
48
+ t.text :issuer, null: false
49
+ t.text :psp_reference, null: false
50
+ t.bigint :settled_amount_cents, null: false
51
+ t.text :currency, null: false
52
+ t.text :network_confirmation_id
53
+ t.timestamptz :settled_at, null: false
54
+ end
19
55
  end
20
56
  end
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 008 — create kiosk.pow_spent.
4
- # The spent proof-of-work challenge ids, so a proof is accepted once across
5
- # every process and every deploy (PowSpentStores::ActiveRecord, the default).
3
+ # The spent ids of proof-of-work challenges and client assertions. The primary
4
+ # key accepts each id once, in every process and across deploys.
6
5
  class CreateKioskPowSpent < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:pow_spent) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".pow_spent)
6
+ def change
7
+ create_table <%= table :pow_spent %>, id: :text do |t|
8
+ # The expiry sweep reads it.
9
+ t.timestamptz :expires_at, null: false, index: true
10
+ end
15
11
  end
16
12
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 012 — create kiosk.refunds.
4
- # The refunds of settled charges (Kiosk::Server::PaymentClaim#refund), which
5
- # recent spend leaves out of the proof-of-work toll.
3
+ # The refunds of settled charges, one row per PSP refund. Recent spend counts
4
+ # a settlement less what was refunded of it.
6
5
  class CreateKioskRefunds < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:refunds) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".refunds)
6
+ def change
7
+ create_table <%= table :refunds %>, id: :uuid do |t|
8
+ t.references :settlement, type: :uuid, null: false,
9
+ foreign_key: { to_table: <%= table :settlements %>, on_delete: :cascade }
10
+ t.text :psp_reference, null: false, index: { unique: true }
11
+ t.bigint :refunded_amount_cents, null: false
12
+ t.timestamptz :refunded_at, null: false
13
+ t.check_constraint 'refunded_amount_cents > 0', name: 'kiosk_refunds_refunded_amount_cents'
14
+ end
15
15
  end
16
16
  end
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 009 — create kiosk.revocations.
4
- # Each agent's token revocation watermark, so a revoke or an unlink holds in
5
- # every process and across restarts (RevocationStores::ActiveRecord, the default).
3
+ # Each agent's revocation watermark: a token issued before it stops verifying,
4
+ # in every process and after a restart.
6
5
  class CreateKioskRevocations < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:revocations) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".revocations)
6
+ def change
7
+ create_table <%= table :revocations %>, primary_key: :agent_id, id: :uuid, default: nil do |t|
8
+ t.timestamptz :revoked_before, null: false
9
+ t.foreign_key <%= table :agents %>, column: :agent_id, on_delete: :cascade
10
+ end
15
11
  end
16
12
  end
@@ -1,16 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 011 — create kiosk.visits.
4
- # The visits the operator records as fulfilled (Kiosk::Visit.record!), which
5
- # lower the proof-of-work toll for a returning customer.
3
+ # The visits the operator records as fulfilled (Kiosk::Visit.record!). Their
4
+ # value lowers the proof-of-work toll for a returning customer.
6
5
  class CreateKioskVisits < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:visits, user_id_type: user_id_type) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".visits)
6
+ def change
7
+ create_table <%= table :visits %>, id: :uuid do |t|
8
+ t.column :user_id, <%= user_id_type %>, null: false
9
+ t.bigint :value_cents, null: false
10
+ t.timestamptz :visited_at, null: false, default: -> { 'now()' }
11
+ t.check_constraint 'value_cents >= 0', name: 'kiosk_visits_value_cents'
12
+ t.index %i[user_id visited_at]
13
+ end
15
14
  end
16
15
  end
@@ -9,17 +9,21 @@ Kiosk.configure do |c|
9
9
  # Type of users.id: :uuid (default), :bigint, :integer or :text.
10
10
  c.user_id_type = :<%= options[:user_id_type] %>
11
11
 
12
- # coerce-then-validate on the per-verb wire is UNCONDITIONAL: arguments are
12
+ # coerce-then-validate on the per-verb endpoints is UNCONDITIONAL: arguments are
13
13
  # always checked against the verb's `input_schema` (§8.1). This flag adds a check:
14
14
  # it validates every answer against the verb's `output_schema`. Off in production,
15
15
  # where a descriptor typo would become a 500 for a caller.
16
16
  c.validate_responses = !Rails.env.production?
17
17
 
18
18
  # GUC namespace for SET LOCAL (and RLS policies). Default "app".
19
- # c.guc_namespace = "<%= options[:guc_namespace] %>"
19
+ # c.guc_namespace = "app"
20
20
 
21
- # Postgres schema for Kiosk helper functions and tables.
22
- # c.schema = "<%= options[:schema] %>"
21
+ # Postgres schema of Kiosk's kiosk_* tables. Default "public".
22
+ <% if public_schema? -%>
23
+ # c.schema = "public"
24
+ <% else -%>
25
+ c.schema = "<%= options[:schema] %>"
26
+ <% end -%>
23
27
 
24
28
  # Opt-in Postgres RLS: add `gem "kiosk-rls"` and create these database roles yourself.
25
29
  # c.app_role = "app_role" # runtime role with NOBYPASSRLS
@@ -42,6 +46,11 @@ Kiosk.configure do |c|
42
46
  # Owner block surfaced in /.well-known/kiosk.json.
43
47
  # c.owner = { name: "Your Company", support: "support@example.com" }
44
48
 
49
+ # The SHA-256 of the skill file that kiosk-server gives. Without it, discovery advertises no skill pin.
50
+ # curl -fsS "$(bin/rails runner 'puts Kiosk.configuration.skill_url')" | shasum -a 256
51
+ # Kiosk::TestHelpers::SkillPin (kiosk-test-support) fails when an upgrade gives a different skill.
52
+ # c.skill_sha256 = "<the 64 hexadecimal characters that the command prints>"
53
+
45
54
  # Optional: an agent IdP of your own (subclass Kiosk::AgentIdentityProviders::Base; agent ids must be UUIDs).
46
55
  # c.agent_idp = MyExternalAgentIdp.new
47
56
 
data/lib/kiosk/payable.rb CHANGED
@@ -30,7 +30,7 @@ module Kiosk
30
30
 
31
31
  class_methods do
32
32
  # Adds `settled_currency`, the currency of a settlement among `settlements`
33
- # whose checkout names the row, or NULL: the caller's own on the wire, all
33
+ # whose checkout names the row, or NULL: the caller's own in the protocol, all
34
34
  # of them in a back office.
35
35
  def with_settlement(settlements)
36
36
  select(arel_table[Arel.star],
@@ -3,12 +3,13 @@
3
3
  module Kiosk
4
4
  module Server
5
5
  # Binds a registered client to a human's account: the client keeps its
6
- # agent_id and reputation and takes the role of its new holder.
6
+ # agent_id and reputation and takes the role of its new holder. The first
7
+ # binding merges the assistant account into the human's account.
7
8
  module AccountBinding
8
9
  module_function
9
10
 
10
11
  # Call only after the client has authenticated.
11
- # `requested_role` always comes from a human's identity, never the wire.
12
+ # `requested_role` always comes from a human's identity, never the request.
12
13
  #
13
14
  # @return [Client] the client under its new holder
14
15
  def bind!(agent_id:, user_id:, requested_role: nil)
@@ -61,6 +62,7 @@ module Kiosk
61
62
  transition = client.user_id.to_s != user_id.to_s
62
63
  conn.transaction do
63
64
  Rows.rebind(conn, config.schema, client.id, user_id, role)
65
+ carry_reputation(client.user_id, user_id) if transition
64
66
  claimed(config, client.id, client.user_id, user_id) if transition && config.assistant_claimed
65
67
  end
66
68
  # The old holder's tokens die (§6.3). The IdP dates new mints at the
@@ -68,6 +70,13 @@ module Kiosk
68
70
  revoke_tokens(config, client.id)
69
71
  end
70
72
 
73
+ # The new account adds the previous account's count of tolled calls,
74
+ # and the first binding merges the assistant account into it.
75
+ def carry_reputation(from, to)
76
+ TolledCalls.carry!(from:, to:)
77
+ AssistantAccounts.merge!(from:, into: to)
78
+ end
79
+
71
80
  # §6.3: the tokens of the previous holder stop.
72
81
  def revoke_tokens(config, agent_id)
73
82
  RevocationStores.revoke_issued(agent_id, store: config.revocation_store)
@@ -122,7 +131,7 @@ module Kiosk
122
131
 
123
132
  def revoke_owned(conn, schema, agent_id, user_id)
124
133
  conn.exec_query(<<~SQL.squish, 'Kiosk agent unlink', [agent_id, user_id, Kiosk.current_issuer]).to_a.first
125
- UPDATE #{schema}.agents
134
+ UPDATE #{schema}.kiosk_agents
126
135
  SET revoked_at = now()
127
136
  WHERE id = $1
128
137
  AND user_id = $2
@@ -137,8 +146,8 @@ module Kiosk
137
146
  role_set, role_binds =
138
147
  role ? [', allowed_roles = ARRAY[$3]::text[]', [role]] : [", allowed_roles = '{}'::text[]", []]
139
148
  conn.exec_query(<<~SQL.squish, 'Kiosk agent rebind', [user_id, agent_id, *role_binds])
140
- UPDATE #{schema}.agents
141
- SET user_id = $1#{role_set}
149
+ UPDATE #{schema}.kiosk_agents
150
+ SET user_id = $1, bound_at = now()#{role_set}
142
151
  WHERE id = $2
143
152
  SQL
144
153
  end
@@ -4,7 +4,8 @@ module Kiosk
4
4
  module Server
5
5
  # Requires a human signed in through the operator's own session
6
6
  # (`user_idp`), never an agent token. A browser is sent to `sign_in_path`;
7
- # any other caller gets a 401 naming where to sign in.
7
+ # any other caller gets a 401 with the `Kiosk-Session` challenge, naming
8
+ # where to sign in.
8
9
  module AccountHolderGate
9
10
  private
10
11
 
@@ -22,6 +23,7 @@ module Kiosk
22
23
  store_return_location
23
24
  redirect_to sign_in_path
24
25
  else
26
+ response.set_header('WWW-Authenticate', Errors::SessionRequired.challenge)
25
27
  render plain: sign_in_path ? "#{prompt} #{sign_in_directions(sign_in_path)}" : prompt,
26
28
  status: :unauthorized
27
29
  end
@@ -70,7 +70,7 @@ module Kiosk
70
70
  # `columns` is always a literal from this class, never caller input.
71
71
  def agent_row(columns, agent_id)
72
72
  ::ActiveRecord::Base.lease_connection.exec_query(
73
- "SELECT #{columns} FROM #{schema}.agents WHERE id = $1 AND revoked_at IS NULL",
73
+ "SELECT #{columns} FROM #{schema}.kiosk_agents WHERE id = $1 AND revoked_at IS NULL",
74
74
  "Kiosk agent #{columns}",
75
75
  [agent_id]
76
76
  ).to_a.first
@@ -51,7 +51,7 @@ module Kiosk
51
51
  conn = ::ActiveRecord::Base.lease_connection
52
52
  conn.transaction do
53
53
  conn.exec_query(<<~SQL.squish, 'Kiosk agent key rotation', [pem, agent_id])
54
- UPDATE #{Kiosk.configuration.schema}.agents SET public_key = $1 WHERE id = $2
54
+ UPDATE #{Kiosk.configuration.schema}.kiosk_agents SET public_key = $1 WHERE id = $2
55
55
  SQL
56
56
  RevocationStores.revoke_issued(agent_id)
57
57
  end
@@ -56,7 +56,7 @@ module Kiosk
56
56
  end
57
57
  end
58
58
 
59
- # What a caller has to send for each string `format` the wire reads.
59
+ # What a caller has to send for each string `format` the protocol reads.
60
60
  FORMAT_SPELLINGS = {
61
61
  'date' => 'a calendar date as YYYY-MM-DD, e.g. 2026-08-19',
62
62
  'date-time' => 'an ISO 8601 timestamp, e.g. 2026-08-19T14:00:00Z'
@@ -76,7 +76,7 @@ module Kiosk
76
76
  "an object argument's leaves are SCALARS: `o%5Bk%5D=v`, not " \
77
77
  '`o%5Bk%5D%5B%5D=v`. Anything richer is an ACTION (POST).'
78
78
 
79
- # The query string's shape: what Rack can decode and the wire accepts.
79
+ # The query string's shape: what Rack can decode and the protocol accepts.
80
80
  module QueryShape
81
81
  module_function
82
82
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The accounts that the engine opened for assistants at registration, in
6
+ # `assistant_accounts`. The first binding of the assistant merges its
7
+ # assistant account into the human's account: from then on the spend of
8
+ # the human's account includes the settlements and visits of the assistant
9
+ # account. Unlinking the assistant later keeps the merge.
10
+ module AssistantAccounts
11
+ module_function
12
+
13
+ # Records `user_id` as the account of a new assistant. An account that
14
+ # `assistant_creation` gave to an earlier key is recorded once.
15
+ def record!(user_id)
16
+ connection.exec_query(%(INSERT INTO "#{schema}".kiosk_assistant_accounts (user_id) VALUES ($1) ON CONFLICT DO NOTHING),
17
+ 'Kiosk assistant account', [user_id])
18
+ nil
19
+ end
20
+
21
+ # Merges the account `from` into the account `into` when `from` is an
22
+ # assistant account that is not merged yet. A binding moves an assistant
23
+ # to the account of the approving human, which is never an assistant
24
+ # account: so the account of one human never merges into the account of
25
+ # another, and merges do not form chains.
26
+ def merge!(from:, into:)
27
+ connection.exec_query(<<~SQL.squish, 'Kiosk assistant account merge', [from, into])
28
+ UPDATE "#{schema}".kiosk_assistant_accounts SET merged_into = $2, merged_at = now()
29
+ WHERE user_id = $1 AND merged_into IS NULL
30
+ SQL
31
+ nil
32
+ end
33
+
34
+ def connection = ::ActiveRecord::Base.lease_connection
35
+ def schema = Kiosk.configuration.schema
36
+ end
37
+ end
38
+ end
@@ -5,15 +5,15 @@ module Kiosk
5
5
  # Optional proof-of-work toll on the auth requests that come before a
6
6
  # token: `POST /oauth/register`, the link-code claim `POST /auth/claim`, and
7
7
  # the first token poll of each device code. It runs before any lookup, so
8
- # the answer never tells a caller whether a key or a code exists. It bounds
9
- # the cost per request, not the rate: an edge rate limit is required
10
- # (deploy/README.md "Edge rate-limit").
8
+ # the answer never tells a caller whether a key or a code exists. It sets
9
+ # the cost of each request, not a rate. The shipped deploy has no edge rate
10
+ # limit (deploy/README.md "Edge rate-limit").
11
11
  module AuthToll
12
12
  module_function
13
13
 
14
14
  def on?(config = Kiosk.configuration) = config.registration_pow_count.to_i.positive?
15
15
 
16
- # `verb` and `body` make the fingerprint, so a proof pays for this request only.
16
+ # `verb` and `body` make the fingerprint, so a proof clears the toll of this request only.
17
17
  def gate(verb:, body:, pow:, config: Kiosk.configuration)
18
18
  return unless on?(config)
19
19
 
@@ -26,7 +26,7 @@ module Kiosk
26
26
  end
27
27
 
28
28
  def secret!(config)
29
- unless defined?(::Kiosk::Reputation) && defined?(::Kiosk::Pow::Equihash)
29
+ unless defined?(::Kiosk::Reputation::Challenge) && defined?(::Kiosk::Pow::Equihash.params)
30
30
  raise Errors::ConfigurationError,
31
31
  'registration_pow_count > 0 requires kiosk-reputation and kiosk-pow-equihash. ' \
32
32
  'Add both gems (and `require` them) to your app.'
@@ -31,7 +31,7 @@ module Kiosk
31
31
  def self.lookup(condition, value)
32
32
  row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk client lookup', [value, Kiosk.current_issuer]).to_a.first
33
33
  SELECT id, user_id, public_key, allowed_roles, extract(epoch FROM created_at)::bigint AS issued_at
34
- FROM #{Kiosk.configuration.schema}.agents
34
+ FROM #{Kiosk.configuration.schema}.kiosk_agents
35
35
  WHERE #{condition} AND issuer = $2 AND revoked_at IS NULL
36
36
  LIMIT 1
37
37
  SQL
@@ -17,7 +17,8 @@ module Kiosk
17
17
  REQUIRED_CLAIMS = %w[iss sub aud exp jti].freeze
18
18
 
19
19
  # A refused client assertion. The `/oauth/*` endpoints answer it as
20
- # `invalid_client`, the other endpoints as `401 unauthenticated`.
20
+ # `invalid_client`, the other endpoints as `401 unauthenticated` with the
21
+ # `Kiosk-Client-Assertion` challenge.
21
22
  class Refused < StandardError
22
23
  attr_reader :hint
23
24
 
@@ -27,7 +28,7 @@ module Kiosk
27
28
  end
28
29
 
29
30
  def to_oauth = OauthError.new(:invalid_client, "#{message}: #{hint}")
30
- def to_problem = Errors::Unauthenticated.new(message, hint: hint)
31
+ def to_problem = Errors::ClientAssertionRefused.new(message, hint: hint)
31
32
  end
32
33
 
33
34
  # A verified assertion that is not yet spent.
@@ -69,7 +69,11 @@ module Kiosk
69
69
  def register(config, key, role)
70
70
  pem = AssistantKey.to_pem(key)
71
71
  conn = ::ActiveRecord::Base.lease_connection
72
- conn.transaction(requires_new: true) { insert_agent(conn, config, [create_assistant_account(config, pem), pem], role) }
72
+ conn.transaction(requires_new: true) do
73
+ account = create_assistant_account(config, pem)
74
+ AssistantAccounts.record!(account)
75
+ insert_agent(conn, config, [account, pem], role)
76
+ end
73
77
  Client.with_key(key)
74
78
  rescue ::ActiveRecord::RecordNotUnique
75
79
  Client.with_key(key)
@@ -79,7 +83,7 @@ module Kiosk
79
83
  def insert_agent(conn, config, binds, role)
80
84
  allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
81
85
  conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, Kiosk.current_issuer, *role_binds])
82
- INSERT INTO #{config.schema}.agents (user_id, public_key, issuer, allowed_roles)
86
+ INSERT INTO #{config.schema}.kiosk_agents (user_id, public_key, issuer, allowed_roles)
83
87
  VALUES ($1, $2, $3, #{allowed_roles_sql})
84
88
  SQL
85
89
  end
@@ -14,7 +14,7 @@ module Kiosk
14
14
  # The leased connection: the cap is read inside the transaction that charges.
15
15
  row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk spending cap', [agent_id]).to_a.first
16
16
  SELECT spending_cap_cents, spending_cap_currency
17
- FROM "#{Kiosk.configuration.schema}".agents
17
+ FROM "#{Kiosk.configuration.schema}".kiosk_agents
18
18
  WHERE id = $1 AND revoked_at IS NULL
19
19
  SQL
20
20
  return nil if row.nil? || row['spending_cap_cents'].nil?