kiosk-server 0.5.20 → 0.6.28
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 +4 -4
- data/CHANGELOG.md +196 -0
- data/README.md +192 -138
- data/app/channels/kiosk_events.rb +30 -17
- data/app/controllers/kiosk/server/application_controller.rb +0 -1
- data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
- data/app/controllers/kiosk/server/auth_controller.rb +21 -65
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
- data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
- data/app/controllers/kiosk/server/wire_controller.rb +2 -2
- data/app/models/kiosk/checkout_mandate.rb +20 -0
- data/app/models/kiosk/settlement.rb +1 -3
- data/app/models/kiosk/visit.rb +0 -2
- data/app/views/kiosk/server/assistants/show.html.erb +20 -24
- data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
- data/app/views/layouts/kiosk/server/application.html.erb +14 -0
- data/lib/generators/kiosk/install/install_generator.rb +25 -69
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +32 -62
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +6 -6
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +154 -0
- data/lib/kiosk/server/client_registration.rb +98 -0
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +64 -75
- data/lib/kiosk/server/current_request.rb +1 -1
- data/lib/kiosk/server/device_authorization.rb +1 -1
- data/lib/kiosk/server/device_authorization_stores.rb +1 -1
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +126 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +16 -13
- data/lib/kiosk/server/errors.rb +28 -7
- data/lib/kiosk/server/event_stores.rb +2 -2
- data/lib/kiosk/server/events.rb +4 -3
- data/lib/kiosk/server/events_connection.rb +38 -10
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +17 -76
- data/lib/kiosk/server/executor.rb +86 -105
- data/lib/kiosk/server/failure_log.rb +3 -3
- data/lib/kiosk/server/handler_mixin.rb +9 -9
- data/lib/kiosk/server/handler_registrations.rb +1 -1
- data/lib/kiosk/server/handler_serving.rb +11 -11
- data/lib/kiosk/server/headers.rb +3 -3
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +28 -29
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +22 -17
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +31 -0
- data/lib/kiosk/server/oauth_error.rb +33 -0
- data/lib/kiosk/server/open_api.rb +31 -29
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +47 -78
- data/lib/kiosk/server/pow_gate.rb +12 -33
- data/lib/kiosk/server/pow_spent_stores.rb +1 -1
- data/lib/kiosk/server/receipts.rb +30 -0
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +3 -3
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +9 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +369 -58
- data/lib/kiosk/server/schemas/binding.schema.json +24 -59
- data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
- data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +4 -4
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +272 -205
- data/lib/kiosk/server/wire_rendering.rb +2 -2
- data/lib/kiosk/server.rb +8 -2
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +34 -24
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
- data/lib/kiosk/server/schema_definitions.rb +0 -351
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Kiosk
|
|
4
|
-
module Server
|
|
5
|
-
# The challenge store, shared by every process on the database (§15.2).
|
|
6
|
-
module AuthChallengeStores
|
|
7
|
-
# The default: the `<schema>.auth_challenges` table ({SchemaDefinitions.auth_challenge_sql}).
|
|
8
|
-
class ActiveRecord
|
|
9
|
-
# Seconds between expired-row sweeps; a sweep bounds growth, not correctness.
|
|
10
|
-
DEFAULT_PRUNE_INTERVAL = 60
|
|
11
|
-
|
|
12
|
-
def initialize(prune_interval: DEFAULT_PRUNE_INTERVAL)
|
|
13
|
-
@prune_interval = prune_interval
|
|
14
|
-
@last_prune_at = 0
|
|
15
|
-
@mutex = Mutex.new
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Replaces any earlier challenge for the key.
|
|
19
|
-
def put(public_key_pem, nonce, exp)
|
|
20
|
-
return if public_key_pem.nil?
|
|
21
|
-
|
|
22
|
-
prune_if_due!
|
|
23
|
-
sql = <<~SQL.squish
|
|
24
|
-
INSERT INTO #{table} AS c (public_key, nonce, expires_at)
|
|
25
|
-
VALUES ($1, $2, to_timestamp($3))
|
|
26
|
-
ON CONFLICT (public_key) DO UPDATE
|
|
27
|
-
SET nonce = EXCLUDED.nonce, expires_at = EXCLUDED.expires_at
|
|
28
|
-
SQL
|
|
29
|
-
connection.exec_query(sql, 'Kiosk auth_challenge put', [public_key_pem, nonce, exp.to_i])
|
|
30
|
-
nil
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# One atomic DELETE, so two processes presenting one challenge get one `true`.
|
|
34
|
-
def take(public_key_pem, nonce) # rubocop:disable Naming/PredicateMethod -- `take` is the store contract `config.auth_challenge_store` names
|
|
35
|
-
return false if public_key_pem.nil? || nonce.nil?
|
|
36
|
-
|
|
37
|
-
sql = <<~SQL.squish
|
|
38
|
-
DELETE FROM #{table}
|
|
39
|
-
WHERE public_key = $1 AND nonce = $2 AND expires_at > now()
|
|
40
|
-
RETURNING public_key
|
|
41
|
-
SQL
|
|
42
|
-
rows = connection.exec_query(
|
|
43
|
-
sql, 'Kiosk auth_challenge take', [public_key_pem, nonce]
|
|
44
|
-
).to_a
|
|
45
|
-
!rows.empty?
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def prune!
|
|
49
|
-
connection.exec_query(%(DELETE FROM #{table} WHERE expires_at <= now()),
|
|
50
|
-
'Kiosk auth_challenge prune')
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def prune_if_due!
|
|
57
|
-
now = Time.now.to_i
|
|
58
|
-
due = @mutex.synchronize do
|
|
59
|
-
if now - @last_prune_at >= @prune_interval
|
|
60
|
-
@last_prune_at = now
|
|
61
|
-
true
|
|
62
|
-
else
|
|
63
|
-
false
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
prune! if due
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def connection = ::ActiveRecord::Base.lease_connection
|
|
70
|
-
def table = %("#{Kiosk.configuration.schema}".auth_challenges)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'jwt'
|
|
4
|
-
require 'openssl'
|
|
5
|
-
require 'rails'
|
|
6
|
-
|
|
7
|
-
module Kiosk
|
|
8
|
-
module Server
|
|
9
|
-
# Verifies an agent's proof-of-possession JWS: RS256 by the presented key,
|
|
10
|
-
# `aud` equal to this origin (the relay defence), and the challenge `nonce`.
|
|
11
|
-
# The caller burns the nonce only after a clean verify.
|
|
12
|
-
module PopVerifier
|
|
13
|
-
# Names no origin: an echoed one is what a relaying server would exploit.
|
|
14
|
-
AUDIENCE_HINT =
|
|
15
|
-
'sign `aud` = the origin you connected to, taken from your own request ' \
|
|
16
|
-
'URL — never from a value echoed back in a response'
|
|
17
|
-
|
|
18
|
-
PROOF_REQUIRED_CLAIMS = %w[aud nonce jti].freeze
|
|
19
|
-
|
|
20
|
-
MAX_KEY_BITS = 4096
|
|
21
|
-
|
|
22
|
-
PROOF_CLAIMS_HINT =
|
|
23
|
-
"a proof's payload carries #{PROOF_REQUIRED_CLAIMS.join(', ')} — `aud` is the origin " \
|
|
24
|
-
'you connected to, `nonce` the value GET /auth/challenge just issued for this key, ' \
|
|
25
|
-
'`jti` a unique id for this proof'.freeze
|
|
26
|
-
|
|
27
|
-
PROOF_SIGNATURE_HINT =
|
|
28
|
-
'a proof is a compact RS256 JWS — header.payload.signature — signed with the ' \
|
|
29
|
-
'private key matching the public_key you sent'
|
|
30
|
-
|
|
31
|
-
module_function
|
|
32
|
-
|
|
33
|
-
def verify!(public_key_pem:, signed:)
|
|
34
|
-
pem = public_key_pem.to_s.strip
|
|
35
|
-
payload = decode(signed, load_public_key(pem))
|
|
36
|
-
check_audience!(payload[:aud])
|
|
37
|
-
raise Errors::Unauthenticated, 'proof key thumbprint mismatch' if payload.key?(:pub) && payload[:pub] != SigningKey.from_pem(pem).kid
|
|
38
|
-
|
|
39
|
-
payload
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def decode(signed, key)
|
|
43
|
-
payload, = ::JWT.decode(signed.to_s, key, true, algorithms: ['RS256'], required_claims: PROOF_REQUIRED_CLAIMS)
|
|
44
|
-
payload.transform_keys(&:to_sym)
|
|
45
|
-
rescue ::JWT::MissingRequiredClaim
|
|
46
|
-
raise Errors::Unauthenticated.new('proof is missing a required claim', hint: PROOF_CLAIMS_HINT)
|
|
47
|
-
rescue ::JWT::DecodeError
|
|
48
|
-
raise Errors::Unauthenticated.new('proof signature invalid', hint: PROOF_SIGNATURE_HINT)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def check_audience!(signed_aud)
|
|
52
|
-
issuer = Kiosk.current_issuer
|
|
53
|
-
return if signed_aud == issuer
|
|
54
|
-
|
|
55
|
-
log_audience_mismatch(signed_aud: signed_aud, issuer: issuer)
|
|
56
|
-
raise Errors::Unauthenticated.new('proof audience mismatch', hint: AUDIENCE_HINT)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Operator log only, since the response must not name an origin: the
|
|
60
|
-
# caller signed a wrong value, or the origin it reached is not served.
|
|
61
|
-
def log_audience_mismatch(signed_aud:, issuer:)
|
|
62
|
-
message = "[kiosk] PoP audience mismatch: caller signed aud=#{signed_aud.inspect}, " \
|
|
63
|
-
"the issuer for this request is #{issuer.inspect}. If the signed value is " \
|
|
64
|
-
'an origin your assistants actually reach, list it in `c.issuer` or ' \
|
|
65
|
-
'`c.additional_origins`.'
|
|
66
|
-
logger = ::Rails.logger
|
|
67
|
-
logger ? logger.warn(message) : warn(message)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Also the key bounds of account binding.
|
|
71
|
-
def load_public_key(pem)
|
|
72
|
-
OpenSSL::PKey::RSA.new(pem).tap { require_key_size!(it.n.num_bits) }
|
|
73
|
-
rescue OpenSSL::PKey::PKeyError
|
|
74
|
-
raise Errors::BadRequest.new(
|
|
75
|
-
'invalid public key',
|
|
76
|
-
hint: "send a PEM-encoded RSA public key of #{SigningKey::MIN_KEY_BITS} to #{MAX_KEY_BITS} bits " \
|
|
77
|
-
'(-----BEGIN PUBLIC KEY----- …)'
|
|
78
|
-
)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def require_key_size!(bits)
|
|
82
|
-
raise Errors::BadRequest, "public key too small (#{bits} bits; minimum #{SigningKey::MIN_KEY_BITS})" if bits < SigningKey::MIN_KEY_BITS
|
|
83
|
-
raise Errors::BadRequest, "public key too large (#{bits} bits; maximum #{MAX_KEY_BITS})" if bits > MAX_KEY_BITS
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Kiosk
|
|
4
|
-
module Server
|
|
5
|
-
# The SQL of the migrations `kiosk:install` emits, one method per
|
|
6
|
-
# migration, each creating its tables in their final shape. No database
|
|
7
|
-
# connection; the host's migrations `execute` it.
|
|
8
|
-
# rubocop:disable-next Metrics/ModuleLength, Metrics/MethodLength -- each method returns one migration's DDL as one SQL heredoc
|
|
9
|
-
module SchemaDefinitions
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
|
-
# 001: the schema and the `current_*()` GUC readers.
|
|
13
|
-
def helper_functions_sql(schema: nil, guc_namespace: nil, user_id_type: nil)
|
|
14
|
-
schema ||= Kiosk.configuration.schema
|
|
15
|
-
guc_namespace ||= Kiosk.configuration.guc_namespace
|
|
16
|
-
user_id_type ||= Kiosk.configuration.user_id_type
|
|
17
|
-
cast = user_id_cast(user_id_type)
|
|
18
|
-
|
|
19
|
-
<<~DDL.strip
|
|
20
|
-
CREATE SCHEMA IF NOT EXISTS "#{schema}";
|
|
21
|
-
|
|
22
|
-
CREATE OR REPLACE FUNCTION "#{schema}".current_user_id() RETURNS #{cast} LANGUAGE sql STABLE AS $$
|
|
23
|
-
SELECT NULLIF(current_setting('#{guc_namespace}.current_user_id', true), '')::#{cast}
|
|
24
|
-
$$;
|
|
25
|
-
|
|
26
|
-
CREATE OR REPLACE FUNCTION "#{schema}".current_role() RETURNS text LANGUAGE sql STABLE AS $$
|
|
27
|
-
SELECT NULLIF(current_setting('#{guc_namespace}.current_role', true), '')
|
|
28
|
-
$$;
|
|
29
|
-
|
|
30
|
-
CREATE OR REPLACE FUNCTION "#{schema}".current_actor() RETURNS text LANGUAGE sql STABLE AS $$
|
|
31
|
-
SELECT NULLIF(current_setting('#{guc_namespace}.current_actor', true), '')
|
|
32
|
-
$$;
|
|
33
|
-
|
|
34
|
-
CREATE OR REPLACE FUNCTION "#{schema}".current_agent_id() RETURNS uuid LANGUAGE sql STABLE AS $$
|
|
35
|
-
SELECT NULLIF(current_setting('#{guc_namespace}.current_agent_id', true), '')::uuid
|
|
36
|
-
$$;
|
|
37
|
-
DDL
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# 002: `agents` (`spending_cap_cents`: NULL unlimited, 0 disabled; a cap
|
|
41
|
-
# has its `spending_cap_currency`), `agent_tokens`, `agent_mappings`.
|
|
42
|
-
def identity_tables_sql(schema: nil, user_id_type: nil, user_table: 'users')
|
|
43
|
-
schema ||= Kiosk.configuration.schema
|
|
44
|
-
user_id_type ||= Kiosk.configuration.user_id_type
|
|
45
|
-
col_type = user_id_cast(user_id_type)
|
|
46
|
-
|
|
47
|
-
<<~DDL.strip
|
|
48
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".agents (
|
|
49
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
50
|
-
user_id #{col_type} NOT NULL REFERENCES "#{user_table}"(id) ON DELETE CASCADE,
|
|
51
|
-
allowed_roles text[] NOT NULL DEFAULT '{}'::text[],
|
|
52
|
-
public_key text,
|
|
53
|
-
human_label text,
|
|
54
|
-
spending_cap_cents bigint,
|
|
55
|
-
spending_cap_currency text CHECK (spending_cap_currency ~ '^[a-z]{3}$'),
|
|
56
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
57
|
-
revoked_at timestamptz,
|
|
58
|
-
issuer text NOT NULL,
|
|
59
|
-
CONSTRAINT agents_spending_cap_currency_set
|
|
60
|
-
CHECK ((spending_cap_cents IS NULL) = (spending_cap_currency IS NULL))
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
CREATE INDEX IF NOT EXISTS idx_agents_user_id ON "#{schema}".agents (user_id) WHERE revoked_at IS NULL;
|
|
64
|
-
-- Dedupe at the DB, not via SELECT-then-INSERT (TOCTOU): two LIVE
|
|
65
|
-
-- rows for one public key on one origin cannot coexist. Partial
|
|
66
|
-
-- (WHERE revoked_at IS NULL) so a revoked key can re-register.
|
|
67
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_issuer_public_key_live
|
|
68
|
-
ON "#{schema}".agents (issuer, public_key) WHERE revoked_at IS NULL;
|
|
69
|
-
|
|
70
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".agent_tokens (
|
|
71
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
72
|
-
agent_id uuid NOT NULL REFERENCES "#{schema}".agents(id) ON DELETE CASCADE,
|
|
73
|
-
token_hash text NOT NULL,
|
|
74
|
-
issued_at timestamptz NOT NULL DEFAULT now(),
|
|
75
|
-
expires_at timestamptz NOT NULL,
|
|
76
|
-
revoked_at timestamptz
|
|
77
|
-
);
|
|
78
|
-
CREATE INDEX IF NOT EXISTS idx_agent_tokens_agent_id ON "#{schema}".agent_tokens (agent_id);
|
|
79
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_tokens_hash ON "#{schema}".agent_tokens (token_hash);
|
|
80
|
-
|
|
81
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".agent_mappings (
|
|
82
|
-
provider text NOT NULL,
|
|
83
|
-
external_id text NOT NULL,
|
|
84
|
-
agent_id uuid NOT NULL REFERENCES "#{schema}".agents(id) ON DELETE CASCADE,
|
|
85
|
-
PRIMARY KEY (provider, external_id)
|
|
86
|
-
);
|
|
87
|
-
DDL
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# 004: account-binding requests; codes are stored hashed only.
|
|
91
|
-
# `requested_role` is the approving human's role, never a client's.
|
|
92
|
-
def device_authorizations_sql(schema: nil, user_id_type: nil)
|
|
93
|
-
schema ||= Kiosk.configuration.schema
|
|
94
|
-
user_id_type ||= Kiosk.configuration.user_id_type
|
|
95
|
-
col_type = user_id_cast(user_id_type)
|
|
96
|
-
|
|
97
|
-
<<~DDL.strip
|
|
98
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".device_authorizations (
|
|
99
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
100
|
-
device_code_hash text NOT NULL,
|
|
101
|
-
user_code_hash text NOT NULL,
|
|
102
|
-
public_key_pem text,
|
|
103
|
-
kind text NOT NULL DEFAULT 'claim',
|
|
104
|
-
client_id text NOT NULL,
|
|
105
|
-
requested_role text,
|
|
106
|
-
status text NOT NULL,
|
|
107
|
-
user_id #{col_type},
|
|
108
|
-
expires_at timestamptz NOT NULL,
|
|
109
|
-
consumed_at timestamptz,
|
|
110
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
111
|
-
CONSTRAINT device_authorizations_status_check
|
|
112
|
-
CHECK (status IN ('pending', 'approved', 'denied', 'consumed', 'expired')),
|
|
113
|
-
CONSTRAINT device_authorizations_kind_check
|
|
114
|
-
CHECK (kind IN ('claim', 'link'))
|
|
115
|
-
);
|
|
116
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_authorizations_code_hash
|
|
117
|
-
ON "#{schema}".device_authorizations (device_code_hash);
|
|
118
|
-
-- Only `pending` rows need a unique user_code; approved/consumed
|
|
119
|
-
-- rows may share codes from past flows without collision.
|
|
120
|
-
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_authorizations_user_code_pending
|
|
121
|
-
ON "#{schema}".device_authorizations (user_code_hash)
|
|
122
|
-
WHERE status = 'pending';
|
|
123
|
-
CREATE INDEX IF NOT EXISTS idx_device_authorizations_expiry
|
|
124
|
-
ON "#{schema}".device_authorizations (expires_at)
|
|
125
|
-
WHERE status IN ('pending', 'approved');
|
|
126
|
-
DDL
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# 005: the AP2 trail. `id` is server-generated; the signed mandate id is
|
|
130
|
-
# `mandate_id`, unique per principal. A cart's `declined_at` records a
|
|
131
|
-
# charge refused with no money moved. A settlement is server-minted, so it
|
|
132
|
-
# carries no `raw_jws`.
|
|
133
|
-
def mandates_sql(schema: nil, user_id_type: nil)
|
|
134
|
-
schema ||= Kiosk.configuration.schema
|
|
135
|
-
user_id_type ||= Kiosk.configuration.user_id_type
|
|
136
|
-
col_type = user_id_cast(user_id_type)
|
|
137
|
-
|
|
138
|
-
<<~DDL.strip
|
|
139
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".intent_mandates (
|
|
140
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
141
|
-
mandate_id text NOT NULL,
|
|
142
|
-
user_id #{col_type} NOT NULL,
|
|
143
|
-
agent_id uuid NOT NULL,
|
|
144
|
-
issuer text NOT NULL,
|
|
145
|
-
scope text NOT NULL,
|
|
146
|
-
cap_amount_cents bigint NOT NULL,
|
|
147
|
-
currency text NOT NULL,
|
|
148
|
-
expires_at timestamptz NOT NULL,
|
|
149
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
150
|
-
raw_jws text NOT NULL,
|
|
151
|
-
UNIQUE (user_id, mandate_id)
|
|
152
|
-
);
|
|
153
|
-
CREATE INDEX IF NOT EXISTS idx_intent_mandates_user_id ON "#{schema}".intent_mandates (user_id);
|
|
154
|
-
CREATE INDEX IF NOT EXISTS idx_intent_mandates_agent_id ON "#{schema}".intent_mandates (agent_id);
|
|
155
|
-
|
|
156
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".cart_mandates (
|
|
157
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
158
|
-
mandate_id text NOT NULL,
|
|
159
|
-
intent_mandate_id uuid NOT NULL REFERENCES "#{schema}".intent_mandates(id) ON DELETE CASCADE,
|
|
160
|
-
user_id #{col_type} NOT NULL,
|
|
161
|
-
agent_id uuid NOT NULL,
|
|
162
|
-
issuer text NOT NULL,
|
|
163
|
-
line_items jsonb NOT NULL,
|
|
164
|
-
total_amount_cents bigint NOT NULL,
|
|
165
|
-
currency text NOT NULL,
|
|
166
|
-
expires_at timestamptz NOT NULL,
|
|
167
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
168
|
-
raw_jws text NOT NULL,
|
|
169
|
-
declined_at timestamptz,
|
|
170
|
-
UNIQUE (user_id, mandate_id)
|
|
171
|
-
);
|
|
172
|
-
CREATE INDEX IF NOT EXISTS idx_cart_mandates_user_id ON "#{schema}".cart_mandates (user_id);
|
|
173
|
-
CREATE INDEX IF NOT EXISTS idx_cart_mandates_intent ON "#{schema}".cart_mandates (intent_mandate_id);
|
|
174
|
-
|
|
175
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".payment_mandates (
|
|
176
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
177
|
-
mandate_id text NOT NULL,
|
|
178
|
-
cart_mandate_id uuid NOT NULL REFERENCES "#{schema}".cart_mandates(id) ON DELETE CASCADE,
|
|
179
|
-
user_id #{col_type} NOT NULL,
|
|
180
|
-
agent_id uuid NOT NULL,
|
|
181
|
-
issuer text NOT NULL,
|
|
182
|
-
payment_method text NOT NULL,
|
|
183
|
-
amount_cents bigint NOT NULL,
|
|
184
|
-
currency text NOT NULL,
|
|
185
|
-
expires_at timestamptz,
|
|
186
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
187
|
-
raw_jws text NOT NULL,
|
|
188
|
-
UNIQUE (user_id, mandate_id)
|
|
189
|
-
);
|
|
190
|
-
CREATE INDEX IF NOT EXISTS idx_payment_mandates_user_id ON "#{schema}".payment_mandates (user_id);
|
|
191
|
-
CREATE INDEX IF NOT EXISTS idx_payment_mandates_cart ON "#{schema}".payment_mandates (cart_mandate_id);
|
|
192
|
-
|
|
193
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".settlements (
|
|
194
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
195
|
-
cart_mandate_id uuid NOT NULL REFERENCES "#{schema}".cart_mandates(id) ON DELETE CASCADE,
|
|
196
|
-
user_id #{col_type} NOT NULL,
|
|
197
|
-
agent_id uuid NOT NULL,
|
|
198
|
-
issuer text NOT NULL,
|
|
199
|
-
psp_reference text NOT NULL,
|
|
200
|
-
settled_amount_cents bigint NOT NULL,
|
|
201
|
-
currency text NOT NULL,
|
|
202
|
-
settled_at timestamptz NOT NULL,
|
|
203
|
-
UNIQUE (cart_mandate_id)
|
|
204
|
-
);
|
|
205
|
-
CREATE INDEX IF NOT EXISTS idx_settlements_user_id ON "#{schema}".settlements (user_id);
|
|
206
|
-
CREATE INDEX IF NOT EXISTS idx_settlements_cart ON "#{schema}".settlements (cart_mandate_id);
|
|
207
|
-
DDL
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# 006: KYC grants and open verifications, keyed on the person.
|
|
211
|
-
def kyc_attributes_sql(schema: nil, user_id_type: nil, user_table: nil)
|
|
212
|
-
schema ||= Kiosk.configuration.schema
|
|
213
|
-
user_id_type ||= Kiosk.configuration.user_id_type
|
|
214
|
-
user_table ||= configured_user_table
|
|
215
|
-
col_type = user_id_cast(user_id_type)
|
|
216
|
-
person = %(#{col_type} NOT NULL REFERENCES "#{user_table}"(id) ON DELETE CASCADE)
|
|
217
|
-
|
|
218
|
-
<<~DDL.strip
|
|
219
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".kyc_attributes (
|
|
220
|
-
user_id #{person},
|
|
221
|
-
name text NOT NULL,
|
|
222
|
-
granted_at timestamptz NOT NULL DEFAULT now(),
|
|
223
|
-
PRIMARY KEY (user_id, name)
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".kyc_requests (
|
|
227
|
-
id text PRIMARY KEY,
|
|
228
|
-
user_id #{person},
|
|
229
|
-
nonce text NOT NULL,
|
|
230
|
-
approved_at timestamptz,
|
|
231
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
232
|
-
);
|
|
233
|
-
CREATE INDEX IF NOT EXISTS idx_kyc_requests_user_id ON "#{schema}".kyc_requests (user_id, created_at);
|
|
234
|
-
DDL
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
# 007: the event tail; `id` is the origin's one monotonic cursor.
|
|
238
|
-
def events_sql(schema: nil)
|
|
239
|
-
schema ||= Kiosk.configuration.schema
|
|
240
|
-
|
|
241
|
-
<<~DDL.strip
|
|
242
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".events (
|
|
243
|
-
id bigserial PRIMARY KEY,
|
|
244
|
-
identity_key text NOT NULL,
|
|
245
|
-
topic text NOT NULL,
|
|
246
|
-
subject text,
|
|
247
|
-
occurred_at timestamptz NOT NULL,
|
|
248
|
-
data jsonb NOT NULL,
|
|
249
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
250
|
-
);
|
|
251
|
-
-- `since` for one subscriber: the cursor scan, in id order.
|
|
252
|
-
CREATE INDEX IF NOT EXISTS idx_events_identity_key_id
|
|
253
|
-
ON "#{schema}".events (identity_key, id);
|
|
254
|
-
-- The retention sweep only.
|
|
255
|
-
CREATE INDEX IF NOT EXISTS idx_events_created_at
|
|
256
|
-
ON "#{schema}".events (created_at);
|
|
257
|
-
DDL
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# 008: spent proof-of-work challenge ids; the primary key is the single-use gate.
|
|
261
|
-
def pow_spent_sql(schema: nil)
|
|
262
|
-
schema ||= Kiosk.configuration.schema
|
|
263
|
-
|
|
264
|
-
<<~DDL.strip
|
|
265
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".pow_spent (
|
|
266
|
-
id text PRIMARY KEY,
|
|
267
|
-
expires_at timestamptz NOT NULL
|
|
268
|
-
);
|
|
269
|
-
-- Supports the TTL sweep only; the PK above is what enforces
|
|
270
|
-
-- single-use.
|
|
271
|
-
CREATE INDEX IF NOT EXISTS idx_pow_spent_expires_at
|
|
272
|
-
ON "#{schema}".pow_spent (expires_at);
|
|
273
|
-
DDL
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# 009: each agent's revocation watermark: a token issued before it stops verifying.
|
|
277
|
-
def revocations_sql(schema: nil)
|
|
278
|
-
schema ||= Kiosk.configuration.schema
|
|
279
|
-
|
|
280
|
-
<<~DDL.strip
|
|
281
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".revocations (
|
|
282
|
-
agent_id uuid PRIMARY KEY REFERENCES "#{schema}".agents(id) ON DELETE CASCADE,
|
|
283
|
-
revoked_before timestamptz NOT NULL
|
|
284
|
-
);
|
|
285
|
-
DDL
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# 011: the visits an operator records as fulfilled; reputation counts their value.
|
|
289
|
-
def visits_sql(schema: nil, user_id_type: nil)
|
|
290
|
-
schema ||= Kiosk.configuration.schema
|
|
291
|
-
col_type = user_id_cast(user_id_type || Kiosk.configuration.user_id_type)
|
|
292
|
-
|
|
293
|
-
<<~DDL.strip
|
|
294
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".visits (
|
|
295
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
296
|
-
user_id #{col_type} NOT NULL,
|
|
297
|
-
value_cents bigint NOT NULL CHECK (value_cents >= 0),
|
|
298
|
-
visited_at timestamptz NOT NULL DEFAULT now()
|
|
299
|
-
);
|
|
300
|
-
CREATE INDEX IF NOT EXISTS idx_visits_user_id ON "#{schema}".visits (user_id, visited_at);
|
|
301
|
-
DDL
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# 012: the refunds of settled charges, one row per PSP refund; recent
|
|
305
|
-
# spend counts a settlement less what was refunded of it.
|
|
306
|
-
def refunds_sql(schema: nil)
|
|
307
|
-
schema ||= Kiosk.configuration.schema
|
|
308
|
-
|
|
309
|
-
<<~DDL.strip
|
|
310
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".refunds (
|
|
311
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
312
|
-
settlement_id uuid NOT NULL REFERENCES "#{schema}".settlements(id) ON DELETE CASCADE,
|
|
313
|
-
psp_reference text NOT NULL UNIQUE,
|
|
314
|
-
refunded_amount_cents bigint NOT NULL CHECK (refunded_amount_cents > 0),
|
|
315
|
-
refunded_at timestamptz NOT NULL
|
|
316
|
-
);
|
|
317
|
-
CREATE INDEX IF NOT EXISTS idx_refunds_settlement_id ON "#{schema}".refunds (settlement_id);
|
|
318
|
-
DDL
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# 010: each public key's outstanding auth challenge; the primary key keeps it single.
|
|
322
|
-
def auth_challenge_sql(schema: nil)
|
|
323
|
-
schema ||= Kiosk.configuration.schema
|
|
324
|
-
|
|
325
|
-
<<~DDL.strip
|
|
326
|
-
CREATE TABLE IF NOT EXISTS "#{schema}".auth_challenges (
|
|
327
|
-
public_key text PRIMARY KEY,
|
|
328
|
-
nonce text NOT NULL,
|
|
329
|
-
expires_at timestamptz NOT NULL
|
|
330
|
-
);
|
|
331
|
-
-- Supports the TTL sweep only.
|
|
332
|
-
CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at
|
|
333
|
-
ON "#{schema}".auth_challenges (expires_at);
|
|
334
|
-
DDL
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def configured_user_table = Kiosk.configuration.user_model.to_s.constantize.table_name
|
|
338
|
-
|
|
339
|
-
def user_id_cast(user_id_type)
|
|
340
|
-
case user_id_type.to_sym
|
|
341
|
-
when :uuid then 'uuid'
|
|
342
|
-
when :bigint, :integer then user_id_type.to_s
|
|
343
|
-
when :text then 'text'
|
|
344
|
-
else
|
|
345
|
-
raise ArgumentError,
|
|
346
|
-
"user_id_type must be one of :uuid, :bigint, :integer, :text — got #{user_id_type.inspect}"
|
|
347
|
-
end
|
|
348
|
-
end
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Kiosk
|
|
4
|
-
module Server
|
|
5
|
-
# What an assistant spent: each settlement less what was refunded of it,
|
|
6
|
-
# never below zero, one tally per currency. The «Link an assistant» page
|
|
7
|
-
# shows this figure. The spending cap counts it plus the carts in flight.
|
|
8
|
-
module SettledSpend
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
# The net cents of the current `settlements` row.
|
|
12
|
-
def net_cents_sql(schema)
|
|
13
|
-
<<~SQL.squish
|
|
14
|
-
GREATEST(settlements.settled_amount_cents - (
|
|
15
|
-
SELECT COALESCE(SUM(r.refunded_amount_cents), 0) FROM #{schema}.refunds r WHERE r.settlement_id = settlements.id
|
|
16
|
-
), 0)
|
|
17
|
-
SQL
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,42 +0,0 @@
|
|
|
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
|