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,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