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,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/uuid_check'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# §11.6 over a PSP adapter: claims the operator's payable row `unpaid →
|
|
8
|
+
# paying` before the capture, checks the cart against the operator's price,
|
|
9
|
+
# flips the row to `paid` once the capture returns, tells its owner on the
|
|
10
|
+
# `payment` topic, and runs `c.after_payment` once the settlement is recorded.
|
|
11
|
+
# A refund through it is recorded against the settlement.
|
|
12
|
+
class PaymentClaim
|
|
13
|
+
UNPAID = 'unpaid'
|
|
14
|
+
PAYING = 'paying'
|
|
15
|
+
PAID = 'paid'
|
|
16
|
+
TOPIC = 'payment'
|
|
17
|
+
EXAMPLE_ID = '5f0c2a8e-3b1d-4c6f-9a7e-2d8b4e1f6c03'
|
|
18
|
+
|
|
19
|
+
attr_reader :currency, :reference
|
|
20
|
+
|
|
21
|
+
# @param model [String] the payable model's name, e.g. "Order"; it includes Kiosk::Owned and Kiosk::Payable
|
|
22
|
+
# @param reference [String] the cart line-item key naming the row, e.g. "order_id"
|
|
23
|
+
# @param query [String] the per-user query that publishes the row's payment state
|
|
24
|
+
def initialize(psp, currency:, model:, reference:, query:)
|
|
25
|
+
@psp = psp
|
|
26
|
+
@currency = currency.to_s.downcase
|
|
27
|
+
@reference = reference
|
|
28
|
+
@query = query
|
|
29
|
+
@row = Row.new(model)
|
|
30
|
+
@options = { currency:, model:, reference:, query: }
|
|
31
|
+
extend SetupReturn if psp.respond_to?(:setup_return_user_id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The same claim over another PSP adapter.
|
|
35
|
+
def over(psp) = self.class.new(psp, **@options)
|
|
36
|
+
|
|
37
|
+
def capture(cart_mandate, payment_method: nil) = charge_row(claim!(cart_mandate), cart_mandate, payment_method)
|
|
38
|
+
|
|
39
|
+
delegate :setup_required?, :setup_url, to: :@psp
|
|
40
|
+
|
|
41
|
+
# The settlements whose cart names this row.
|
|
42
|
+
def settlements(id) = Kiosk::Settlement.joins(:cart_mandate).merge(Kiosk::CartMandate.referencing(@reference => id))
|
|
43
|
+
|
|
44
|
+
def settled?(id) = settlements(id).exists?
|
|
45
|
+
|
|
46
|
+
# `paying → paid`, then the `payment` event, once: only the call that
|
|
47
|
+
# moved the row announces it. A failure here never surfaces: the charge
|
|
48
|
+
# has happened and the settlement row records it.
|
|
49
|
+
def mark_paid!(id)
|
|
50
|
+
announce(id, PAID) if @row.flip(id, from: PAYING, to: PAID).positive?
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
FailureLog.report("#{self.class} could not mark #{id} paid", e)
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `paying → unpaid`, for a claim under which no money moved.
|
|
57
|
+
def release!(id) = @row.flip(id, from: PAYING, to: UNPAID)
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def charge_row(id, cart_mandate, payment_method)
|
|
62
|
+
settled = charge(id, cart_mandate, payment_method)
|
|
63
|
+
mark_paid!(id)
|
|
64
|
+
settled
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Releases the claim when the PSP refuses before any money moved.
|
|
68
|
+
def charge(id, cart_mandate, payment_method)
|
|
69
|
+
@psp.capture(cart_mandate, payment_method: payment_method)
|
|
70
|
+
rescue Kiosk::PaymentProviders::SetupRequired
|
|
71
|
+
release!(id)
|
|
72
|
+
raise
|
|
73
|
+
rescue Kiosk::PaymentProviders::PaymentFailed => e
|
|
74
|
+
release!(id) if e.retryable?
|
|
75
|
+
raise
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def claim!(cart)
|
|
79
|
+
id = referenced_id(cart)
|
|
80
|
+
refuse_unclaimable!(id, cart.user_id.to_s) unless @row.take(id, cart.user_id.to_s, from: UNPAID, to: PAYING).positive?
|
|
81
|
+
begin
|
|
82
|
+
check_price!(cart, id)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
release!(id)
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
id
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The one row the cart's line items name, in the operator's currency.
|
|
91
|
+
def referenced_id(cart)
|
|
92
|
+
deny "cart currency #{cart.currency.inspect} rejected — this operator prices in #{@currency.upcase}" unless cart.currency.to_s.downcase == @currency
|
|
93
|
+
Lines.reference(cart.line_items, @reference)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_price!(cart, id)
|
|
97
|
+
total = cart.total_amount_cents.to_i
|
|
98
|
+
lines = Array(cart.line_items).reject { |li| li[@reference] }
|
|
99
|
+
Lines.check_sum!(total, lines)
|
|
100
|
+
|
|
101
|
+
price = checker.call(id, lines)
|
|
102
|
+
deny price if price.is_a?(String)
|
|
103
|
+
return if total == price
|
|
104
|
+
|
|
105
|
+
deny "cart total #{total} does not equal this operator's price #{price} for #{noun} #{id} — " \
|
|
106
|
+
'sign the `cart` that the action which created it returned'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def checker
|
|
110
|
+
Kiosk.configuration.cart_price_checker or
|
|
111
|
+
raise Errors::ConfigurationError, "PaymentClaim needs c.cart_price_checker, the operator's price"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def refuse_unclaimable!(id, payer)
|
|
115
|
+
status = @row.status(id, payer)
|
|
116
|
+
deny "#{noun} not found or not yours" if status.nil?
|
|
117
|
+
refuse_paying!(id) if status == PAYING
|
|
118
|
+
deny "#{noun} #{id} is already paid (#{status}) — do not pay it again"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def refuse_paying!(id)
|
|
122
|
+
if settled?(id)
|
|
123
|
+
mark_paid!(id)
|
|
124
|
+
deny "#{noun} #{id} is already paid"
|
|
125
|
+
end
|
|
126
|
+
deny "#{noun} #{id} has a payment in progress under another chain. Send that chain again, " \
|
|
127
|
+
'byte for byte: it cannot charge twice. Do not sign a new chain. ' \
|
|
128
|
+
"GET <endpoint>/#{@query} shows the payment state."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def noun = @reference.delete_suffix('_id')
|
|
132
|
+
|
|
133
|
+
def deny(message)
|
|
134
|
+
raise Errors::Forbidden, message
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The cart's line items: the one row they name, and the priced lines' sum.
|
|
138
|
+
module Lines
|
|
139
|
+
module_function
|
|
140
|
+
|
|
141
|
+
def reference(line_items, key)
|
|
142
|
+
refs = Array(line_items).filter_map { |li| li[key] }.map(&:to_s).uniq
|
|
143
|
+
deny "cart line_items must reference exactly one #{key}" unless refs.size == 1
|
|
144
|
+
id = refs.first
|
|
145
|
+
return id if Kiosk::UuidCheck.valid?(id)
|
|
146
|
+
|
|
147
|
+
raise Errors::BadRequest.new("cart line_items #{key} #{id.inspect} is not a uuid",
|
|
148
|
+
hint: "use the `#{key}` this operator returned, verbatim")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The priced lines, when any, sum to the cart total.
|
|
152
|
+
def check_sum!(total, lines)
|
|
153
|
+
priced = lines.select { |li| li['price_cents'] }
|
|
154
|
+
return if priced.empty?
|
|
155
|
+
|
|
156
|
+
sum = priced.sum do |li|
|
|
157
|
+
qty = li['qty'].to_i
|
|
158
|
+
price = li['price_cents'].to_i
|
|
159
|
+
deny 'each priced line needs a positive qty and price_cents' if qty <= 0 || price <= 0
|
|
160
|
+
qty * price
|
|
161
|
+
end
|
|
162
|
+
deny "cart total #{total} does not equal the sum of its line items #{sum}" unless total == sum
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def deny(message)
|
|
166
|
+
raise Errors::Forbidden, message
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A repeat of a chain whose capture did not complete. The row stays with
|
|
171
|
+
# the newest unfinished chain, and the PSP keys the capture by the cart
|
|
172
|
+
# mandate id, so the repeat cannot charge twice.
|
|
173
|
+
module Resume
|
|
174
|
+
def resume(cart_mandate, payment_method: nil) = charge_row(reclaim!(cart_mandate), cart_mandate, payment_method)
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
# The row this repeated chain claimed, or the row it may claim now.
|
|
179
|
+
def reclaim!(cart)
|
|
180
|
+
id = referenced_id(cart)
|
|
181
|
+
payer = cart.user_id.to_s
|
|
182
|
+
status = @row.status(id, payer)
|
|
183
|
+
return claim!(cart) if status == UNPAID
|
|
184
|
+
return id if [PAYING, PAID].include?(status) && !settled?(id) && unfinished_chain(id, payer) == cart.id
|
|
185
|
+
|
|
186
|
+
refuse_unclaimable!(id, payer)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The signed id of the newest cart for this row that is neither settled nor declined.
|
|
190
|
+
def unfinished_chain(id, payer)
|
|
191
|
+
Kiosk::CartMandate.referencing(@reference => id).where(user_id: payer, declined_at: nil)
|
|
192
|
+
.where.missing(:settlements).order(created_at: :desc, id: :desc).pick(:mandate_id)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
include Resume
|
|
196
|
+
|
|
197
|
+
# Money the operator gives back, recorded against its settlement.
|
|
198
|
+
module Refund
|
|
199
|
+
# Returns `amount_cents` of the charge `psp_reference` to the card and
|
|
200
|
+
# records the refund against its settlement, so recent spend leaves it
|
|
201
|
+
# out. `key` names this refund among the refunds of the charge: a repeat
|
|
202
|
+
# with the same key is the same refund, and is recorded once. The PSP's receipt.
|
|
203
|
+
def refund(psp_reference:, amount_cents:, key:)
|
|
204
|
+
receipt = @psp.refund(psp_reference:, amount_cents:, key:)
|
|
205
|
+
binds = receipt.values_at(:psp_reference, :refunded_amount_cents, :refunded_at, :refunded_psp_reference)
|
|
206
|
+
::ActiveRecord::Base.lease_connection.exec_query(format(INSERT_SQL, schema: Kiosk.configuration.schema), 'Kiosk refund', binds)
|
|
207
|
+
receipt
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
INSERT_SQL = <<~SQL.squish
|
|
211
|
+
INSERT INTO "%<schema>s".refunds (settlement_id, psp_reference, refunded_amount_cents, refunded_at)
|
|
212
|
+
SELECT id, $1, $2, $3 FROM "%<schema>s".settlements WHERE psp_reference = $4
|
|
213
|
+
ON CONFLICT (psp_reference) DO NOTHING
|
|
214
|
+
SQL
|
|
215
|
+
end
|
|
216
|
+
include Refund
|
|
217
|
+
|
|
218
|
+
# The operator's work after a payment, which reads the settlement.
|
|
219
|
+
module AfterPayment
|
|
220
|
+
# The executor calls this once, after it records the settlement of
|
|
221
|
+
# `cart_mandate`, so `c.after_payment` reads the row's `settlement`. A
|
|
222
|
+
# failure here never surfaces on the wire: the log carries it.
|
|
223
|
+
def settled(cart_mandate)
|
|
224
|
+
id = Lines.reference(cart_mandate.line_items, @reference)
|
|
225
|
+
Kiosk.configuration.after_payment&.call(id)
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
FailureLog.report("#{self.class} after_payment failed for #{id}", e)
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
include AfterPayment
|
|
232
|
+
|
|
233
|
+
# The `payment` topic, which the engine serves beside `payment_setup`, and
|
|
234
|
+
# its events: where the money for one row stands.
|
|
235
|
+
module Topic
|
|
236
|
+
# Tells the row's owner where its money stands: `paid` or `refunded`.
|
|
237
|
+
def announce(id, payment_state)
|
|
238
|
+
Events.emit(topic: TOPIC, subject: id, identity_scope: [@row.owner(id)],
|
|
239
|
+
data: { @reference => id, 'payment_state' => payment_state })
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def topic
|
|
243
|
+
@topic ||= { name: TOPIC, reach: :principal,
|
|
244
|
+
description: "The money for one #{noun} of yours moved. `paid`: the charge went through. " \
|
|
245
|
+
'`refunded`: the charge went back to the card.',
|
|
246
|
+
payload_schema: { type: 'object', additionalProperties: false,
|
|
247
|
+
properties: { @reference => { type: 'string', format: 'uuid' },
|
|
248
|
+
'payment_state' => { enum: [PAID, Kiosk::Payable::REFUNDED] } },
|
|
249
|
+
required: [@reference, 'payment_state'] },
|
|
250
|
+
examples: [PAID, Kiosk::Payable::REFUNDED].map { { @reference => EXAMPLE_ID, 'payment_state' => it } },
|
|
251
|
+
subject_reachable: ->(id, identity) { !@row.status(id, identity.user_id.to_s).nil? } }.freeze
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
include Topic
|
|
255
|
+
|
|
256
|
+
# The PSP names whose human came back from the setup page; the engine
|
|
257
|
+
# serves the `payment_setup` topic only for a provider that has this method.
|
|
258
|
+
module SetupReturn
|
|
259
|
+
delegate :setup_return_user_id, to: :@psp
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The operator's payable rows: the status moves under a compare-and-set.
|
|
263
|
+
class Row
|
|
264
|
+
def initialize(model)
|
|
265
|
+
@model = model
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Moves this payer's row `from → to`; the count of rows moved, 0 or 1.
|
|
269
|
+
def take(id, payer, from:, to:) = move(model.owned_by(payer).where(id:), from:, to:)
|
|
270
|
+
|
|
271
|
+
def status(id, payer) = model.owned_by(payer).where(id:).pick(:payment_status)
|
|
272
|
+
|
|
273
|
+
def owner(id) = model.where(id:).pick(:user_id)
|
|
274
|
+
|
|
275
|
+
def flip(id, from:, to:) = move(model.where(id:), from:, to:)
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def move(rows, from:, to:)
|
|
280
|
+
# One conditional UPDATE is the atomic compare-and-set: the row count says which caller won.
|
|
281
|
+
rows.where(payment_status: from).update_all({ payment_status: to }.merge(timestamps)) # rubocop:disable Rails/SkipsModelValidations -- a per-row save cannot move a row atomically
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def timestamps
|
|
285
|
+
now = model.current_time_from_proper_timezone
|
|
286
|
+
model.timestamp_attributes_for_update_in_model.index_with { now }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def model = @model.constantize
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# `payment_setup`: can this principal pay now, or which page must their
|
|
6
|
+
# human finish first. Served on every origin with a `payment_provider`.
|
|
7
|
+
module PaymentSetup
|
|
8
|
+
NAME = 'payment_setup'
|
|
9
|
+
RETURN_PATH = 'payment_setup/return'
|
|
10
|
+
|
|
11
|
+
DESCRIPTION =
|
|
12
|
+
'Check whether you can pay now. Returns {status: "ready"} when `pay` will be accepted. ' \
|
|
13
|
+
'Returns {status: "setup_required", setup_url: "…"} when your human must first open ' \
|
|
14
|
+
'setup_url and finish there (for example, save a card); call payment_setup again ' \
|
|
15
|
+
'before paying. Call it before `pay`. ' \
|
|
16
|
+
'POLLING, where this origin publishes no payment_setup topic: while your human is at ' \
|
|
17
|
+
'the page, re-check every ~5 seconds for the first minute, then every ~15 seconds, and ' \
|
|
18
|
+
'GIVE UP after about 5 minutes — tell your human the setup is still not finished rather ' \
|
|
19
|
+
'than polling indefinitely; they can finish later and you re-check then. ' \
|
|
20
|
+
'Relay ONE link: if a later check returns a different url, leave your human on the page ' \
|
|
21
|
+
'they already have open unless they say it stopped working.'
|
|
22
|
+
|
|
23
|
+
INPUT_SCHEMA = { type: 'object', additionalProperties: false, properties: {}, required: [] }.freeze
|
|
24
|
+
|
|
25
|
+
OUTPUT_SCHEMA = {
|
|
26
|
+
oneOf: [
|
|
27
|
+
{ type: 'object', additionalProperties: false,
|
|
28
|
+
description: 'Nothing to set up — proceed to `pay`.',
|
|
29
|
+
properties: { status: { const: 'ready' } },
|
|
30
|
+
required: ['status'] },
|
|
31
|
+
{ type: 'object', additionalProperties: false,
|
|
32
|
+
description: 'Your human must finish the page at setup_url first.',
|
|
33
|
+
properties: {
|
|
34
|
+
status: { const: 'setup_required' },
|
|
35
|
+
setup_url: { type: 'string', description: 'The page to hand to your human.' }
|
|
36
|
+
},
|
|
37
|
+
required: %w[status setup_url] }
|
|
38
|
+
]
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
TOPIC = {
|
|
42
|
+
name: NAME,
|
|
43
|
+
reach: :principal,
|
|
44
|
+
description: 'Your human finished the payment setup. `pay` will now be accepted — ' \
|
|
45
|
+
'no further payment_setup call is needed.',
|
|
46
|
+
payload_schema: { type: 'object', additionalProperties: false,
|
|
47
|
+
properties: { status: { enum: %w[ready] } },
|
|
48
|
+
required: %w[status] },
|
|
49
|
+
subject_reachable: ->(subject, identity) { subject.to_s == identity.user_id.to_s }
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def provider = Kiosk.configuration.payment_provider
|
|
54
|
+
|
|
55
|
+
def served!
|
|
56
|
+
return if provider
|
|
57
|
+
|
|
58
|
+
raise Errors::ModuleNotServed.new(
|
|
59
|
+
'this operator does not serve the payment module',
|
|
60
|
+
hint: "`pay` is absent from this origin's capabilities; hand the transaction to your human"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Publishes the action; the topic when the provider can say whose human
|
|
65
|
+
# came back from the setup page; and a PaymentClaim's `payment` topic.
|
|
66
|
+
def register!
|
|
67
|
+
Actions.declare(NAME, method(:call), description: DESCRIPTION,
|
|
68
|
+
input_schema: INPUT_SCHEMA, output_schema: OUTPUT_SCHEMA)
|
|
69
|
+
Events.register(**TOPIC) if provider.respond_to?(:setup_return_user_id)
|
|
70
|
+
Events.register(**provider.topic) if provider.respond_to?(:topic)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call(_args)
|
|
74
|
+
user_id = CurrentRequest.identity.user_id
|
|
75
|
+
return { 'status' => 'ready' } unless provider.setup_required?(user_id: user_id)
|
|
76
|
+
|
|
77
|
+
{ 'status' => 'setup_required', 'setup_url' => provider.setup_url(user_id: user_id, return_url: return_url) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def return_url
|
|
81
|
+
"#{Kiosk.current_issuer.to_s.chomp('/')}#{Kiosk.configuration.mount_path}/#{RETURN_PATH}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Unauthenticated, so only a hint: the provider names the principal and
|
|
85
|
+
# readiness is asked again. Never raises.
|
|
86
|
+
def returned(params)
|
|
87
|
+
return false unless Events.fetch(NAME)
|
|
88
|
+
|
|
89
|
+
user_id = provider.setup_return_user_id(params).to_s
|
|
90
|
+
return false if user_id.empty? || provider.setup_required?(user_id: user_id)
|
|
91
|
+
|
|
92
|
+
Events.emit(topic: NAME, subject: user_id, identity_scope: [user_id],
|
|
93
|
+
data: { 'status' => 'ready' })
|
|
94
|
+
true
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
FailureLog.report("#{RETURN_PATH} could not push #{NAME}", e)
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'rails'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module Server
|
|
9
|
+
# Verifies an agent's proof-of-possession JWS: RS256 by the presented key,
|
|
10
|
+
# `aud` equal to this origin (the relay defence), and the challenge `nonce`.
|
|
11
|
+
# The caller burns the nonce only after a clean verify.
|
|
12
|
+
module PopVerifier
|
|
13
|
+
# Names no origin: an echoed one is what a relaying server would exploit.
|
|
14
|
+
AUDIENCE_HINT =
|
|
15
|
+
'sign `aud` = the origin you connected to, taken from your own request ' \
|
|
16
|
+
'URL — never from a value echoed back in a response'
|
|
17
|
+
|
|
18
|
+
PROOF_REQUIRED_CLAIMS = %w[aud nonce jti].freeze
|
|
19
|
+
|
|
20
|
+
MAX_KEY_BITS = 4096
|
|
21
|
+
|
|
22
|
+
PROOF_CLAIMS_HINT =
|
|
23
|
+
"a proof's payload carries #{PROOF_REQUIRED_CLAIMS.join(', ')} — `aud` is the origin " \
|
|
24
|
+
'you connected to, `nonce` the value GET /auth/challenge just issued for this key, ' \
|
|
25
|
+
'`jti` a unique id for this proof'.freeze
|
|
26
|
+
|
|
27
|
+
PROOF_SIGNATURE_HINT =
|
|
28
|
+
'a proof is a compact RS256 JWS — header.payload.signature — signed with the ' \
|
|
29
|
+
'private key matching the public_key you sent'
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def verify!(public_key_pem:, signed:)
|
|
34
|
+
pem = public_key_pem.to_s.strip
|
|
35
|
+
payload = decode(signed, load_public_key(pem))
|
|
36
|
+
check_audience!(payload[:aud])
|
|
37
|
+
raise Errors::Unauthenticated, 'proof key thumbprint mismatch' if payload.key?(:pub) && payload[:pub] != SigningKey.from_pem(pem).kid
|
|
38
|
+
|
|
39
|
+
payload
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def decode(signed, key)
|
|
43
|
+
payload, = ::JWT.decode(signed.to_s, key, true, algorithms: ['RS256'], required_claims: PROOF_REQUIRED_CLAIMS)
|
|
44
|
+
payload.transform_keys(&:to_sym)
|
|
45
|
+
rescue ::JWT::MissingRequiredClaim
|
|
46
|
+
raise Errors::Unauthenticated.new('proof is missing a required claim', hint: PROOF_CLAIMS_HINT)
|
|
47
|
+
rescue ::JWT::DecodeError
|
|
48
|
+
raise Errors::Unauthenticated.new('proof signature invalid', hint: PROOF_SIGNATURE_HINT)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_audience!(signed_aud)
|
|
52
|
+
issuer = Kiosk.current_issuer
|
|
53
|
+
return if signed_aud == issuer
|
|
54
|
+
|
|
55
|
+
log_audience_mismatch(signed_aud: signed_aud, issuer: issuer)
|
|
56
|
+
raise Errors::Unauthenticated.new('proof audience mismatch', hint: AUDIENCE_HINT)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Operator log only, since the response must not name an origin: the
|
|
60
|
+
# caller signed a wrong value, or the origin it reached is not served.
|
|
61
|
+
def log_audience_mismatch(signed_aud:, issuer:)
|
|
62
|
+
message = "[kiosk] PoP audience mismatch: caller signed aud=#{signed_aud.inspect}, " \
|
|
63
|
+
"the issuer for this request is #{issuer.inspect}. If the signed value is " \
|
|
64
|
+
'an origin your assistants actually reach, list it in `c.issuer` or ' \
|
|
65
|
+
'`c.additional_origins`.'
|
|
66
|
+
logger = ::Rails.logger
|
|
67
|
+
logger ? logger.warn(message) : warn(message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Also the key bounds of account binding.
|
|
71
|
+
def load_public_key(pem)
|
|
72
|
+
OpenSSL::PKey::RSA.new(pem).tap { require_key_size!(it.n.num_bits) }
|
|
73
|
+
rescue OpenSSL::PKey::PKeyError
|
|
74
|
+
raise Errors::BadRequest.new(
|
|
75
|
+
'invalid public key',
|
|
76
|
+
hint: "send a PEM-encoded RSA public key of #{SigningKey::MIN_KEY_BITS} to #{MAX_KEY_BITS} bits " \
|
|
77
|
+
'(-----BEGIN PUBLIC KEY----- …)'
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def require_key_size!(bits)
|
|
82
|
+
raise Errors::BadRequest, "public key too small (#{bits} bits; minimum #{SigningKey::MIN_KEY_BITS})" if bits < SigningKey::MIN_KEY_BITS
|
|
83
|
+
raise Errors::BadRequest, "public key too large (#{bits} bits; maximum #{MAX_KEY_BITS})" if bits > MAX_KEY_BITS
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# The proof-of-work toll, in front of every verb that reaches the executor.
|
|
9
|
+
# It is paid once per access token: after one paid toll, every call made
|
|
10
|
+
# with that token proceeds until the token expires. With no
|
|
11
|
+
# `reputation_policy` it returns `:proceed` and touches nothing;
|
|
12
|
+
# `kiosk-reputation` is required only when a policy is set.
|
|
13
|
+
module PowGate
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# §10, §15.2: SHA256("<METHOD> <verb>\n<canonical args>"), so a proof is
|
|
17
|
+
# spendable on this request only. The proof rides in a header, not the body.
|
|
18
|
+
def request_fingerprint(method:, verb:, body:)
|
|
19
|
+
Digest::SHA256.hexdigest(
|
|
20
|
+
"#{method.to_s.upcase} #{verb}\n#{canonical_json(body || {})}"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# `command` is the policy verb ({Executor::VERBS}); `verb` is the wire name
|
|
25
|
+
# the fingerprint binds.
|
|
26
|
+
def gate(identity:, command:, body:, pow:, method: 'POST', verb: nil) # rubocop:disable Metrics/ParameterLists -- the wire controller and the specs pass these six by name
|
|
27
|
+
config = Kiosk.configuration
|
|
28
|
+
policy = config.reputation_policy
|
|
29
|
+
return :proceed if policy.nil? || session_paid?(config, identity)
|
|
30
|
+
|
|
31
|
+
secret = pow_secret!(config)
|
|
32
|
+
spec = toll_for(policy, identity, command.to_sym)
|
|
33
|
+
return :proceed if spec.nil?
|
|
34
|
+
|
|
35
|
+
fp = request_fingerprint(method: method, verb: verb || command, body: body)
|
|
36
|
+
PowToll.new(spec: spec, fingerprint: fp, secret: secret, config: config)
|
|
37
|
+
.collect!(pow).tap { paid(config, identity) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def session_paid?(config, identity)
|
|
41
|
+
session = session_id(identity)
|
|
42
|
+
!session.nil? && config.pow_spent_store.spent?(session)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Records the token's session as paid.
|
|
46
|
+
def paid(config, identity)
|
|
47
|
+
session = session_id(identity)
|
|
48
|
+
config.pow_spent_store.mark_spent(session, identity.claims[:exp]) if session
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The access token a toll pays for, by its `jti`, or nil for a caller without one.
|
|
52
|
+
def session_id(identity)
|
|
53
|
+
claims = identity&.claims || {}
|
|
54
|
+
"session:#{claims[:jti]}" if claims[:jti] && claims[:exp]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The policy's toll for this caller and verb, or nil for none.
|
|
58
|
+
def toll_for(policy, identity, verb)
|
|
59
|
+
policy.challenge_for(identity: identity, verb: verb, factors: ReputationFactors.for(identity:, policy:))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pow_secret!(config)
|
|
63
|
+
unless defined?(::Kiosk::Reputation)
|
|
64
|
+
raise Errors::ConfigurationError,
|
|
65
|
+
'Kiosk::Server: reputation_policy is set but kiosk-reputation is not loaded. ' \
|
|
66
|
+
"Add `require 'kiosk/reputation'` (and `gem 'kiosk-reputation'`) to your app."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
secret = config.pow_secret
|
|
70
|
+
return secret unless secret.nil? || secret.to_s.strip.empty?
|
|
71
|
+
|
|
72
|
+
raise Errors::ConfigurationError,
|
|
73
|
+
'Kiosk::Server: reputation_policy is set but pow_secret is nil or empty. ' \
|
|
74
|
+
"Set: Kiosk.configure { |c| c.pow_secret = ENV.fetch('KIOSK_POW_SECRET') }"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# One proof, a JSON array, repeated header lines (Rack joins them with
|
|
78
|
+
# "\n") or comma-joined ones; nil when absent.
|
|
79
|
+
def proofs_from_header(raw)
|
|
80
|
+
return nil if raw.nil?
|
|
81
|
+
|
|
82
|
+
proofs = raw.split("\n").map(&:strip).reject(&:empty?).flat_map { |value| proofs_in(value) }
|
|
83
|
+
proofs.empty? ? nil : proofs
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
raise Errors::BadRequest.new('malformed Kiosk-PoW header', hint: POW_HEADER_HINT)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def proofs_in(value)
|
|
89
|
+
Array(JSON.parse(value.start_with?('[') ? value : "[#{value}]", symbolize_names: true))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
POW_HEADER_HINT =
|
|
93
|
+
'the Kiosk-PoW header carries the proof(s) as raw minified JSON: a single ' \
|
|
94
|
+
'proof {"challenge": <the challenge object from the 402, echoed verbatim>, ' \
|
|
95
|
+
'"nonce": {"indices": […], "header_nonce"?}}, OR a JSON array of such ' \
|
|
96
|
+
'proofs [{…},{…}]. Repeated Kiosk-PoW header lines (one proof each) also ' \
|
|
97
|
+
'work. Solve every challenge issued in the pow_required 402 and echo it ' \
|
|
98
|
+
'back verbatim.'
|
|
99
|
+
|
|
100
|
+
# Unversioned: the server cannot know which skill cut its caller read.
|
|
101
|
+
POW_SOLVER_URL = 'https://kiosk.tech/pow/solve.py'
|
|
102
|
+
|
|
103
|
+
POW_INVALID_HINT =
|
|
104
|
+
"solve with the reference solver at #{POW_SOLVER_URL} — " \
|
|
105
|
+
'a hand-written Equihash solver will not match this verifier'.freeze
|
|
106
|
+
|
|
107
|
+
def canonical_json(obj)
|
|
108
|
+
case obj
|
|
109
|
+
when Hash
|
|
110
|
+
pairs = obj.sort_by { |k, _| k.to_s }
|
|
111
|
+
.map { |k, v| "#{JSON.generate(k.to_s)}:#{canonical_json(v)}" }
|
|
112
|
+
"{#{pairs.join(',')}}"
|
|
113
|
+
when Array
|
|
114
|
+
"[#{obj.map { |v| canonical_json(v) }.join(',')}]"
|
|
115
|
+
else
|
|
116
|
+
JSON.generate(obj)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|