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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The challenge store, shared by every process on the database (§15.2).
6
+ module AuthChallengeStores
7
+ # The default: the `<schema>.auth_challenges` table ({SchemaDefinitions.auth_challenge_sql}).
8
+ class ActiveRecord
9
+ # Seconds between expired-row sweeps; a sweep bounds growth, not correctness.
10
+ DEFAULT_PRUNE_INTERVAL = 60
11
+
12
+ def initialize(prune_interval: DEFAULT_PRUNE_INTERVAL)
13
+ @prune_interval = prune_interval
14
+ @last_prune_at = 0
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ # Replaces any earlier challenge for the key.
19
+ def put(public_key_pem, nonce, exp)
20
+ return if public_key_pem.nil?
21
+
22
+ prune_if_due!
23
+ sql = <<~SQL.squish
24
+ INSERT INTO #{table} AS c (public_key, nonce, expires_at)
25
+ VALUES ($1, $2, to_timestamp($3))
26
+ ON CONFLICT (public_key) DO UPDATE
27
+ SET nonce = EXCLUDED.nonce, expires_at = EXCLUDED.expires_at
28
+ SQL
29
+ connection.exec_query(sql, 'Kiosk auth_challenge put', [public_key_pem, nonce, exp.to_i])
30
+ nil
31
+ end
32
+
33
+ # One atomic DELETE, so two processes presenting one challenge get one `true`.
34
+ def take(public_key_pem, nonce) # rubocop:disable Naming/PredicateMethod -- `take` is the store contract `config.auth_challenge_store` names
35
+ return false if public_key_pem.nil? || nonce.nil?
36
+
37
+ sql = <<~SQL.squish
38
+ DELETE FROM #{table}
39
+ WHERE public_key = $1 AND nonce = $2 AND expires_at > now()
40
+ RETURNING public_key
41
+ SQL
42
+ rows = connection.exec_query(
43
+ sql, 'Kiosk auth_challenge take', [public_key_pem, nonce]
44
+ ).to_a
45
+ !rows.empty?
46
+ end
47
+
48
+ def prune!
49
+ connection.exec_query(%(DELETE FROM #{table} WHERE expires_at <= now()),
50
+ 'Kiosk auth_challenge prune')
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ def prune_if_due!
57
+ now = Time.now.to_i
58
+ due = @mutex.synchronize do
59
+ if now - @last_prune_at >= @prune_interval
60
+ @last_prune_at = now
61
+ true
62
+ else
63
+ false
64
+ end
65
+ end
66
+ prune! if due
67
+ end
68
+
69
+ def connection = ::ActiveRecord::Base.lease_connection
70
+ def table = %("#{Kiosk.configuration.schema}".auth_challenges)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Optional proof-of-work toll on the auth requests that come before a
6
+ # token: `POST /auth/register`, the link-code claim `POST /auth/claim`, and
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").
11
+ module AuthToll
12
+ module_function
13
+
14
+ def on?(config = Kiosk.configuration) = config.registration_pow_count.to_i.positive?
15
+
16
+ # `verb` and `body` make the fingerprint, so a proof pays for this request only.
17
+ def gate(verb:, body:, pow:, config: Kiosk.configuration)
18
+ return unless on?(config)
19
+
20
+ secret = secret!(config)
21
+ spec = { alg: 'equihash', params: ::Kiosk::Pow::Equihash.params, count: config.registration_pow_count.to_i }
22
+ fp = PowGate.request_fingerprint(method: 'POST', verb: verb, body: body)
23
+
24
+ PowToll.new(spec: spec, fingerprint: fp, secret: secret, config: config).collect!(pow)
25
+ nil
26
+ end
27
+
28
+ def secret!(config)
29
+ unless defined?(::Kiosk::Reputation) && defined?(::Kiosk::Pow::Equihash)
30
+ raise Errors::ConfigurationError,
31
+ 'registration_pow_count > 0 requires kiosk-reputation and kiosk-pow-equihash. ' \
32
+ 'Add both gems (and `require` them) to your app.'
33
+ end
34
+
35
+ secret = config.pow_secret
36
+ return secret unless secret.nil? || secret.to_s.strip.empty?
37
+
38
+ raise Errors::ConfigurationError,
39
+ 'registration_pow_count > 0 requires pow_secret. ' \
40
+ "Set: Kiosk.configure { |c| c.pow_secret = ENV.fetch('KIOSK_POW_SECRET') }"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Answers `501 module_not_served` on every account-binding endpoint when
6
+ # `c.serve_account_binding` is false. Runs before the request is read; on
7
+ # `/oauth/*` it is the one problem document served instead of an OAuth error.
8
+ module BindingModuleGate
9
+ DETAIL = 'this operator does not serve the account binding module'
10
+ HINT = "register a fresh assistant account at this origin's register_url instead"
11
+
12
+ private
13
+
14
+ def refuse_unserved_binding
15
+ return if Kiosk.configuration.serve_account_binding
16
+
17
+ error = Errors::ModuleNotServed.new(DETAIL, hint: HINT)
18
+ Headers.add_to(response.headers)
19
+ Headers.add_cache_policy(response.headers, status: error.http_status)
20
+ render json: error.to_problem, status: error.http_status,
21
+ content_type: Errors::PROBLEM_CONTENT_TYPE
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/time/zones'
4
+ require 'kiosk/protocol'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # `Kiosk-Timezone`: the zone of the human the assistant acts for. A service
9
+ # time is on the clock of its place whatever this says; an operator may use it
10
+ # to tell the caller that the place's clock differs from theirs.
11
+ module CallerTimezone
12
+ HEADER = Kiosk::Protocol::HEADER_TIMEZONE
13
+ ENV_KEY = 'HTTP_KIOSK_TIMEZONE'
14
+
15
+ # An IANA `Area/Location` name or `UTC` only: no offsets (no DST), no Rails
16
+ # friendly names, no legacy ids like `EST`.
17
+ SHAPE = %r{\A(?:UTC|[A-Za-z][A-Za-z0-9_+-]*(?:/[A-Za-z0-9_+-]+)+)\z}
18
+
19
+ HINT = 'send an IANA zone name — Area/Location (e.g. Europe/Dublin) or the literal UTC. ' \
20
+ 'A UTC offset (+03:00) is not accepted: it cannot carry a DST transition. ' \
21
+ 'Take the zone from the human you are acting for, never from the machine you run on.'
22
+
23
+ module_function
24
+
25
+ # nil when the caller declared no zone; raises BadRequest on one it cannot read.
26
+ def from_env(env)
27
+ from_value(env && env[ENV_KEY])
28
+ end
29
+
30
+ def from_value(raw)
31
+ value = raw.to_s.strip
32
+ return nil if value.empty?
33
+
34
+ refuse(raw) unless SHAPE.match?(value)
35
+ zone = ::Time.find_zone(value)
36
+ refuse(raw) if zone.nil?
37
+
38
+ zone
39
+ end
40
+
41
+ # §9.1: refused by name, never silently reinterpreted.
42
+ def refuse(raw)
43
+ raise Errors::BadRequest.new(
44
+ "invalid #{HEADER}: #{raw.to_s.strip.inspect}",
45
+ hint: HINT
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # A `config.spending_cap` that reads `agents.spending_cap_cents` and
6
+ # `agents.spending_cap_currency`: `{ amount_cents:, currency: }`, or nil (no
7
+ # cap, or no live assistant).
8
+ #
9
+ # Kiosk.configure { |c| c.spending_cap = Kiosk::Server::ColumnSpendingCap.new }
10
+ class ColumnSpendingCap
11
+ def call(agent_id:)
12
+ return nil if agent_id.nil?
13
+
14
+ # The leased connection: the cap is read inside the transaction that charges.
15
+ row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk spending cap', [agent_id]).to_a.first
16
+ SELECT spending_cap_cents, spending_cap_currency
17
+ FROM "#{Kiosk.configuration.schema}".agents
18
+ WHERE id = $1 AND revoked_at IS NULL
19
+ SQL
20
+ return nil if row.nil? || row['spending_cap_cents'].nil?
21
+
22
+ { amount_cents: row['spending_cap_cents'].to_i, currency: row['spending_cap_currency'] }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Server settings, added to {Kiosk::Configuration}.
6
+ module ConfigurationExtension
7
+ # Serialises the first touch of each lazy store slot, so racing threads
8
+ # cannot each build their own store (a double-spent proof of work).
9
+ LAZY_STORE_MUTEX = Mutex.new
10
+
11
+ # The discovery settings: where the wire is mounted and what it advertises.
12
+ module Discovery
13
+ # Default `/kiosk`. Discovery advertises `endpoint = origin + mount_path`.
14
+ attr_writer :mount_path
15
+
16
+ def mount_path
17
+ @mount_path ||= Kiosk::Protocol::DEFAULT_MOUNT_PATH
18
+ end
19
+
20
+ # Module names advertised in `/.well-known/kiosk.json`. Computed from what
21
+ # is registered and configured. An operator cannot set them.
22
+ def capabilities
23
+ computed_capabilities
24
+ end
25
+
26
+ # Free-form owner block for discovery, e.g. `{ name:, support: }`.
27
+ attr_writer :owner
28
+
29
+ def owner
30
+ @owner ||= {}
31
+ end
32
+
33
+ # The skill this origin was built against. Discovery emits the `skill`
34
+ # block only when `skill_sha256` is set.
35
+ attr_writer :skill_url
36
+
37
+ def skill_url
38
+ @skill_url ||= 'https://kiosk.tech/skill-v0.5.20.md'
39
+ end
40
+ attr_accessor :skill_sha256
41
+
42
+ private
43
+
44
+ # The spec's order; `events` stays last.
45
+ def computed_capabilities
46
+ has_queries = Kiosk::Server::Queries.known.any?
47
+ has_actions = Kiosk::Server::Actions.known.any?
48
+ {
49
+ 'schema' => has_queries || has_actions,
50
+ 'queries' => has_queries,
51
+ 'actions' => has_actions,
52
+ 'pay' => !payment_provider.nil?,
53
+ 'events' => Kiosk::Server::Events.known.any?
54
+ }.select { |_, served| served }.keys.freeze
55
+ end
56
+ end
57
+
58
+ # The proof-of-work settings: registration proofs and the reputation challenge.
59
+ module ProofOfWork
60
+ # Equihash proofs asked at `POST /auth/register`, at the link-code claim and
61
+ # at the first token poll of each device code, at the gem's default params.
62
+ # Default 0 (open). Above 0 needs `pow_secret` and `kiosk-pow-equihash`.
63
+ attr_writer :registration_pow_count
64
+
65
+ def registration_pow_count
66
+ @registration_pow_count ||= 0
67
+ end
68
+
69
+ # Decides when and how hard to challenge. Default nil: never.
70
+ # `#challenge_for(identity:, verb:, factors:) → {alg:, params:, count:} | nil`;
71
+ # the verb it receives is `:run`, never `:action`.
72
+ def reputation_policy=(value)
73
+ VerbVocabulary.assert!(value, :challenge_for, 'reputation_policy #challenge_for') unless value.nil?
74
+ @reputation_policy = value
75
+ end
76
+
77
+ def reputation_policy
78
+ @reputation_policy
79
+ end
80
+
81
+ # HMAC key for proof-of-work challenges, at least 32 bytes.
82
+ attr_reader :pow_secret
83
+
84
+ def pow_secret=(value)
85
+ if value.to_s.bytesize < 32
86
+ raise Errors::ConfigurationError,
87
+ "c.pow_secret must be at least 32 bytes (got #{value.to_s.bytesize}). " \
88
+ 'Generate one with `openssl rand -hex 32`.'
89
+ end
90
+
91
+ @pow_secret = value
92
+ end
93
+
94
+ # Seconds. Default 300.
95
+ attr_writer :pow_ttl
96
+
97
+ def pow_ttl
98
+ @pow_ttl ||= 300
99
+ end
100
+ end
101
+
102
+ # The anonymized-KYC attestation settings.
103
+ module Kyc
104
+ # Anonymized attributes gated actions need, e.g. %w[age_over_18].
105
+ attr_writer :kyc_claims
106
+
107
+ def kyc_claims
108
+ @kyc_claims || []
109
+ end
110
+
111
+ # Must match the `iss` of submitted KYC attestations.
112
+ attr_writer :kyc_issuer
113
+
114
+ def kyc_issuer
115
+ @kyc_issuer
116
+ end
117
+
118
+ # The `aud` a KYC attestation must carry. Default: the origin being served.
119
+ attr_writer :kyc_audience
120
+
121
+ def kyc_audience
122
+ @kyc_audience || Kiosk.current_issuer
123
+ end
124
+
125
+ # The KYC provider's RSA key: an OpenSSL::PKey or a PEM string.
126
+ def kyc_public_key
127
+ @kyc_public_key
128
+ end
129
+
130
+ def kyc_public_key=(value)
131
+ @kyc_public_key = case value
132
+ when OpenSSL::PKey::PKey then value
133
+ when String then OpenSSL::PKey::RSA.new(value)
134
+ when nil then nil
135
+ else
136
+ raise ArgumentError,
137
+ "kyc_public_key must be an OpenSSL::PKey or PEM string, got #{value.class}"
138
+ end
139
+ end
140
+ end
141
+
142
+ # The database settings: where Kiosk's tables live and how a request reaches them.
143
+ module Database
144
+ # Postgres schema for Kiosk's own tables and functions. Default 'kiosk'.
145
+ attr_writer :schema
146
+
147
+ def schema
148
+ @schema ||= 'kiosk'
149
+ end
150
+
151
+ # Prefix of the session GUCs a request sets, e.g. `app.user_id`. Default 'app'.
152
+ attr_writer :guc_namespace
153
+
154
+ def guc_namespace
155
+ @guc_namespace ||= Kiosk::GUC::DEFAULT_NAMESPACE
156
+ end
157
+
158
+ # When true, every request transaction runs `SET LOCAL ROLE <app_role>`;
159
+ # that role then needs grants on every table the verbs touch. Default false.
160
+ attr_writer :enforce_db_role
161
+
162
+ def enforce_db_role
163
+ @enforce_db_role ||= false
164
+ end
165
+ end
166
+
167
+ include Discovery
168
+ include ProofOfWork
169
+ include Kyc
170
+ include Database
171
+
172
+ # Required. A SigningKey or a PEM string.
173
+ def signing_key
174
+ @signing_key || raise(Errors::ConfigurationError,
175
+ 'c.signing_key is not set. Generate one with `openssl genrsa 2048`.')
176
+ end
177
+
178
+ def signing_key=(value)
179
+ @signing_key = case value
180
+ when Kiosk::Server::SigningKey, nil then value
181
+ when String then Kiosk::Server::SigningKey.from_pem(value)
182
+ else raise ArgumentError, "signing_key must be a SigningKey or PEM string, got #{value.class}"
183
+ end
184
+ end
185
+
186
+ # The role every self-registered agent is pinned to, and the default a
187
+ # binding falls back to. Required, and one of {#roles}, when {#roles} is set.
188
+ # Kiosk.configure { |c| c.roles = %i[customer]; c.registration_role = :customer }
189
+ attr_accessor :registration_role
190
+
191
+ # Optional `->(public_key) { record_id }` creating the account behind a
192
+ # self-registered agent. Unset: `user_model.constantize.create!`.
193
+ attr_accessor :assistant_creation
194
+
195
+ # Account-binding ceremony state. Default: the shared database store.
196
+ attr_writer :device_authorization_store
197
+
198
+ def device_authorization_store
199
+ @device_authorization_store ||
200
+ LAZY_STORE_MUTEX.synchronize do
201
+ @device_authorization_store ||=
202
+ Kiosk::Server::DeviceAuthorizationStores::ActiveRecord.new
203
+ end
204
+ end
205
+
206
+ # Per-identity event tail. Default: the shared database table.
207
+ attr_writer :event_store
208
+
209
+ def event_store
210
+ @event_store ||
211
+ LAZY_STORE_MUTEX.synchronize { @event_store ||= Kiosk::Server::EventStores::ActiveRecord.new }
212
+ end
213
+
214
+ # Optional sign-in path a browser is redirected to from the assistants and
215
+ # verify pages. Unset, or for an API request: a plain 401.
216
+ attr_accessor :sign_in_path
217
+
218
+ # Default true. False answers every binding path `501 module_not_served`.
219
+ attr_writer :serve_account_binding
220
+
221
+ def serve_account_binding
222
+ return @serve_account_binding unless @serve_account_binding.nil?
223
+
224
+ true
225
+ end
226
+
227
+ # Called as `(agent:, from:, to:)` when an assistant's key moves from one
228
+ # account to another, inside the rebind transaction: the place to move
229
+ # the operator's own rows. `from` and `to` are `user_model` records.
230
+ attr_accessor :assistant_claimed
231
+
232
+ # Called as `(agent:, account:)` after an assistant is unlinked from
233
+ # `account`, a `user_model` record, and its tokens are revoked.
234
+ attr_accessor :assistant_unlinked
235
+
236
+ # Optional `(agent_id:) → { amount_cents:, currency: } | nil`, checked
237
+ # before capture; 0 blocks payments, and so does a cart in another currency.
238
+ # {Kiosk::Server::ColumnSpendingCap} reads
239
+ # `agents.spending_cap_cents`, the column edited by the manage-assistants
240
+ # page, and `agents.spending_cap_currency`.
241
+ attr_accessor :spending_cap
242
+
243
+ # Days of settled spend summed against the cap. Default nil: all time.
244
+ attr_accessor :spending_cap_window_days
245
+
246
+ # `call(id, lines) → cents | String`: the payable row's price, or why the cart is refused.
247
+ attr_accessor :cart_price_checker
248
+
249
+ # Optional `call(id)`, run once the settlement that paid row `id` is recorded.
250
+ attr_accessor :after_payment
251
+
252
+ # Default true. Malformed `Kiosk-PoW` and reserved-endpoint bodies are a 400
253
+ # naming the problem, rather than an endless re-challenge.
254
+ attr_writer :validate_requests
255
+
256
+ def validate_requests
257
+ return @validate_requests unless @validate_requests.nil?
258
+
259
+ true
260
+ end
261
+
262
+ # Default false. Checks each answer against its verb's `output_schema`
263
+ # and raises on mismatch: an operator-side bug, so for development and CI.
264
+ attr_writer :validate_responses
265
+
266
+ def validate_responses
267
+ @validate_responses ||= false
268
+ end
269
+
270
+ # Optional callable receiving one {Kiosk::Server::ActionEvent} per action
271
+ # invocation, arguments unredacted. Default nil: nothing is emitted.
272
+ # A sink that raises does not fail the action.
273
+ attr_reader :audit_sink
274
+
275
+ def audit_sink=(value)
276
+ if !value.nil? && !value.respond_to?(:call)
277
+ raise ArgumentError,
278
+ 'audit_sink must be callable (a lambda or any object answering #call) ' \
279
+ "or nil to emit nothing, got #{value.class}"
280
+ end
281
+
282
+ @audit_sink = value
283
+ end
284
+
285
+ # Spent challenge ids, so a proof is accepted once. Default: the shared database table.
286
+ attr_writer :pow_spent_store
287
+
288
+ def pow_spent_store
289
+ @pow_spent_store ||
290
+ LAZY_STORE_MUTEX.synchronize { @pow_spent_store ||= Kiosk::Server::PowSpentStores::ActiveRecord.new }
291
+ end
292
+
293
+ # Outstanding auth nonces. Default: the shared database table.
294
+ attr_writer :auth_challenge_store
295
+
296
+ def auth_challenge_store
297
+ @auth_challenge_store ||
298
+ LAZY_STORE_MUTEX.synchronize do
299
+ @auth_challenge_store ||= Kiosk::Server::AuthChallengeStores::ActiveRecord.new
300
+ end
301
+ end
302
+
303
+ # Seconds an access token lives. One paid proof-of-work toll lasts as long. Default 3600.
304
+ attr_writer :access_token_ttl
305
+
306
+ def access_token_ttl
307
+ @access_token_ttl ||= JwtIssuer::DEFAULT_EXPIRES_IN
308
+ end
309
+
310
+ # Seconds between `GET /auth/challenge` and the signed POST. Default covers
311
+ # the auth toll's proof-of-work window plus 60s.
312
+ attr_writer :auth_challenge_ttl
313
+
314
+ def auth_challenge_ttl
315
+ @auth_challenge_ttl ||= (pow_ttl * [registration_pow_count.to_i, 1].max) + 60
316
+ end
317
+
318
+ # Revocation watermarks, checked on every access token. Default: the
319
+ # shared database table, so a revoke or an unlink holds in every process.
320
+ attr_writer :revocation_store
321
+
322
+ def revocation_store
323
+ @revocation_store ||
324
+ LAZY_STORE_MUTEX.synchronize { @revocation_store ||= Kiosk::Server::RevocationStores::ActiveRecord.new }
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The registry entry of a verb that a controller action answers. A named
6
+ # controller is held by name and resolved per request, so a development
7
+ # reload serves the edited class.
8
+ class ControllerVerb
9
+ attr_reader :method_name, :wire_name, :kind
10
+
11
+ def initialize(controller:, method_name:, wire_name:, kind:)
12
+ @controller = controller.is_a?(Class) && controller.name ? controller.name : controller
13
+ @method_name = method_name.to_s
14
+ @wire_name = wire_name.to_s
15
+ @kind = kind
16
+ end
17
+
18
+ # nil for an anonymous controller class.
19
+ def controller_name = @controller.is_a?(String) ? @controller : @controller.name
20
+
21
+ # The controller class whose action answers the verb, or nil when none can.
22
+ def controller
23
+ resolve!
24
+ rescue Errors::VerbNotFound
25
+ nil
26
+ end
27
+
28
+ # {VerbController} reaches this only when {#controller} is nil; it raises why no action answers.
29
+ def call(_args)
30
+ controller = resolve!
31
+ raise ArgumentError, "#{controller}##{@method_name} answers #{@wire_name.inspect} through its own route"
32
+ end
33
+
34
+ # Handy in `p`/logs and in the specs that assert what got registered.
35
+ def inspect
36
+ "#<#{self.class.name} #{@kind} #{@wire_name.inspect} → " \
37
+ "#{controller_name || @controller}##{@method_name}>"
38
+ end
39
+
40
+ private
41
+
42
+ def resolve!
43
+ controller = @controller.is_a?(String) ? constantize! : @controller
44
+ return controller if controller.action_methods.include?(@method_name)
45
+
46
+ raise Errors::VerbNotFound.new(
47
+ "#{@kind} #{@wire_name.inspect} is no longer dispatchable",
48
+ hint: "#{controller_name || controller}##{@method_name} is not a public controller action"
49
+ )
50
+ end
51
+
52
+ def constantize!
53
+ @controller.constantize
54
+ rescue NameError
55
+ # `verb_not_found`: nothing was addressed, and the caller should stop calling this name.
56
+ raise Errors::VerbNotFound.new(
57
+ "#{@kind} #{@wire_name.inspect} is registered to #{@controller}, which is not loaded",
58
+ hint: 'the handler class was renamed or removed; restart the server after moving it'
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The wire request being dispatched, visible to its handler for the duration
6
+ # of one block. Fiber-local: a handler that hands work to another thread must
7
+ # pass what it needs.
8
+ module CurrentRequest
9
+ KEY = :kiosk_server_current_request
10
+ EMPTY = {}.freeze
11
+
12
+ module_function
13
+
14
+ # An inner `with` blanks what it omits.
15
+ def with(identity: nil, timezone: nil)
16
+ previous = Thread.current[KEY]
17
+ Thread.current[KEY] = { identity: identity, timezone: timezone }
18
+ yield
19
+ ensure
20
+ Thread.current[KEY] = previous
21
+ end
22
+
23
+ def identity = current[:identity]
24
+
25
+ # The caller's {CallerTimezone}, or nil when it declared none.
26
+ def timezone = current[:timezone]
27
+
28
+ def current = Thread.current[KEY] || EMPTY
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # An offset cursor, published as a decimal integer in clear: a cursor
6
+ # authorises nothing, and opacity is the client's contract, not the token's.
7
+ # A present cursor that is not one this helper wrote is a 400.
8
+ module Cursor
9
+ OFFSET_RE = /\A[0-9]+\z/
10
+
11
+ module_function
12
+
13
+ def encode_offset(offset)
14
+ offset.to_i.to_s
15
+ end
16
+
17
+ def decode_offset(cursor, default: 0)
18
+ return default if cursor.nil? || cursor.to_s.empty?
19
+
20
+ raw = cursor.to_s
21
+ unless raw.match?(OFFSET_RE)
22
+ raise Errors::BadRequest.new(
23
+ "cursor #{raw.inspect} is not a cursor this endpoint issued",
24
+ hint: 'do not build a cursor: fetch the `Link: <…>; rel="next"` target verbatim, ' \
25
+ 'or copy its `cursor` parameter byte for byte. Omit `cursor` for the first page.'
26
+ )
27
+ end
28
+
29
+ Integer(raw, 10)
30
+ end
31
+ end
32
+ end
33
+ end