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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- 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
|