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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Stores for the PoW spent-id set (`config.pow_spent_store`). A store
|
|
6
|
+
# answers `claim(id, exp)` (atomic: true iff this caller claimed it),
|
|
7
|
+
# `release(id)`, `spent?(id)` and `mark_spent(id, exp)`.
|
|
8
|
+
module PowSpentStores
|
|
9
|
+
# The default store: the `<schema>.pow_spent` table, shared by every process.
|
|
10
|
+
class ActiveRecord
|
|
11
|
+
# The sweep bounds table growth; correctness does not depend on it.
|
|
12
|
+
DEFAULT_PRUNE_INTERVAL = 60
|
|
13
|
+
|
|
14
|
+
def initialize(prune_interval: DEFAULT_PRUNE_INTERVAL)
|
|
15
|
+
@prune_interval = prune_interval
|
|
16
|
+
@last_prune_at = 0
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One statement: the primary key picks the winner; an expired row is reclaimable.
|
|
21
|
+
def claim(id, exp) # rubocop:disable Naming/PredicateMethod -- `claim` is the store contract `config.pow_spent_store` names
|
|
22
|
+
return false if id.nil?
|
|
23
|
+
|
|
24
|
+
prune_if_due!
|
|
25
|
+
rows = connection.exec_query(claim_sql, 'Kiosk pow_spent claim', [id, exp.to_i]).to_a
|
|
26
|
+
!rows.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def release(id)
|
|
30
|
+
return if id.nil?
|
|
31
|
+
|
|
32
|
+
connection.exec_query(%(DELETE FROM #{table} WHERE id = $1), 'Kiosk pow_spent release', [id])
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def spent?(id)
|
|
37
|
+
return false if id.nil?
|
|
38
|
+
|
|
39
|
+
sql = <<~SQL.squish
|
|
40
|
+
SELECT 1 FROM #{table}
|
|
41
|
+
WHERE id = $1 AND expires_at > now()
|
|
42
|
+
LIMIT 1
|
|
43
|
+
SQL
|
|
44
|
+
row = connection.exec_query(sql, 'Kiosk pow_spent lookup', [id]).to_a.first
|
|
45
|
+
!row.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def mark_spent(id, exp)
|
|
49
|
+
return if id.nil?
|
|
50
|
+
|
|
51
|
+
sql = <<~SQL.squish
|
|
52
|
+
INSERT INTO #{table} (id, expires_at)
|
|
53
|
+
VALUES ($1, to_timestamp($2))
|
|
54
|
+
ON CONFLICT (id) DO UPDATE SET expires_at = EXCLUDED.expires_at
|
|
55
|
+
SQL
|
|
56
|
+
connection.exec_query(sql, 'Kiosk pow_spent mark', [id, exp.to_i])
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prune!
|
|
61
|
+
connection.exec_query(%(DELETE FROM #{table} WHERE expires_at <= now()), 'Kiosk pow_spent prune')
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def claim_sql
|
|
68
|
+
<<~SQL.squish
|
|
69
|
+
INSERT INTO #{table} AS s (id, expires_at)
|
|
70
|
+
VALUES ($1, to_timestamp($2))
|
|
71
|
+
ON CONFLICT (id) DO UPDATE
|
|
72
|
+
SET expires_at = EXCLUDED.expires_at
|
|
73
|
+
WHERE s.expires_at <= now()
|
|
74
|
+
RETURNING s.id
|
|
75
|
+
SQL
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def prune_if_due!
|
|
79
|
+
now = Time.now.to_i
|
|
80
|
+
due = @mutex.synchronize do
|
|
81
|
+
if now - @last_prune_at >= @prune_interval
|
|
82
|
+
@last_prune_at = now
|
|
83
|
+
true
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
prune! if due
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def connection = ::ActiveRecord::Base.lease_connection
|
|
92
|
+
def table = %("#{Kiosk.configuration.schema}".pow_spent)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# One proof-of-work toll on one request: `count` distinct, unspent, valid
|
|
6
|
+
# proofs, or the error that sends the caller back. Shared by
|
|
7
|
+
# {PowGate.gate} and {AuthToll}.
|
|
8
|
+
class PowToll
|
|
9
|
+
def initialize(spec:, fingerprint:, secret:, config:)
|
|
10
|
+
@spec = spec
|
|
11
|
+
@fingerprint = fingerprint
|
|
12
|
+
@secret = secret
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns `:proceed`, or raises PowRequired (402), BadRequest or Forbidden.
|
|
17
|
+
def collect!(pow)
|
|
18
|
+
validate_spec_params!
|
|
19
|
+
submitted = Array(pow)
|
|
20
|
+
raise pow_required if submitted.empty?
|
|
21
|
+
|
|
22
|
+
accepted = accept_all(submitted)
|
|
23
|
+
return :proceed if accepted.size >= count
|
|
24
|
+
|
|
25
|
+
# Released, so the retry with fresh challenges is not blocked by our own claim.
|
|
26
|
+
accepted.each_key { |id| spent_store.release(id) }
|
|
27
|
+
raise pow_required
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# `count` distinct, unspent, valid proofs; one wrong proof is a 403.
|
|
33
|
+
# Proofs past `count` are not looked at, so a header cannot buy extra work.
|
|
34
|
+
def accept_all(submitted)
|
|
35
|
+
submitted.first(count).each_with_object({}) { |proof, accepted| accept(proof, accepted) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def accept(proof, accepted)
|
|
39
|
+
challenge = proof[:challenge].is_a?(Hash) ? proof[:challenge] : {}
|
|
40
|
+
return unless claimed?(challenge, accepted)
|
|
41
|
+
|
|
42
|
+
settle(verify(challenge, proof[:nonce]), challenge, accepted)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The cheap checks first, so only a live challenge this origin signed
|
|
46
|
+
# claims its id, at the `exp` it was signed with; the claim comes before
|
|
47
|
+
# the expensive verify. A lost claim is a replay: skipped, not penalised.
|
|
48
|
+
def claimed?(challenge, accepted)
|
|
49
|
+
id = challenge[:id]
|
|
50
|
+
return false if id.nil? || accepted.key?(id)
|
|
51
|
+
|
|
52
|
+
check_challenge_params!(challenge)
|
|
53
|
+
cheap_failure(challenge).nil? && spent_store.claim(id, challenge[:exp])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cheap_failure(challenge)
|
|
57
|
+
::Kiosk::Reputation::Challenge.check(
|
|
58
|
+
challenge: challenge, request_fingerprint: @fingerprint, secret: @secret,
|
|
59
|
+
now: Time.now.to_i, expect: { alg: alg, params: params }
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_challenge_params!(challenge)
|
|
64
|
+
return if challenge[:params].is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
raise Errors::BadRequest.new(
|
|
67
|
+
'malformed PoW proof: challenge.params is missing or not an object',
|
|
68
|
+
hint: PowGate::POW_HEADER_HINT
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# `expect`: honoured only at the difficulty live config demands now.
|
|
73
|
+
def verify(challenge, nonce)
|
|
74
|
+
::Kiosk::Reputation::Challenge.verify(
|
|
75
|
+
challenge: challenge, nonce: nonce, request_fingerprint: @fingerprint,
|
|
76
|
+
secret: @secret, now: Time.now.to_i, expect: { alg: alg, params: params }
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def settle(outcome, challenge, accepted)
|
|
81
|
+
case outcome
|
|
82
|
+
when :ok
|
|
83
|
+
accepted[challenge[:id]] = challenge[:exp].to_i
|
|
84
|
+
when :bad_proof
|
|
85
|
+
# The id stays consumed, so one challenge drives at most one verify.
|
|
86
|
+
raise Errors::Forbidden.new('invalid proof of work', hint: PowGate::POW_INVALID_HINT)
|
|
87
|
+
when :expired, :bad_sig, :bad_params
|
|
88
|
+
# Not bad faith (clock skew, a difficulty change): release the claim
|
|
89
|
+
# and re-challenge below.
|
|
90
|
+
spent_store.release(challenge[:id])
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_backend!
|
|
95
|
+
return if ::Kiosk::Reputation::Backends.known.include?(alg.to_s)
|
|
96
|
+
|
|
97
|
+
raise Errors::ConfigurationError,
|
|
98
|
+
"Kiosk::Server: no proof-of-work backend is registered for #{alg.to_s.inspect}. " \
|
|
99
|
+
"Register one: Kiosk::Reputation::Backends.register(#{alg.to_s.inspect}, backend)."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_spec_params!
|
|
103
|
+
validate_backend!
|
|
104
|
+
return if ::Kiosk::Reputation::Backends.valid_params?(alg, params)
|
|
105
|
+
|
|
106
|
+
raise Errors::ConfigurationError,
|
|
107
|
+
"Kiosk::Server: the #{alg.to_s.inspect} proof-of-work backend refuses the configured " \
|
|
108
|
+
"parameters #{params.inspect} — no proof solved at them could ever verify, so every " \
|
|
109
|
+
'honest client would be told its correct proof was invalid. Fix the `params:` ' \
|
|
110
|
+
'your reputation_policy returns from #challenge_for.'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Equihash has no difficulty dial: escalation is by proof count.
|
|
114
|
+
def count
|
|
115
|
+
[(@spec[:count] || 1).to_i, 1].max
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# TTL scales with `count`, so the first expires no sooner than the last is solved.
|
|
119
|
+
def pow_required
|
|
120
|
+
ttl = @config.pow_ttl * count
|
|
121
|
+
Errors::PowRequired.new(challenges: Array.new(count) { issue_challenge(ttl) })
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def issue_challenge(ttl)
|
|
125
|
+
::Kiosk::Reputation::Challenge.issue(
|
|
126
|
+
alg: alg, params: params, request_fingerprint: @fingerprint,
|
|
127
|
+
secret: @secret, ttl: ttl, now: Time.now.to_i
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def alg = @spec[:alg]
|
|
132
|
+
def params = @spec[:params]
|
|
133
|
+
def spent_store = @config.pow_spent_store
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# The query registry (`kind :query`); everything else is {VerbRegistry}.
|
|
6
|
+
# Read-only is a convention the provider upholds (the registry does not enforce it).
|
|
7
|
+
module Queries
|
|
8
|
+
SCOPE = :query
|
|
9
|
+
Entry = VerbRegistry::Entry
|
|
10
|
+
|
|
11
|
+
extend VerbRegistry
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# The reputation inputs the engine knows itself: what the customer paid
|
|
6
|
+
# here and kept paid, and the visits the operator recorded, within a policy's window.
|
|
7
|
+
module ReputationFactors
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Empty factors unless the policy names a window (`#within`, in seconds).
|
|
11
|
+
def for(identity:, policy:)
|
|
12
|
+
return ::Kiosk::Reputation::Factors.empty unless policy.respond_to?(:within) && identity&.user_id
|
|
13
|
+
|
|
14
|
+
count, cents = recent_spend(identity.user_id, Time.current - policy.within.to_i)
|
|
15
|
+
::Kiosk::Reputation::Factors.empty.with(settled_purchases_count: count, settled_purchases_cents: cents)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Settlements and visits of this customer since `since`: their count and their cents.
|
|
19
|
+
# A settlement counts less what was refunded of it; one refunded in full does not count.
|
|
20
|
+
def recent_spend(user_id, since)
|
|
21
|
+
sql = format(RECENT_SPEND_SQL, schema: Kiosk.configuration.schema)
|
|
22
|
+
row = ::ActiveRecord::Base.lease_connection.exec_query(sql, 'Kiosk recent spend', [user_id, since]).to_a.first
|
|
23
|
+
[row.fetch('count').to_i, row.fetch('cents').to_i]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
RECENT_SPEND_SQL = <<~SQL.squish
|
|
27
|
+
SELECT count(*) AS count, COALESCE(SUM(cents), 0) AS cents FROM (
|
|
28
|
+
SELECT s.settled_amount_cents - COALESCE(SUM(r.refunded_amount_cents), 0) AS cents
|
|
29
|
+
FROM "%<schema>s".settlements s LEFT JOIN "%<schema>s".refunds r ON r.settlement_id = s.id
|
|
30
|
+
WHERE s.user_id = $1 AND s.settled_at >= $2 GROUP BY s.id
|
|
31
|
+
HAVING s.settled_amount_cents > COALESCE(SUM(r.refunded_amount_cents), 0)
|
|
32
|
+
UNION ALL
|
|
33
|
+
SELECT value_cents FROM "%<schema>s".visits WHERE user_id = $1 AND visited_at >= $2
|
|
34
|
+
) spend
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Validates requests against the vendored normative schemas: `Kiosk-PoW`
|
|
6
|
+
# proofs and reserved-plane bodies behind `c.validate_requests` (§16.3), and
|
|
7
|
+
# verb arguments against their `input_schema` always (§8.1 item 5).
|
|
8
|
+
module RequestValidation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
SCHEMA_DIR = File.expand_path('schemas', __dir__)
|
|
12
|
+
|
|
13
|
+
POW_SCHEMA_PATH = File.join(SCHEMA_DIR, 'pow.schema.json')
|
|
14
|
+
|
|
15
|
+
# RESERVED-PLANE REQUEST BODY => the published object that types it.
|
|
16
|
+
# The `/oauth/*` requests are form-encoded and have no schema.
|
|
17
|
+
BODY_SCHEMAS = {
|
|
18
|
+
'POST <endpoint>/auth/register' => 'auth.schema.json#/$defs/credentialRequest',
|
|
19
|
+
'POST <endpoint>/auth/login' => 'auth.schema.json#/$defs/credentialRequest',
|
|
20
|
+
'POST <endpoint>/auth/claim' => 'binding.schema.json#/$defs/claimRequest',
|
|
21
|
+
'POST <endpoint>/auth/unlink' => 'binding.schema.json#/$defs/unlinkRequest',
|
|
22
|
+
'POST <endpoint>/agents/kyc' => 'kyc.schema.json#/$defs/request',
|
|
23
|
+
'POST <endpoint>/pay' => 'mandates.schema.json#/$defs/payRequest'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def validate_proofs!(proofs)
|
|
27
|
+
Array(proofs).each do |proof|
|
|
28
|
+
errors = Schemas.proof.validate(normalize(proof)).to_a
|
|
29
|
+
next if errors.empty?
|
|
30
|
+
|
|
31
|
+
raise Errors::BadRequest.new(
|
|
32
|
+
'malformed Kiosk-PoW proof',
|
|
33
|
+
hint: POW_SHAPE_HINT
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A missing member keeps the wire's own `missing field:` sentence.
|
|
39
|
+
def validate_body!(body, exchange:)
|
|
40
|
+
return unless Kiosk.configuration.validate_requests
|
|
41
|
+
|
|
42
|
+
pointer = BODY_SCHEMAS.fetch(exchange)
|
|
43
|
+
errors = Schemas.wire(pointer).validate(normalize(body)).to_a
|
|
44
|
+
return if errors.empty?
|
|
45
|
+
|
|
46
|
+
absent = missing_member(errors, body)
|
|
47
|
+
raise Errors.missing_field(absent, hint: body_hint(pointer)) if absent
|
|
48
|
+
|
|
49
|
+
raise Errors::BadRequest.new("#{exchange}: #{summary(errors)}", hint: body_hint(pointer))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def missing_member(errors, body)
|
|
53
|
+
return nil unless errors.any? { |error| error['type'] == 'required' }
|
|
54
|
+
|
|
55
|
+
present = normalize(body).keys
|
|
56
|
+
errors.filter_map { |error| error['details']&.fetch('missing_keys', nil) }
|
|
57
|
+
.flatten.find { |name| present.exclude?(name) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def body_hint(pointer)
|
|
61
|
+
file, fragment = pointer.split('#', 2)
|
|
62
|
+
"the body must satisfy #{file} #{fragment}, published at " \
|
|
63
|
+
"https://kiosk.tech/spec/schemas/#{file}."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Runs on the coerced arguments. On a query an undeclared `limit`/`cursor`
|
|
67
|
+
# is exempt: a query always accepts those names (§8.1 item 6). An action
|
|
68
|
+
# has no pages, so its body is held to its input_schema with no exemption.
|
|
69
|
+
def validate_arguments!(arguments, input_schema:, verb:, query:)
|
|
70
|
+
return if input_schema.nil?
|
|
71
|
+
|
|
72
|
+
require_schemer!
|
|
73
|
+
exempt = query ? ArgumentDecoder::RESERVED.keys - declared_property_names(input_schema) : []
|
|
74
|
+
payload = normalize(arguments).except(*exempt)
|
|
75
|
+
errors = JSONSchemer.schema(normalize(input_schema)).validate(payload).to_a
|
|
76
|
+
return if errors.empty?
|
|
77
|
+
|
|
78
|
+
raise Errors::BadRequest.new("#{verb}: #{summary(errors)}", hint: arguments_hint(errors))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def arguments_hint(errors)
|
|
82
|
+
"#{format_spellings(errors)}GET <endpoint>/schema publishes this verb's input_schema; the " \
|
|
83
|
+
'arguments must satisfy it. A query also accepts `limit` and `cursor`.'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def summary(errors) = errors.pluck('error').compact.join('; ')
|
|
87
|
+
|
|
88
|
+
# What to send for each argument that failed its declared `format`.
|
|
89
|
+
def format_spellings(errors)
|
|
90
|
+
errors.filter_map do |error|
|
|
91
|
+
next unless error['type'] == 'format'
|
|
92
|
+
|
|
93
|
+
spelling = ArgumentDecoder::FORMAT_SPELLINGS[error.dig('schema', 'format')]
|
|
94
|
+
"#{error['data_pointer'].delete_prefix('/')}: send #{spelling}. " if spelling
|
|
95
|
+
end.uniq.join
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def declared_property_names(input_schema)
|
|
99
|
+
properties = ArgumentDecoder.fetch(input_schema, :properties)
|
|
100
|
+
properties.is_a?(Hash) ? properties.keys.map(&:to_s) : []
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The nonce is named relative to `alg`: its shape depends on the backend.
|
|
104
|
+
POW_SHAPE_HINT =
|
|
105
|
+
'each Kiosk-PoW proof = ' \
|
|
106
|
+
'{challenge: <the challenge object from the 402, echoed verbatim>, ' \
|
|
107
|
+
"nonce: <the solution, in the shape the challenge's `alg` defines — " \
|
|
108
|
+
'for equihash, {indices: […], header_nonce?}>}; the header carries one proof as ' \
|
|
109
|
+
'raw JSON or a JSON array of proofs. ' \
|
|
110
|
+
'Solve each challenge issued in the pow_required 402 and echo it back verbatim.'
|
|
111
|
+
|
|
112
|
+
def reset! = Schemas.reset!
|
|
113
|
+
|
|
114
|
+
# The vendored schemas, parsed once.
|
|
115
|
+
module Schemas
|
|
116
|
+
module_function
|
|
117
|
+
|
|
118
|
+
def proof
|
|
119
|
+
@proof ||= build_proof
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Rooted at one `$def`, with the file's other `$defs` in scope.
|
|
123
|
+
def wire(pointer)
|
|
124
|
+
@wire ||= {}
|
|
125
|
+
@wire[pointer] ||= begin
|
|
126
|
+
RequestValidation.require_schemer!
|
|
127
|
+
file, fragment = pointer.split('#', 2)
|
|
128
|
+
doc = JSON.parse(File.read(File.join(SCHEMA_DIR, file)))
|
|
129
|
+
JSONSchemer.schema(doc.merge('$ref' => "##{fragment}"))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def reset!
|
|
134
|
+
@proof = nil
|
|
135
|
+
@wire = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_proof
|
|
139
|
+
RequestValidation.require_schemer!
|
|
140
|
+
doc = JSON.parse(File.read(POW_SCHEMA_PATH))
|
|
141
|
+
root = doc.merge('$ref' => '#/$defs/proof')
|
|
142
|
+
root.delete('oneOf')
|
|
143
|
+
JSONSchemer.schema(root)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def require_schemer!
|
|
148
|
+
require 'json_schemer'
|
|
149
|
+
rescue LoadError
|
|
150
|
+
raise Errors::ConfigurationError,
|
|
151
|
+
'Kiosk::Server: validate_requests is enabled but the json_schemer gem ' \
|
|
152
|
+
'is not loadable. It is a RUNTIME dependency of kiosk-server ' \
|
|
153
|
+
'and should already be in your lockfile — check that the bundle is ' \
|
|
154
|
+
'installed and not pruned (`bundle install`, or `bundle list | grep ' \
|
|
155
|
+
'json_schemer`); add `gem "json_schemer"` to your Gemfile only if you ' \
|
|
156
|
+
'load kiosk-server outside Bundler.'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def normalize(obj)
|
|
160
|
+
case obj
|
|
161
|
+
when Hash
|
|
162
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = normalize(v) }
|
|
163
|
+
when Array
|
|
164
|
+
obj.map { |v| normalize(v) }
|
|
165
|
+
else
|
|
166
|
+
obj
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# With `c.validate_responses`, checks every verb's answer against its own
|
|
6
|
+
# `output_schema` and fails loudly on a mismatch. Off by default: in
|
|
7
|
+
# production a descriptor typo would become a 500 for an innocent caller.
|
|
8
|
+
module ResponseValidation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def validate_payload!(payload, output_schema:, verb:, kind:)
|
|
12
|
+
return if output_schema.nil?
|
|
13
|
+
|
|
14
|
+
RequestValidation.require_schemer!
|
|
15
|
+
schemer = JSONSchemer.schema(RequestValidation.normalize(output_schema))
|
|
16
|
+
errors = schemer.validate(RequestValidation.normalize(payload)).to_a
|
|
17
|
+
return if errors.empty?
|
|
18
|
+
|
|
19
|
+
raise Errors::ActionFailed.new(
|
|
20
|
+
"#{kind} #{verb.inspect} rendered a payload its own output_schema rejects: " \
|
|
21
|
+
"#{summarise(errors)}",
|
|
22
|
+
hint: mismatch_hint(verb)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mismatch_hint(verb)
|
|
27
|
+
"the descriptor and the handler disagree — `#{verb}` publishes an " \
|
|
28
|
+
'output_schema that its rendered answer does not satisfy. Fix whichever ' \
|
|
29
|
+
'is wrong; a published schema an assistant cannot rely on is worse than ' \
|
|
30
|
+
'none. (Kiosk.configuration.validate_responses is on.)'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
MAX_REPORTED = 5
|
|
34
|
+
|
|
35
|
+
def summarise(errors)
|
|
36
|
+
reported = errors.first(MAX_REPORTED).map do |error|
|
|
37
|
+
pointer = error['data_pointer'].to_s
|
|
38
|
+
"#{pointer.empty? ? '(root)' : pointer}: #{error['error']}"
|
|
39
|
+
end
|
|
40
|
+
reported << "(#{errors.length - MAX_REPORTED} more)" if errors.length > MAX_REPORTED
|
|
41
|
+
reported.join('; ')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# The executor's internal carrier for a successful call; only {#to_payload}
|
|
6
|
+
# reaches the wire, as the body.
|
|
7
|
+
class Result < Data.define(:kind, :payload, :next_cursor, :total)
|
|
8
|
+
KINDS = %i[rows value].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(kind:, payload:, next_cursor: nil, total: nil)
|
|
11
|
+
kind = kind.to_sym
|
|
12
|
+
raise ArgumentError, "kind must be one of #{KINDS.inspect}, got #{kind.inspect}" unless KINDS.include?(kind)
|
|
13
|
+
raise ArgumentError, "next_cursor is only valid on a :rows result (got #{kind.inspect})" if next_cursor && kind != :rows
|
|
14
|
+
raise ArgumentError, "total is only valid on a :rows result (got #{kind.inspect})" if total && kind != :rows
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def http_status = 200
|
|
20
|
+
|
|
21
|
+
# Verbatim, with no envelope; a page's facts travel as headers (§8.2, §8.4).
|
|
22
|
+
def to_payload = payload
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Stores for `config.revocation_store`. A store answers
|
|
6
|
+
# `revoke_all(agent_id, at:)`, `watermark_for(agent_id)` and
|
|
7
|
+
# `revoked?(agent_id:, iat:)`: a token whose `iat` is strictly before the
|
|
8
|
+
# agent's watermark is revoked.
|
|
9
|
+
module RevocationStores
|
|
10
|
+
# The default store: the `<schema>.revocations` table, shared by every
|
|
11
|
+
# process and kept across restarts. A token of an unlinked or deleted
|
|
12
|
+
# agent is revoked too.
|
|
13
|
+
class ActiveRecord
|
|
14
|
+
# The watermark only moves forward.
|
|
15
|
+
def revoke_all(agent_id, at:)
|
|
16
|
+
return if agent_id.nil?
|
|
17
|
+
|
|
18
|
+
connection.exec_query(<<~SQL.squish, 'Kiosk revocation', [agent_id, at.to_i])
|
|
19
|
+
INSERT INTO #{table('revocations')} AS r (agent_id, revoked_before)
|
|
20
|
+
VALUES ($1, to_timestamp($2))
|
|
21
|
+
ON CONFLICT (agent_id) DO UPDATE
|
|
22
|
+
SET revoked_before = GREATEST(r.revoked_before, EXCLUDED.revoked_before)
|
|
23
|
+
SQL
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def watermark_for(agent_id)
|
|
28
|
+
return nil if agent_id.nil?
|
|
29
|
+
|
|
30
|
+
row = connection.exec_query(<<~SQL.squish, 'Kiosk revocation watermark', [agent_id]).to_a.first
|
|
31
|
+
SELECT extract(epoch FROM revoked_before)::bigint AS at
|
|
32
|
+
FROM #{table('revocations')} WHERE agent_id = $1
|
|
33
|
+
SQL
|
|
34
|
+
row&.fetch('at')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def revoked?(agent_id:, iat:)
|
|
38
|
+
return false if agent_id.nil? || iat.nil?
|
|
39
|
+
|
|
40
|
+
connection.exec_query(<<~SQL.squish, 'Kiosk revocation check', [agent_id, iat.to_i]).to_a.empty?
|
|
41
|
+
SELECT 1
|
|
42
|
+
FROM #{table('agents')} a
|
|
43
|
+
LEFT JOIN #{table('revocations')} r ON r.agent_id = a.id
|
|
44
|
+
WHERE a.id = $1
|
|
45
|
+
AND a.revoked_at IS NULL
|
|
46
|
+
AND (r.revoked_before IS NULL OR r.revoked_before <= to_timestamp($2))
|
|
47
|
+
SQL
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def connection = ::ActiveRecord::Base.lease_connection
|
|
53
|
+
def table(name) = %("#{Kiosk.configuration.schema}".#{name})
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_dispatch/routing'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# `kiosk do … end` in config/routes.rb. Mounts the engine at the configured
|
|
8
|
+
# mount_path first, so the protocol plane wins by first match, then draws
|
|
9
|
+
# GET <mount_path>/<name> for each query and POST for each action, each to
|
|
10
|
+
# the controller action that declares the verb.
|
|
11
|
+
#
|
|
12
|
+
# kiosk do
|
|
13
|
+
# queries :catalog
|
|
14
|
+
# actions :place_order
|
|
15
|
+
# end
|
|
16
|
+
module Routing
|
|
17
|
+
def kiosk(&block)
|
|
18
|
+
mount Kiosk::Server::Engine => Kiosk.configuration.mount_path
|
|
19
|
+
Verbs.new(self).instance_exec(&block) if block
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The block's receiver: a query or an action are the only verbs there are.
|
|
23
|
+
class Verbs
|
|
24
|
+
def initialize(mapper)
|
|
25
|
+
@mapper = mapper
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def queries(*names) = draw(:get, :query, names)
|
|
29
|
+
def actions(*names) = draw(:post, :action, names)
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def draw(method, kind, names)
|
|
34
|
+
names.each do |name|
|
|
35
|
+
@mapper.public_send(method, "#{Kiosk.configuration.mount_path}/#{name}", to: VerbRoute.new(kind, name.to_s))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The endpoint of one verb route. The controller action that declares the
|
|
41
|
+
# verb answers the wire request; the name is looked up per request, so a
|
|
42
|
+
# development reload is served. A name that no action answers goes to
|
|
43
|
+
# {VerbController}, which refuses it.
|
|
44
|
+
class VerbRoute
|
|
45
|
+
attr_reader :kind, :name
|
|
46
|
+
|
|
47
|
+
def initialize(kind, name)
|
|
48
|
+
@kind = kind
|
|
49
|
+
@name = name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call(env)
|
|
53
|
+
controller, action, extra = target
|
|
54
|
+
env[HandlerServing::VERB_KEY] = @name
|
|
55
|
+
env['action_dispatch.request.path_parameters'] = { controller: controller.controller_path, action: action, **extra }
|
|
56
|
+
controller.action(action).call(env)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The action this route reaches now, as `controller#action`; `bin/rails routes` prints it.
|
|
60
|
+
def endpoint = target.then { |controller, action| "#{controller.controller_path}##{action}" }
|
|
61
|
+
alias inspect endpoint
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def target
|
|
66
|
+
registry = HandlerMixin.registry_for(@kind)
|
|
67
|
+
handler = registry.fetch(@name) if registry.known.include?(@name)
|
|
68
|
+
controller = handler.controller if handler.is_a?(ControllerVerb)
|
|
69
|
+
return [controller, handler.method_name, {}] if controller
|
|
70
|
+
|
|
71
|
+
[VerbController, @kind == :query ? 'show' : 'create', { kiosk_verb: @name }]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|