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