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,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # The discovery documents — kiosk.json, agents.txt, agents.json,
8
+ # agent-configuration, api-catalog and auth.md — rendered as pure functions
9
+ # of one configuration and the request's base URL, so they cannot drift.
10
+ module WellKnown
11
+ DOCUMENT_VERSION = '1.0'
12
+
13
+ AGENTS_VERSION = '1.0'
14
+ AGENTS_STANDARD = 'https://agents-txt.com/standard'
15
+
16
+ # Modules with one fixed endpoint; the catalog links every verb separately.
17
+ MODULE_ENDPOINTS = { 'pay' => 'pay' }.freeze
18
+
19
+ PAYMENTS = {
20
+ ap2: { description: 'Mandate-trust layer with VC presentations' },
21
+ required: true
22
+ }.freeze
23
+
24
+ AUTHORIZATION = {
25
+ protocols: %w[agent-auth auth-md],
26
+ discovery: '/.well-known/agent-configuration',
27
+ identity: 'required'
28
+ }.freeze
29
+
30
+ # `/.well-known/kiosk.json`.
31
+ def self.build(base_url:, config: Kiosk.configuration)
32
+ Shared.validate_issuer!(config)
33
+ endpoint = base_url.to_s.chomp('/') + config.mount_path
34
+ kiosk = Shared.kiosk_document(endpoint, config)
35
+ # Only when a topic is registered: advertise what is served.
36
+ kiosk[:events_url] = "#{endpoint.sub(/\Ahttp/, 'ws')}/events" if Kiosk::Server::Events.known.any?
37
+
38
+ kiosk[:skill] = { url: config.skill_url, sha256: config.skill_sha256 } if config.skill_sha256
39
+
40
+ { kiosk: kiosk }
41
+ end
42
+
43
+ def self.build_json(**)
44
+ JSON.generate(build(**))
45
+ end
46
+
47
+ # agents.txt v1.0 (agents-txt.com).
48
+ def self.agents_txt(base_url:, config: Kiosk.configuration)
49
+ Shared.validate_issuer!(config)
50
+ base = base_url.to_s.chomp('/')
51
+
52
+ lines = ['# agents.txt — https://agents-txt.com', "# JSON: #{base}/agents.json"]
53
+ lines.push('', 'Protocols: ap2', 'Payments: required') if Shared.pay_served?(config)
54
+ lines.push('', 'Authorization: agent-auth auth-md', 'Identity: required')
55
+ lines.push('', "Skills: #{config.skill_url}") if config.skill_url && !config.skill_url.to_s.empty?
56
+
57
+ "#{lines.join("\n")}\n"
58
+ end
59
+
60
+ # agents.json v1.0; Kiosk's pointers ride the `x-kiosk` extension.
61
+ def self.agents_json(base_url:, config: Kiosk.configuration)
62
+ Shared.validate_issuer!(config)
63
+ doc = { version: AGENTS_VERSION, standard: AGENTS_STANDARD,
64
+ site: { name: site_name(config), url: base_url.to_s.chomp('/') } }
65
+ doc[:payments] = PAYMENTS if Shared.pay_served?(config)
66
+ doc[:authorization] = AUTHORIZATION
67
+ doc[:skills] = Shared.skills_list(config)
68
+ doc[:'x-kiosk'] = Shared.x_kiosk(config)
69
+ doc
70
+ end
71
+
72
+ # `/.well-known/agent-configuration`: the kiosk-pop auth endpoints.
73
+ def self.agent_configuration(base_url:, config: Kiosk.configuration)
74
+ Shared.validate_issuer!(config)
75
+ base = base_url.to_s.chomp('/')
76
+ endpoint = base + config.mount_path
77
+
78
+ {
79
+ issuer: Kiosk.current_issuer,
80
+ endpoints: Shared.auth_urls(endpoint),
81
+ jwks_uri: "#{endpoint}/.well-known/jwks.json",
82
+ auth_modes: %w[kiosk-pop user-claimed link-code],
83
+ auth_md: "#{base}/auth.md"
84
+ }
85
+ end
86
+
87
+ # `/.well-known/api-catalog` (RFC 9727): the two service descriptions, then
88
+ # every verb with its method in the `kiosk-method` extension attribute.
89
+ # Composed from configuration and the registries only, never a query.
90
+ def self.api_catalog(base_url:, config: Kiosk.configuration)
91
+ Shared.validate_issuer!(config)
92
+ base = base_url.to_s.chomp('/')
93
+ endpoint = base + config.mount_path
94
+ modules = Array(config.capabilities)
95
+
96
+ items = Catalog.service_descs(endpoint, modules, config) + Catalog.verb_items(endpoint) +
97
+ Catalog.module_items(endpoint, modules)
98
+ items << { href: "#{base}/agents.json", rel: 'item' }
99
+
100
+ { linkset: [{ anchor: "#{base}/.well-known/api-catalog", item: items }] }
101
+ end
102
+
103
+ # `/auth.md`: the auth methods in auth.md's vocabulary and section order.
104
+ def self.auth_md(base_url:, config: Kiosk.configuration)
105
+ Shared.validate_issuer!(config)
106
+ base = base_url.to_s.chomp('/')
107
+ endpoint = base + config.mount_path
108
+
109
+ AuthMd.render(site: site_name(config), base: base, endpoint: endpoint,
110
+ urls: Shared.auth_urls(endpoint), skill_line: Shared.skill_discover_line(config))
111
+ end
112
+
113
+ # The owner's name, else the issuer's host. Also titles the OpenAPI document.
114
+ def self.site_name(config)
115
+ name = config.owner.is_a?(Hash) ? config.owner[:name] : nil
116
+ return name if name && !name.to_s.empty?
117
+
118
+ Shared.host_of(Kiosk.current_issuer)
119
+ end
120
+
121
+ # The parts the discovery documents share.
122
+ module Shared
123
+ module_function
124
+
125
+ def kiosk_document(endpoint, config)
126
+ {
127
+ version: DOCUMENT_VERSION,
128
+ endpoint: endpoint,
129
+ auth: auth_section(endpoint),
130
+ # The modules served, not the verb names; published here only.
131
+ capabilities: Array(config.capabilities),
132
+ # Versioned, so the catalog may be cached for a year at this URL.
133
+ schema_url: "#{endpoint}/schema?v=#{SchemaDocument.digest(config: config)}",
134
+ min_client: Kiosk::Protocol::MIN_CLIENT,
135
+ issuer: Kiosk.current_issuer,
136
+ owner: config.owner
137
+ }
138
+ end
139
+
140
+ def auth_section(endpoint)
141
+ {
142
+ kind: 'kiosk-pop',
143
+ **auth_urls(endpoint).transform_keys { |step| :"#{step}_url" },
144
+ # Additive: a client that ignores binding still has register/login.
145
+ device_authorization_url: "#{endpoint}/oauth/device_authorization",
146
+ claim_url: "#{endpoint}/auth/claim"
147
+ }
148
+ end
149
+
150
+ # Pointers, never a copy of `kiosk.json`'s facts.
151
+ def x_kiosk(config)
152
+ {
153
+ schema: "#{config.mount_path}/schema?v=#{SchemaDocument.digest(config: config)}",
154
+ api_catalog: '/.well-known/api-catalog',
155
+ mount_path: config.mount_path,
156
+ api_version: Kiosk::Protocol::API_VERSION
157
+ }
158
+ end
159
+
160
+ def auth_urls(endpoint)
161
+ {
162
+ challenge: "#{endpoint}/auth/challenge",
163
+ register: "#{endpoint}/auth/register",
164
+ login: "#{endpoint}/auth/login",
165
+ revoke: "#{endpoint}/auth/revoke"
166
+ }
167
+ end
168
+
169
+ def pay_served?(config)
170
+ Array(config.capabilities).map(&:to_s).include?('pay')
171
+ end
172
+
173
+ def host_of(issuer)
174
+ require 'uri'
175
+ URI.parse(issuer.to_s).host || issuer.to_s
176
+ rescue URI::InvalidURIError
177
+ issuer.to_s
178
+ end
179
+
180
+ def skill_discover_line(config)
181
+ url = config.skill_url
182
+ return '' if url.nil? || url.to_s.empty?
183
+
184
+ sha = config.skill_sha256
185
+ line = "- Wire skill for AI assistants: `#{url}`"
186
+ line += " (sha256 `#{sha}`)" if sha && !sha.to_s.empty?
187
+ line
188
+ end
189
+
190
+ def skills_list(config)
191
+ return [] if config.skill_url.nil? || config.skill_url.to_s.empty?
192
+
193
+ [{ url: config.skill_url, description: 'Kiosk wire skill' }]
194
+ end
195
+
196
+ def validate_issuer!(config)
197
+ return if config.issuer && !config.issuer.to_s.empty?
198
+
199
+ raise ArgumentError,
200
+ 'Kiosk.configuration.issuer must be set before serving ' \
201
+ '/.well-known/kiosk.json — the issuer ' \
202
+ 'is the AP2 mandate `iss` anchor'
203
+ end
204
+ end
205
+
206
+ # The api-catalog's linkset items, in document order.
207
+ module Catalog
208
+ module_function
209
+
210
+ # Versioned links: this pointer is short-lived, its targets are immutable.
211
+ # The only place the derived OpenAPI document is advertised.
212
+ def service_descs(endpoint, modules, config)
213
+ return [] unless modules.include?('schema')
214
+
215
+ version = SchemaDocument.digest(config: config)
216
+ %w[schema openapi.json].map { |path| { href: "#{endpoint}/#{path}?v=#{version}", rel: 'service-desc' } }
217
+ end
218
+
219
+ # Sorted, so the document is byte-stable across boots.
220
+ def verb_items(endpoint)
221
+ Queries.known.sort.map { |name| verb_item(endpoint, name, 'GET') } +
222
+ Actions.known.sort.map { |name| verb_item(endpoint, name, 'POST') }
223
+ end
224
+
225
+ def module_items(endpoint, modules)
226
+ MODULE_ENDPOINTS.filter_map do |mod, path|
227
+ { href: "#{endpoint}/#{path}", rel: 'item' } if modules.include?(mod)
228
+ end
229
+ end
230
+
231
+ # RFC 9264 §4.2.4.3: an extension attribute is an array of strings.
232
+ def verb_item(endpoint, name, method)
233
+ { href: "#{endpoint}/#{name}", rel: 'item', 'kiosk-method': [method] }
234
+ end
235
+ end
236
+
237
+ # The `/auth.md` document.
238
+ # rubocop:disable-next Metrics/ModuleLength -- one Markdown document; its lines are prose
239
+ module AuthMd
240
+ def self.render(site:, base:, endpoint:, urls:, skill_line:)
241
+ <<~MARKDOWN
242
+ # #{site} — agent authentication
243
+
244
+ How AI assistants authenticate against this provider's Kiosk
245
+ endpoint (`#{endpoint}`). Wire contract: the Kiosk specification
246
+ (https://kiosk.tech/specification.html).
247
+
248
+ ## Discover
249
+
250
+ - This file: `#{base}/auth.md`
251
+ - Agent auth configuration: `#{base}/.well-known/agent-configuration`
252
+ - Kiosk discovery document: `#{base}/.well-known/kiosk.json`
253
+ - Verb catalogue (PUBLIC, no token): `#{endpoint}/schema`
254
+ - Token-verification keys (JWKS): `#{endpoint}/.well-known/jwks.json`
255
+ #{skill_line}
256
+
257
+ ## Pick a method
258
+
259
+ - **Anonymous + proof-of-possession (kiosk-pop)** — supported.
260
+ Self-registration of a per-provider RSA keypair: no human
261
+ account needed. In auth.md terms this is the anonymous class,
262
+ upgraded with a key-possession proof on every register/login.
263
+ - **User claimed** — supported. The claim ceremony below binds an
264
+ agent key to an EXISTING account after its holder approves in
265
+ their own browser session.
266
+ - **Link code** — supported (Kiosk extension: auth.md defines no
267
+ human-initiated direction). The account holder mints a
268
+ single-use code on the provider's site and hands it to the
269
+ assistant.
270
+ - **Identity assertion (ID-JAG)** — not supported (planned).
271
+
272
+ ## Register
273
+
274
+ kiosk-pop self-registration (anonymous class):
275
+
276
+ 1. `GET #{urls[:challenge]}?public_key=<PEM>` → `{ challenge, exp }`
277
+ 2. Sign a compact RS256 JWS over `{aud, nonce, jti}` with the
278
+ private key (`aud` = the origin you dialed; `nonce` = the
279
+ challenge).
280
+ 3. `POST #{urls[:register]}` `{ public_key, signed }`
281
+ → `201 { agent_id, user_id, access_token }`
282
+
283
+ Registration may be priced with an Equihash proof-of-work. When it
284
+ is, step 3 answers `402 pow_required` with
285
+ `WWW-Authenticate: Kiosk-PoW realm="<issuer>"` and the body carries
286
+ the `challenges` array; the toll binds to the public key you are
287
+ registering. Solve EVERY challenge and resubmit the SAME body — the
288
+ possession proof is NOT consumed by the 402, so reuse the same
289
+ `signed` — carrying the proof(s) in a `Kiosk-PoW` REQUEST HEADER as
290
+ raw minified JSON (no base64):
291
+
292
+ Kiosk-PoW: {"challenge":{…},"nonce":{"indices":[…],"header_nonce":0}}
293
+
294
+ Send N proofs as a JSON array in that one header, or as one repeated
295
+ `Kiosk-PoW` header line per proof. The proof NEVER travels in the
296
+ request body: a body `pow` field is ignored, and changing the body
297
+ invalidates the proofs. The same header answers a `402 pow_required`
298
+ on the wire verbs too, and most of them are GETs, which have no body
299
+ to carry a proof. The ONE endpoint under `#{endpoint}` that is never
300
+ tolled is `GET #{endpoint}/schema`: it is public, it is served from
301
+ memory, and a toll needs an identity to charge.
302
+
303
+ Do not write your own Equihash solver. The verifier is exact about
304
+ the seed construction, the index width and the tree ordering, and a
305
+ mismatch comes back `403 forbidden` with no indication of which check
306
+ failed. Fetch the reference solver from
307
+ `#{PowGate::POW_SOLVER_URL}` — the same file that 403's `hint` names.
308
+ That URL is unversioned and always serves the CURRENT solver, so
309
+ check its SHA-256 against the content-addressed pin the wire skill
310
+ above publishes before you execute it.
311
+
312
+ ## Claim ceremony
313
+
314
+ User-claimed binding (RFC 8628 wire) — binds YOUR key to the
315
+ account holder's existing account; single-use, short-TTL codes:
316
+
317
+ 1. `POST #{endpoint}/oauth/device_authorization`
318
+ (form-encoded: `client_id`, `public_key` — required) →
319
+ `{ device_code, user_code, verification_uri, expires_in, interval }`
320
+ 2. Show the holder: "open <verification_uri>, enter <user_code>".
321
+ They approve in their own signed-in browser session.
322
+ 3. Poll `POST #{endpoint}/oauth/token` (form-encoded:
323
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code`,
324
+ `device_code`, and `signed` — the same challenge-response
325
+ possession proof as register/login, for the SAME key from
326
+ step 1) → `{ access_token, token_type, expires_in }`.
327
+ No binding happens without a valid possession proof.
328
+
329
+ Link flow (Kiosk extension, mirror direction): the holder mints a
330
+ code on the provider's site and pastes it to you; redeem with
331
+ `POST #{endpoint}/auth/claim` `{ code, public_key, signed }`
332
+ → `201 { agent_id, user_id, access_token }`.
333
+
334
+ Where registration has a toll, the link-code claim and the FIRST
335
+ token poll of each `device_code` have the same toll. It comes
336
+ before the code is looked up. Solve it as for register and send the
337
+ SAME request again with the `Kiosk-PoW` header. Later polls of the
338
+ same `device_code` are free.
339
+
340
+ A fresh key becomes a linked assistant account under the holder's
341
+ account; an already-registered key is re-bound (its reputation
342
+ carries over — claiming never resets an identity).
343
+
344
+ ## Exchange
345
+
346
+ There is no separate exchange step: registration, the claim
347
+ ceremony and the link redeem each return the access token
348
+ directly. Refresh by logging in with your key —
349
+ `POST #{urls[:login]}` `{ public_key, signed }` — the ceremony
350
+ never repeats.
351
+
352
+ ## Use the access_token
353
+
354
+ Send `Authorization: Bearer <access_token>` to the wire verbs
355
+ under `#{endpoint}` — every query, every action, and `pay`. Tokens
356
+ are RS256 JWTs verifiable against the JWKS above.
357
+ `GET #{endpoint}/schema` is the exception: it is PUBLIC, so read
358
+ the catalogue before you register if you like.
359
+
360
+ ## Errors
361
+
362
+ - Kiosk endpoints (`/auth/*`, `pay`, every query and every action)
363
+ answer an error as an RFC 9457 problem document, served as
364
+ `application/problem+json`:
365
+
366
+ {"type":"https://kiosk.tech/problems/pow_required",
367
+ "title":"Proof-of-work required","status":402,
368
+ "detail":"proof-of-work required","code":"pow_required"}
369
+
370
+ Branch on `code` — a FLAT member of the document, not nested — from
371
+ the closed vocabulary (`unauthenticated`, `not_found`, `conflict`,
372
+ `pow_required`, …); `type` is `https://kiosk.tech/problems/<code>`
373
+ and names the same fact. Some codes add members of their own, such
374
+ as the `challenges` array on a `402 pow_required`.
375
+ - Three codes say "it is not here" and they mean three different
376
+ things. `404 verb_not_found` — no verb by that NAME is registered
377
+ here; `hint` lists the ones that are, so re-read the catalogue and
378
+ call something that exists. `404 not_found` — the verb is real and
379
+ an ARGUMENT addressed something absent; the answer is final, so
380
+ stop. `501 module_not_served` — this origin does not serve that
381
+ optional module at all (binding, payment, KYC, the event stream;
382
+ `detail` names which); do not retry, and fall back to what you
383
+ would do at an operator that never offered it.
384
+ - The claim ceremony's OAuth endpoints use the OAuth error shape
385
+ `{ error, error_description }` — a documented exception, and the
386
+ only one, with two carve-outs that are ordinary problem documents:
387
+ an origin that does not serve binding at all answers
388
+ `501 module_not_served` there, because none of the OAuth codes
389
+ means it, and the toll on the token poll answers as the toll does
390
+ everywhere (`402 pow_required`, and its refusals of a proof). The
391
+ OAuth vocabulary is closed at eight. Six describe the
392
+ ceremony (RFC 8628 §3.5): `authorization_pending`, `slow_down`,
393
+ `expired_token`, `access_denied`, `invalid_grant`,
394
+ `invalid_client`. Two are request-level (RFC 6749 §5.2):
395
+ `invalid_request` — a required parameter absent or malformed, or
396
+ one the request must not carry, such as a `role`/`scope` on the
397
+ device-authorization call — and `unsupported_grant_type` for any
398
+ grant but the device-code one. Everything answers `400` except
399
+ `invalid_client`, which answers `401`.
400
+
401
+ ## Revocation
402
+
403
+ - Credential layer: `POST #{urls[:revoke]}` (Bearer) revokes every
404
+ outstanding token for your identity and returns a fresh one.
405
+ - Registration layer (unlink): the account holder — or the
406
+ provider — deactivates a key's binding (`POST
407
+ #{endpoint}/auth/unlink`, session-authenticated, or the
408
+ provider's linked-assistants page). An unlinked key stops
409
+ verifying and can no longer log in; it does NOT revert to a
410
+ standalone account.
411
+ MARKDOWN
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # How a wire answer is written: headers, cache policy, body. Success is the
8
+ # verb's payload; a refusal is an RFC 9457 problem document.
9
+ module WireRendering
10
+ private
11
+
12
+ # Shared with {OpenApiController#show}.
13
+ def render_public_document(json, version:, etag:, content_type: nil)
14
+ Kiosk::Server::Headers.add_to(response.headers)
15
+ Kiosk::Server::Headers.add_public_cache_policy(
16
+ response.headers, etag: etag, immutable: params[:v].to_s == version
17
+ )
18
+
19
+ if if_none_match?(etag)
20
+ head :not_modified
21
+ else
22
+ render(**render_options(json, :ok, content_type))
23
+ end
24
+
25
+ # Last: Rails stamps `Vary: Accept` at render time.
26
+ response.headers.delete('Vary')
27
+ end
28
+
29
+ # RFC 9110 §13.1.2; not `fresh_when`, which hashes the validator it is given.
30
+ def if_none_match?(etag)
31
+ raw = request.get_header('HTTP_IF_NONE_MATCH').to_s
32
+ return false if raw.empty?
33
+ return true if raw.strip == '*'
34
+
35
+ raw.split(',').any? { |tag| tag.strip.delete_prefix('W/') == etag }
36
+ end
37
+
38
+ def render_result(result)
39
+ add_pagination_headers(result)
40
+ render_wire_body(result.to_payload, status: result.http_status)
41
+ end
42
+
43
+ # §8.4: `Link` only on a truncated page; `X-Total-Count` is the array's
44
+ # length on a complete answer, else only what the handler passed.
45
+ def add_pagination_headers(result)
46
+ return unless result.kind == :rows
47
+
48
+ add_link_header(next_page_link(result.next_cursor)) if result.next_cursor
49
+ total = page_total(result)
50
+ response.headers['X-Total-Count'] = total.to_s unless total.nil?
51
+ end
52
+
53
+ def page_total(result)
54
+ return result.total unless result.total.nil?
55
+
56
+ result.payload.length if result.next_cursor.nil? && result.payload.is_a?(::Array)
57
+ end
58
+
59
+ def add_link_header(value)
60
+ existing = response.headers['Link'].to_s
61
+ response.headers['Link'] = existing.empty? ? value : "#{existing}, #{value}"
62
+ end
63
+
64
+ # Edits the raw query string, so the caller's bracket spellings survive.
65
+ def next_page_link(cursor)
66
+ pairs = request.query_string.to_s.split('&').reject do |pair|
67
+ pair.split('=', 2).first == 'cursor'
68
+ end
69
+ pairs << "cursor=#{CGI.escape(cursor.to_s)}"
70
+
71
+ %(<#{request.base_url}#{request.path}?#{pairs.join('&')}>; rel="next")
72
+ end
73
+
74
+ def render_wire_error(error)
75
+ render_wire_body(
76
+ error.to_problem,
77
+ status: error.http_status,
78
+ error: error,
79
+ content_type: Errors::PROBLEM_CONTENT_TYPE
80
+ )
81
+ end
82
+
83
+ def render_wire_body(body, status:, error: nil, content_type: nil)
84
+ Kiosk::Server::Headers.add_to(response.headers)
85
+ Kiosk::Server::Headers.add_cache_policy(
86
+ response.headers, status: ::Rack::Utils.status_code(status)
87
+ )
88
+ add_error_headers(error) if error
89
+ render(**render_options(body, status, content_type))
90
+ end
91
+
92
+ def render_options(body, status, content_type)
93
+ options = { json: body, status: status }
94
+ options[:content_type] = content_type if content_type
95
+ options
96
+ end
97
+
98
+ def add_error_headers(error)
99
+ error.response_headers.each { |name, value| response.set_header(name, value) }
100
+ challenge = www_authenticate_for(error)
101
+ response.set_header('WWW-Authenticate', challenge) if challenge
102
+ end
103
+
104
+ # RFC 7235: the header names which 402 gate answered, keyed on the code.
105
+ def www_authenticate_for(error)
106
+ issuer = Kiosk.current_issuer
107
+ case error.code
108
+ when 'pow_required'
109
+ %(Kiosk-PoW realm="#{issuer}")
110
+ when 'payment_setup_required'
111
+ %(Payment realm="#{issuer}", method="ap2")
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # The account holder's browser pages. A JSON caller without a CSRF token is
8
+ # an assistant at the wrong door: the answer points it at the wire. Browser
9
+ # requests re-raise untouched. Each page's controller sets WRONG_DOOR_MESSAGE.
10
+ module WrongDoor
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ rescue_from ::ActionController::InvalidAuthenticityToken do |error|
15
+ raise error unless json_request?
16
+
17
+ render json: wrong_door_envelope, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # An explicit JSON Accept or a JSON body; anything ambiguous counts as a browser.
24
+ def json_request?
25
+ return true if request.format.json?
26
+
27
+ !!request.content_mime_type&.json?
28
+ rescue StandardError
29
+ false
30
+ end
31
+
32
+ # Not a wire problem document or wire error code: this page is not the wire.
33
+ def wrong_door_envelope
34
+ hint = "assistants use the wire: GET #{request.base_url}/.well-known/kiosk.json " \
35
+ 'for the register/login endpoints, then GET <endpoint>/schema ' \
36
+ '(public) for the verbs this origin serves'
37
+ { ok: false,
38
+ error: { code: 'invalid_authenticity_token', message: self.class::WRONG_DOOR_MESSAGE, hint: hint } }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # kiosk-server: the Rails engine, its wire, auth and discovery controllers, and
4
+ # the install generator. See https://kiosk.tech.
5
+
6
+ require 'kiosk'
7
+ require 'active_record'
8
+ require 'action_dispatch/routing'
9
+ require 'active_support/core_ext' # the core extensions a Rails host loads, which the engine's code uses
10
+ require 'zeitwerk'
11
+
12
+ Zeitwerk::Loader.for_gem_extension(Kiosk).setup
13
+
14
+ module Kiosk
15
+ module Server
16
+ end
17
+
18
+ # The user id and role of the principal the wire resolved.
19
+ def self.current_user_id = Server::SessionContext.identity.user_id
20
+ def self.current_role = Server::SessionContext.identity.role
21
+
22
+ # The zone the caller declared in `Kiosk-Timezone`, else `default`. Never the clock of a service time.
23
+ def self.reader_zone(default = nil) = Server::CurrentRequest.timezone || default
24
+
25
+ # "<name> runs on <zone> time, 2 hours ahead of your <caller_zone>." when the two clocks
26
+ # differ now, else nil. A place adds a second sentence: every time in the answer is the place's.
27
+ def self.clock_gap_note(name, zone, caller_zone)
28
+ return if caller_zone.nil?
29
+
30
+ gap = zone.now.utc_offset - caller_zone.now.utc_offset
31
+ return if gap.zero?
32
+
33
+ hours = format('%g', gap.abs / 3600.0)
34
+ "#{name} runs on #{zone.name} time, #{hours} #{hours == '1' ? 'hour' : 'hours'} " \
35
+ "#{gap.positive? ? 'ahead of' : 'behind'} your #{caller_zone.name}."
36
+ end
37
+ end
38
+
39
+ Kiosk::Configuration.include(Kiosk::Server::ConfigurationExtension)
40
+ ActionDispatch::Routing::Mapper.include(Kiosk::Server::Routing)
41
+ # Rails registers an engine when its class is defined.
42
+ Kiosk::Server::Engine
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Kiosk
6
+ # `include Kiosk::SkillLink` in a page controller: every answer carries
7
+ # `Link: <skill_url>; rel="kiosk"`, the discovery signal for a client that
8
+ # does not read HTML.
9
+ module SkillLink
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ before_action { response.set_header('Link', %(<#{Kiosk.configuration.skill_url}>; rel="kiosk")) }
14
+ end
15
+ end
16
+ end