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,543 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# `GET <endpoint>/openapi.json`: an OpenAPI 3.1 rendering of the same
|
|
9
|
+
# registries `/schema` renders, for tooling. `/schema` stays canonical;
|
|
10
|
+
# nothing here may say what the descriptors do not.
|
|
11
|
+
module OpenApi
|
|
12
|
+
# 3.1: its Schema Objects are JSON Schema 2020-12, so descriptors embed verbatim.
|
|
13
|
+
OPENAPI_VERSION = '3.1.0'
|
|
14
|
+
|
|
15
|
+
JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'
|
|
16
|
+
|
|
17
|
+
CONTENT_TYPE = 'application/vnd.oai.openapi+json;version=3.1'
|
|
18
|
+
|
|
19
|
+
# A verb or topic name as a JSON Schema pattern, which is ECMA-262: it anchors with `^` and `$`.
|
|
20
|
+
NAME_PATTERN = "^#{HandlerMixin::NAME_PATTERN.source.delete_prefix('\\A').delete_suffix('\\z')}$".freeze
|
|
21
|
+
|
|
22
|
+
# No operation answers 405: the other method at a verb's path matches no route.
|
|
23
|
+
METHOD_NOT_ALLOWED = 'method_not_allowed'
|
|
24
|
+
|
|
25
|
+
INFO_DESCRIPTION = <<~TEXT.strip
|
|
26
|
+
A DERIVED description of this origin's Kiosk verbs, generated from the
|
|
27
|
+
same registry `GET <endpoint>/schema` renders. `schema` is the
|
|
28
|
+
canonical catalog and this document is a convenience for tooling; where
|
|
29
|
+
the two could ever disagree, `schema` is right.
|
|
30
|
+
|
|
31
|
+
Every verb is its own endpoint: a query is a GET whose arguments are in
|
|
32
|
+
the query string, an action is a POST whose arguments are a JSON body.
|
|
33
|
+
There is no third channel. This origin draws one route per verb with
|
|
34
|
+
the method its kind requires and nothing else, so calling a verb with
|
|
35
|
+
the other method matches no route and answers an ordinary 404 with no
|
|
36
|
+
problem document and no `Allow`. Read the kind off this document, or
|
|
37
|
+
off `GET <endpoint>/schema`, and dial the method it names.
|
|
38
|
+
|
|
39
|
+
A success body is the verb's result and nothing else: no envelope, no
|
|
40
|
+
`ok` flag, no `kind`. An error body is an RFC 9457 problem document
|
|
41
|
+
served as `application/problem+json`; branch on its `code` member, not
|
|
42
|
+
on the HTTP status alone.
|
|
43
|
+
|
|
44
|
+
`limit` and `cursor` are reserved parameter names this wire always
|
|
45
|
+
accepts on a query. A query may declare `limit` to bound its page
|
|
46
|
+
size; no verb declares `cursor`. The two names drive the cursor
|
|
47
|
+
pagination of the specification's Section 8.4. A
|
|
48
|
+
paginated answer is still a bare array: the next page's URI arrives in
|
|
49
|
+
an RFC 8288 `Link` response header with `rel="next"`, and the count of
|
|
50
|
+
matching rows in `X-Total-Count`.
|
|
51
|
+
|
|
52
|
+
Normative specification: https://kiosk.tech/specification.html
|
|
53
|
+
TEXT
|
|
54
|
+
|
|
55
|
+
EXTERNAL_DOCS = {
|
|
56
|
+
description: 'The Kiosk specification (normative)',
|
|
57
|
+
url: 'https://kiosk.tech/specification.html'
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
TAGS = [
|
|
61
|
+
{ name: 'wire', description: "The protocol's own reserved endpoints." },
|
|
62
|
+
{ name: 'queries', description: 'Reads. GET, arguments in the query string.' },
|
|
63
|
+
{ name: 'actions', description: 'Writes. POST, arguments in a JSON body.' }
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
def self.build(base_url:, config: Kiosk.configuration)
|
|
67
|
+
endpoint = base_url.to_s.chomp('/') + config.mount_path
|
|
68
|
+
schemas, paths = Wire.surface(Array(config.capabilities).map(&:to_s))
|
|
69
|
+
Verbs.add_entries(schemas, paths)
|
|
70
|
+
document(endpoint, config, paths, schemas)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.document(endpoint, config, paths, schemas)
|
|
74
|
+
{
|
|
75
|
+
openapi: OPENAPI_VERSION, jsonSchemaDialect: JSON_SCHEMA_DIALECT,
|
|
76
|
+
info: info(config),
|
|
77
|
+
externalDocs: EXTERNAL_DOCS,
|
|
78
|
+
servers: [{ url: endpoint, description: "This origin's Kiosk endpoint." }],
|
|
79
|
+
security: [{ bearerAuth: [] }],
|
|
80
|
+
tags: TAGS,
|
|
81
|
+
paths: paths,
|
|
82
|
+
components: Wire.components(schemas)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
private_class_method :document
|
|
86
|
+
|
|
87
|
+
def self.info(config)
|
|
88
|
+
{
|
|
89
|
+
title: "#{WellKnown.site_name(config)} — Kiosk",
|
|
90
|
+
version: Kiosk::Protocol::API_VERSION,
|
|
91
|
+
description: INFO_DESCRIPTION
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
private_class_method :info
|
|
95
|
+
|
|
96
|
+
# Unmemoized; the served bytes come from {.json}.
|
|
97
|
+
def self.build_json(**)
|
|
98
|
+
JSON.generate(build(**))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Memoized per `[base_url, SchemaDocument.digest]`: `servers[0].url` names
|
|
102
|
+
# the requesting origin, and every other input is in the catalog digest.
|
|
103
|
+
DIGEST_LENGTH = 32
|
|
104
|
+
|
|
105
|
+
class << self
|
|
106
|
+
def json(base_url:, config: Kiosk.configuration)
|
|
107
|
+
derive(base_url: base_url, config: config).fetch(:json)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def etag(base_url:, config: Kiosk.configuration)
|
|
111
|
+
%("#{derive(base_url: base_url, config: config).fetch(:digest)}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Called from the engine's `to_prepare`.
|
|
115
|
+
def reset!
|
|
116
|
+
@memo = nil
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def derive(base_url:, config:)
|
|
123
|
+
key = [base_url.to_s, SchemaDocument.digest(config: config)]
|
|
124
|
+
return @memo if @memo && @memo[:key] == key
|
|
125
|
+
|
|
126
|
+
json = build_json(base_url: base_url, config: config)
|
|
127
|
+
@memo = { key: key, json: json.freeze,
|
|
128
|
+
digest: Digest::SHA256.hexdigest(json)[0, DIGEST_LENGTH].freeze }.freeze
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The registered verbs: one path and its components per verb.
|
|
133
|
+
module Verbs
|
|
134
|
+
def self.add_entries(schemas, paths)
|
|
135
|
+
entries.each do |kind, descriptor|
|
|
136
|
+
name = descriptor[:name].to_s
|
|
137
|
+
input = descriptor[:input_schema]
|
|
138
|
+
schemas.merge!(Refs.components_for(descriptor[:output_schema], "#{name}.response"))
|
|
139
|
+
schemas.merge!(Refs.components_for(input, "#{name}.request")) if request_component?(kind, input)
|
|
140
|
+
paths["/#{name}"] = { http_method(kind) => operation(kind, descriptor) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Sorted by name across both registries; one name is one kind.
|
|
145
|
+
def self.entries
|
|
146
|
+
(Queries.catalog.map { |d| [:query, d] } + Actions.catalog.map { |d| [:action, d] })
|
|
147
|
+
.sort_by { |(_kind, descriptor)| descriptor[:name].to_s }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.http_method(kind) = kind == :query ? :get : :post
|
|
151
|
+
|
|
152
|
+
def self.operation(kind, descriptor)
|
|
153
|
+
name = descriptor[:name].to_s
|
|
154
|
+
op = { operationId: name, tags: [kind == :query ? 'queries' : 'actions'] }
|
|
155
|
+
op[:description] = descriptor[:description] unless descriptor[:description].nil?
|
|
156
|
+
if kind == :query
|
|
157
|
+
op[:parameters] = query_parameters(name, descriptor[:input_schema])
|
|
158
|
+
else
|
|
159
|
+
op[:requestBody] = request_body(name, descriptor[:input_schema])
|
|
160
|
+
end
|
|
161
|
+
op[:responses] = responses(kind, descriptor)
|
|
162
|
+
op
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.query_parameters(name, input_schema)
|
|
166
|
+
properties = ArgumentDecoder.fetch(input_schema, :properties) || {}
|
|
167
|
+
required = Array(ArgumentDecoder.fetch(input_schema, :required)).map(&:to_s)
|
|
168
|
+
base = "#{name}.request"
|
|
169
|
+
map = Refs.ref_map(input_schema, base)
|
|
170
|
+
declared = properties.map do |property, schema|
|
|
171
|
+
query_parameter(property.to_s, schema, required, Refs.rewrite_refs(schema, map, base))
|
|
172
|
+
end
|
|
173
|
+
declared + reserved_parameter_refs(properties.keys.map(&:to_s))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.query_parameter(name, schema, required_names, rewritten)
|
|
177
|
+
{
|
|
178
|
+
name: name,
|
|
179
|
+
in: 'query',
|
|
180
|
+
required: required_names.include?(name),
|
|
181
|
+
# Explicit: some tools ignore the OAS defaults.
|
|
182
|
+
style: style_for(schema),
|
|
183
|
+
explode: true,
|
|
184
|
+
schema: rewritten
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `limit`/`cursor` are always accepted, so add each one the verb did not declare.
|
|
189
|
+
def self.reserved_parameter_refs(declared)
|
|
190
|
+
ArgumentDecoder::RESERVED.keys.reject { |reserved| declared.include?(reserved) }
|
|
191
|
+
.map { |reserved| { '$ref': "#/components/parameters/#{reserved}" } }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# OAS 3.1.1 §4.8.12.3: `deepObject` is for objects only.
|
|
195
|
+
def self.style_for(schema)
|
|
196
|
+
ArgumentDecoder.declared_type(schema) == 'object' ? 'deepObject' : 'form'
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Required only when the schema requires a property; an absent body reads as `{}`.
|
|
200
|
+
def self.request_body(name, input_schema)
|
|
201
|
+
{
|
|
202
|
+
required: !Array(ArgumentDecoder.fetch(input_schema, :required)).empty?,
|
|
203
|
+
content: Refs.json_content("#{name}.request")
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.responses(kind, descriptor)
|
|
208
|
+
ok = {
|
|
209
|
+
description: ArgumentDecoder.fetch(descriptor[:output_schema], :description) || "The verb's result.",
|
|
210
|
+
content: Refs.json_content("#{descriptor[:name]}.response")
|
|
211
|
+
}
|
|
212
|
+
ok[:headers] = Parameters::PAGINATION_HEADERS.keys.index_with { |h| { '$ref': "#/components/headers/#{h}" } } if kind == :query
|
|
213
|
+
{ '200' => ok }.merge(Wire.problem_refs)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A query's input is a component only when a parameter points into it.
|
|
217
|
+
def self.request_component?(kind, input_schema)
|
|
218
|
+
kind == :action || Refs.contains_ref?(input_schema)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# `$defs` hoisting and `$ref` rewriting between a descriptor and `components/schemas`.
|
|
223
|
+
module Refs
|
|
224
|
+
module_function
|
|
225
|
+
|
|
226
|
+
def json_content(component)
|
|
227
|
+
{ 'application/json' => { schema: { '$ref': "#/components/schemas/#{component}" } } }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Hoists the schema's root `$defs` into `components/schemas` as
|
|
231
|
+
# `<verb>.<slot>.<name>` and rewrites the `$ref`s to match.
|
|
232
|
+
def components_for(schema, base)
|
|
233
|
+
defs = ArgumentDecoder.fetch(schema, :$defs)
|
|
234
|
+
map = ref_map(schema, base)
|
|
235
|
+
body = schema.is_a?(::Hash) ? schema.reject { |key, _| key.to_s == '$defs' } : schema
|
|
236
|
+
out = { base => rewrite_refs(body, map, base) }
|
|
237
|
+
return out unless defs.is_a?(::Hash)
|
|
238
|
+
|
|
239
|
+
defs.each { |name, definition| out["#{base}.#{name}"] = rewrite_refs(definition, map, "#{base}.#{name}") }
|
|
240
|
+
out
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ref_map(schema, base)
|
|
244
|
+
defs = ArgumentDecoder.fetch(schema, :$defs)
|
|
245
|
+
return {} unless defs.is_a?(::Hash)
|
|
246
|
+
|
|
247
|
+
defs.keys.to_h { |name| ["#/$defs/#{name}", "#/components/schemas/#{base}.#{name}"] }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Document-relative refs only; external ones are left alone.
|
|
251
|
+
def rewrite_refs(node, map, base)
|
|
252
|
+
case node
|
|
253
|
+
when ::Hash then node.to_h { |key, value| [key, rewrite_member(key, value, map, base)] }
|
|
254
|
+
when ::Array then node.map { |element| rewrite_refs(element, map, base) }
|
|
255
|
+
else node
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def rewrite_member(key, value, map, base)
|
|
260
|
+
return rewrite_refs(value, map, base) unless key.to_s == '$ref' && value.is_a?(::String) && value.start_with?('#')
|
|
261
|
+
|
|
262
|
+
rewrite_ref(value, map, "#/components/schemas/#{base}")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def rewrite_ref(ref, map, prefix)
|
|
266
|
+
return prefix if ref == '#'
|
|
267
|
+
|
|
268
|
+
hit = map.keys.find { |pointer| ref == pointer || ref.start_with?("#{pointer}/") }
|
|
269
|
+
return "#{map.fetch(hit)}#{ref.delete_prefix(hit)}" if hit
|
|
270
|
+
|
|
271
|
+
"#{prefix}#{ref.delete_prefix('#')}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def contains_ref?(node)
|
|
275
|
+
case node
|
|
276
|
+
when ::Hash then node.any? { |key, value| key.to_s == '$ref' || contains_ref?(value) }
|
|
277
|
+
when ::Array then node.any? { |element| contains_ref?(element) }
|
|
278
|
+
else false
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The protocol's own endpoints (§8.3, §11.3), declared rather than derived,
|
|
284
|
+
# and the problem responses every operation shares.
|
|
285
|
+
module Wire
|
|
286
|
+
SCHEMA_DESCRIPTION = "This origin's catalog: every query and action it publishes, " \
|
|
287
|
+
'with their descriptions and schemas. THE canonical surface ' \
|
|
288
|
+
'description — this OpenAPI document is derived from it. ' \
|
|
289
|
+
'PUBLIC: no credential is required. The MODULE set this origin ' \
|
|
290
|
+
'serves is `capabilities` in /.well-known/kiosk.json.'
|
|
291
|
+
|
|
292
|
+
PAY_DESCRIPTION = 'Settle an AP2 cart: submit the signed intent -> cart -> payment ' \
|
|
293
|
+
'mandate chain. Answers 402 `payment_setup_required` when the ' \
|
|
294
|
+
'identity has no card on file and 402 `payment_failed` when the ' \
|
|
295
|
+
"charge did not settle — branch on the problem document's `code`."
|
|
296
|
+
|
|
297
|
+
module_function
|
|
298
|
+
|
|
299
|
+
# The `Problem` schema, plus `/schema` and `/pay` when their modules are served.
|
|
300
|
+
def surface(modules)
|
|
301
|
+
schemas = { 'Problem' => Components::PROBLEM }
|
|
302
|
+
paths = {}
|
|
303
|
+
if modules.include?('schema')
|
|
304
|
+
schemas.merge!(Components::SCHEMA)
|
|
305
|
+
paths['/schema'] = { get: schema_operation }
|
|
306
|
+
end
|
|
307
|
+
return [schemas, paths] unless modules.include?('pay')
|
|
308
|
+
|
|
309
|
+
[schemas.merge(Components::PAY), paths.merge('/pay' => { post: pay_operation })]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def components(schemas)
|
|
313
|
+
{
|
|
314
|
+
securitySchemes: { bearerAuth: Parameters::BEARER_SCHEME },
|
|
315
|
+
parameters: Parameters::RESERVED_PARAMETERS,
|
|
316
|
+
headers: Parameters::PAGINATION_HEADERS,
|
|
317
|
+
responses: problem_responses,
|
|
318
|
+
schemas: schemas
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def schema_operation
|
|
323
|
+
{
|
|
324
|
+
operationId: 'schema',
|
|
325
|
+
tags: ['wire'],
|
|
326
|
+
description: SCHEMA_DESCRIPTION,
|
|
327
|
+
# Public: opts out of the global `bearerAuth`.
|
|
328
|
+
security: [],
|
|
329
|
+
responses: ok_responses('The catalog.', 'schema.response')
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def pay_operation
|
|
334
|
+
{
|
|
335
|
+
operationId: 'pay',
|
|
336
|
+
tags: ['wire'],
|
|
337
|
+
description: PAY_DESCRIPTION,
|
|
338
|
+
requestBody: { required: true, content: Refs.json_content('pay.request') },
|
|
339
|
+
responses: ok_responses('The settlement receipt.', 'pay.response')
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def ok_responses(description, component)
|
|
344
|
+
{ '200' => { description: description, content: Refs.json_content(component) } }.merge(problem_refs)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def problem_refs
|
|
348
|
+
problem_statuses.to_h { |status| [status.to_s, { '$ref': "#/components/responses/problem#{status}" }] }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def problem_statuses
|
|
352
|
+
Errors::CODES.reject { |code, _| code == METHOD_NOT_ALLOWED }.values.uniq.sort
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def problem_responses
|
|
356
|
+
problem_statuses.to_h { |status| ["problem#{status}", problem_response(status)] }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def problem_response(status)
|
|
360
|
+
codes = Errors::CODES.select { |_, value| value == status }.keys
|
|
361
|
+
response = {
|
|
362
|
+
description: "#{codes.join(' · ')} — see the problem document's `code`.",
|
|
363
|
+
content: { Errors::PROBLEM_CONTENT_TYPE => { schema: { '$ref': '#/components/schemas/Problem' } } }
|
|
364
|
+
}
|
|
365
|
+
status == 402 ? response.merge(headers: Parameters::CHALLENGE_HEADERS) : response
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The declared schemas and parameters of the protocol's own endpoints.
|
|
370
|
+
module Components
|
|
371
|
+
PROBLEM = {
|
|
372
|
+
type: 'object',
|
|
373
|
+
title: 'Problem document (RFC 9457)',
|
|
374
|
+
description: 'Every error on this wire. Branch on `code`.',
|
|
375
|
+
properties: {
|
|
376
|
+
type: {
|
|
377
|
+
type: 'string', format: 'uri',
|
|
378
|
+
description: "`#{Errors::PROBLEM_TYPE_BASE}<code>` — an IDENTIFIER for the " \
|
|
379
|
+
'problem type, not a document to fetch. Never branch on it.'
|
|
380
|
+
},
|
|
381
|
+
title: { type: 'string',
|
|
382
|
+
description: 'A constant summary of the problem TYPE, not of this incident.' },
|
|
383
|
+
status: { type: 'integer', description: 'The HTTP status, repeated.' },
|
|
384
|
+
detail: { type: 'string', description: 'What went wrong THIS time.' },
|
|
385
|
+
code: { type: 'string', enum: Errors::CODES.keys,
|
|
386
|
+
description: 'THE BRANCH POINT: the closed Kiosk error vocabulary.' },
|
|
387
|
+
hint: { type: 'string',
|
|
388
|
+
description: 'How to recover, when the error knows. Present on the errors ' \
|
|
389
|
+
'a caller can do something about.' },
|
|
390
|
+
challenges: { type: 'array',
|
|
391
|
+
description: 'On a `pow_required` 402: the proof-of-work challenges to ' \
|
|
392
|
+
'solve and echo back in the `Kiosk-PoW` request header.' }
|
|
393
|
+
},
|
|
394
|
+
required: %w[type title status code]
|
|
395
|
+
}.freeze
|
|
396
|
+
|
|
397
|
+
DESCRIPTOR = {
|
|
398
|
+
type: 'object', title: 'One verb descriptor',
|
|
399
|
+
properties: {
|
|
400
|
+
name: { type: 'string', pattern: OpenApi::NAME_PATTERN },
|
|
401
|
+
description: { type: %w[string null],
|
|
402
|
+
description: "The verb's SEMANTICS, in prose. Authoritative." },
|
|
403
|
+
reach: { type: 'string', enum: HandlerMixin::REACHES.map(&:to_s),
|
|
404
|
+
description: 'Whose rows this verb may touch (spec §7.2). ' \
|
|
405
|
+
'`principal` is the default and the norm; the ' \
|
|
406
|
+
'other three are declared departures.' },
|
|
407
|
+
input_schema: { type: 'object',
|
|
408
|
+
description: "JSON Schema (draft 2020-12) for this verb's inputs. " \
|
|
409
|
+
'The authoritative input contract.' },
|
|
410
|
+
output_schema: { type: 'object',
|
|
411
|
+
description: 'JSON Schema (draft 2020-12) for what this verb returns.' },
|
|
412
|
+
example_params: { description: 'Example inputs an assistant can copy as a ' \
|
|
413
|
+
'starting call. It ILLUSTRATES `input_schema` ' \
|
|
414
|
+
'and is not the contract: where the two ' \
|
|
415
|
+
'disagree, the schema is right.' },
|
|
416
|
+
example_row: { description: 'Example of one result element — a ' \
|
|
417
|
+
'representative row for a query, or the return ' \
|
|
418
|
+
'value for an action. It ILLUSTRATES ' \
|
|
419
|
+
'`output_schema` and is not the contract.' }
|
|
420
|
+
},
|
|
421
|
+
required: %w[name description reach input_schema output_schema],
|
|
422
|
+
additionalProperties: false
|
|
423
|
+
}.freeze
|
|
424
|
+
|
|
425
|
+
TOPIC = {
|
|
426
|
+
type: 'object', title: 'One event topic',
|
|
427
|
+
properties: {
|
|
428
|
+
name: DESCRIPTOR[:properties][:name],
|
|
429
|
+
description: { type: %w[string null], description: "The topic's SEMANTICS, in prose. Authoritative." },
|
|
430
|
+
reach: { type: 'string', enum: HandlerMixin::REACHES.map(&:to_s), description: 'Whose events a subscriber may read (spec §7.2).' },
|
|
431
|
+
payload_schema: { description: "JSON Schema (draft 2020-12) for the event's `data`." },
|
|
432
|
+
examples: { type: 'array', description: 'Example `data` objects. They ILLUSTRATE `payload_schema` and are not the contract.' }
|
|
433
|
+
},
|
|
434
|
+
required: %w[name description reach payload_schema],
|
|
435
|
+
additionalProperties: false
|
|
436
|
+
}.freeze
|
|
437
|
+
|
|
438
|
+
SCHEMA = {
|
|
439
|
+
'schema.response' => {
|
|
440
|
+
type: 'object', title: 'The `schema` catalog',
|
|
441
|
+
properties: {
|
|
442
|
+
queries: { type: 'array', items: { '$ref': '#/components/schemas/schema.descriptor' } },
|
|
443
|
+
actions: { type: 'array', items: { '$ref': '#/components/schemas/schema.descriptor' } },
|
|
444
|
+
events: { type: 'array', items: { '$ref': '#/components/schemas/schema.topic' } }
|
|
445
|
+
},
|
|
446
|
+
required: %w[queries actions events],
|
|
447
|
+
additionalProperties: false
|
|
448
|
+
},
|
|
449
|
+
'schema.descriptor' => DESCRIPTOR,
|
|
450
|
+
'schema.topic' => TOPIC
|
|
451
|
+
}.freeze
|
|
452
|
+
|
|
453
|
+
PAY = {
|
|
454
|
+
'pay.request' => {
|
|
455
|
+
type: 'object', title: 'The AP2 mandate chain',
|
|
456
|
+
properties: %w[intent cart payment].to_h do |what|
|
|
457
|
+
[:"#{what}_mandate_jws", { type: 'string', description: "Compact RS256 JWS: the signed #{what} mandate." }]
|
|
458
|
+
end,
|
|
459
|
+
required: %w[intent_mandate_jws cart_mandate_jws payment_mandate_jws],
|
|
460
|
+
additionalProperties: false
|
|
461
|
+
},
|
|
462
|
+
'pay.response' => {
|
|
463
|
+
type: 'object', title: 'Settlement receipt',
|
|
464
|
+
properties: {
|
|
465
|
+
settlement_id: { type: 'string', description: "This origin's settlement row id." },
|
|
466
|
+
psp_reference: { type: 'string', description: "The processor's own reference." },
|
|
467
|
+
settled_amount_cents: { type: 'integer' },
|
|
468
|
+
currency: { type: 'string' }
|
|
469
|
+
},
|
|
470
|
+
required: %w[settlement_id psp_reference settled_amount_cents currency],
|
|
471
|
+
additionalProperties: false
|
|
472
|
+
}
|
|
473
|
+
}.freeze
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The reserved query parameters, the shared response headers and the bearer scheme.
|
|
477
|
+
module Parameters
|
|
478
|
+
BEARER_SCHEME = {
|
|
479
|
+
type: 'http',
|
|
480
|
+
scheme: 'bearer',
|
|
481
|
+
bearerFormat: 'JWT',
|
|
482
|
+
description: 'An access token from the kiosk-pop auth plane ' \
|
|
483
|
+
'(`POST <endpoint>/auth/register` or `/auth/login`), ' \
|
|
484
|
+
'verifiable against `<endpoint>/.well-known/jwks.json`.'
|
|
485
|
+
}.freeze
|
|
486
|
+
|
|
487
|
+
# §8.4. OAS 3.1 §4.8.21.1: a Header Object carries no `name` or `in`.
|
|
488
|
+
PAGINATION_HEADERS = {
|
|
489
|
+
'Link' => {
|
|
490
|
+
description: 'RFC 8288 (Web Linking). Carries `rel="next"` when this answer was ' \
|
|
491
|
+
'TRUNCATED: fetch that target URI verbatim for the following page. ' \
|
|
492
|
+
'ABSENT means this is the last (or only) page. The target repeats this ' \
|
|
493
|
+
'request with the reserved `cursor` parameter set to an OPAQUE token — ' \
|
|
494
|
+
'follow it, do not parse it.',
|
|
495
|
+
required: false,
|
|
496
|
+
schema: { type: 'string' }
|
|
497
|
+
},
|
|
498
|
+
'X-Total-Count' => {
|
|
499
|
+
description: 'How many rows MATCH the query across all pages — not how many this ' \
|
|
500
|
+
'response carries. A DE-FACTO CONVENTION, not a standard: no RFC ' \
|
|
501
|
+
'defines it. Omitted when the operator does not know the total, so ' \
|
|
502
|
+
'treat it as advisory and never as a loop bound.',
|
|
503
|
+
required: false,
|
|
504
|
+
schema: { type: 'integer', minimum: 0 }
|
|
505
|
+
}
|
|
506
|
+
}.freeze
|
|
507
|
+
|
|
508
|
+
# The two 402 gates differ by challenge header (RFC 7235).
|
|
509
|
+
CHALLENGE_HEADERS = {
|
|
510
|
+
'WWW-Authenticate' => {
|
|
511
|
+
description: '`Kiosk-PoW realm="<issuer>"` for a proof-of-work toll, ' \
|
|
512
|
+
'`Payment realm="<issuer>", method="ap2"` for payment setup.',
|
|
513
|
+
schema: { type: 'string' }
|
|
514
|
+
}
|
|
515
|
+
}.freeze
|
|
516
|
+
|
|
517
|
+
RESERVED_PARAMETERS = {
|
|
518
|
+
'limit' => {
|
|
519
|
+
name: 'limit',
|
|
520
|
+
in: 'query',
|
|
521
|
+
required: false,
|
|
522
|
+
style: 'form',
|
|
523
|
+
explode: true,
|
|
524
|
+
description: 'Maximum rows in one page. The operator MAY clamp it. ' \
|
|
525
|
+
'Reserved: always accepted. A query MAY declare it to bound the page size.',
|
|
526
|
+
schema: { type: ArgumentDecoder::RESERVED.fetch('limit'), minimum: 1 }
|
|
527
|
+
},
|
|
528
|
+
'cursor' => {
|
|
529
|
+
name: 'cursor',
|
|
530
|
+
in: 'query',
|
|
531
|
+
required: false,
|
|
532
|
+
style: 'form',
|
|
533
|
+
explode: true,
|
|
534
|
+
description: 'The OPAQUE `next` token from the previous page, echoed back ' \
|
|
535
|
+
'verbatim. Never parse or construct one. ' \
|
|
536
|
+
'Reserved: always accepted, never declared.',
|
|
537
|
+
schema: { type: ArgumentDecoder::RESERVED.fetch('cursor') }
|
|
538
|
+
}
|
|
539
|
+
}.freeze
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# What a query handler returns to paginate: this page's `rows`, an opaque
|
|
6
|
+
# `next_cursor` (nil on the last page) and an optional `total` matching
|
|
7
|
+
# count. The wire sends the last two as `Link` and `X-Total-Count` headers.
|
|
8
|
+
Page = Data.define(:rows, :next_cursor, :total) do
|
|
9
|
+
def initialize(rows:, next_cursor: nil, total: nil)
|
|
10
|
+
if !next_cursor.nil? && next_cursor.to_s.empty?
|
|
11
|
+
raise ArgumentError, 'next_cursor is empty. Pass nil on the last page: a next link with an ' \
|
|
12
|
+
'empty cursor leads back to page one (§8.4).'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|