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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +192 -138
  4. data/app/channels/kiosk_events.rb +30 -17
  5. data/app/controllers/kiosk/server/application_controller.rb +0 -1
  6. data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
  7. data/app/controllers/kiosk/server/auth_controller.rb +21 -65
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
  10. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  11. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  12. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  13. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
  14. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
  16. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  17. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  18. data/app/models/kiosk/checkout_mandate.rb +20 -0
  19. data/app/models/kiosk/settlement.rb +1 -3
  20. data/app/models/kiosk/visit.rb +0 -2
  21. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  22. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  23. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  24. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  25. data/lib/generators/kiosk/install/install_generator.rb +25 -69
  26. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  27. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  28. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  29. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  30. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
  31. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  32. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  33. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  34. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  35. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  36. data/lib/kiosk/payable.rb +19 -25
  37. data/lib/kiosk/server/account_binding.rb +32 -62
  38. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  39. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  40. data/lib/kiosk/server/agent_rotation.rb +62 -0
  41. data/lib/kiosk/server/argument_decoder.rb +2 -2
  42. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  43. data/lib/kiosk/server/assistant_key.rb +86 -0
  44. data/lib/kiosk/server/auth_toll.rb +6 -6
  45. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  46. data/lib/kiosk/server/checkout.rb +57 -0
  47. data/lib/kiosk/server/client.rb +61 -0
  48. data/lib/kiosk/server/client_assertion.rb +154 -0
  49. data/lib/kiosk/server/client_registration.rb +98 -0
  50. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  51. data/lib/kiosk/server/configuration_extension.rb +64 -75
  52. data/lib/kiosk/server/current_request.rb +1 -1
  53. data/lib/kiosk/server/device_authorization.rb +1 -1
  54. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  55. data/lib/kiosk/server/device_code_grant.rb +38 -69
  56. data/lib/kiosk/server/dpop.rb +126 -0
  57. data/lib/kiosk/server/eddsa.rb +38 -0
  58. data/lib/kiosk/server/engine.rb +16 -13
  59. data/lib/kiosk/server/errors.rb +28 -7
  60. data/lib/kiosk/server/event_stores.rb +2 -2
  61. data/lib/kiosk/server/events.rb +4 -3
  62. data/lib/kiosk/server/events_connection.rb +38 -10
  63. data/lib/kiosk/server/events_replay.rb +79 -0
  64. data/lib/kiosk/server/executor/repeat.rb +13 -12
  65. data/lib/kiosk/server/executor/spending_cap.rb +17 -76
  66. data/lib/kiosk/server/executor.rb +86 -105
  67. data/lib/kiosk/server/failure_log.rb +3 -3
  68. data/lib/kiosk/server/handler_mixin.rb +9 -9
  69. data/lib/kiosk/server/handler_registrations.rb +1 -1
  70. data/lib/kiosk/server/handler_serving.rb +11 -11
  71. data/lib/kiosk/server/headers.rb +3 -3
  72. data/lib/kiosk/server/identity_resolution.rb +12 -3
  73. data/lib/kiosk/server/jwk.rb +83 -0
  74. data/lib/kiosk/server/jwks.rb +6 -0
  75. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  76. data/lib/kiosk/server/kyc.rb +5 -7
  77. data/lib/kiosk/server/kyc_verifier.rb +28 -29
  78. data/lib/kiosk/server/link_code.rb +9 -18
  79. data/lib/kiosk/server/linked_assistants.rb +22 -17
  80. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  81. data/lib/kiosk/server/money.rb +41 -0
  82. data/lib/kiosk/server/oauth_answers.rb +31 -0
  83. data/lib/kiosk/server/oauth_error.rb +33 -0
  84. data/lib/kiosk/server/open_api.rb +31 -29
  85. data/lib/kiosk/server/page.rb +1 -1
  86. data/lib/kiosk/server/payment_claim.rb +47 -78
  87. data/lib/kiosk/server/pow_gate.rb +12 -33
  88. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  89. data/lib/kiosk/server/receipts.rb +30 -0
  90. data/lib/kiosk/server/reputation_factors.rb +12 -5
  91. data/lib/kiosk/server/request_validation.rb +3 -3
  92. data/lib/kiosk/server/result.rb +1 -1
  93. data/lib/kiosk/server/revocation_stores.rb +9 -1
  94. data/lib/kiosk/server/routing.rb +1 -1
  95. data/lib/kiosk/server/schemas/auth.schema.json +369 -58
  96. data/lib/kiosk/server/schemas/binding.schema.json +24 -59
  97. data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
  98. data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
  99. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  100. data/lib/kiosk/server/sd_jwt.rb +104 -0
  101. data/lib/kiosk/server/session_context.rb +2 -2
  102. data/lib/kiosk/server/signing_key.rb +18 -52
  103. data/lib/kiosk/server/tolled_calls.rb +46 -0
  104. data/lib/kiosk/server/verb_wire.rb +4 -4
  105. data/lib/kiosk/server/version.rb +1 -1
  106. data/lib/kiosk/server/well_known.rb +272 -205
  107. data/lib/kiosk/server/wire_rendering.rb +2 -2
  108. data/lib/kiosk/server.rb +8 -2
  109. data/lib/kiosk/skill_link.rb +6 -2
  110. data/listen.py +38 -8
  111. metadata +34 -24
  112. data/app/models/kiosk/cart_mandate.rb +0 -21
  113. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  114. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  115. data/lib/kiosk/server/agent_login.rb +0 -49
  116. data/lib/kiosk/server/agent_registration.rb +0 -88
  117. data/lib/kiosk/server/auth_challenge.rb +0 -33
  118. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  119. data/lib/kiosk/server/pop_verifier.rb +0 -87
  120. data/lib/kiosk/server/schema_definitions.rb +0 -351
  121. data/lib/kiosk/server/settled_spend.rb +0 -21
  122. 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