kiosk-server 0.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- metadata +349 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 010 — create kiosk.auth_challenges.
|
|
4
|
+
# Each public key's outstanding auth challenge, so a challenge issued by one
|
|
5
|
+
# process is redeemed by any other (AuthChallengeStores::ActiveRecord, the default).
|
|
6
|
+
class CreateKioskAuthChallenges < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
+
def up
|
|
8
|
+
execute <<~'SQL'
|
|
9
|
+
<%= ddl(:auth_challenge) %>
|
|
10
|
+
SQL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".auth_challenges)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 004 — kiosk.device_authorizations: the account-binding state
|
|
4
|
+
# machine behind the RFC 8628-shaped claim and link ceremonies, in the shape
|
|
5
|
+
# DeviceAuthorizationStores::ActiveRecord reads and writes (hashed device and
|
|
6
|
+
# user codes, the bound public key, claim vs link).
|
|
7
|
+
class CreateKioskDeviceAuthorizations < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
8
|
+
def up
|
|
9
|
+
execute <<~'SQL'
|
|
10
|
+
<%= ddl(:device_authorizations, user_id_type: user_id_type) %>
|
|
11
|
+
SQL
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".device_authorizations)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 007 — create kiosk.events.
|
|
4
|
+
# The per-identity EVENT TAIL an operator serving any topic needs. `id` is a
|
|
5
|
+
# bigserial, and that is what makes the wire's event id a per-origin monotonic
|
|
6
|
+
# integer — one cursor then resumes every subscription on a socket with one
|
|
7
|
+
# integer comparison.
|
|
8
|
+
# `identity_key` is a user_id, not an agent_id: a human's second assistant must
|
|
9
|
+
# read the same tail, and a revoked assistant is stopped at the socket.
|
|
10
|
+
# Laid down unconditionally, like kiosk.kyc_attributes: the route is drawn
|
|
11
|
+
# whether or not this origin declares a topic, and an empty table costs nothing.
|
|
12
|
+
class CreateKioskEvents < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
13
|
+
def up
|
|
14
|
+
execute <<~'SQL'
|
|
15
|
+
<%= ddl(:events) %>
|
|
16
|
+
SQL
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def down
|
|
20
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".events)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 002 — Kiosk identity tables: agents, agent_tokens, agent_mappings.
|
|
4
|
+
class CreateKioskIdentityTables < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
5
|
+
def up
|
|
6
|
+
execute <<~'SQL'
|
|
7
|
+
<%= ddl(:identity_tables, user_id_type: user_id_type, user_table: options[:user_table]) %>
|
|
8
|
+
SQL
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def down
|
|
12
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".agent_mappings)
|
|
13
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".agent_tokens)
|
|
14
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".agents)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 006 — create kiosk.kyc_attributes.
|
|
4
|
+
# ONE ROW per NAMED ANONYMIZED boolean attribute a valid KYC attestation
|
|
5
|
+
# granted an agent (e.g. age_over_18, licence_a). Only the NAMES are stored —
|
|
6
|
+
# never the DOB, licence number, or any underlying document.
|
|
7
|
+
# There is no value column: the grant IS the row, so every gate is an EXISTS
|
|
8
|
+
# and no reader has to decide which spelling of `true` counts.
|
|
9
|
+
# Additive/opt-in: needed only if an Action gates on specific KYC attributes.
|
|
10
|
+
class CreateKioskKycAttributes < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
11
|
+
def up
|
|
12
|
+
execute <<~'SQL'
|
|
13
|
+
<%= ddl(:kyc_attributes, user_id_type: user_id_type, user_table: options[:user_table]) %>
|
|
14
|
+
SQL
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def down
|
|
18
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".kyc_attributes)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 005 — kiosk AP2 mandate trail:
|
|
4
|
+
# intent_mandates, cart_mandates, payment_mandates, settlements.
|
|
5
|
+
# See the Payment (AP2 mandate chain) section of the spec.
|
|
6
|
+
class CreateKioskMandates < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
+
def up
|
|
8
|
+
execute <<~'SQL'
|
|
9
|
+
<%= ddl(:mandates, user_id_type: user_id_type) %>
|
|
10
|
+
SQL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
# Reverse-FK order: payment_mandates and settlements both reference
|
|
15
|
+
# cart_mandates, so both drop before it; cart_mandates before intent_mandates.
|
|
16
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".payment_mandates)
|
|
17
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".settlements)
|
|
18
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".cart_mandates)
|
|
19
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".intent_mandates)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 008 — create kiosk.pow_spent.
|
|
4
|
+
# The spent proof-of-work challenge ids, so a proof is accepted once across
|
|
5
|
+
# every process and every deploy (PowSpentStores::ActiveRecord, the default).
|
|
6
|
+
class CreateKioskPowSpent < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
+
def up
|
|
8
|
+
execute <<~'SQL'
|
|
9
|
+
<%= ddl(:pow_spent) %>
|
|
10
|
+
SQL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".pow_spent)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 012 — create kiosk.refunds.
|
|
4
|
+
# The refunds of settled charges (Kiosk::Server::PaymentClaim#refund), which
|
|
5
|
+
# recent spend leaves out of the proof-of-work toll.
|
|
6
|
+
class CreateKioskRefunds < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
+
def up
|
|
8
|
+
execute <<~'SQL'
|
|
9
|
+
<%= ddl(:refunds) %>
|
|
10
|
+
SQL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".refunds)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 009 — create kiosk.revocations.
|
|
4
|
+
# Each agent's token revocation watermark, so a revoke or an unlink holds in
|
|
5
|
+
# every process and across restarts (RevocationStores::ActiveRecord, the default).
|
|
6
|
+
class CreateKioskRevocations < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
+
def up
|
|
8
|
+
execute <<~'SQL'
|
|
9
|
+
<%= ddl(:revocations) %>
|
|
10
|
+
SQL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".revocations)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 001 — Kiosk schema + the four current_*() helper functions.
|
|
4
|
+
#
|
|
5
|
+
# `ActiveRecord::Migration[…]` resolves at host-app load time, so this
|
|
6
|
+
# file picks up whatever Rails version the host is on.
|
|
7
|
+
class CreateKioskSchema < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
8
|
+
def up
|
|
9
|
+
execute <<~'SQL'
|
|
10
|
+
<%= ddl(:helper_functions, guc_namespace: options[:guc_namespace], user_id_type: user_id_type) %>
|
|
11
|
+
SQL
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
execute %(DROP SCHEMA IF EXISTS "<%= options[:schema] %>" CASCADE)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Migration 011 — create kiosk.visits.
|
|
4
|
+
# The visits the operator records as fulfilled (Kiosk::Visit.record!), which
|
|
5
|
+
# lower the proof-of-work toll for a returning customer.
|
|
6
|
+
class CreateKioskVisits < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
+
def up
|
|
8
|
+
execute <<~'SQL'
|
|
9
|
+
<%= ddl(:visits, user_id_type: user_id_type) %>
|
|
10
|
+
SQL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".visits)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Kiosk configuration. Every option: https://kiosk.tech and the kiosk-server README.
|
|
4
|
+
|
|
5
|
+
Kiosk.configure do |c|
|
|
6
|
+
# Your user model class name, resolved at request time.
|
|
7
|
+
c.user_model = "<%= options[:user_table].classify %>"
|
|
8
|
+
|
|
9
|
+
# Type of users.id: :uuid (default), :bigint, :integer or :text.
|
|
10
|
+
c.user_id_type = :<%= options[:user_id_type] %>
|
|
11
|
+
|
|
12
|
+
# coerce-then-validate on the per-verb wire is UNCONDITIONAL: arguments are
|
|
13
|
+
# always checked against the verb's `input_schema` (§8.1). This flag adds a check:
|
|
14
|
+
# it validates every answer against the verb's `output_schema`. Off in production,
|
|
15
|
+
# where a descriptor typo would become a 500 for a caller.
|
|
16
|
+
c.validate_responses = !Rails.env.production?
|
|
17
|
+
|
|
18
|
+
# GUC namespace for SET LOCAL (and RLS policies). Default "app".
|
|
19
|
+
# c.guc_namespace = "<%= options[:guc_namespace] %>"
|
|
20
|
+
|
|
21
|
+
# Postgres schema for Kiosk helper functions and tables.
|
|
22
|
+
# c.schema = "<%= options[:schema] %>"
|
|
23
|
+
|
|
24
|
+
# Opt-in Postgres RLS: add `gem "kiosk-rls"` and create these database roles yourself.
|
|
25
|
+
# c.app_role = "app_role" # runtime role with NOBYPASSRLS
|
|
26
|
+
# c.enforce_db_role = true # SET LOCAL ROLE app_role per request
|
|
27
|
+
|
|
28
|
+
# The exact origin assistants dial (scheme + host, no trailing slash); it must
|
|
29
|
+
# match `kiosk.issuer` in /.well-known/kiosk.json, or every proof is rejected.
|
|
30
|
+
# c.issuer = "https://<your-host>"
|
|
31
|
+
|
|
32
|
+
# Further origins this deployment serves, each its own operator.
|
|
33
|
+
# c.additional_origins = ["https://<another-host>"]
|
|
34
|
+
|
|
35
|
+
# Role vocabulary; set together with `registration_role`. Your `user_idp` must
|
|
36
|
+
# resolve a role for every human who can approve a binding (§6.3).
|
|
37
|
+
# c.roles = %i[customer]
|
|
38
|
+
|
|
39
|
+
# The default role for self-registered assistants and role-less bindings; your least-privileged one.
|
|
40
|
+
# c.registration_role = :customer
|
|
41
|
+
|
|
42
|
+
# Owner block surfaced in /.well-known/kiosk.json.
|
|
43
|
+
# c.owner = { name: "Your Company", support: "support@example.com" }
|
|
44
|
+
|
|
45
|
+
# Optional: an agent IdP of your own (subclass Kiosk::AgentIdentityProviders::Base; agent ids must be UUIDs).
|
|
46
|
+
# c.agent_idp = MyExternalAgentIdp.new
|
|
47
|
+
|
|
48
|
+
# Optional: a user IdP binding your signed-in humans, e.g. `gem "kiosk-user-idp-devise"`.
|
|
49
|
+
# c.user_idp = Kiosk::UserIdentityProviders::Devise.new
|
|
50
|
+
|
|
51
|
+
# Optional audit sink: one ActionEvent per action, arguments in full and unredacted.
|
|
52
|
+
# c.audit_sink = ->(e) { Rails.logger.info(e.with_arg_types.to_h.to_json) }
|
|
53
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
# Include into any controller to declare verbs: a query (`GET <mount>/<name>`)
|
|
5
|
+
# or an action (`POST <mount>/<name>`). Handlers live in app/controllers/kiosk.
|
|
6
|
+
#
|
|
7
|
+
# class Kiosk::BoardController < ActionController::API
|
|
8
|
+
# include Kiosk::Handler
|
|
9
|
+
#
|
|
10
|
+
# kind :query
|
|
11
|
+
# description "Lists what this shop has in stock right now, so the " \
|
|
12
|
+
# "assistant can decide what to put in a basket."
|
|
13
|
+
# input_schema type: "object", additionalProperties: false,
|
|
14
|
+
# properties: { q: { type: "string" } }
|
|
15
|
+
# output_schema type: "array",
|
|
16
|
+
# items: { type: "object",
|
|
17
|
+
# properties: { sku: { type: "string" },
|
|
18
|
+
# price_cents: { type: "integer" } } }
|
|
19
|
+
# def catalog
|
|
20
|
+
# render json: Product.in_stock.search(params[:q]).as_json
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# kind :action
|
|
24
|
+
# description "Places an order for the assistant's human. Returns the " \
|
|
25
|
+
# "order and what it will cost; nothing is charged until `pay`."
|
|
26
|
+
# input_schema type: "object", additionalProperties: false,
|
|
27
|
+
# properties: { items: { type: "array", items: { type: "object" } } },
|
|
28
|
+
# required: %w[items]
|
|
29
|
+
# output_schema type: "object",
|
|
30
|
+
# properties: { order_id: { type: "string" },
|
|
31
|
+
# total_cents: { type: "integer" } }
|
|
32
|
+
# def create_order
|
|
33
|
+
# order = Orders::Place.call(user_id: kiosk_identity.user_id, params: params)
|
|
34
|
+
# render json: { order_id: order.id, total_cents: order.total_cents }
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
module Handler
|
|
38
|
+
def self.included(base)
|
|
39
|
+
Kiosk::Server::HandlerMixin.install(base)
|
|
40
|
+
Kiosk::Server::HandlerRegistrations.add(base)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/kiosk/owned.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
# `include Kiosk::Owned` in a model with a `user_id` column gives it `own`,
|
|
7
|
+
# the current principal's rows: `Order.own.find(id)`, and `owned_by(user_id)`,
|
|
8
|
+
# one principal's rows. `user_id` holds `Kiosk.current_user_id`.
|
|
9
|
+
module Owned
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
scope :owned_by, ->(user_id) { where(user_id:) }
|
|
14
|
+
scope :own, -> { owned_by(Kiosk.current_user_id) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
# `include Kiosk::Payable` in the model that `Kiosk::Server::PaymentClaim`
|
|
7
|
+
# names. It gives the row its `payment_state`, the `cart` an assistant signs
|
|
8
|
+
# to pay for it, and the `settlement` that paid it.
|
|
9
|
+
module Payable
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
UNPAID = 'unpaid'
|
|
13
|
+
PENDING = 'pending'
|
|
14
|
+
PAID = 'paid'
|
|
15
|
+
REFUNDED = 'refunded'
|
|
16
|
+
|
|
17
|
+
# The `payment_state` member of a per-user query's row.
|
|
18
|
+
STATE_SCHEMA = {
|
|
19
|
+
type: 'string', enum: [UNPAID, PENDING, PAID, REFUNDED],
|
|
20
|
+
description: '`unpaid`: nothing is charged; pay for it. `pending`: a charge is in progress and can ' \
|
|
21
|
+
'already have taken the money; read again, do not pay again. `paid`: charged. ' \
|
|
22
|
+
'`refunded`: the charge went back to the card.'
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# The `cart` member of the answer of an action that creates a payable row.
|
|
26
|
+
CART_SCHEMA = {
|
|
27
|
+
type: 'object', additionalProperties: false,
|
|
28
|
+
description: 'Sign the cart mandate with these three values, unchanged.',
|
|
29
|
+
properties: {
|
|
30
|
+
currency: { type: 'string' },
|
|
31
|
+
total_amount_cents: { type: 'integer' },
|
|
32
|
+
line_items: { type: 'array', items: { type: 'object' } }
|
|
33
|
+
},
|
|
34
|
+
required: %w[currency total_amount_cents line_items]
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
# Adds `settled_currency`, the currency of a settlement among `settlements`
|
|
39
|
+
# whose cart names the row, or NULL: the caller's own on the wire, all of
|
|
40
|
+
# them in a back office.
|
|
41
|
+
def with_settlement(settlements)
|
|
42
|
+
select(arel_table[Arel.star],
|
|
43
|
+
Arel::Nodes::Grouping.new(settlements.joins(:cart_mandate).where(names_row).select(:currency)
|
|
44
|
+
.limit(1).arel).as('settled_currency'))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def payment_claim = Kiosk.configuration.payment_provider
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# `cart_mandates.line_items @> [{"<reference>": "<this row's id>"}]`.
|
|
52
|
+
def names_row
|
|
53
|
+
line = Arel::Nodes::NamedFunction.new(
|
|
54
|
+
'jsonb_build_object', [Arel::Nodes.build_quoted(payment_claim.reference),
|
|
55
|
+
Arel::Nodes::NamedFunction.new('CAST', [arel_table[:id].as(Arel.sql('text'))])]
|
|
56
|
+
)
|
|
57
|
+
Arel::Nodes::InfixOperation.new('@>', Kiosk::CartMandate.arel_table[:line_items],
|
|
58
|
+
Arel::Nodes::NamedFunction.new('jsonb_build_array', [line]))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `pending` means a capture is in flight: the caller reads again and does
|
|
63
|
+
# not sign a new mandate. A row read without `with_settlement` asks the
|
|
64
|
+
# settlements itself.
|
|
65
|
+
def payment_state
|
|
66
|
+
status = self[:payment_status]
|
|
67
|
+
return REFUNDED if status == REFUNDED
|
|
68
|
+
return PAID if status == PAID || settled?
|
|
69
|
+
return PENDING if status == Kiosk::Server::PaymentClaim::PAYING
|
|
70
|
+
|
|
71
|
+
UNPAID
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The cart that pays for this row: a line naming the row, then `lines`,
|
|
75
|
+
# each `{qty:, price_cents:, ...}`, which sum to the total.
|
|
76
|
+
def cart(lines)
|
|
77
|
+
claim = self.class.payment_claim
|
|
78
|
+
{ currency: claim.currency,
|
|
79
|
+
total_amount_cents: lines.sum { it[:qty] * it[:price_cents] },
|
|
80
|
+
line_items: [{ claim.reference => id }, *lines] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The first settlement whose cart names this row, or nil.
|
|
84
|
+
def settlement = self.class.payment_claim.settlements(id).order(:created_at).first
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def settled? = has_attribute?(:settled_currency) ? !settled_currency.nil? : !settlement.nil?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Binds a proven public key to a human's account. A fresh key gets a new
|
|
6
|
+
# linked agent row; a known key is rebound, keeping its agent_id and
|
|
7
|
+
# reputation and adopting the new holder's role.
|
|
8
|
+
module AccountBinding
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Call only after possession of `public_key_pem` has been proven.
|
|
12
|
+
# `requested_role` always comes from a human's identity, never the wire.
|
|
13
|
+
#
|
|
14
|
+
# @return [Hash] { agent_id:, user_id:, access_token:, fresh: }
|
|
15
|
+
def bind!(public_key_pem:, user_id:, requested_role: nil)
|
|
16
|
+
config = Kiosk.configuration
|
|
17
|
+
pem = public_key_pem.to_s.strip
|
|
18
|
+
raise ArgumentError, 'user_id required' if user_id.nil? || user_id.to_s.empty?
|
|
19
|
+
|
|
20
|
+
warn_role_resolution_not_total(config, requested_role)
|
|
21
|
+
|
|
22
|
+
# `lease_connection`: the operator's hooks must share this transaction.
|
|
23
|
+
conn = ::ActiveRecord::Base.lease_connection
|
|
24
|
+
existing = Rows.live_by_key(conn, config.schema, pem)
|
|
25
|
+
return rebind(conn, config, existing, user_id, requested_role) if existing
|
|
26
|
+
|
|
27
|
+
register_linked(conn, config, pem, user_id, requested_role)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Revokes `agent_id`'s binding, which must belong to `user_id`; its tokens
|
|
31
|
+
# stop verifying and its `/auth/login` is denied.
|
|
32
|
+
#
|
|
33
|
+
# @raise [Errors::NotFound] when no live agent row matches the pair.
|
|
34
|
+
def unlink!(agent_id:, user_id:)
|
|
35
|
+
config = Kiosk.configuration
|
|
36
|
+
raise Errors::BadRequest, 'agent_id required' if agent_id.nil? || agent_id.to_s.empty?
|
|
37
|
+
|
|
38
|
+
# The ownership predicate is the security boundary: both values are binds.
|
|
39
|
+
raise not_linked unless Rows.revoke_owned(::ActiveRecord::Base.lease_connection, config.schema, agent_id, user_id)
|
|
40
|
+
|
|
41
|
+
revoke_tokens(config, agent_id)
|
|
42
|
+
config.assistant_unlinked&.call(agent: agent_id, account: config.user_model.constantize.find(user_id))
|
|
43
|
+
{ agent_id: agent_id }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
ROLE_NOT_TOTAL =
|
|
47
|
+
'[kiosk-server] an account-binding ceremony resolved NO role for the approving ' \
|
|
48
|
+
'human, and this origin declares more than one role ' \
|
|
49
|
+
'(%<roles>s). Role resolution must be TOTAL: an operator that ' \
|
|
50
|
+
'assigns roles at all assigns one to EVERY human who can approve a binding. A ' \
|
|
51
|
+
'role for staff and nothing for customers is not a supported configuration — ' \
|
|
52
|
+
'this binding lands on %<role>s, the role registration would assign, so a human ' \
|
|
53
|
+
'who should hold a privileged one gets an assistant ' \
|
|
54
|
+
'that cannot act for them. Fix the identity system, not the ceremony: a ' \
|
|
55
|
+
'`#kiosk_role` that can answer nil is the usual cause, and returning the ' \
|
|
56
|
+
'least-privileged declared role instead makes it total.'
|
|
57
|
+
private_constant :ROLE_NOT_TOTAL
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Known key: remap the holder and role, keep agent_id and reputation.
|
|
63
|
+
# The role is never read from the row: it belonged to the previous holder.
|
|
64
|
+
def rebind(conn, config, existing, user_id, requested_role = nil)
|
|
65
|
+
agent_id = existing.fetch('id')
|
|
66
|
+
previous = existing.fetch('user_id')
|
|
67
|
+
role = resolved_role(config, requested_role)
|
|
68
|
+
# The same holder still gets the role remap and revocation; only the hook is skipped.
|
|
69
|
+
transition = previous.to_s != user_id.to_s
|
|
70
|
+
conn.transaction do
|
|
71
|
+
Rows.rebind(conn, config.schema, agent_id, user_id, role)
|
|
72
|
+
claimed(config, agent_id, previous, user_id) if transition && config.assistant_claimed
|
|
73
|
+
end
|
|
74
|
+
# The old holder's tokens die (§6.3). The IdP dates new mints at the
|
|
75
|
+
# watermark, so the token minted below survives it.
|
|
76
|
+
revoke_tokens(config, agent_id)
|
|
77
|
+
{ agent_id: agent_id, user_id: user_id.to_s, access_token: issue_token(agent_id, role), fresh: false }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Next second: JWT `iat` is whole seconds and the check is `iat < watermark` (§6.3).
|
|
81
|
+
def revoke_tokens(config, agent_id)
|
|
82
|
+
config.revocation_store.revoke_all(agent_id, at: Time.now.to_i + 1)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def claimed(config, agent_id, previous, user_id)
|
|
86
|
+
accounts = config.user_model.constantize
|
|
87
|
+
config.assistant_claimed.call(agent: agent_id, from: accounts.find(previous), to: accounts.find(user_id))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fresh key: a new linked agent row under the approving human.
|
|
91
|
+
def register_linked(conn, config, pem, user_id, requested_role)
|
|
92
|
+
role = resolved_role(config, requested_role)
|
|
93
|
+
agent_id = conn.transaction { Rows.insert_linked(conn, config.schema, user_id, pem, role) }
|
|
94
|
+
{ agent_id: agent_id, user_id: user_id.to_s, access_token: issue_token(agent_id, role), fresh: true }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def not_linked
|
|
98
|
+
Errors::NotFound.new(
|
|
99
|
+
'no linked assistant account with this agent_id',
|
|
100
|
+
hint: 'only assistant accounts bound to the signed-in account can be unlinked'
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The ceremony's role, else the operator's default; never the key's previous role.
|
|
105
|
+
def resolved_role(config, requested_role)
|
|
106
|
+
validated_role(config, requested_role || config.registration_role)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Rejects a role outside `config.roles`.
|
|
110
|
+
def validated_role(config, candidate)
|
|
111
|
+
role = candidate&.to_s
|
|
112
|
+
role = nil if role && role.empty?
|
|
113
|
+
if role && config.roles.map(&:to_s).exclude?(role)
|
|
114
|
+
raise Errors::ConfigurationError,
|
|
115
|
+
"binding role #{role.inspect} is not among configured roles #{config.roles.inspect}"
|
|
116
|
+
end
|
|
117
|
+
role
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# An origin with several roles must resolve one for every approving human (§6.3).
|
|
121
|
+
def warn_role_resolution_not_total(config, requested_role)
|
|
122
|
+
return unless requested_role.nil? || requested_role.to_s.strip.empty?
|
|
123
|
+
return unless config.roles.to_a.size > 1
|
|
124
|
+
|
|
125
|
+
log_warning(format(ROLE_NOT_TOTAL, roles: config.roles.inspect, role: config.registration_role.inspect))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Rails.logger is nil before the host app boots.
|
|
129
|
+
def log_warning(message)
|
|
130
|
+
logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
131
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def issue_token(agent_id, role)
|
|
135
|
+
AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The agents-table statements a binding runs.
|
|
140
|
+
module Rows
|
|
141
|
+
module_function
|
|
142
|
+
|
|
143
|
+
def live_by_key(conn, schema, pem)
|
|
144
|
+
conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
|
|
145
|
+
SELECT id, user_id FROM #{schema}.agents
|
|
146
|
+
WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
|
|
147
|
+
LIMIT 1
|
|
148
|
+
SQL
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def revoke_owned(conn, schema, agent_id, user_id)
|
|
152
|
+
conn.exec_query(<<~SQL.squish, 'Kiosk agent unlink', [agent_id, user_id, Kiosk.current_issuer]).to_a.first
|
|
153
|
+
UPDATE #{schema}.agents
|
|
154
|
+
SET revoked_at = now()
|
|
155
|
+
WHERE id = $1
|
|
156
|
+
AND user_id = $2
|
|
157
|
+
AND issuer = $3
|
|
158
|
+
AND revoked_at IS NULL
|
|
159
|
+
RETURNING id
|
|
160
|
+
SQL
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# An empty array cannot travel as a bind, so "no role" is a different statement.
|
|
164
|
+
def rebind(conn, schema, agent_id, user_id, role)
|
|
165
|
+
role_set, role_binds =
|
|
166
|
+
role ? [', allowed_roles = ARRAY[$3]::text[]', [role]] : [", allowed_roles = '{}'::text[]", []]
|
|
167
|
+
conn.exec_query(<<~SQL.squish, 'Kiosk agent rebind', [user_id, agent_id, *role_binds])
|
|
168
|
+
UPDATE #{schema}.agents
|
|
169
|
+
SET user_id = $1#{role_set}
|
|
170
|
+
WHERE id = $2
|
|
171
|
+
SQL
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The column is NOT NULL: no role is `'{}'`, never NULL.
|
|
175
|
+
def insert_linked(conn, schema, user_id, pem, role)
|
|
176
|
+
allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
|
|
177
|
+
rows = conn.exec_query(<<~SQL.squish, 'Kiosk linked agent insert', [user_id, pem, Kiosk.current_issuer, *role_binds])
|
|
178
|
+
INSERT INTO #{schema}.agents (user_id, allowed_roles, public_key, issuer)
|
|
179
|
+
VALUES ($1, #{allowed_roles_sql}, $2, $3)
|
|
180
|
+
RETURNING id
|
|
181
|
+
SQL
|
|
182
|
+
rows.to_a.first.fetch('id')
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
private_constant :Rows
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Requires a human signed in through the operator's own session
|
|
6
|
+
# (`user_idp`), never an agent token. A browser is sent to `sign_in_path`;
|
|
7
|
+
# any other caller gets a 401 naming where to sign in.
|
|
8
|
+
module AccountHolderGate
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Answers the signed-in identity, or nil after it has answered the request.
|
|
12
|
+
def require_account_holder!(prompt:, flash_alert:)
|
|
13
|
+
@identity = Kiosk.configuration.user_idp&.verify(request)
|
|
14
|
+
refuse_signed_out(prompt, flash_alert) unless @identity
|
|
15
|
+
@identity
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def refuse_signed_out(prompt, flash_alert)
|
|
19
|
+
sign_in_path = Kiosk.configuration.sign_in_path
|
|
20
|
+
if sign_in_path && html_request?
|
|
21
|
+
flash_sign_in_alert(flash_alert)
|
|
22
|
+
store_return_location
|
|
23
|
+
redirect_to sign_in_path
|
|
24
|
+
else
|
|
25
|
+
render plain: sign_in_path ? "#{prompt} #{sign_in_directions(sign_in_path)}" : prompt,
|
|
26
|
+
status: :unauthorized
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sign_in_directions(sign_in_path)
|
|
31
|
+
"Sign in at #{request.base_url}#{sign_in_path} — or open #{request.original_url} " \
|
|
32
|
+
'in a browser, which goes there and comes back here.'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def html_request?
|
|
36
|
+
return true if request.format.html?
|
|
37
|
+
|
|
38
|
+
request.headers['Accept'].to_s.include?('text/html')
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A host without the flash middleware must still redirect.
|
|
44
|
+
def flash_sign_in_alert(message)
|
|
45
|
+
flash[:alert] = message
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Devise's return-to key.
|
|
51
|
+
def store_return_location
|
|
52
|
+
session['user_return_to'] = request.fullpath
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|