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,351 @@
|
|
|
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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# The public `GET <endpoint>/schema` catalog, `{queries, actions, events}`,
|
|
9
|
+
# derived at boot and served from memory. Its digest is the origin's
|
|
10
|
+
# document version: the ETag and the `?v=` of the discovery links.
|
|
11
|
+
module SchemaDocument
|
|
12
|
+
DIGEST_LENGTH = 32
|
|
13
|
+
|
|
14
|
+
MUTEX = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def document(config: Kiosk.configuration)
|
|
18
|
+
derive(config: config).fetch(:document)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def json(config: Kiosk.configuration)
|
|
22
|
+
derive(config: config).fetch(:json)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def digest(config: Kiosk.configuration)
|
|
26
|
+
derive(config: config).fetch(:digest)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def etag(config: Kiosk.configuration)
|
|
30
|
+
%("#{digest(config: config)}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def derived?
|
|
34
|
+
!@memo.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Called at boot. With a data-derived slot the database may not exist
|
|
38
|
+
# yet (`db:create`), so a failure leaves the memo empty for the first read.
|
|
39
|
+
def derive!(config: Kiosk.configuration)
|
|
40
|
+
MUTEX.synchronize { @memo = build(config) }
|
|
41
|
+
self
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
raise unless SchemaSlots.dynamic_declarations?
|
|
44
|
+
|
|
45
|
+
@memo = nil
|
|
46
|
+
deferred_derivation_warning(e)
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reset!
|
|
51
|
+
@memo = nil
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def derive(config:)
|
|
58
|
+
key = cache_key(config)
|
|
59
|
+
memo = @memo
|
|
60
|
+
return memo if memo && memo[:key] == key
|
|
61
|
+
|
|
62
|
+
MUTEX.synchronize do
|
|
63
|
+
# Re-read inside the lock: another thread may have built it.
|
|
64
|
+
key = cache_key(config)
|
|
65
|
+
memo = @memo
|
|
66
|
+
next memo if memo && memo[:key] == key
|
|
67
|
+
|
|
68
|
+
@memo = build(config, key: key)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build(config, key: nil)
|
|
73
|
+
document = { queries: Queries.catalog, actions: Actions.catalog,
|
|
74
|
+
events: Events.catalog }.freeze
|
|
75
|
+
inputs = digest_inputs(config, document)
|
|
76
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(inputs))[0, DIGEST_LENGTH]
|
|
77
|
+
|
|
78
|
+
{ key: key || cache_key(config), document: document,
|
|
79
|
+
json: JSON.generate(document).freeze, digest: digest.freeze }.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `SchemaSlots.epoch` moves when a data-derived slot refreshes; else it is 0.
|
|
83
|
+
def cache_key(config)
|
|
84
|
+
[config.object_id, Queries.known.sort, Actions.known.sort, Events.known.sort,
|
|
85
|
+
Array(config.capabilities), SchemaSlots.epoch]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def deferred_derivation_warning(error)
|
|
89
|
+
message =
|
|
90
|
+
'[kiosk-server] the schema catalog could not be derived at boot ' \
|
|
91
|
+
"(#{error.class}: #{error.message}). A descriptor slot on this origin is " \
|
|
92
|
+
'data-derived (a proc), and the data was not reachable — normal during ' \
|
|
93
|
+
'db:create, db:migrate and assets:precompile. It will be derived on first read.'
|
|
94
|
+
logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
95
|
+
logger ? logger.info(message) : warn(message)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every input of `/schema` and `/openapi.json`, and nothing else.
|
|
99
|
+
def digest_inputs(config, document)
|
|
100
|
+
{ gem_version: Kiosk::Server::VERSION, protocol_version: Kiosk::Protocol::API_VERSION,
|
|
101
|
+
**config_digest_inputs(config), document: document }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def config_digest_inputs(config)
|
|
105
|
+
{
|
|
106
|
+
origins: config.origins,
|
|
107
|
+
mount_path: config.mount_path.to_s,
|
|
108
|
+
capabilities: Array(config.capabilities),
|
|
109
|
+
owner: config.owner,
|
|
110
|
+
skill: [config.skill_url.to_s, config.skill_sha256.to_s]
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Data-derived descriptor slots: a proc in a declaration
|
|
6
|
+
# (`enum: -> { Category.pluck(:slug) }`) is resolved lazily, memoized, and
|
|
7
|
+
# re-resolved every {REFRESH_SECONDS}. An origin with no proc pays nothing.
|
|
8
|
+
module SchemaSlots
|
|
9
|
+
# Not `kind` or `wire_name`: those are fixed when the route is drawn.
|
|
10
|
+
RESOLVABLE_SLOTS = %i[input_schema output_schema example_params example_row].freeze
|
|
11
|
+
|
|
12
|
+
# Matches {Headers::SHORT_MAX_AGE}, the pointer document's lifetime.
|
|
13
|
+
REFRESH_SECONDS = 60
|
|
14
|
+
|
|
15
|
+
MAX_DEPTH = 32
|
|
16
|
+
|
|
17
|
+
MUTEX = Mutex.new
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# `0` or less re-resolves on every read.
|
|
21
|
+
def refresh_seconds
|
|
22
|
+
defined?(@refresh_seconds) && !@refresh_seconds.nil? ? @refresh_seconds : REFRESH_SECONDS
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_writer :refresh_seconds
|
|
26
|
+
|
|
27
|
+
# Inspects, never calls.
|
|
28
|
+
def dynamic?(value, depth = 0)
|
|
29
|
+
return false if depth > MAX_DEPTH
|
|
30
|
+
|
|
31
|
+
case value
|
|
32
|
+
when Proc then true
|
|
33
|
+
when Hash then value.any? { |_key, member| dynamic?(member, depth + 1) }
|
|
34
|
+
when Array then value.any? { |member| dynamic?(member, depth + 1) }
|
|
35
|
+
else false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Latching: one dynamic declaration puts every verb on the resolving path.
|
|
40
|
+
def note_declaration(slots)
|
|
41
|
+
return true if dynamic_declarations?
|
|
42
|
+
|
|
43
|
+
@dynamic = RESOLVABLE_SLOTS.any? { |slot| dynamic?(slots[slot]) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dynamic_declarations?
|
|
47
|
+
defined?(@dynamic) ? !!@dynamic : false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Monotonic, so a stepped wall clock does not move it.
|
|
51
|
+
def epoch
|
|
52
|
+
return 0 unless dynamic_declarations?
|
|
53
|
+
|
|
54
|
+
seconds = refresh_seconds.to_f
|
|
55
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
return now if seconds <= 0
|
|
57
|
+
|
|
58
|
+
(now / seconds).floor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Double-checked under {MUTEX}, so a proc runs once per key per epoch.
|
|
62
|
+
# The cache is replaced, never mutated, so the unlocked read is safe.
|
|
63
|
+
def descriptor(scope, name, entry, &)
|
|
64
|
+
return yield unless dynamic_declarations?
|
|
65
|
+
|
|
66
|
+
key = [scope, name.to_s].freeze
|
|
67
|
+
now_epoch = epoch
|
|
68
|
+
hit = cache[key]
|
|
69
|
+
return hit[:value] if fresh?(hit, entry, now_epoch)
|
|
70
|
+
|
|
71
|
+
MUTEX.synchronize { cached_or_resolved(key, entry, now_epoch, &) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve(value, depth = 0)
|
|
75
|
+
raise ArgumentError, "kiosk: descriptor slot nests procs more than #{MAX_DEPTH} deep" if depth > MAX_DEPTH
|
|
76
|
+
|
|
77
|
+
case value
|
|
78
|
+
when Proc then resolve(value.call, depth + 1)
|
|
79
|
+
when Hash, Array then dynamic?(value) ? resolve_members(value, depth + 1) : value
|
|
80
|
+
else value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Called before the registry is rebuilt: a reload may remove the only proc.
|
|
85
|
+
def reset!
|
|
86
|
+
MUTEX.synchronize do
|
|
87
|
+
@cache = {}.freeze
|
|
88
|
+
@dynamic = false
|
|
89
|
+
end
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Runs under {MUTEX}: re-reads the cache, else resolves and stores.
|
|
96
|
+
def cached_or_resolved(key, entry, now_epoch)
|
|
97
|
+
hit = cache[key]
|
|
98
|
+
return hit[:value] if fresh?(hit, entry, now_epoch)
|
|
99
|
+
|
|
100
|
+
value = resolve(yield).freeze
|
|
101
|
+
@cache = cache.merge(key => { value: value, entry: entry, epoch: now_epoch }.freeze).freeze
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_members(value, depth)
|
|
106
|
+
value.is_a?(Hash) ? value.transform_values { |m| resolve(m, depth) } : value.map { |m| resolve(m, depth) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fresh?(hit, entry, now_epoch)
|
|
110
|
+
!hit.nil? && hit[:epoch] == now_epoch && hit[:entry].equal?(entry)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def cache
|
|
114
|
+
@cache ||= {}.freeze
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|