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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. 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