kiosk-server 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration 010 — create kiosk.auth_challenges.
4
+ # Each public key's outstanding auth challenge, so a challenge issued by one
5
+ # process is redeemed by any other (AuthChallengeStores::ActiveRecord, the default).
6
+ class CreateKioskAuthChallenges < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
+ def up
8
+ execute <<~'SQL'
9
+ <%= ddl(:auth_challenge) %>
10
+ SQL
11
+ end
12
+
13
+ def down
14
+ execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".auth_challenges)
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
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).
7
+ 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)
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
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.
12
+ 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)
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration 002 — Kiosk identity tables: agents, agent_tokens, agent_mappings.
4
+ 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
10
+
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)
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
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.
10
+ 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
16
+
17
+ def down
18
+ execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".kyc_attributes)
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration 005 — kiosk AP2 mandate trail:
4
+ # intent_mandates, cart_mandates, payment_mandates, settlements.
5
+ # See the Payment (AP2 mandate chain) section of the spec.
6
+ 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
12
+
13
+ def down
14
+ # Reverse-FK order: payment_mandates and settlements both reference
15
+ # cart_mandates, so both drop before it; cart_mandates before intent_mandates.
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] %>".cart_mandates)
19
+ execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".intent_mandates)
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
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).
6
+ 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)
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
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.
6
+ 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)
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
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).
6
+ 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)
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration 001 — Kiosk schema + the four current_*() helper functions.
4
+ #
5
+ # `ActiveRecord::Migration[…]` resolves at host-app load time, so this
6
+ # file picks up whatever Rails version the host is on.
7
+ class CreateKioskSchema < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
8
+ def up
9
+ execute <<~'SQL'
10
+ <%= ddl(:helper_functions, guc_namespace: options[:guc_namespace], user_id_type: user_id_type) %>
11
+ SQL
12
+ end
13
+
14
+ def down
15
+ execute %(DROP SCHEMA IF EXISTS "<%= options[:schema] %>" CASCADE)
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
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.
6
+ 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)
15
+ end
16
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kiosk configuration. Every option: https://kiosk.tech and the kiosk-server README.
4
+
5
+ Kiosk.configure do |c|
6
+ # Your user model class name, resolved at request time.
7
+ c.user_model = "<%= options[:user_table].classify %>"
8
+
9
+ # Type of users.id: :uuid (default), :bigint, :integer or :text.
10
+ c.user_id_type = :<%= options[:user_id_type] %>
11
+
12
+ # coerce-then-validate on the per-verb wire is UNCONDITIONAL: arguments are
13
+ # always checked against the verb's `input_schema` (§8.1). This flag adds a check:
14
+ # it validates every answer against the verb's `output_schema`. Off in production,
15
+ # where a descriptor typo would become a 500 for a caller.
16
+ c.validate_responses = !Rails.env.production?
17
+
18
+ # GUC namespace for SET LOCAL (and RLS policies). Default "app".
19
+ # c.guc_namespace = "<%= options[:guc_namespace] %>"
20
+
21
+ # Postgres schema for Kiosk helper functions and tables.
22
+ # c.schema = "<%= options[:schema] %>"
23
+
24
+ # Opt-in Postgres RLS: add `gem "kiosk-rls"` and create these database roles yourself.
25
+ # c.app_role = "app_role" # runtime role with NOBYPASSRLS
26
+ # c.enforce_db_role = true # SET LOCAL ROLE app_role per request
27
+
28
+ # The exact origin assistants dial (scheme + host, no trailing slash); it must
29
+ # match `kiosk.issuer` in /.well-known/kiosk.json, or every proof is rejected.
30
+ # c.issuer = "https://<your-host>"
31
+
32
+ # Further origins this deployment serves, each its own operator.
33
+ # c.additional_origins = ["https://<another-host>"]
34
+
35
+ # Role vocabulary; set together with `registration_role`. Your `user_idp` must
36
+ # resolve a role for every human who can approve a binding (§6.3).
37
+ # c.roles = %i[customer]
38
+
39
+ # The default role for self-registered assistants and role-less bindings; your least-privileged one.
40
+ # c.registration_role = :customer
41
+
42
+ # Owner block surfaced in /.well-known/kiosk.json.
43
+ # c.owner = { name: "Your Company", support: "support@example.com" }
44
+
45
+ # Optional: an agent IdP of your own (subclass Kiosk::AgentIdentityProviders::Base; agent ids must be UUIDs).
46
+ # c.agent_idp = MyExternalAgentIdp.new
47
+
48
+ # Optional: a user IdP binding your signed-in humans, e.g. `gem "kiosk-user-idp-devise"`.
49
+ # c.user_idp = Kiosk::UserIdentityProviders::Devise.new
50
+
51
+ # Optional audit sink: one ActionEvent per action, arguments in full and unredacted.
52
+ # c.audit_sink = ->(e) { Rails.logger.info(e.with_arg_types.to_h.to_json) }
53
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ # Include into any controller to declare verbs: a query (`GET <mount>/<name>`)
5
+ # or an action (`POST <mount>/<name>`). Handlers live in app/controllers/kiosk.
6
+ #
7
+ # class Kiosk::BoardController < ActionController::API
8
+ # include Kiosk::Handler
9
+ #
10
+ # kind :query
11
+ # description "Lists what this shop has in stock right now, so the " \
12
+ # "assistant can decide what to put in a basket."
13
+ # input_schema type: "object", additionalProperties: false,
14
+ # properties: { q: { type: "string" } }
15
+ # output_schema type: "array",
16
+ # items: { type: "object",
17
+ # properties: { sku: { type: "string" },
18
+ # price_cents: { type: "integer" } } }
19
+ # def catalog
20
+ # render json: Product.in_stock.search(params[:q]).as_json
21
+ # end
22
+ #
23
+ # kind :action
24
+ # description "Places an order for the assistant's human. Returns the " \
25
+ # "order and what it will cost; nothing is charged until `pay`."
26
+ # input_schema type: "object", additionalProperties: false,
27
+ # properties: { items: { type: "array", items: { type: "object" } } },
28
+ # required: %w[items]
29
+ # output_schema type: "object",
30
+ # properties: { order_id: { type: "string" },
31
+ # total_cents: { type: "integer" } }
32
+ # def create_order
33
+ # order = Orders::Place.call(user_id: kiosk_identity.user_id, params: params)
34
+ # render json: { order_id: order.id, total_cents: order.total_cents }
35
+ # end
36
+ # end
37
+ module Handler
38
+ def self.included(base)
39
+ Kiosk::Server::HandlerMixin.install(base)
40
+ Kiosk::Server::HandlerRegistrations.add(base)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Kiosk
6
+ # `include Kiosk::Owned` in a model with a `user_id` column gives it `own`,
7
+ # the current principal's rows: `Order.own.find(id)`, and `owned_by(user_id)`,
8
+ # one principal's rows. `user_id` holds `Kiosk.current_user_id`.
9
+ module Owned
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ scope :owned_by, ->(user_id) { where(user_id:) }
14
+ scope :own, -> { owned_by(Kiosk.current_user_id) }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Kiosk
6
+ # `include Kiosk::Payable` in the model that `Kiosk::Server::PaymentClaim`
7
+ # names. It gives the row its `payment_state`, the `cart` an assistant signs
8
+ # to pay for it, and the `settlement` that paid it.
9
+ module Payable
10
+ extend ActiveSupport::Concern
11
+
12
+ UNPAID = 'unpaid'
13
+ PENDING = 'pending'
14
+ PAID = 'paid'
15
+ REFUNDED = 'refunded'
16
+
17
+ # The `payment_state` member of a per-user query's row.
18
+ STATE_SCHEMA = {
19
+ type: 'string', enum: [UNPAID, PENDING, PAID, REFUNDED],
20
+ description: '`unpaid`: nothing is charged; pay for it. `pending`: a charge is in progress and can ' \
21
+ 'already have taken the money; read again, do not pay again. `paid`: charged. ' \
22
+ '`refunded`: the charge went back to the card.'
23
+ }.freeze
24
+
25
+ # The `cart` member of the answer of an action that creates a payable row.
26
+ CART_SCHEMA = {
27
+ type: 'object', additionalProperties: false,
28
+ description: 'Sign the cart mandate with these three values, unchanged.',
29
+ properties: {
30
+ currency: { type: 'string' },
31
+ total_amount_cents: { type: 'integer' },
32
+ line_items: { type: 'array', items: { type: 'object' } }
33
+ },
34
+ required: %w[currency total_amount_cents line_items]
35
+ }.freeze
36
+
37
+ class_methods do
38
+ # Adds `settled_currency`, the currency of a settlement among `settlements`
39
+ # whose cart names the row, or NULL: the caller's own on the wire, all of
40
+ # them in a back office.
41
+ def with_settlement(settlements)
42
+ select(arel_table[Arel.star],
43
+ Arel::Nodes::Grouping.new(settlements.joins(:cart_mandate).where(names_row).select(:currency)
44
+ .limit(1).arel).as('settled_currency'))
45
+ end
46
+
47
+ def payment_claim = Kiosk.configuration.payment_provider
48
+
49
+ private
50
+
51
+ # `cart_mandates.line_items @> [{"<reference>": "<this row's id>"}]`.
52
+ def names_row
53
+ line = Arel::Nodes::NamedFunction.new(
54
+ 'jsonb_build_object', [Arel::Nodes.build_quoted(payment_claim.reference),
55
+ Arel::Nodes::NamedFunction.new('CAST', [arel_table[:id].as(Arel.sql('text'))])]
56
+ )
57
+ Arel::Nodes::InfixOperation.new('@>', Kiosk::CartMandate.arel_table[:line_items],
58
+ Arel::Nodes::NamedFunction.new('jsonb_build_array', [line]))
59
+ end
60
+ end
61
+
62
+ # `pending` means a capture is in flight: the caller reads again and does
63
+ # not sign a new mandate. A row read without `with_settlement` asks the
64
+ # settlements itself.
65
+ def payment_state
66
+ status = self[:payment_status]
67
+ return REFUNDED if status == REFUNDED
68
+ return PAID if status == PAID || settled?
69
+ return PENDING if status == Kiosk::Server::PaymentClaim::PAYING
70
+
71
+ UNPAID
72
+ end
73
+
74
+ # The cart that pays for this row: a line naming the row, then `lines`,
75
+ # each `{qty:, price_cents:, ...}`, which sum to the total.
76
+ def cart(lines)
77
+ claim = self.class.payment_claim
78
+ { currency: claim.currency,
79
+ total_amount_cents: lines.sum { it[:qty] * it[:price_cents] },
80
+ line_items: [{ claim.reference => id }, *lines] }
81
+ end
82
+
83
+ # The first settlement whose cart names this row, or nil.
84
+ def settlement = self.class.payment_claim.settlements(id).order(:created_at).first
85
+
86
+ private
87
+
88
+ def settled? = has_attribute?(:settled_currency) ? !settled_currency.nil? : !settlement.nil?
89
+ end
90
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Binds a proven public key to a human's account. A fresh key gets a new
6
+ # linked agent row; a known key is rebound, keeping its agent_id and
7
+ # reputation and adopting the new holder's role.
8
+ module AccountBinding
9
+ module_function
10
+
11
+ # Call only after possession of `public_key_pem` has been proven.
12
+ # `requested_role` always comes from a human's identity, never the wire.
13
+ #
14
+ # @return [Hash] { agent_id:, user_id:, access_token:, fresh: }
15
+ def bind!(public_key_pem:, user_id:, requested_role: nil)
16
+ config = Kiosk.configuration
17
+ pem = public_key_pem.to_s.strip
18
+ raise ArgumentError, 'user_id required' if user_id.nil? || user_id.to_s.empty?
19
+
20
+ warn_role_resolution_not_total(config, requested_role)
21
+
22
+ # `lease_connection`: the operator's hooks must share this transaction.
23
+ conn = ::ActiveRecord::Base.lease_connection
24
+ existing = Rows.live_by_key(conn, config.schema, pem)
25
+ return rebind(conn, config, existing, user_id, requested_role) if existing
26
+
27
+ register_linked(conn, config, pem, user_id, requested_role)
28
+ end
29
+
30
+ # Revokes `agent_id`'s binding, which must belong to `user_id`; its tokens
31
+ # stop verifying and its `/auth/login` is denied.
32
+ #
33
+ # @raise [Errors::NotFound] when no live agent row matches the pair.
34
+ def unlink!(agent_id:, user_id:)
35
+ config = Kiosk.configuration
36
+ raise Errors::BadRequest, 'agent_id required' if agent_id.nil? || agent_id.to_s.empty?
37
+
38
+ # The ownership predicate is the security boundary: both values are binds.
39
+ raise not_linked unless Rows.revoke_owned(::ActiveRecord::Base.lease_connection, config.schema, agent_id, user_id)
40
+
41
+ revoke_tokens(config, agent_id)
42
+ config.assistant_unlinked&.call(agent: agent_id, account: config.user_model.constantize.find(user_id))
43
+ { agent_id: agent_id }
44
+ end
45
+
46
+ ROLE_NOT_TOTAL =
47
+ '[kiosk-server] an account-binding ceremony resolved NO role for the approving ' \
48
+ 'human, and this origin declares more than one role ' \
49
+ '(%<roles>s). Role resolution must be TOTAL: an operator that ' \
50
+ 'assigns roles at all assigns one to EVERY human who can approve a binding. A ' \
51
+ 'role for staff and nothing for customers is not a supported configuration — ' \
52
+ 'this binding lands on %<role>s, the role registration would assign, so a human ' \
53
+ 'who should hold a privileged one gets an assistant ' \
54
+ 'that cannot act for them. Fix the identity system, not the ceremony: a ' \
55
+ '`#kiosk_role` that can answer nil is the usual cause, and returning the ' \
56
+ 'least-privileged declared role instead makes it total.'
57
+ private_constant :ROLE_NOT_TOTAL
58
+
59
+ class << self
60
+ private
61
+
62
+ # Known key: remap the holder and role, keep agent_id and reputation.
63
+ # The role is never read from the row: it belonged to the previous holder.
64
+ def rebind(conn, config, existing, user_id, requested_role = nil)
65
+ agent_id = existing.fetch('id')
66
+ previous = existing.fetch('user_id')
67
+ role = resolved_role(config, requested_role)
68
+ # The same holder still gets the role remap and revocation; only the hook is skipped.
69
+ transition = previous.to_s != user_id.to_s
70
+ conn.transaction do
71
+ Rows.rebind(conn, config.schema, agent_id, user_id, role)
72
+ claimed(config, agent_id, previous, user_id) if transition && config.assistant_claimed
73
+ end
74
+ # The old holder's tokens die (§6.3). The IdP dates new mints at the
75
+ # watermark, so the token minted below survives it.
76
+ revoke_tokens(config, agent_id)
77
+ { agent_id: agent_id, user_id: user_id.to_s, access_token: issue_token(agent_id, role), fresh: false }
78
+ end
79
+
80
+ # Next second: JWT `iat` is whole seconds and the check is `iat < watermark` (§6.3).
81
+ def revoke_tokens(config, agent_id)
82
+ config.revocation_store.revoke_all(agent_id, at: Time.now.to_i + 1)
83
+ end
84
+
85
+ def claimed(config, agent_id, previous, user_id)
86
+ accounts = config.user_model.constantize
87
+ config.assistant_claimed.call(agent: agent_id, from: accounts.find(previous), to: accounts.find(user_id))
88
+ end
89
+
90
+ # Fresh key: a new linked agent row under the approving human.
91
+ def register_linked(conn, config, pem, user_id, requested_role)
92
+ role = resolved_role(config, requested_role)
93
+ agent_id = conn.transaction { Rows.insert_linked(conn, config.schema, user_id, pem, role) }
94
+ { agent_id: agent_id, user_id: user_id.to_s, access_token: issue_token(agent_id, role), fresh: true }
95
+ end
96
+
97
+ def not_linked
98
+ Errors::NotFound.new(
99
+ 'no linked assistant account with this agent_id',
100
+ hint: 'only assistant accounts bound to the signed-in account can be unlinked'
101
+ )
102
+ end
103
+
104
+ # The ceremony's role, else the operator's default; never the key's previous role.
105
+ def resolved_role(config, requested_role)
106
+ validated_role(config, requested_role || config.registration_role)
107
+ end
108
+
109
+ # Rejects a role outside `config.roles`.
110
+ def validated_role(config, candidate)
111
+ role = candidate&.to_s
112
+ role = nil if role && role.empty?
113
+ if role && config.roles.map(&:to_s).exclude?(role)
114
+ raise Errors::ConfigurationError,
115
+ "binding role #{role.inspect} is not among configured roles #{config.roles.inspect}"
116
+ end
117
+ role
118
+ end
119
+
120
+ # An origin with several roles must resolve one for every approving human (§6.3).
121
+ def warn_role_resolution_not_total(config, requested_role)
122
+ return unless requested_role.nil? || requested_role.to_s.strip.empty?
123
+ return unless config.roles.to_a.size > 1
124
+
125
+ log_warning(format(ROLE_NOT_TOTAL, roles: config.roles.inspect, role: config.registration_role.inspect))
126
+ end
127
+
128
+ # Rails.logger is nil before the host app boots.
129
+ def log_warning(message)
130
+ logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
131
+ logger ? logger.warn(message) : Kernel.warn(message)
132
+ end
133
+
134
+ def issue_token(agent_id, role)
135
+ AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
136
+ end
137
+ end
138
+
139
+ # The agents-table statements a binding runs.
140
+ module Rows
141
+ module_function
142
+
143
+ def live_by_key(conn, schema, pem)
144
+ conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
145
+ SELECT id, user_id FROM #{schema}.agents
146
+ WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
147
+ LIMIT 1
148
+ SQL
149
+ end
150
+
151
+ def revoke_owned(conn, schema, agent_id, user_id)
152
+ conn.exec_query(<<~SQL.squish, 'Kiosk agent unlink', [agent_id, user_id, Kiosk.current_issuer]).to_a.first
153
+ UPDATE #{schema}.agents
154
+ SET revoked_at = now()
155
+ WHERE id = $1
156
+ AND user_id = $2
157
+ AND issuer = $3
158
+ AND revoked_at IS NULL
159
+ RETURNING id
160
+ SQL
161
+ end
162
+
163
+ # An empty array cannot travel as a bind, so "no role" is a different statement.
164
+ def rebind(conn, schema, agent_id, user_id, role)
165
+ role_set, role_binds =
166
+ role ? [', allowed_roles = ARRAY[$3]::text[]', [role]] : [", allowed_roles = '{}'::text[]", []]
167
+ conn.exec_query(<<~SQL.squish, 'Kiosk agent rebind', [user_id, agent_id, *role_binds])
168
+ UPDATE #{schema}.agents
169
+ SET user_id = $1#{role_set}
170
+ WHERE id = $2
171
+ SQL
172
+ end
173
+
174
+ # The column is NOT NULL: no role is `'{}'`, never NULL.
175
+ def insert_linked(conn, schema, user_id, pem, role)
176
+ allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
177
+ rows = conn.exec_query(<<~SQL.squish, 'Kiosk linked agent insert', [user_id, pem, Kiosk.current_issuer, *role_binds])
178
+ INSERT INTO #{schema}.agents (user_id, allowed_roles, public_key, issuer)
179
+ VALUES ($1, #{allowed_roles_sql}, $2, $3)
180
+ RETURNING id
181
+ SQL
182
+ rows.to_a.first.fetch('id')
183
+ end
184
+ end
185
+ private_constant :Rows
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Requires a human signed in through the operator's own session
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.
8
+ module AccountHolderGate
9
+ private
10
+
11
+ # Answers the signed-in identity, or nil after it has answered the request.
12
+ def require_account_holder!(prompt:, flash_alert:)
13
+ @identity = Kiosk.configuration.user_idp&.verify(request)
14
+ refuse_signed_out(prompt, flash_alert) unless @identity
15
+ @identity
16
+ end
17
+
18
+ def refuse_signed_out(prompt, flash_alert)
19
+ sign_in_path = Kiosk.configuration.sign_in_path
20
+ if sign_in_path && html_request?
21
+ flash_sign_in_alert(flash_alert)
22
+ store_return_location
23
+ redirect_to sign_in_path
24
+ else
25
+ render plain: sign_in_path ? "#{prompt} #{sign_in_directions(sign_in_path)}" : prompt,
26
+ status: :unauthorized
27
+ end
28
+ end
29
+
30
+ def sign_in_directions(sign_in_path)
31
+ "Sign in at #{request.base_url}#{sign_in_path} — or open #{request.original_url} " \
32
+ 'in a browser, which goes there and comes back here.'
33
+ end
34
+
35
+ def html_request?
36
+ return true if request.format.html?
37
+
38
+ request.headers['Accept'].to_s.include?('text/html')
39
+ rescue StandardError
40
+ false
41
+ end
42
+
43
+ # A host without the flash middleware must still redirect.
44
+ def flash_sign_in_alert(message)
45
+ flash[:alert] = message
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ # Devise's return-to key.
51
+ def store_return_location
52
+ session['user_return_to'] = request.fullpath
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end