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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # One action invocation, as `Kiosk.configuration.audit_sink` receives it.
6
+ # {#args} arrive unredacted: whoever stores this event controls that data.
7
+ # {#cause_class}/{#cause_message} carry the handler's own error under a Kiosk wrapper.
8
+ class ActionEvent < Data.define(:action, :user_id, :agent_id, :role, :actor, :args,
9
+ :status, :error_class, :error_message,
10
+ :cause_class, :cause_message, :invoked_at)
11
+ OK = 'ok'
12
+ ERROR = 'error'
13
+
14
+ def self.build(identity:, name:, args:, status:, error: nil, invoked_at: Time.current) # rubocop:disable Metrics/ParameterLists -- the public keyword API that the executor and audit sinks call
15
+ new(action: name.to_s, **principal(identity), args: args.is_a?(Hash) ? args : {},
16
+ status: status.to_s, **failure(error), invoked_at: invoked_at)
17
+ end
18
+
19
+ def self.principal(identity)
20
+ { user_id: identity.user_id, agent_id: identity.agent_id, role: identity.role, actor: identity.actor }
21
+ end
22
+
23
+ # The error and its cause as class names and messages; all nil without an error.
24
+ def self.failure(error)
25
+ cause = cause_of(error)
26
+ { error_class: error&.class&.name, error_message: error && error.message.to_s,
27
+ cause_class: cause&.class&.name, cause_message: cause && cause.message.to_s }
28
+ end
29
+
30
+ # An exception re-raised inside its own `rescue` can be its own cause.
31
+ def self.cause_of(error)
32
+ return nil unless error.is_a?(::Exception)
33
+
34
+ cause = error.cause
35
+ cause.is_a?(::Exception) && !cause.equal?(error) ? cause : nil
36
+ end
37
+
38
+ # `{"salon_id" => "integer", "slot" => "string"}`, in JSON Schema type names.
39
+ def arg_types
40
+ args.to_h { |key, value| [key.to_s, self.class.json_type(value)] }
41
+ end
42
+
43
+ def with_arg_types = with(args: arg_types)
44
+
45
+ def without_args = with(args: {})
46
+
47
+ def self.json_type(value)
48
+ case value
49
+ when nil then 'null'
50
+ when true, false then 'boolean'
51
+ when Integer then 'integer'
52
+ when Float, Numeric then 'number'
53
+ when Array then 'array'
54
+ when Hash then 'object'
55
+ else 'string'
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Registry of actions (`kind :action`): verbs that change something the
6
+ # operator owns. The behaviour is {VerbRegistry}.
7
+ module Actions
8
+ SCOPE = :action
9
+ Entry = VerbRegistry::Entry
10
+
11
+ extend VerbRegistry
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/agent_identity_providers/base'
4
+
5
+ module Kiosk
6
+ module Server
7
+ module AgentIdentityProviders
8
+ # The bundled agent IdP: RS256 tokens signed with the origin's own key;
9
+ # payment keys from the agents table.
10
+ class DefaultAgentIdp < Kiosk::AgentIdentityProviders::Base
11
+ def verify(request)
12
+ header = authorization_for(request)
13
+ return nil if header.blank?
14
+
15
+ claims = verified_claims(header.sub(/\ABearer\s+/i, ''))
16
+ Kiosk::Identity.new(
17
+ user_id: claims[:sub], role: claims[:role], actor: 'agent',
18
+ agent_id: claims[:agent_id], claims: claims
19
+ )
20
+ rescue JwtIssuer::Error
21
+ nil
22
+ end
23
+
24
+ def issue(agent_id:, role:)
25
+ claims = { sub: lookup_user_id(agent_id), agent_id: agent_id, actor: 'agent' }
26
+ claims[:role] = role.to_s unless role.nil? || role.to_s.empty?
27
+ JwtIssuer.issue(
28
+ claims: claims, audience: Kiosk.current_issuer, now: mint_instant(agent_id),
29
+ expires_in: Kiosk.configuration.access_token_ttl
30
+ )
31
+ end
32
+
33
+ def agent_payment_key(agent_id)
34
+ pem = agents_column('public_key', agent_id)&.fetch('public_key', nil)
35
+ raise Kiosk::AgentIdentityProviders::InvalidToken, "no key for agent #{agent_id}" if pem.nil?
36
+
37
+ OpenSSL::PKey::RSA.new(pem)
38
+ end
39
+
40
+ private
41
+
42
+ def verified_claims(token)
43
+ JwtIssuer.verify(
44
+ token: token,
45
+ jwks: Jwks.build(keys: [Kiosk.configuration.signing_key]),
46
+ audience: Kiosk.current_issuer,
47
+ issuer: Kiosk.current_issuer
48
+ )
49
+ end
50
+
51
+ # Never earlier than the revocation watermark, so a token minted in the
52
+ # second a rebind revoked (§6.3) still survives `iat < watermark`.
53
+ def mint_instant(agent_id)
54
+ now = Time.current
55
+ watermark = Kiosk.configuration.revocation_store.watermark_for(agent_id)
56
+ return now if watermark.nil? || watermark <= now.to_i
57
+
58
+ Time.at(watermark).utc
59
+ end
60
+
61
+ def lookup_user_id(agent_id)
62
+ row = agents_column('user_id', agent_id)
63
+ raise Kiosk::AgentIdentityProviders::InvalidToken, "unknown agent #{agent_id}" if row.nil?
64
+
65
+ row.fetch('user_id')
66
+ end
67
+
68
+ # `column` is always a literal from this class, never caller input.
69
+ def agents_column(column, agent_id)
70
+ ::ActiveRecord::Base.lease_connection.exec_query(
71
+ "SELECT #{column} FROM #{schema}.agents WHERE id = $1 AND revoked_at IS NULL",
72
+ "Kiosk agent #{column}",
73
+ [agent_id]
74
+ ).to_a.first
75
+ end
76
+
77
+ def schema = Kiosk.configuration.schema
78
+
79
+ def authorization_for(request)
80
+ if request.respond_to?(:headers)
81
+ request.headers['Authorization'] || request.headers['authorization']
82
+ elsif request.is_a?(Hash)
83
+ request['HTTP_AUTHORIZATION']
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Login with an already-registered public key: proves possession and mints a
6
+ # fresh token. An unknown key is a 404, never a new account.
7
+ module AgentLogin
8
+ module_function
9
+
10
+ def call(public_key_pem:, signed:)
11
+ pem = public_key_pem.to_s.strip
12
+
13
+ # Prove possession BEFORE any lookup or state change.
14
+ payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
15
+ AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
16
+
17
+ row = registered_agent(pem)
18
+ agent_id = row.fetch('id')
19
+ role = primary_role(row.fetch('allowed_roles'))
20
+ token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
21
+ { access_token: token }
22
+ end
23
+
24
+ def registered_agent(pem)
25
+ row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
26
+ SELECT id, user_id, allowed_roles FROM #{Kiosk.configuration.schema}.agents
27
+ WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
28
+ LIMIT 1
29
+ SQL
30
+ return row if row
31
+
32
+ raise Errors::NotFound.new(
33
+ 'no agent registered for this public key',
34
+ hint: 'POST /auth/register to create an identity for a new key'
35
+ )
36
+ end
37
+ private_class_method :registered_agent
38
+
39
+ # `allowed_roles` is an Array or a text[] literal ("{customer}"), by adapter.
40
+ def primary_role(allowed_roles)
41
+ case allowed_roles
42
+ when Array then allowed_roles.first
43
+ else allowed_roles.to_s.delete('{}').split(',').first
44
+ end
45
+ end
46
+ private_class_method :primary_role
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support/core_ext/string/inflections' # String#constantize
5
+
6
+ module Kiosk
7
+ module Server
8
+ # Self-registration with no human: creates the assistant account, binds an
9
+ # agent to the key, and mints its first token.
10
+ module AgentRegistration
11
+ module_function
12
+
13
+ def call(public_key_pem:, signed:, pow: nil)
14
+ config = Kiosk.configuration
15
+ role = registration_role(config)
16
+ public_key_pem = public_key_pem.to_s.strip
17
+
18
+ AuthToll.gate(verb: 'auth/register', body: { public_key: public_key_pem }, pow: pow, config: config)
19
+
20
+ # Prove possession of the private key before any lookup or write.
21
+ payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
22
+ AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
23
+
24
+ conn = ::ActiveRecord::Base.lease_connection
25
+ refuse_known_key!(conn, config, public_key_pem)
26
+ conn.transaction { register(conn, config, public_key_pem, role) }
27
+ end
28
+
29
+ # The operator's `assistant_creation` factory, else a bare `user_model.create!`.
30
+ def create_assistant_account(config, public_key_pem)
31
+ return config.user_model.constantize.create!.id unless config.assistant_creation
32
+
33
+ id = config.assistant_creation.call(public_key_pem)
34
+ return id unless id.nil?
35
+
36
+ raise Errors::ConfigurationError,
37
+ 'config.assistant_creation returned nil. The block must create the ' \
38
+ 'assistant account and RETURN its id (used as agents.user_id), e.g. ' \
39
+ 'c.assistant_creation = ->(pubkey) { AssistantAccount.create!(...).id }'
40
+ end
41
+
42
+ # The role is the server's choice, never the agent's.
43
+ def registration_role(config)
44
+ role = config.registration_role&.to_s
45
+ return if role.blank?
46
+ return role if config.roles.map(&:to_s).include?(role)
47
+
48
+ raise Errors::ConfigurationError,
49
+ "registration_role #{role.inspect} is not among configured roles " \
50
+ "#{config.roles.inspect}"
51
+ end
52
+
53
+ # A known key logs in instead; it is never registered twice.
54
+ def refuse_known_key!(conn, config, public_key_pem)
55
+ existing = conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [public_key_pem, Kiosk.current_issuer]).to_a.first
56
+ SELECT id FROM #{config.schema}.agents
57
+ WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
58
+ LIMIT 1
59
+ SQL
60
+ return unless existing
61
+
62
+ raise Errors::Conflict.new(
63
+ 'public key already registered',
64
+ hint: 'use POST /auth/login to refresh a token for an existing key'
65
+ )
66
+ end
67
+
68
+ def register(conn, config, public_key_pem, role)
69
+ assistant_account_id = create_assistant_account(config, public_key_pem)
70
+ agent_id = insert_agent(conn, config, [assistant_account_id, public_key_pem, Kiosk.current_issuer], role)
71
+ token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
72
+ { agent_id: agent_id, user_id: assistant_account_id.to_s, access_token: token }
73
+ end
74
+
75
+ # No role is the empty array: `allowed_roles` is NOT NULL.
76
+ def insert_agent(conn, config, binds, role)
77
+ allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
78
+ rows = conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, *role_binds])
79
+ INSERT INTO #{config.schema}.agents (user_id, allowed_roles, public_key, issuer)
80
+ VALUES ($1, #{allowed_roles_sql}, $2, $3)
81
+ RETURNING id
82
+ SQL
83
+ rows.to_a.first.fetch('id')
84
+ end
85
+ private_class_method :registration_role, :refuse_known_key!, :register, :insert_agent
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+ require 'rack'
6
+
7
+ module Kiosk
8
+ module Server
9
+ # Decodes a query's arguments from the URL query string and coerces them
10
+ # to the types `input_schema` declares. Validation runs afterwards, on the
11
+ # coerced result (RequestValidation).
12
+ module ArgumentDecoder
13
+ module_function
14
+
15
+ # Reserved names and their types. A verb never declares `cursor`; it may declare `limit` (§8.1 item 6).
16
+ RESERVED = { 'limit' => 'integer', 'cursor' => 'string' }.freeze
17
+
18
+ # @param input_schema [Hash, nil] the verb's `input_schema`, symbol- or string-keyed
19
+ # @raise [Errors::BadRequest] naming the parameter
20
+ def decode(query_string, input_schema: nil)
21
+ raw = QueryShape.parse!(query_string)
22
+ raw = QueryShape.fold_declared_arrays(raw, query_string, input_schema)
23
+ QueryShape.reject_undecodable_shapes!(raw)
24
+ coerce_all(raw, input_schema)
25
+ end
26
+
27
+ def coerce_all(raw, input_schema)
28
+ raw.each_with_object({}) do |(name, value), out|
29
+ out[name.to_sym] = Coercion.coerce(value, property_for(name, input_schema), name.to_s)
30
+ end
31
+ end
32
+
33
+ # An undeclared parameter stays a String; whether it is allowed is the validator's call.
34
+ def property_for(name, input_schema)
35
+ declared = fetch(fetch(input_schema, :properties), name)
36
+ return declared unless declared.nil?
37
+
38
+ reserved = RESERVED[name.to_s]
39
+ reserved && { 'type' => reserved }
40
+ end
41
+
42
+ def fetch(hash, key)
43
+ return nil unless hash.is_a?(::Hash)
44
+ return hash[key.to_sym] if hash.key?(key.to_sym)
45
+
46
+ hash[key.to_s]
47
+ end
48
+
49
+ # A nullable union like `["integer", "null"]` coerces to its non-null member.
50
+ def declared_type(property)
51
+ type = fetch(property, :type)
52
+ case type
53
+ when ::Array then type.map(&:to_s).reject { |t| t == 'null' }.first
54
+ when nil then nil
55
+ else type.to_s
56
+ end
57
+ end
58
+
59
+ # What a caller has to send for each string `format` the wire reads.
60
+ FORMAT_SPELLINGS = {
61
+ 'date' => 'a calendar date as YYYY-MM-DD, e.g. 2026-08-19',
62
+ 'date-time' => 'an ISO 8601 timestamp, e.g. 2026-08-19T14:00:00Z'
63
+ }.freeze
64
+
65
+ SHAPE_HINT =
66
+ 'query arguments are scalars (`a=v`), arrays of scalars (repeated ' \
67
+ '`a%5B%5D=v`) or one level of object with scalar leaves ' \
68
+ '(`o%5Bk%5D=v`); a name is one shape or the other, never both.'
69
+
70
+ DEPTH_HINT =
71
+ "a query's arguments are one level deep. A read whose input needs an " \
72
+ 'array of objects or two levels of nesting is an ACTION — POST it to ' \
73
+ '<endpoint>/<action-name> with a JSON body.'
74
+
75
+ NARROWING_HINT =
76
+ "an object argument's leaves are SCALARS: `o%5Bk%5D=v`, not " \
77
+ '`o%5Bk%5D%5B%5D=v`. Anything richer is an ACTION (POST).'
78
+
79
+ # The query string's shape: what Rack can decode and the wire accepts.
80
+ module QueryShape
81
+ module_function
82
+
83
+ def parse!(query_string)
84
+ Rack::Utils.parse_nested_query(query_string.to_s)
85
+ rescue ::Rack::BadRequest
86
+ raise Errors::BadRequest.new(
87
+ 'the query string could not be decoded',
88
+ hint: SHAPE_HINT
89
+ )
90
+ end
91
+
92
+ # A bare repeated `a=1&a=2` becomes an array only when the verb declares
93
+ # `type: "array"`; otherwise Rack's last value wins.
94
+ def fold_declared_arrays(raw, query_string, input_schema)
95
+ flat = nil
96
+ raw.each_with_object({}) do |(name, value), out|
97
+ unless value.is_a?(::String) && declared_array?(name, input_schema)
98
+ out[name] = value
99
+ next
100
+ end
101
+
102
+ flat ||= ::Rack::Utils.parse_query(query_string.to_s)
103
+ repeated = flat[name]
104
+ out[name] = repeated.nil? ? [value] : Array(repeated)
105
+ end
106
+ end
107
+
108
+ def declared_array?(name, input_schema)
109
+ ArgumentDecoder.declared_type(ArgumentDecoder.property_for(name, input_schema)) == 'array'
110
+ end
111
+
112
+ def reject_undecodable_shapes!(raw)
113
+ raw.each do |name, value|
114
+ case value
115
+ when ::Array then reject_nonscalar_elements!(name, value)
116
+ when ::Hash then reject_nonscalar_leaves!(name, value)
117
+ end
118
+ end
119
+ raw
120
+ end
121
+
122
+ def reject_nonscalar_elements!(name, value)
123
+ value.each do |element|
124
+ next if element.nil? || element.is_a?(::String)
125
+
126
+ raise Errors::BadRequest.new(
127
+ "parameter #{name.inspect} is an array of " \
128
+ "#{element.is_a?(::Hash) ? 'objects' : 'arrays'}, which a query cannot carry",
129
+ hint: DEPTH_HINT
130
+ )
131
+ end
132
+ end
133
+
134
+ # An array leaf is the scalar-leaves rule; a hash leaf is the depth limit.
135
+ def reject_nonscalar_leaves!(name, value)
136
+ value.each do |key, leaf|
137
+ next if leaf.nil? || leaf.is_a?(::String)
138
+
139
+ raise nonscalar_leaf(name, key, leaf)
140
+ end
141
+ end
142
+
143
+ def nonscalar_leaf(name, key, leaf)
144
+ if leaf.is_a?(::Array)
145
+ return Errors::BadRequest.new(
146
+ "parameter #{name.inspect} has an array-valued leaf at #{name}[#{key}]",
147
+ hint: NARROWING_HINT
148
+ )
149
+ end
150
+
151
+ Errors::BadRequest.new(
152
+ "parameter #{name.inspect} nests two levels deep at #{name}[#{key}]",
153
+ hint: DEPTH_HINT
154
+ )
155
+ end
156
+ end
157
+
158
+ # Coerces one decoded value to the type its property declares.
159
+ module Coercion
160
+ module_function
161
+
162
+ SCALARS = { 'integer' => :to_integer, 'number' => :to_number, 'boolean' => :to_boolean }.freeze
163
+
164
+ def coerce(value, property, path)
165
+ return value if value.nil?
166
+
167
+ type = ArgumentDecoder.declared_type(property)
168
+ return public_send(SCALARS.fetch(type), value, path) if SCALARS.key?(type)
169
+
170
+ case type
171
+ when 'string' then to_string(value, property, path)
172
+ when 'array' then to_array(value, property, path)
173
+ when 'object' then to_object(value, property, path)
174
+ else value
175
+ end
176
+ end
177
+
178
+ def to_integer(value, path)
179
+ scalar!(value, 'an integer', path)
180
+ Integer(value, 10)
181
+ rescue ::ArgumentError, ::TypeError
182
+ refuse(path, value, 'an integer', 'a JSON integer literal, e.g. 4')
183
+ end
184
+
185
+ def to_number(value, path)
186
+ scalar!(value, 'a number', path)
187
+ Float(value)
188
+ rescue ::ArgumentError, ::TypeError
189
+ refuse(path, value, 'a number', 'a JSON number literal, e.g. 4 or 4.5')
190
+ end
191
+
192
+ # Only the literals `true` and `false`.
193
+ def to_boolean(value, path)
194
+ scalar!(value, 'a boolean', path)
195
+ return true if value == 'true'
196
+ return false if value == 'false'
197
+
198
+ refuse(path, value, 'a boolean', 'the literal true or false')
199
+ end
200
+
201
+ # A string stays a String; a declared `format` is checked here.
202
+ def to_string(value, property, path)
203
+ scalar!(value, 'a string', path)
204
+ case ArgumentDecoder.fetch(property, :format).to_s
205
+ when 'date' then check_date!(value, path)
206
+ when 'date-time' then check_date_time!(value, path)
207
+ end
208
+ value
209
+ end
210
+
211
+ def check_date!(value, path)
212
+ parsed = begin
213
+ ::Date.strptime(value, '%Y-%m-%d')
214
+ rescue ::ArgumentError, ::TypeError
215
+ nil
216
+ end
217
+ return if parsed && parsed.strftime('%Y-%m-%d') == value
218
+
219
+ refuse(path, value, 'a date', FORMAT_SPELLINGS['date'])
220
+ end
221
+
222
+ def check_date_time!(value, path)
223
+ ::Time.iso8601(value)
224
+ rescue ::ArgumentError, ::TypeError
225
+ refuse(path, value, 'a timestamp', FORMAT_SPELLINGS['date-time'])
226
+ end
227
+
228
+ def to_array(value, property, path)
229
+ refuse(path, value, 'an array', "repeated #{path}%5B%5D=… parameters") if value.is_a?(::Hash)
230
+
231
+ items = ArgumentDecoder.fetch(property, :items)
232
+ Array(value).each_with_index.map { |element, i| coerce(element, items, "#{path}[#{i}]") }
233
+ end
234
+
235
+ def to_object(value, property, path)
236
+ refuse(path, value, 'an object', "one #{path}%5Bkey%5D=… parameter per key") unless value.is_a?(::Hash)
237
+
238
+ properties = ArgumentDecoder.fetch(property, :properties)
239
+ value.each_with_object({}) do |(key, leaf), out|
240
+ out[key.to_sym] = coerce(leaf, ArgumentDecoder.fetch(properties, key), "#{path}[#{key}]")
241
+ end
242
+ end
243
+
244
+ def scalar!(value, expected, path)
245
+ return unless value.is_a?(::Array) || value.is_a?(::Hash)
246
+
247
+ refuse(path, value, expected, "a single #{path}=… parameter")
248
+ end
249
+
250
+ def refuse(path, value, expected, spelling)
251
+ raise Errors::BadRequest.new(
252
+ "parameter #{path.inspect} is not #{expected}: #{value.inspect}",
253
+ hint: "#{path} is declared #{expected} — send #{spelling}. " \
254
+ "GET <endpoint>/schema publishes this verb's input_schema."
255
+ )
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Hands one {ActionEvent} per action invocation, success or failure, to the
6
+ # operator's `c.audit_sink` callable; with no sink nothing is built or stored.
7
+ # Queries, `pay` and refusals before an action ran are not emitted.
8
+ #
9
+ # Kiosk.configure do |c|
10
+ # c.audit_sink = ->(event) { AuditLog.create!(**event.to_h) }
11
+ # end
12
+ #
13
+ # Called after the action's transaction has closed; a sink that raises is
14
+ # logged and never fails the action.
15
+ module AuditSink
16
+ class << self
17
+ def configured? = !Kiosk.configuration.audit_sink.nil?
18
+
19
+ def emit(event, sink: Kiosk.configuration.audit_sink)
20
+ return false if sink.nil?
21
+
22
+ sink.call(event)
23
+ true
24
+ rescue StandardError => e
25
+ report(event, e)
26
+ false
27
+ end
28
+
29
+ private
30
+
31
+ def report(event, error)
32
+ message = '[kiosk-server] audit_sink raised for action ' \
33
+ "#{event.action.inspect}: #{error.class}: #{error.message}"
34
+ logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
35
+ logger ? logger.error(message) : warn(message)
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # Server side of the proof-of-possession challenge: a single-use, short-lived
8
+ # nonce per public key.
9
+ module AuthChallenge
10
+ module_function
11
+
12
+ # The agent signs `challenge` into its JWS; `exp` is the Unix expiry.
13
+ def issue(public_key_pem:, now: Time.current)
14
+ config = Kiosk.configuration
15
+ nonce = SecureRandom.urlsafe_base64(32)
16
+ exp = now.to_i + config.auth_challenge_ttl
17
+ config.auth_challenge_store.put(public_key_pem.to_s.strip, nonce, exp)
18
+ { challenge: nonce, exp: exp }
19
+ end
20
+
21
+ def consume!(public_key_pem:, nonce:)
22
+ ok = Kiosk.configuration.auth_challenge_store.take(public_key_pem.to_s.strip, nonce.to_s)
23
+ return true if ok
24
+
25
+ raise Errors::Unauthenticated.new(
26
+ 'no matching auth challenge',
27
+ hint: 'GET /auth/challenge?public_key=… first, then sign the returned nonce; ' \
28
+ 'challenges are single-use and short-lived'
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end