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
data/README.md
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
# kiosk-server
|
|
2
|
+
|
|
3
|
+
The Kiosk Rails engine — host-side surface for [Kiosk](https://kiosk.tech).
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## What's in this release
|
|
7
|
+
|
|
8
|
+
The full host-side surface is shipped and covered by the gem's own suite
|
|
9
|
+
(`bundle exec rspec` in this directory runs it and prints how many examples that is):
|
|
10
|
+
|
|
11
|
+
- **Wire-protocol controllers** — each verb route (`GET <mount>/<query-name>`, `POST <mount>/<action-name>`) reaches the operator's controller action that declares the verb, and `Kiosk::Handler` runs the wire's checks around it; `VerbController` refuses a verb route that no action answers; `WireController` serves the two reserved endpoints `GET <mount>/schema` and `POST <mount>/pay`; `VerbController` also serves `POST <mount>/payment_setup` against the payment provider port, and `PaymentSetupController` the page the provider returns the human to; `VerbController` serves `POST <mount>/request_kyc` against the KYC provider port, and `KycCallbackController` the provider's callback; `OpenApiController` serves a derived OpenAPI description of both at `GET <mount>/openapi.json`; `AuthController` runs the register/login proof-of-possession challenge-response (kiosk-pop — the auth story); JWKS backs stateless token verification.
|
|
12
|
+
- **Account binding** — the claim/link ceremonies bind an agent's public key to an existing assistant-account holder's account: OAuth/RFC 8628-shaped device authorization + possession-proof-gated token poll, a session-authenticated verify page and «Link an assistant» page (minimal overridable engine views), link-code mint/redeem, and unlink. Tokens stay kiosk-pop-minted; the durable `DeviceAuthorizationStores::ActiveRecord` store (migration 004) is the default.
|
|
13
|
+
- **`Kiosk::Server::Executor`** — dispatches resolved commands to the host's registered queries and Actions.
|
|
14
|
+
- **`Kiosk::Handler`** — the mixin an operator includes into a controller of their own to declare verbs as ordinary Rails actions; each declaration's `kind` says whether it is a query or an action, so one controller may declare both. The engine registers the controllers in `app/controllers/kiosk/` at boot and after every reload (see [Declaring queries and actions](#declaring-queries-and-actions)).
|
|
15
|
+
- **`Kiosk::Server::PaymentClaim`** — the PSP decorator that keeps §11.6's operator half: one capture per payable row, and a paid state anchored to the capture (see [Payments](#payments)).
|
|
16
|
+
- **Agent registration & login** — `AgentRegistration`, `AgentLogin`, `AuthToll`, and the pluggable agent-IdP resolve and mint per-agent identities.
|
|
17
|
+
- **PoW gate** — `PowGate` enforces the reputation policy's N×PoW challenge-response (soft dependency on `kiosk-reputation`; zero overhead when no policy is set).
|
|
18
|
+
- **`Kiosk::Server::WellKnown`** — pure-Ruby builder for `/.well-known/kiosk.json`.
|
|
19
|
+
- **`Kiosk::Server::Headers`** + **`HeadersMiddleware`** — Rack middleware that injects `Kiosk-Server-Version`, `Kiosk-API-Version`, `Kiosk-Min-Client` on `/kiosk/*` responses.
|
|
20
|
+
- **`IssuerMiddleware`** — answers `Kiosk.current_issuer` for each request: its origin when that is `c.issuer` or one of `c.additional_origins`, else `c.issuer`. Every origin served is its own operator.
|
|
21
|
+
- **The audit sink** — `c.audit_sink` receives one `Kiosk::Server::ActionEvent` per action invocation, success and failure alike. The default is no sink, so no `ActionEvent` goes anywhere. The engine keeps its own state in the `kiosk` schema (agents, mandates, settlements, refunds, events, visits), but it writes no audit record there. See [The audit sink](#the-audit-sink).
|
|
22
|
+
- **`Kiosk::Server::SchemaDefinitions`** — SQL generators for every table and helper that `bin/rails g kiosk:install` creates.
|
|
23
|
+
- **`Kiosk::Server::Engine`** — the Rails engine: `kiosk` in `config/routes.rb` mounts it, drawing the full mount-prefixed PROTOCOL surface (the reserved wire, auth, JWKS, KYC, account binding), then one route per verb its block names — installs the root discovery routes when mounted, and auto-injects the headers and issuer middleware (see [Draw the routes](#draw-the-routes)).
|
|
24
|
+
- **`Kiosk::Server::ConfigurationExtension`** — adds `mount_path`, `owner` (and the reputation/PoW slots) to `Kiosk::Configuration`.
|
|
25
|
+
- **`bin/rails g kiosk:install`** — the install generator lays down the initializer, the migrations and the `kiosk` block in `config/routes.rb` that mounts the engine.
|
|
26
|
+
|
|
27
|
+
kiosk-server is a Rails gem: it depends on railties, actionpack, actioncable, activerecord and activesupport (`~> 8.1`), and `require "kiosk/server"` loads them. Pieces such as `WellKnown` and `SchemaDefinitions` still work without a BOOTED Rails app — they just need the framework on the load path.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
> **Not on RubyGems yet** — so every `gem` line below carries `github: "kiosk-hq/kiosk"`, which is what makes it copy-pasteable today. Publication status and the canonical install are stated once, in the monorepo README's [Install](https://github.com/kiosk-hq/kiosk#install) section.
|
|
32
|
+
|
|
33
|
+
### Preconditions
|
|
34
|
+
|
|
35
|
+
**Rails `~> 8.1`.** This gemspec pins `railties`, `actionpack`, `actioncable`,
|
|
36
|
+
`activerecord` and `activesupport` at `~> 8.1`. The only other Kiosk gemspec
|
|
37
|
+
that names a Rails gem is `kiosk-pay-stripe`, which pins `activerecord` at
|
|
38
|
+
`~> 8.1`. **Rails 7.x and 8.0.x are excluded** — bundler refuses to resolve
|
|
39
|
+
against an app on either, so for most "add Kiosk to an existing Rails app" this
|
|
40
|
+
is the first thing to check, not a footnote. Older Rails lines are untested
|
|
41
|
+
here, so they are not claimed; widening the floor means adding a CI leg first.
|
|
42
|
+
|
|
43
|
+
**Ruby `>= 4.0`**, which every Kiosk gemspec declares and CI runs.
|
|
44
|
+
|
|
45
|
+
**PostgreSQL.** The kiosk schema, the identity tables and the optional RLS
|
|
46
|
+
backstop are Postgres; no other database is supported.
|
|
47
|
+
|
|
48
|
+
### The line
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
gem "kiosk-server", github: "kiosk-hq/kiosk"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or, via the meta-gem:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
gem "kiosk-all", github: "kiosk-hq/kiosk"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configure
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# config/initializers/kiosk.rb
|
|
64
|
+
Kiosk.configure do |c|
|
|
65
|
+
c.issuer = "https://api.acme.example"
|
|
66
|
+
# Further origins this deployment serves, each a separate operator with its
|
|
67
|
+
# own discovery document and assistant accounts. Redirect an alias of the
|
|
68
|
+
# same business to `c.issuer` instead.
|
|
69
|
+
# c.additional_origins = ["https://shop.acme.example"]
|
|
70
|
+
c.user_model = "User"
|
|
71
|
+
c.user_id_type = :uuid
|
|
72
|
+
c.roles = %i[customer master support]
|
|
73
|
+
# The default role. The engine refuses to boot when `c.roles` is set and
|
|
74
|
+
# this is not one of them. An assistant admitted with no role of its own (a
|
|
75
|
+
# self-registration, or a binding whose approving human resolves none) gets
|
|
76
|
+
# this one. Name your least-privileged role. An origin that assigns roles to
|
|
77
|
+
# nobody sets neither line, and then no token carries a `role` claim.
|
|
78
|
+
c.registration_role = :customer
|
|
79
|
+
c.owner = { name: "Acme Inc.", support: "support@acme.example" }
|
|
80
|
+
# c.mount_path = "/kiosk" # default
|
|
81
|
+
# Optional: one event per action invocation. See "The audit sink".
|
|
82
|
+
# c.audit_sink = ->(event) { AuditRow.create!(**event.to_h) }
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### KYC
|
|
87
|
+
|
|
88
|
+
Set a KYC provider adapter (a `kiosk-kyc-*` gem) and the engine serves
|
|
89
|
+
`POST <mount>/request_kyc`, the provider's `POST <mount>/kyc/callback` and the
|
|
90
|
+
`kyc_verification` topic, and records the verified attributes against the
|
|
91
|
+
person. Declare the attributes your gated actions need; gate with
|
|
92
|
+
`Kiosk::Server::Kyc.require!`, which refuses `403 kyc_required` until the
|
|
93
|
+
calling principal holds them. An action that needs only some of them passes
|
|
94
|
+
their names, `require!(%w[age_over_18])`, and may pass its own `detail:`.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
Kiosk.configure do |c|
|
|
98
|
+
c.kyc_provider = Kiosk::KycProviders::Prove.new(operator_id: "acme", intake_secret: ENV["PROVE_SECRET"])
|
|
99
|
+
c.kyc_claims = %w[age_over_18]
|
|
100
|
+
c.kyc_issuer = "https://kyc.example" # the `iss` the provider signs
|
|
101
|
+
c.kyc_public_key = ENV["KYC_PUBLIC_KEY_PEM"]
|
|
102
|
+
c.kyc_audience = "acme" # the `aud` it mints for you
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Payments
|
|
107
|
+
|
|
108
|
+
Set a PSP adapter (a `kiosk-pay-*` gem) and the engine serves `pay`. Wrap it in
|
|
109
|
+
`Kiosk::Server::PaymentClaim` and the engine keeps §11.6's operator half for you:
|
|
110
|
+
it claims your payable row (`unpaid → paying`) before the capture, so a second
|
|
111
|
+
`pay` for that row is refused before the processor is reached, and marks it
|
|
112
|
+
`paid` when the capture returns. Under the claim it checks the signed cart:
|
|
113
|
+
your currency, priced lines that sum to the total, and a total equal to your
|
|
114
|
+
own price for the row. That price is the one thing you write — a checker that
|
|
115
|
+
answers it from your catalog, given the row and the cart's item lines as
|
|
116
|
+
signed, or returns a String saying why the cart is refused.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
module PriceChecker
|
|
120
|
+
def self.call(order_id, _lines) = Order.where(id: order_id).pick(:total_cents)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Kiosk.configure do |c|
|
|
124
|
+
c.payment_provider = Kiosk::Server::PaymentClaim.new(
|
|
125
|
+
psp, currency: "eur", model: "Order", reference: "order_id", query: "my_orders"
|
|
126
|
+
)
|
|
127
|
+
c.cart_price_checker = PriceChecker
|
|
128
|
+
c.after_payment = ->(order_id) { CourierDispatchJob.arm!(order_id) } # optional
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`model` names your payable model; it includes `Kiosk::Owned`, so the claim
|
|
133
|
+
takes only the payer's own row. The status lives in its `payment_status` column,
|
|
134
|
+
whose unpaid value is `unpaid`.
|
|
135
|
+
|
|
136
|
+
The model also includes `Kiosk::Payable`, which gives each row:
|
|
137
|
+
|
|
138
|
+
- `payment_state`: `unpaid`, `pending` while a capture is in flight, `paid`, or
|
|
139
|
+
`refunded`. Your per-user query publishes it, so a capture in flight never
|
|
140
|
+
reads as *not paid*. `Order.own.with_settlement(Kiosk::Settlement.own)` reads
|
|
141
|
+
it for many rows in one query.
|
|
142
|
+
- `cart(lines)`: the cart your action returns for the assistant to sign, a line
|
|
143
|
+
that names the row and then your priced lines.
|
|
144
|
+
- `settlement`: the receipt of the capture that paid the row. `c.after_payment`
|
|
145
|
+
runs once this receipt is recorded, so it can refund against it.
|
|
146
|
+
|
|
147
|
+
When the capture returns, the engine pushes `{"order_id": "…", "payment_state":
|
|
148
|
+
"paid"}` to the row's owner on the `payment` topic. Refund through the claim,
|
|
149
|
+
`refund(psp_reference:, amount_cents:, key:)`: `key` names this refund among
|
|
150
|
+
the refunds of the charge, so a retry with the same key refunds once. It
|
|
151
|
+
records the refund in `kiosk.refunds`, and recent spend leaves the refunded
|
|
152
|
+
cents out. The spending cap gets the refunded amount back. When the refund
|
|
153
|
+
returns the whole charge, set the row's status column to `refunded`, which is
|
|
154
|
+
where `payment_state` reads it. Then call `announce(id, "refunded")` on the
|
|
155
|
+
claim to push the same topic.
|
|
156
|
+
|
|
157
|
+
A repeat of a chain whose capture did not complete runs the capture again under
|
|
158
|
+
the same key, the cart mandate id, so your PSP adapter must key its charge by
|
|
159
|
+
it (`kiosk-pay-stripe` does). A charge refused with no money moved marks the
|
|
160
|
+
cart declined: it stops counting against `spending_cap` at once, and a repeat of
|
|
161
|
+
that chain answers `409 conflict`.
|
|
162
|
+
|
|
163
|
+
Set `c.spending_cap = Kiosk::Server::ColumnSpendingCap.new` to cap what each
|
|
164
|
+
assistant spends. The human sets the cap on `/kiosk/auth/assistants`: an amount
|
|
165
|
+
in cents and a currency. The engine refuses a `pay` that passes the cap, or a
|
|
166
|
+
`pay` in another currency, with `403 spending_cap_exceeded` before the capture.
|
|
167
|
+
|
|
168
|
+
### Proof of work
|
|
169
|
+
|
|
170
|
+
Set a policy and the engine tolls the first call of each access token; the
|
|
171
|
+
token then calls freely until it expires (`access_token_ttl`, 3600 seconds by
|
|
172
|
+
default). With the ready-made policy, the more the customer spent here, the
|
|
173
|
+
smaller the chance that a session pays the toll:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Kiosk.configure do |c|
|
|
177
|
+
c.pow_secret = ENV.fetch("KIOSK_POW_SECRET")
|
|
178
|
+
c.reputation_policy = Kiosk::Reputation::Policies::RecentSpend.new(within: 1.week, cap: 100)
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The engine counts the customer's settlements, and the visits you record when
|
|
183
|
+
you honour a booking that takes no payment:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
Kiosk::Visit.record!(user_id: booking.user_id, value_cents: 45_00)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
## Upgrading
|
|
191
|
+
|
|
192
|
+
### The schema
|
|
193
|
+
|
|
194
|
+
`rails generate kiosk:install` emits the **genesis** — the whole current shape,
|
|
195
|
+
as migrations. That is the fresh-install path. Migrations already in
|
|
196
|
+
`db/migrate/` are never re-emitted and never edited: a change to the Kiosk
|
|
197
|
+
schema arrives as a new file you copy in and migrate, so `db:migrate` on your
|
|
198
|
+
existing database transfers only what it has not run.
|
|
199
|
+
|
|
200
|
+
Each MAJOR publishes its own genesis and drops the previous major's chain, so
|
|
201
|
+
**an upgrade crossing a major stops at it.** Install that major, `bin/rails
|
|
202
|
+
db:migrate`, then move on — one major at a time. A fresh install at any major
|
|
203
|
+
gets that major's genesis and replays no history at all.
|
|
204
|
+
|
|
205
|
+
MINOR and PATCH releases only ever add files to the chain.
|
|
206
|
+
|
|
207
|
+
### The configuration
|
|
208
|
+
|
|
209
|
+
`config/initializers/kiosk.rb` is **yours**. Nothing regenerates it, and no
|
|
210
|
+
upgrade edits it.
|
|
211
|
+
|
|
212
|
+
- **Every new setting ships a working default**, so an initializer you never
|
|
213
|
+
touch keeps working across an upgrade. The generated file is documentation of
|
|
214
|
+
what can be set, not the source of truth for what is set.
|
|
215
|
+
- **A setting that cannot have a safe default fails closed, naming itself**,
|
|
216
|
+
with a `Kiosk::Server::Errors::ConfigurationError`. `c.roles` with no
|
|
217
|
+
`c.registration_role` among them stops the boot. `Kiosk.configure` refuses a
|
|
218
|
+
`c.pow_secret` shorter than 32 bytes. With the toll enabled and no
|
|
219
|
+
`c.pow_secret`, the first tolled request is refused.
|
|
220
|
+
- **An unknown, removed or renamed setting is a `NoMethodError` on
|
|
221
|
+
`Kiosk::Configuration`**, which names the key you typed, so an initializer
|
|
222
|
+
carrying an old name stops the boot instead of being silently ignored.
|
|
223
|
+
|
|
224
|
+
The principle behind all three: generate as little as possible, derive at boot
|
|
225
|
+
whatever can be derived, default whatever can be defaulted, and fail closed on
|
|
226
|
+
what can be neither.
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
## Multi-process deployments
|
|
230
|
+
|
|
231
|
+
The PoW gate keeps spent challenge ids in `c.pow_spent_store`, by default
|
|
232
|
+
`Kiosk::Server::PowSpentStores::ActiveRecord`: the `kiosk.pow_spent` table that
|
|
233
|
+
`bin/rails g kiosk:install` lays down. Every process and every deploy shares it,
|
|
234
|
+
so a proof is accepted once (kiosk.tech `protocol.md` §15.2).
|
|
235
|
+
|
|
236
|
+
Any other backend works: the contract is `claim(id, exp) → Boolean`,
|
|
237
|
+
`release(id)`, `spent?(id)`, `mark_spent(id, exp)`, and `claim` **MUST** be one
|
|
238
|
+
atomic operation (Redis `SET … NX EX`, or SQL `INSERT … ON CONFLICT`). A
|
|
239
|
+
read-then-write reintroduces exactly the replay race the gate closes.
|
|
240
|
+
|
|
241
|
+
Token revocation lives in `c.revocation_store`, by default
|
|
242
|
+
`Kiosk::Server::RevocationStores::ActiveRecord`: the `kiosk.revocations` table,
|
|
243
|
+
which `bin/rails g kiosk:install` lays down too. A revoke, a rebind or an unlink
|
|
244
|
+
stops the agent's older tokens in every process and after a restart, and a
|
|
245
|
+
token of an unlinked agent stops at once. The contract is
|
|
246
|
+
`revoke_all(agent_id, at:)`, `watermark_for(agent_id)` and
|
|
247
|
+
`revoked?(agent_id:, iat:)`.
|
|
248
|
+
|
|
249
|
+
Outstanding auth challenges live in `c.auth_challenge_store`, by default
|
|
250
|
+
`Kiosk::Server::AuthChallengeStores::ActiveRecord`: the `kiosk.auth_challenges`
|
|
251
|
+
table, which `bin/rails g kiosk:install` lays down too. A challenge issued by one
|
|
252
|
+
process is redeemed by any other. Any other backend works: the contract is
|
|
253
|
+
`put(public_key_pem, nonce, exp)` / `take(public_key_pem, nonce) → Boolean`, and
|
|
254
|
+
`take` **MUST** be one atomic operation (SQL `DELETE … RETURNING`, Redis
|
|
255
|
+
`GETDEL`) so the store, not the application, decides which of two concurrent
|
|
256
|
+
presentations wins.
|
|
257
|
+
|
|
258
|
+
Events live in `c.event_store`, by default `Kiosk::Server::EventStores::ActiveRecord`:
|
|
259
|
+
the `kiosk.events` table from `bin/rails g kiosk:install`. The event stream runs
|
|
260
|
+
on the Action Cable adapter that `config/cable.yml` names for the environment. In
|
|
261
|
+
production that adapter must be one every process shares, such as `solid_cable`
|
|
262
|
+
in the host's Gemfile;
|
|
263
|
+
a production origin that serves events with no adapter there does not boot.
|
|
264
|
+
|
|
265
|
+
## The audit sink
|
|
266
|
+
|
|
267
|
+
**Kiosk does not keep an audit trail. It hands you one and gets out of the way.**
|
|
268
|
+
|
|
269
|
+
Set a callable and it receives one `Kiosk::Server::ActionEvent` for **every
|
|
270
|
+
action invocation — successful and failed alike**:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# config/initializers/kiosk.rb
|
|
274
|
+
Kiosk.configure do |c|
|
|
275
|
+
c.audit_sink = ->(event) { AuditRow.create!(**event.to_h) }
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**The default is no sink**: nothing is emitted, nothing is built, and Kiosk
|
|
280
|
+
writes no audit record to any table of its own. It keeps no audit table, and
|
|
281
|
+
will not grow one: an audit trail the framework keeps is a retention policy the
|
|
282
|
+
framework decided for your customers' data.
|
|
283
|
+
|
|
284
|
+
### What the event carries
|
|
285
|
+
|
|
286
|
+
| field | |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `action` | the action's wire name |
|
|
289
|
+
| `user_id` / `agent_id` | the principal, and the assistant credential that acted (nil when the caller is not an agent) |
|
|
290
|
+
| `role` / `actor` | the active role (may be nil), and `"agent"` \| `"human"` \| `"service"` |
|
|
291
|
+
| `args` | **the arguments exactly as the handler received them** |
|
|
292
|
+
| `status` | `"ok"` or `"error"` |
|
|
293
|
+
| `error_class` / `error_message` | the raised error, untruncated, on the `"error"` branch |
|
|
294
|
+
| `cause_class` / `cause_message` | the error's own `#cause`, when it has one distinct from itself — the handler's exception behind a wrapper |
|
|
295
|
+
| `invoked_at` | when the invocation started |
|
|
296
|
+
|
|
297
|
+
### The arguments arrive unredacted, and the PII is yours
|
|
298
|
+
|
|
299
|
+
`event.args` is verbatim: the delivery address, the passenger name, the cart,
|
|
300
|
+
the booking reference — whatever your verb takes. **Kiosk does not redact them
|
|
301
|
+
for you, and the moment you write them somewhere you are the data controller
|
|
302
|
+
for whatever they contain** — retention, encryption at rest, access control and
|
|
303
|
+
deletion requests are all yours. That is the deliberate shape of this seam:
|
|
304
|
+
Kiosk gives you the capability and does not pretend to have made your privacy
|
|
305
|
+
decisions for you.
|
|
306
|
+
|
|
307
|
+
Withholding them is one call, if that is what you want:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# names and JSON types, never values: {"salon_id" => "integer", "slot" => "string"}
|
|
311
|
+
c.audit_sink = ->(e) { Rails.logger.info(e.with_arg_types.to_h.to_json) }
|
|
312
|
+
|
|
313
|
+
# nothing at all
|
|
314
|
+
c.audit_sink = ->(e) { Siem.record(e.without_args.to_h) }
|
|
315
|
+
|
|
316
|
+
# per-field, because only you know which of your fields are hot
|
|
317
|
+
c.audit_sink = ->(e) { Siem.record(e.to_h.merge(args: e.args.except(:card_token))) }
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### What is emitted, and what is not
|
|
321
|
+
|
|
322
|
+
Actions only. A **query** is not emitted (it changes nothing, and every read
|
|
323
|
+
would drown the trail). **`pay`** is not emitted — it already writes the far
|
|
324
|
+
richer AP2 mandate trail (`intent_mandates`, `cart_mandates`,
|
|
325
|
+
`payment_mandates`, `settlements`). A **refusal that never reached an action**
|
|
326
|
+
is not emitted: a 401 with no identity, a 404 for an unregistered name, a 405,
|
|
327
|
+
a 400 from argument validation, a 402 from the toll. Nothing was invoked, so
|
|
328
|
+
there is nothing to report — this is an action trail, not a request log. Put a
|
|
329
|
+
Rack middleware in front of the engine if a request log is what you want.
|
|
330
|
+
|
|
331
|
+
### A sink that raises does not fail the action
|
|
332
|
+
|
|
333
|
+
The sink is your code and it is called after the action has already succeeded
|
|
334
|
+
or failed. If it raises a `StandardError` the error is logged (`Rails.logger`,
|
|
335
|
+
or `warn` outside Rails) and the invocation stands: a booking that succeeded
|
|
336
|
+
must not come back as a 500 because a broker was down. Emission also happens
|
|
337
|
+
**after** the action's transaction closes, so a slow sink cannot hold a
|
|
338
|
+
database transaction open and a failed action's `ROLLBACK` cannot erase the
|
|
339
|
+
record of it.
|
|
340
|
+
|
|
341
|
+
Any `#call`-able works — a lambda, or an instance of a class of yours when the
|
|
342
|
+
sink has state. A non-callable is rejected when you configure it, so a typo is
|
|
343
|
+
a boot failure rather than an audit trail that silently was never there.
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
## Draw the routes
|
|
347
|
+
|
|
348
|
+
Bundling the gem draws no route at all, and an origin with no mount answers
|
|
349
|
+
nothing. `bin/rails g kiosk:install` writes the `kiosk` block into
|
|
350
|
+
`config/routes.rb`; you name your verbs in it:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
# config/routes.rb
|
|
354
|
+
Rails.application.routes.draw do
|
|
355
|
+
kiosk do
|
|
356
|
+
queries :catalog
|
|
357
|
+
actions :place_order
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`kiosk` mounts the engine at `Kiosk.configuration.mount_path`, then draws
|
|
363
|
+
`GET <mount_path>/<name>` for each query and `POST <mount_path>/<name>` for each
|
|
364
|
+
action. Each route reaches the controller action that declares the verb, and
|
|
365
|
+
`bin/rails routes` prints that action. A block-less `kiosk` only mounts.
|
|
366
|
+
|
|
367
|
+
**The mount draws the PROTOCOL PLANE** — the paths whose shape is the spec's and
|
|
368
|
+
not yours. Under the mount: the reserved `schema`, `openapi.json` and `pay`,
|
|
369
|
+
`payment_setup` and the `payment_setup/return` page a payment provider sends the
|
|
370
|
+
human back to (both answer `501 module_not_served` without a `payment_provider`), the
|
|
371
|
+
kiosk-pop auth plane (`auth/challenge`, `auth/register`, `auth/login`,
|
|
372
|
+
`auth/revoke`), JWKS (`.well-known/jwks.json`), KYC (`agents/kyc`, and
|
|
373
|
+
`request_kyc` and `kyc/callback`, which answer `501 module_not_served` without a
|
|
374
|
+
`kyc_provider`) and the whole account-binding ceremony (the RFC 8628 claim wire,
|
|
375
|
+
`auth/link`/`claim`/`unlink`, the verify and «Link an assistant» pages). The
|
|
376
|
+
engine also installs the ROOT-relative discovery documents — `/agents.txt`,
|
|
377
|
+
`/agents.json`, `/auth.md`,
|
|
378
|
+
`/.well-known/{agent-configuration,kiosk.json,api-catalog}` — into the host app
|
|
379
|
+
via `routes.append`, because the agents.txt standard and RFC 8615 place them at
|
|
380
|
+
the origin root, outside any mount prefix. That install happens ONLY when the
|
|
381
|
+
engine is mounted: merely bundling the gem adds no routes.
|
|
382
|
+
|
|
383
|
+
**You name your OWN VERBS, and the METHOD FOLLOWS THE KIND** — GET for a
|
|
384
|
+
`kind :query`, POST for a `kind :action`. That is what the protocol says a verb
|
|
385
|
+
IS, so the routes state it instead of hiding it, and `bin/rails routes` prints
|
|
386
|
+
your actual wire.
|
|
387
|
+
|
|
388
|
+
**The mount comes FIRST.** Rails dispatches the first matching route, so
|
|
389
|
+
everything the engine draws wins over every verb route and no verb of yours can
|
|
390
|
+
shadow `schema`, `pay` or the auth plane. (You could not declare such a verb
|
|
391
|
+
anyway — `Kiosk::Handler` refuses a reserved name at boot.)
|
|
392
|
+
|
|
393
|
+
A path under the mount that names no route — including a verb called with the
|
|
394
|
+
other method — matches nothing, so it is the ordinary 404 Rails answers at any
|
|
395
|
+
unrouted path: no `code`, no `hint`. The mount-path middleware still stamps the
|
|
396
|
+
version headers on it. If you want the wire's own `404 verb_not_found` there,
|
|
397
|
+
draw a catch-all action of your own at the end of your routes; the engine does not
|
|
398
|
+
draw one, because an assistant reads `GET <mount>/schema` before it dials.
|
|
399
|
+
Declared-but-unrouted is the bug class this trade opens: every verb you declare
|
|
400
|
+
needs its name in the block.
|
|
401
|
+
|
|
402
|
+
**The protocol plane comes from the mount and from nowhere else.** Copying those
|
|
403
|
+
paths into your routes by hand is not a supported second way to draw them:
|
|
404
|
+
the mount above the copy already answers, so the line is dead, and a protocol
|
|
405
|
+
this gem keeps in step with the spec becomes a table you now own. Write your own
|
|
406
|
+
verbs; mount the rest.
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
## Declaring queries and actions
|
|
410
|
+
|
|
411
|
+
An assistant reaches a provider at ONE ENDPOINT PER VERB: a query is
|
|
412
|
+
`GET <mount>/<query-name>` with its arguments in the query string, an action is
|
|
413
|
+
`POST <mount>/<action-name>` with its arguments in a JSON body. The operator
|
|
414
|
+
decides what those names are and what they mean. `Kiosk::Handler` is the module
|
|
415
|
+
that lets a controller answer them, and you name each one in the `kiosk` block — see
|
|
416
|
+
[Draw the routes](#draw-the-routes).
|
|
417
|
+
|
|
418
|
+
Kiosk ships a **mixin, not a base class**. Which superclass a handler controller
|
|
419
|
+
has is your decision; the `include` is the whole contract.
|
|
420
|
+
|
|
421
|
+
Which verb reaches a handler is a property of the **declaration**, not of the
|
|
422
|
+
class: `kind :query` puts it on `GET`, `kind :action` on `POST`, and one
|
|
423
|
+
controller may declare both — a resource you think of as one thing is one
|
|
424
|
+
controller. Split when your domain splits.
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
# app/controllers/kiosk/catalog_controller.rb
|
|
428
|
+
class Kiosk::CatalogController < ActionController::API
|
|
429
|
+
include Kiosk::Handler
|
|
430
|
+
|
|
431
|
+
kind :query
|
|
432
|
+
description "Lists what the shop has in stock right now, so the assistant " \
|
|
433
|
+
"can decide what to put in a basket."
|
|
434
|
+
input_schema type: "object", additionalProperties: false,
|
|
435
|
+
properties: { q: { type: "string" } }
|
|
436
|
+
output_schema type: "array",
|
|
437
|
+
items: { type: "object",
|
|
438
|
+
properties: { sku: { type: "string" },
|
|
439
|
+
price_cents: { type: "integer" } } }
|
|
440
|
+
example_params({ q: "milk" })
|
|
441
|
+
def catalog
|
|
442
|
+
render json: Product.in_stock.search(params[:q]).as_json
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
# app/controllers/kiosk/orders_controller.rb
|
|
449
|
+
class Kiosk::OrdersController < ActionController::API
|
|
450
|
+
include Kiosk::Handler
|
|
451
|
+
|
|
452
|
+
kind :action
|
|
453
|
+
description "Places an order for the assistant's human and reserves the " \
|
|
454
|
+
"chosen delivery window. Nothing is charged until `pay`."
|
|
455
|
+
input_schema type: "object",
|
|
456
|
+
properties: { items: { type: "array" }, slot_at: { type: "string", format: "date-time" } },
|
|
457
|
+
required: %w[items slot_at]
|
|
458
|
+
output_schema type: "object",
|
|
459
|
+
properties: { order_id: { type: "string" },
|
|
460
|
+
total_cents: { type: "integer" } },
|
|
461
|
+
required: %w[order_id total_cents]
|
|
462
|
+
def create_order
|
|
463
|
+
order = Orders::Place.call(user_id: kiosk_identity.user_id, params: params)
|
|
464
|
+
render json: { order_id: order.id, total_cents: order.total_cents }
|
|
465
|
+
rescue Orders::SlotTaken => e
|
|
466
|
+
render json: { error: e.message, hint: "call delivery_slots again" }, status: :conflict
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Put handler controllers in `app/controllers/kiosk/`. The engine loads that
|
|
472
|
+
directory at boot and after every development reload, and each class that
|
|
473
|
+
includes `Kiosk::Handler` registers its verbs, so an edited, added or removed
|
|
474
|
+
verb lands without restarting the server.
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
### What the macros do
|
|
478
|
+
|
|
479
|
+
Each macro records a declaration; the **next `def`** claims all pending ones and
|
|
480
|
+
becomes a wire verb. A method with no declarations above it is not a verb, so
|
|
481
|
+
helper methods stay invisible to the wire.
|
|
482
|
+
|
|
483
|
+
| macro | what it declares |
|
|
484
|
+
| --- | --- |
|
|
485
|
+
| `kind` | **Required.** `:query` (reached by `GET <mount>/<name>`) or `:action` (`POST <mount>/<name>`). A property of the declaration, so one controller may carry both. There is no default: either one would silently pick an HTTP method for you. `:action` is the DECLARATION name and nothing else: the wire command an action travels under is **`:run`**, so a `kind :action` verb reaches a reputation policy as `verb: :run` (`Kiosk::Handler` serves an action as `:run`; the reserved pay endpoint arrives as `:pay`). Branch on `:run` there, not on `:action` — see `kiosk-reputation`'s README. |
|
|
486
|
+
| `reach` | Optional, and the default is the strong one. Whose rows this verb may touch (spec §7.2): `:principal` (default — only the caller's own, or rows that belong to no principal), `:published`, `:consented` or `:role`. Declare nothing and the verb is held to per-principal scoping absolutely; a departure costs one line and is published on the catalog, so an assistant can tell an open board from a scoping bug. |
|
|
487
|
+
| `description` | Semantics **only**: what the verb does, how, and what it returns *in meaning*. Never a field list, a type, a required marker or a param name — those live in the schemas. |
|
|
488
|
+
| `input_schema` | **Required.** JSON Schema for the params. The input contract: every name, type, enum and range — and the wire coerces and validates every call against it. A verb that takes nothing still declares the empty closed object. |
|
|
489
|
+
| `output_schema` | **Required.** JSON Schema for what comes back, so an assistant knows the result shape without a call-and-observe probe — the only machine-readable statement of the answer, now that there is no response envelope. |
|
|
490
|
+
| `example_params` | A params object an assistant can copy verbatim. |
|
|
491
|
+
| `example_row` | A worked example of the result. |
|
|
492
|
+
| `wire_name` | Optional. The name agents call the verb by, when it cannot be the method name. |
|
|
493
|
+
|
|
494
|
+
The **Required** rows are enforced at declaration time, not at request time: a
|
|
495
|
+
verb that omits any of them raises an `ArgumentError` as its class body is read,
|
|
496
|
+
so the app fails to boot instead of publishing an incomplete contract.
|
|
497
|
+
|
|
498
|
+
All of them surface in `GET <mount>/schema`, which is how an assistant discovers
|
|
499
|
+
the surface.
|
|
500
|
+
|
|
501
|
+
**A schema slot may be a proc, when the constraint is a fact about your data.**
|
|
502
|
+
Any part of `input_schema`, `output_schema`, `example_params` or `example_row`
|
|
503
|
+
may be a zero-arity proc:
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
input_schema type: "object", additionalProperties: false,
|
|
507
|
+
properties: {
|
|
508
|
+
category_slug: { type: "string", enum: -> { Category.pluck(:slug) } },
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Write the proc, not the plain call. `enum: Category.pluck(:slug)` runs while the
|
|
513
|
+
class body is read — which is `db:create`, `db:migrate` and
|
|
514
|
+
`assets:precompile` as well as a serving boot, where there is no table to read —
|
|
515
|
+
and it captures a list that then goes stale for the life of the process. The
|
|
516
|
+
proc is called when the descriptor is SERVED, resolved once and reused for a
|
|
517
|
+
short window, and re-resolved after it: so adding a category publishes itself,
|
|
518
|
+
with no restart and no deploy. The catalog's `?v=` version moves with it, and
|
|
519
|
+
the discovery document republishes the new link within its own minute.
|
|
520
|
+
`description`, `kind`, `reach` and `wire_name` are not resolvable — the first is
|
|
521
|
+
prose semantics, two are routing facts fixed when the route is drawn, and
|
|
522
|
+
`reach` is a security claim about the verb: one computed from your rows could
|
|
523
|
+
change under a caller between the catalog it read and the call it made.
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
### Two things to know
|
|
527
|
+
|
|
528
|
+
**Draw verb routes with `kiosk` only.** A route you draw at a handler action
|
|
529
|
+
yourself answers 404: the `kiosk` block names the verb that the action answers.
|
|
530
|
+
`Kiosk::Handler` runs authentication, the argument check, the proof-of-work gate
|
|
531
|
+
and the transaction around the action, before your own `before_action` filters.
|
|
532
|
+
|
|
533
|
+
### What you get inside a handler
|
|
534
|
+
|
|
535
|
+
It is an ordinary Rails action. `before_action` filters run, `rescue_from`
|
|
536
|
+
applies, `params` is `ActionController::Parameters`, and the answer is whatever
|
|
537
|
+
you `render` — or the action's JSON template (a Jbuilder view, say), which is
|
|
538
|
+
the one rendered whatever `Accept` the caller sent. On top of that:
|
|
539
|
+
|
|
540
|
+
- `kiosk_identity` — the `Kiosk::Identity` the wire resolved (`user_id`,
|
|
541
|
+
`agent_id`, `role`, `actor`). The four transaction-local GUCs are already applied to
|
|
542
|
+
the connection, so SQL-side and RLS scoping work whether or not you read it.
|
|
543
|
+
- `render_kiosk_page(rows, next_cursor:, total:)` — answer one page of a large
|
|
544
|
+
query; leave out `rows` and the action's template renders them. The body stays the bare array every query answers: the cursor leaves as
|
|
545
|
+
an RFC 8288 `Link: <…?cursor=…>; rel="next"` response header and the
|
|
546
|
+
matching-row count as `X-Total-Count`. `Kiosk::Server::Cursor` has an offset
|
|
547
|
+
helper; pass `total:` only when you know it.
|
|
548
|
+
- `include Kiosk::Owned` — gives a model with a `user_id` column the scope
|
|
549
|
+
`own`, the current principal's rows. `Kiosk.current_user_id` is the same id,
|
|
550
|
+
for a column with another name.
|
|
551
|
+
- `Kiosk.reader_zone(default = nil)` — the zone the caller declared in
|
|
552
|
+
`Kiosk-Timezone`, else `default`.
|
|
553
|
+
- `Kiosk.clock_gap_note(name, zone, caller_zone)` — when the place's `zone`
|
|
554
|
+
and `caller_zone` are on different clocks now, one sentence:
|
|
555
|
+
`Pera House runs on Europe/Istanbul time, 8 hours ahead of your America/New_York.`
|
|
556
|
+
Else `nil`. Pass `Kiosk.reader_zone` as `caller_zone`. Add your own sentence
|
|
557
|
+
that every time in the answer is the place's.
|
|
558
|
+
- `include Kiosk::SkillLink` — in a page controller: every answer carries
|
|
559
|
+
`Link: <skill_url>; rel="kiosk"`, the discovery signal for a client that
|
|
560
|
+
does not read HTML.
|
|
561
|
+
- `Kiosk::Settlement` and `Kiosk::CartMandate` — read models over the receipts
|
|
562
|
+
`pay` records. `Kiosk::Settlement.own` is the caller's own;
|
|
563
|
+
`Kiosk::Settlement.joins(:cart_mandate).merge(Kiosk::CartMandate.referencing(order_id: id))`
|
|
564
|
+
is the settlement whose cart names your row by the key your line items carry.
|
|
565
|
+
- The handler runs inside the wire's GUC-scoped transaction, so raising rolls
|
|
566
|
+
back — and so does rendering a non-2xx, which the seam converts into a raise.
|
|
567
|
+
|
|
568
|
+
Errors are Rails' idiom, end to end. The error-**code** vocabulary is the wire
|
|
569
|
+
contract — a closed table, not a class hierarchy. Mind which side of the seam
|
|
570
|
+
you are on: a handler names a code the Rails way, under `error.code` in the body
|
|
571
|
+
it renders, and the wire re-renders that as an RFC 9457
|
|
572
|
+
`application/problem+json` document whose `code` is a FLAT top-level member —
|
|
573
|
+
`error.code` is the handler-side spelling, `code` is what travels and what an
|
|
574
|
+
assistant branches on. Three Rails-native moves cover all of it:
|
|
575
|
+
|
|
576
|
+
- `render json: {...}, status: :bad_request` — the status becomes its wire
|
|
577
|
+
code (400 `bad_request`, 401 `unauthenticated`, 403 `forbidden`,
|
|
578
|
+
404 `not_found`, 409 `conflict`, 422 `bad_request`, 429 `quota_exceeded`).
|
|
579
|
+
- Raise what you would raise anyway. Any exception Rails knows a status for —
|
|
580
|
+
`params.require`, `ActiveRecord::RecordNotFound`, anything your app
|
|
581
|
+
registered in `config.action_dispatch.rescue_responses` (the same registry
|
|
582
|
+
policy libraries use) — is mapped to that status' wire code by one
|
|
583
|
+
`rescue_from` the include installs. Your own `rescue_from` declarations win
|
|
584
|
+
over it. Anything unregistered stays a 500 `action_failed`.
|
|
585
|
+
**The CODE travels; the exception's own SENTENCE does not.** That branch is
|
|
586
|
+
for exceptions you did not author, so the message would be some library's
|
|
587
|
+
wording — actionpack's «param is missing or the value is empty or invalid:
|
|
588
|
+
sku» is what `params.require` puts on a 400 — and it moves when a
|
|
589
|
+
dependency moves. The caller gets a Kiosk sentence and a `hint` for that
|
|
590
|
+
code; the exception's class, message and backtrace go to your `Rails.logger`.
|
|
591
|
+
When you mean to say something to the assistant, say it: render the envelope,
|
|
592
|
+
or raise a `Kiosk::Server::Errors` class with your own `message:` and
|
|
593
|
+
`hint:`. Both carry your words verbatim.
|
|
594
|
+
- For a code a bare status cannot name — `rls_denied`, or a *specific* 402
|
|
595
|
+
(`payment_setup_required` vs `payment_failed` vs `pow_required`) — render
|
|
596
|
+
the code explicitly:
|
|
597
|
+
`render json: { error: { code: "rls_denied", message: "…" } },
|
|
598
|
+
status: :forbidden`. It travels verbatim — the code becomes the problem
|
|
599
|
+
document's top-level `code`, the message its `detail` — while a bare 402/500
|
|
600
|
+
is never guessed at. (The gate-style `Kiosk::Server::Errors` classes remain
|
|
601
|
+
raisable too.)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
### The initializer holds configuration, not verbs
|
|
605
|
+
|
|
606
|
+
There is no second way to declare a verb — no block you register from an
|
|
607
|
+
initializer. A block there cannot be reloaded, cannot be reached by your
|
|
608
|
+
filters, `rescue_from` or strong parameters, and would teach — in the very file
|
|
609
|
+
an adopter copies — that Rails does not apply to the surface you expose to
|
|
610
|
+
assistants. Write a controller in `app/controllers/kiosk/`, and the initializer
|
|
611
|
+
keeps what an initializer is for: the identity providers, the payment provider,
|
|
612
|
+
the PoW gates.
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
## Well-known endpoint (no booted Rails app required)
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
require "kiosk/server"
|
|
619
|
+
|
|
620
|
+
# The issuer is the AP2 mandate `iss` anchor; the builder refuses to answer
|
|
621
|
+
# without it, booted app or not.
|
|
622
|
+
Kiosk.configure { |c| c.issuer = "https://api.acme.example" }
|
|
623
|
+
|
|
624
|
+
doc = Kiosk::Server::WellKnown.build_json(base_url: "https://api.acme.example")
|
|
625
|
+
# => '{"kiosk":{"version":"1.0","endpoint":"https://api.acme.example/kiosk",...}}'
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
## License
|
|
629
|
+
|
|
630
|
+
Apache-2.0 — see `LICENSE.txt`.
|
|
631
|
+
|
|
632
|
+
## Links
|
|
633
|
+
|
|
634
|
+
- [kiosk.tech](https://kiosk.tech)
|
|
635
|
+
- [Issue tracker](https://github.com/kiosk-hq/kiosk/issues)
|