touchque 5.0.0

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +105 -0
  3. data/LICENSE +21 -0
  4. data/README.md +383 -0
  5. data/lib/touchque/agent.rb +211 -0
  6. data/lib/touchque/agent_card.rb +159 -0
  7. data/lib/touchque/approvals.rb +214 -0
  8. data/lib/touchque/client.rb +81 -0
  9. data/lib/touchque/code_reply.rb +53 -0
  10. data/lib/touchque/cross_app_access.rb +204 -0
  11. data/lib/touchque/crypto.rb +215 -0
  12. data/lib/touchque/errors.rb +246 -0
  13. data/lib/touchque/flow.rb +362 -0
  14. data/lib/touchque/hosted_login.rb +401 -0
  15. data/lib/touchque/http.rb +127 -0
  16. data/lib/touchque/login_session.rb +86 -0
  17. data/lib/touchque/org_agent.rb +398 -0
  18. data/lib/touchque/rack/relay.rb +75 -0
  19. data/lib/touchque/rails/guard.rb +132 -0
  20. data/lib/touchque/rails/hosted_login.rb +143 -0
  21. data/lib/touchque/rails/sessions_controller.rb +12 -0
  22. data/lib/touchque/railtie.rb +34 -0
  23. data/lib/touchque/relay.rb +533 -0
  24. data/lib/touchque/resources/actions.rb +212 -0
  25. data/lib/touchque/resources/agents.rb +251 -0
  26. data/lib/touchque/resources/device_risk.rb +130 -0
  27. data/lib/touchque/resources/identity.rb +331 -0
  28. data/lib/touchque/resources/login.rb +325 -0
  29. data/lib/touchque/resources/mobile.rb +115 -0
  30. data/lib/touchque/resources/people.rb +237 -0
  31. data/lib/touchque/resources/qr_pairings.rb +82 -0
  32. data/lib/touchque/resources/safe_guard.rb +87 -0
  33. data/lib/touchque/resources/webhook.rb +177 -0
  34. data/lib/touchque/safe_approval.rb +283 -0
  35. data/lib/touchque/security_events.rb +162 -0
  36. data/lib/touchque/util.rb +260 -0
  37. data/lib/touchque/version.rb +5 -0
  38. data/lib/touchque.rb +124 -0
  39. metadata +83 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ module Resources
5
+ # Action types (LOGIN, SEND_MONEY...): what a user is asked to approve. Define them
6
+ # from code at start-up instead of clicking them into the dashboard.
7
+ class ActionTypes
8
+ # The Second Thought questions an action type can ask (at most two).
9
+ SECOND_THOUGHT_QUESTIONS = %w[someone_guiding safe_account remote_access asked_for_code support_call
10
+ met_online promised_return know_recipient sim_in_hand].freeze
11
+
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Creates the action type, or updates its name, description, critical flag and
17
+ # Second Thought questions ({questions: [...], locale: "tr" | "en"}; questions: []
18
+ # turns them off; nil keeps what the dashboard has). Never re-enables a type an
19
+ # admin disabled.
20
+ def define(type, name: nil, description: nil, critical: nil, second_thought: nil)
21
+ body = { "type" => type, "name" => name || type }
22
+ body["description"] = description unless description.nil?
23
+ body["critical"] = critical unless critical.nil?
24
+ body["secondThought"] = Util.stringify(second_thought) unless second_thought.nil?
25
+ @http.post("/action-types", body)
26
+ end
27
+
28
+ def list
29
+ @http.get("/action-types")
30
+ end
31
+ end
32
+
33
+ # Touchque Actions: your own code at points of the approval pipeline (before the push
34
+ # is sent, the silent sign-in decision, after an approval, fraud reports, device and
35
+ # recovery events). Touchque runs it in a sandbox; this resource deploys, tests,
36
+ # replays and observes it.
37
+ #
38
+ # tq.actions.deploy("night-alert", trigger: "approval.decided",
39
+ # template: { id: "alert", params: { channel: "slack", when: { countries: ["TR"] } } })
40
+ #
41
+ # Action code itself is JavaScript (exports.onExecute = async (event, api) => {...}),
42
+ # whatever language your server is written in.
43
+ class Actions
44
+ SLUG = /\A[a-z0-9][a-z0-9-]{1,62}\z/.freeze
45
+ TEMPLATE = /\A[a-z_]{1,64}\z/.freeze
46
+
47
+ attr_reader :secrets
48
+
49
+ def initialize(http)
50
+ @http = http
51
+ @secrets = ActionSecrets.new(http)
52
+ end
53
+
54
+ # Creates the Action or adds a version and makes it live (idempotent). A version
55
+ # asking for a loosening capability (skip_number_match, override_block,
56
+ # allow_silent) waits for an admin's phone approval: then "deployed" is false and
57
+ # "approval" => {"required" => true}.
58
+ def deploy(slug, trigger: nil, code: nil, template: nil, name: nil, secrets: nil, allowed_hosts: nil,
59
+ capabilities: nil, on_failure: nil, order: nil, enabled: nil, activate: nil)
60
+ path = slug_path(slug)
61
+ raise ConfigError, "actions.deploy() needs code or template" if code.nil? && template.nil?
62
+
63
+ body = draft(trigger: trigger, code: code, template: template, secrets: secrets, allowed_hosts: allowed_hosts, capabilities: capabilities)
64
+ body["name"] = name unless name.nil?
65
+ body["onFailure"] = on_failure unless on_failure.nil?
66
+ body["order"] = order unless order.nil?
67
+ body["enabled"] = enabled unless enabled.nil?
68
+ body["activate"] = activate unless activate.nil?
69
+ @http.put(path, body)
70
+ end
71
+
72
+ def list
73
+ @http.get("/actions")["actions"] || []
74
+ end
75
+
76
+ # The Action, the active version's code and its versions.
77
+ def get(slug)
78
+ @http.get(slug_path(slug))
79
+ end
80
+
81
+ def version(slug, number)
82
+ @http.get("#{slug_path(slug)}/versions/#{Integer(number)}")
83
+ end
84
+
85
+ # Rename, turn on or off, reorder, change on_failure.
86
+ def update(slug, name: nil, enabled: nil, order: nil, on_failure: nil)
87
+ body = { "name" => name, "enabled" => enabled, "order" => order, "onFailure" => on_failure }.reject { |_, v| v.nil? }
88
+ res = @http.patch(slug_path(slug), body)
89
+ res["action"] || res
90
+ end
91
+
92
+ def delete(slug)
93
+ @http.delete(slug_path(slug))
94
+ end
95
+
96
+ # Back to +version+, or to the newest runnable version before the active one.
97
+ def rollback(slug, version: nil)
98
+ @http.post("#{slug_path(slug)}/rollback", version.nil? ? {} : { "version" => version })
99
+ end
100
+
101
+ # Runs a draft (or the deployed Action +slug+) once on a recorded event, your own
102
+ # event or the trigger's sample. Sends nothing.
103
+ def test(trigger: nil, code: nil, template: nil, slug: nil, secrets: nil, allowed_hosts: nil, capabilities: nil,
104
+ event_id: nil, event: nil, network: nil)
105
+ body = draft(trigger: trigger, code: code, template: template, slug: slug, secrets: secrets, allowed_hosts: allowed_hosts, capabilities: capabilities)
106
+ body["eventId"] = event_id unless event_id.nil?
107
+ body["event"] = Util.stringify(event) unless event.nil?
108
+ body["network"] = network unless network.nil?
109
+ @http.post("/actions/test", body)
110
+ end
111
+
112
+ # Replays a draft against recent real decisions: how many it would have changed
113
+ # ("changed", "transitions" such as "push→number_match" => 12), notifications,
114
+ # errors and timing. +replaces+: the deployed Action it would replace.
115
+ def dry_run(trigger: nil, code: nil, template: nil, slug: nil, secrets: nil, allowed_hosts: nil, capabilities: nil,
116
+ replaces: nil, on_failure: nil, limit: nil, since: nil)
117
+ body = draft(trigger: trigger, code: code, template: template, slug: slug, secrets: secrets, allowed_hosts: allowed_hosts, capabilities: capabilities)
118
+ body["replaces"] = replaces unless replaces.nil?
119
+ body["onFailure"] = on_failure unless on_failure.nil?
120
+ body["limit"] = limit unless limit.nil?
121
+ body["since"] = since.respond_to?(:iso8601) ? since.iso8601 : since unless since.nil?
122
+ @http.post("/actions/dry-run", body)
123
+ end
124
+
125
+ # Recorded events (newest first, kept 30 days); use an id with test(event_id:).
126
+ def events(trigger: nil, limit: nil)
127
+ @http.get("/actions/events", { "trigger" => trigger, "limit" => limit })["events"] || []
128
+ end
129
+
130
+ # Live runs: decision, notifications, console output, errors.
131
+ def logs(slug, limit: nil, before: nil)
132
+ before = before.iso8601 if before.respond_to?(:iso8601)
133
+ @http.get("#{slug_path(slug)}/logs", { "limit" => limit, "before" => before })["logs"] || []
134
+ end
135
+
136
+ # +window+: 1h, 24h (default), 7d or 30d.
137
+ def metrics(slug, window: nil)
138
+ @http.get("#{slug_path(slug)}/metrics", { "window" => window })
139
+ end
140
+
141
+ # Every trigger with its decisions, capabilities, notification types and a sample event.
142
+ def triggers
143
+ @http.get("/actions/triggers")
144
+ end
145
+
146
+ def templates
147
+ @http.get("/actions/templates")["templates"] || []
148
+ end
149
+
150
+ # A template as plain code (to read, or to start a code Action from).
151
+ def render_template(id, trigger, params = {})
152
+ raise ConfigError, "Unknown template id" unless id.to_s.match?(TEMPLATE)
153
+
154
+ @http.post("/actions/templates/#{id}/render", { "trigger" => trigger, "params" => Util.stringify(params || {}) })
155
+ end
156
+
157
+ private
158
+
159
+ def slug_path(slug)
160
+ raise ConfigError, "An Action slug is 2-63 lowercase letters, digits or - (e.g. \"night-alert\")" unless slug.to_s.match?(SLUG)
161
+
162
+ "/actions/#{slug}"
163
+ end
164
+
165
+ def draft(trigger: nil, code: nil, template: nil, slug: nil, secrets: nil, allowed_hosts: nil, capabilities: nil)
166
+ body = {}
167
+ body["trigger"] = trigger unless trigger.nil?
168
+ body["slug"] = slug unless slug.nil?
169
+ body["code"] = code unless code.nil?
170
+ body["template"] = Util.stringify(template) unless template.nil?
171
+ body["secrets"] = secrets unless secrets.nil?
172
+ body["allowedHosts"] = allowed_hosts unless allowed_hosts.nil?
173
+ body["capabilities"] = capabilities unless capabilities.nil?
174
+ body
175
+ end
176
+ end
177
+
178
+ # Secrets your Actions read (event.secrets.NAME) or notify with. A value is encrypted
179
+ # and never readable again through the API.
180
+ class ActionSecrets
181
+ NAME = /\A[A-Z][A-Z0-9_]{0,63}\z/.freeze
182
+
183
+ def initialize(http)
184
+ @http = http
185
+ end
186
+
187
+ def set(name, value)
188
+ path = path(name)
189
+ raise ConfigError, "A secret value is a non-empty string" if value.to_s.empty?
190
+
191
+ @http.put(path, { "value" => value })
192
+ end
193
+
194
+ # Names only.
195
+ def list
196
+ @http.get("/actions/secrets")["secrets"] || []
197
+ end
198
+
199
+ def delete(name)
200
+ @http.delete(path(name))
201
+ end
202
+
203
+ private
204
+
205
+ def path(name)
206
+ raise ConfigError, "A secret name is capital letters, digits or _ (e.g. SLACK_WEBHOOK_URL)" unless name.to_s.match?(NAME)
207
+
208
+ "/actions/secrets/#{name}"
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ module Resources
5
+ # AI agents as identities of their own: register an agent with its PUBLIC key, give
6
+ # it an accountable owner and the action types it may ask approvals for, suspend,
7
+ # revoke or stop every agent at once. The agent itself never gets your API key: it
8
+ # signs in with its own key (Touchque::Agent).
9
+ #
10
+ # keys = Touchque::AgentKeys.generate # where the agent runs
11
+ # agent = tq.agents.create("Invoice bot", ["PAY_INVOICE"], public_key: keys["publicJwk"])
12
+ class Agents
13
+ attr_reader :vault
14
+
15
+ def initialize(http)
16
+ @http = http
17
+ @vault = AgentVault.new(http)
18
+ @jwks = nil
19
+ @jwks_at = 0
20
+ @audience = nil
21
+ @lock = Mutex.new
22
+ end
23
+
24
+ # Uses an agent's approval exactly once, right before you execute: checks the
25
+ # expectation against the token first (nothing is used on a mismatch), then
26
+ # Touchque checks the token and marks it used, and the answer is checked again.
27
+ def redeem_approval(token, user: nil, type: nil, authorization_details: nil)
28
+ expect = { user: user, type: type, authorization_details: authorization_details }
29
+ ApprovalToken.assert_matches!(ApprovalToken.decode(token)["claims"], **expect)
30
+ approval = @http.post("/agents/approvals/redeem", { "token" => token })["approval"] || {}
31
+ ApprovalToken.assert_matches!({ "sub" => approval["externalUsername"], "type" => approval["type"],
32
+ "authorization_details" => approval["authorizationDetails"] }, **expect)
33
+ approval
34
+ end
35
+
36
+ # The aud of this workspace's approval tokens (touchque:workspace:<id>), from
37
+ # GET /agents/approval-audience; asked once, then kept.
38
+ def approval_audience
39
+ @lock.synchronize do
40
+ unless @audience
41
+ audience = (@http.get("/agents/approval-audience") || {})["audience"]
42
+ raise ApprovalMismatchError, "Touchque did not name this workspace's approval audience" if !audience.is_a?(String) || audience.empty?
43
+
44
+ @audience = audience
45
+ end
46
+ @audience
47
+ end
48
+ end
49
+
50
+ # Verifies an approval token offline (Touchque's published keys, expiry, aud, the
51
+ # expectation). aud must be this workspace's: audience defaults to approval_audience,
52
+ # and a token without aud or for another workspace is refused. It does not make
53
+ # the token single-use: redeem_approval does.
54
+ def verify_approval_token(token, audience: nil, user: nil, type: nil, authorization_details: nil)
55
+ audience = approval_audience if audience.nil? || audience.to_s.empty?
56
+ expect = { audience: audience, user: user, type: type, authorization_details: authorization_details }
57
+ ApprovalToken.verify_with(token, jwks(false), **expect)
58
+ rescue ApprovalMismatchError => e
59
+ raise unless e.message.include?("unknown key")
60
+
61
+ ApprovalToken.verify_with(token, jwks(true), **expect)
62
+ end
63
+
64
+ # Registers an agent (needs the agents:write scope). owner_email: the accountable
65
+ # member (default the API key's creator; the agent stops when they leave).
66
+ def create(name, allowed_types, public_key: nil, public_keys: nil, description: nil, owner_email: nil, platform: nil, review_in_days: nil)
67
+ body = { "name" => name, "allowedTypes" => allowed_types.to_a }
68
+ body["description"] = description unless description.nil?
69
+ body["ownerEmail"] = owner_email unless owner_email.nil?
70
+ body["platform"] = platform unless platform.nil?
71
+ body["reviewInDays"] = review_in_days unless review_in_days.nil?
72
+ body["publicKey"] = public_only(public_key) unless public_key.nil?
73
+ body["publicKeys"] = public_keys.map { |k| public_only(k) } unless public_keys.nil?
74
+ @http.post("/agents", body)["agent"]
75
+ end
76
+
77
+ # {"agents" => [...], "killSwitch" => {...}}.
78
+ def list(status: nil)
79
+ @http.get("/agents", { "status" => status })
80
+ end
81
+
82
+ def get(agent_id)
83
+ @http.get(path(agent_id))["agent"]
84
+ end
85
+
86
+ # Changes, hands over (owner_email) or renews (review_in_days) an agent.
87
+ def update(agent_id, name: nil, description: nil, owner_email: nil, platform: nil, allowed_types: nil, review_in_days: nil)
88
+ body = { "name" => name, "description" => description, "ownerEmail" => owner_email, "platform" => platform,
89
+ "allowedTypes" => allowed_types, "reviewInDays" => review_in_days }.reject { |_, v| v.nil? }
90
+ @http.patch(path(agent_id), body)["agent"]
91
+ end
92
+
93
+ # Adds a key (rotation: add the new one, move the agent to it, then remove the old one).
94
+ def add_key(agent_id, public_key)
95
+ @http.post("#{path(agent_id)}/keys", { "publicKey" => public_only(public_key) })["agent"]
96
+ end
97
+
98
+ # Removes a key; tokens the agent got with it stop at once.
99
+ def remove_key(agent_id, kid)
100
+ @http.delete("#{path(agent_id)}/keys/#{Util.seg(kid)}")["agent"]
101
+ end
102
+
103
+ # Stops an agent at once (its tokens too) until resumed.
104
+ def suspend(agent_id, reason: nil)
105
+ @http.post("#{path(agent_id)}/suspend", Util.compact({ "reason" => reason }))["agent"]
106
+ end
107
+
108
+ def resume(agent_id)
109
+ @http.post("#{path(agent_id)}/resume", {})["agent"]
110
+ end
111
+
112
+ # Revokes an agent for good (the record stays for the audit trail).
113
+ def revoke(agent_id)
114
+ @http.delete(path(agent_id))["agent"]
115
+ end
116
+
117
+ # Sets the agent's budgets, amounts, recipients and background rule (a full
118
+ # replacement): requestsPer10Min, requestsPerUserPer10Min, amountPerRequest /
119
+ # amountPerDay ({"TRY" => "1000.00"}), background (non_critical | never),
120
+ # newRecipients (ask | deny), recipientAllowlist, fourEyes, requirePasskeyAbove
121
+ # ({"TRY" => "10000.00"}: over it the person approves with a passkey on your page,
122
+ # never on the phone or with a mandate).
123
+ def set_policy(agent_id, policy)
124
+ @http.put("#{path(agent_id)}/policy", { "policy" => Util.stringify(policy) })["agent"]
125
+ end
126
+
127
+ # The workspace's agent events for a SIEM, oldest first: { "events", "next", "hasMore" }.
128
+ # Keep "next" and pass it as +after+; the first call reads from +since+ (ISO 8601;
129
+ # default the last 24 hours).
130
+ def list_events(after: nil, since: nil, limit: nil)
131
+ query = { "after" => after, "since" => since, "limit" => limit }.compact
132
+ @http.get("/agents/events", query.empty? ? nil : query)
133
+ end
134
+
135
+ # The workspace's policy: every agent's floor (an agent's own policy can only make it stricter).
136
+ def workspace_policy
137
+ @http.get("/agents/workspace-policy")["policy"]
138
+ end
139
+
140
+ # The name every other Touchque SDK uses (getWorkspacePolicy); workspace_policy stays.
141
+ alias get_workspace_policy workspace_policy
142
+
143
+ # Sets the workspace's policy (a full replacement; a field left out sets no floor).
144
+ def set_workspace_policy(policy)
145
+ @http.put("/agents/workspace-policy", { "policy" => Util.stringify(policy) })["policy"]
146
+ end
147
+
148
+ # The people who stopped this agent asking them (reason not_me, coached or rejections).
149
+ def list_blocks(agent_id)
150
+ @http.get("#{path(agent_id)}/blocks")["blocks"] || []
151
+ end
152
+
153
+ # Lets the agent ask this person again (check with them first).
154
+ def lift_block(agent_id, user)
155
+ @http.delete("#{path(agent_id)}/blocks/#{Util.seg(user)}")
156
+ nil
157
+ end
158
+
159
+ # An agent's mandates, optionally one person's.
160
+ def list_mandates(agent_id, user: nil)
161
+ @http.get("#{path(agent_id)}/mandates", { "externalUsername" => user })["mandates"] || []
162
+ end
163
+
164
+ # Ends an agent's mandate at once.
165
+ def revoke_mandate(agent_id, mandate_id)
166
+ @http.post("#{path(agent_id)}/mandates/#{Util.seg(mandate_id)}/revoke", {})
167
+ end
168
+
169
+ # Stops (true) or restarts (false) every agent of the workspace at once.
170
+ def set_kill_switch(engaged, reason: nil)
171
+ body = { "engaged" => engaged }
172
+ body["reason"] = reason if reason
173
+ @http.put("/agents/kill-switch", body)["killSwitch"]
174
+ end
175
+
176
+ private
177
+
178
+ def path(agent_id)
179
+ "/agents/#{Util.seg(agent_id)}"
180
+ end
181
+
182
+ # Refuses a private key before it leaves this process.
183
+ def public_only(jwk)
184
+ jwk = Util.stringify(jwk)
185
+ if jwk.key?("d")
186
+ raise ConfigError, "Pass the agent's PUBLIC key: this one has private parts (d). The private key must stay where the agent runs."
187
+ end
188
+
189
+ jwk
190
+ end
191
+
192
+ def jwks(force)
193
+ @lock.synchronize do
194
+ if force || @jwks.nil? || Util.now - @jwks_at > 300
195
+ @jwks = @http.get("/agents/jwks.json")
196
+ @jwks_at = Util.now
197
+ end
198
+ @jwks
199
+ end
200
+ end
201
+ end
202
+
203
+ # The token vault for your agents (tq.agents.vault): after your own OAuth flow with a
204
+ # person, deposit their tokens; Touchque keeps them sealed and refreshes them, and the
205
+ # agents a connection names get a short-lived access token
206
+ # (Touchque::Agent#get_connection_token), never the refresh token.
207
+ class AgentVault
208
+ def initialize(http)
209
+ @http = http
210
+ end
211
+
212
+ def list_connections
213
+ @http.get("/agents/vault/connections")["connections"] || []
214
+ end
215
+
216
+ # Adds or replaces a connection; client_secret is kept when left out of a replacement.
217
+ # provider: google, microsoft, github, facebook, linkedin, x or oidc.
218
+ def put_connection(name, provider:, client_id:, client_secret: nil, tenant: nil, issuer: nil, agent_ids: nil)
219
+ body = { "provider" => provider, "clientId" => client_id }
220
+ body.merge!(Util.compact({ "clientSecret" => client_secret, "tenant" => tenant, "issuer" => issuer }))
221
+ body["agentIds"] = agent_ids.to_a unless agent_ids.nil?
222
+ @http.put("/agents/vault/connections/#{Util.seg(name)}", body)["connection"]
223
+ end
224
+
225
+ # Removes it, and every person's tokens kept for it.
226
+ def remove_connection(name)
227
+ @http.delete("/agents/vault/connections/#{Util.seg(name)}")
228
+ end
229
+
230
+ # Keeps a person's tokens (replacing earlier ones) after your own OAuth flow with them.
231
+ def deposit(connection, user, access_token:, refresh_token: nil, expires_in: nil, scope: nil)
232
+ body = { "accessToken" => access_token }
233
+ body["refreshToken"] = refresh_token unless refresh_token.nil?
234
+ body["expiresIn"] = expires_in unless expires_in.nil?
235
+ body["scope"] = scope unless scope.nil?
236
+ @http.put("/agents/vault/accounts/#{Util.seg(connection)}/#{Util.seg(user)}", body)["account"]
237
+ end
238
+
239
+ # A person's accounts in the vault (never a token).
240
+ def list_accounts(user)
241
+ @http.get("/agents/vault/accounts", { "externalUsername" => user })["accounts"] || []
242
+ end
243
+
244
+ # Deletes a person's tokens: agents get nothing more for them there.
245
+ def remove_account(connection, user)
246
+ @http.delete("/agents/vault/accounts/#{Util.seg(connection)}/#{Util.seg(user)}")
247
+ nil
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ module Resources
5
+ # Device risk (a paid add-on, off until your workspace turns it on): a visitor ID for
6
+ # every browser on your sign-up, password-reset, sign-in or approval page. The page
7
+ # identifies with @touchque/web (deviceRisk.identify({ siteKey, purpose }) gives
8
+ # { visitorId, requestId, confidence }) and sends you the requestId. Never trust the
9
+ # page's copy: read the identification here (get_event) or check it for an account
10
+ # (check). Touchque keeps keyed hashes only, with a key of your workspace alone, and its
11
+ # risk engine decides.
12
+ #
13
+ # Every signal only ever asks for more: "stepUp" means an extra check on this page (an
14
+ # emailed code, a passkey, a number-matched approval), never a refusal. For sign-ins,
15
+ # pass the requestId to login.request / risk.check as device_risk_request_id: instead.
16
+ # Collect it only on these pages and name it in your privacy notice.
17
+ #
18
+ # With the script tag (device.js), the form arrives with touchque_request_id:
19
+ #
20
+ # r = tq.device_risk.check(params[:touchque_request_id], event: "sign_in", external_username: email)
21
+ #
22
+ # Pass it as it came. Missing, empty, made up or expired, it is still a verdict
23
+ # ("report" missing, unknown, expired; the signal device_identification_missing), never an
24
+ # exception: a page without the tag, or with it blocked, only gets the visitor a step-up.
25
+ class DeviceRisk
26
+ # The pages a device report may come from.
27
+ EVENTS = %w[sign_in sign_up password_reset approval].freeze
28
+ # What the page had sent: ok, replayed, or no usable identification.
29
+ REPORTS = %w[ok replayed missing unknown expired invalid].freeze
30
+ REQUEST_ID = /\Adre_[A-Za-z0-9_-]{22}\z/
31
+
32
+ def initialize(http)
33
+ @http = http
34
+ end
35
+
36
+ # Reads an answer, failing toward asking: "stepUp" is true unless it says exactly
37
+ # false; junk signals are dropped.
38
+ def self.parse_check(raw)
39
+ body = raw.is_a?(Hash) ? raw : {}
40
+ signals = (body["signals"].is_a?(Array) ? body["signals"] : []).filter_map do |s|
41
+ next unless s.is_a?(Hash) && s["code"].is_a?(String)
42
+
43
+ item = { "code" => s["code"], "text" => s["text"].is_a?(String) ? s["text"] : "",
44
+ "strength" => %w[passkey none].include?(s["strength"]) ? s["strength"] : "number_match" }
45
+ item["enforced"] = s["enforced"] if [true, false].include?(s["enforced"])
46
+ item["evidence"] = s["evidence"] if s["evidence"].is_a?(Hash)
47
+ item
48
+ end
49
+ out = {
50
+ "report" => REPORTS.include?(body["report"]) ? body["report"] : "invalid",
51
+ "requestId" => body["requestId"].is_a?(String) ? body["requestId"] : nil,
52
+ "visitorId" => body["visitorId"].is_a?(String) ? body["visitorId"] : nil,
53
+ "confidence" => body["confidence"].is_a?(Numeric) ? body["confidence"] : nil,
54
+ "visitor" => body["visitor"].is_a?(Hash) ? body["visitor"] : nil,
55
+ "counts" => body["counts"].is_a?(Hash) ? body["counts"] : nil,
56
+ # The address (never coordinates), the visitor's counts, one answer to "a bot?" (detected | suspected | not_detected).
57
+ "network" => body["network"].is_a?(Hash) ? body["network"] : nil,
58
+ "velocity" => body["velocity"].is_a?(Hash) ? body["velocity"] : nil,
59
+ "bot" => body["bot"].is_a?(Hash) ? body["bot"] : nil,
60
+ "signals" => signals,
61
+ "stepUp" => body["stepUp"] != false,
62
+ "wouldStepUp" => body["wouldStepUp"] == true,
63
+ "requires" => %w[number_match passkey].include?(body["requires"]) ? body["requires"] : "none",
64
+ "mode" => body["mode"] == "enforce" ? "enforce" : "shadow",
65
+ # The engine's state for this check; "unavailable" when the risk engine could not be reached and the verdict is a safe default.
66
+ "engine" => body["engine"].is_a?(String) ? body["engine"] : nil
67
+ }
68
+ out["caseCode"] = body["caseCode"] if body["caseCode"].is_a?(String)
69
+ out
70
+ end
71
+
72
+ # Checks the page's identification for an account: binds it (one account per
73
+ # identification, 15 minutes) and asks the risk engine. Pass the form field as it came:
74
+ # nil, empty or made up is still a verdict ("report" missing, unknown), never an
75
+ # exception. 409 device_risk_off until your workspace turns Device risk on.
76
+ # device_risk: instead of request_id, the page's report (deviceRisk.collect).
77
+ #
78
+ # r = tq.device_risk.check(params[:touchque_request_id], event: "sign_up", external_username: email, client_ip: request.remote_ip)
79
+ # ask_for_email_code if r["stepUp"] # never refuse on it alone
80
+ def check(request_id = nil, event: "sign_in", external_username: nil, client_ip: nil, device_risk: nil)
81
+ raise ConfigError, "device_risk.check(): event must be one of #{EVENTS.join(", ")}" unless EVENTS.include?(event)
82
+
83
+ body = {}
84
+ if request_id.is_a?(String) && !request_id.empty?
85
+ body["requestId"] = request_id[0, 64]
86
+ elsif !device_risk.to_s.empty?
87
+ body["deviceRisk"] = device_risk
88
+ end
89
+ body["event"] = event
90
+ body["externalUsername"] = external_username unless external_username.to_s.empty?
91
+ body["clientIp"] = client_ip unless client_ip.to_s.empty?
92
+ self.class.parse_check(@http.post("/device-risk/check", body))
93
+ end
94
+
95
+ SEARCH_FILTERS = { from: "from", to: "to", signal: "signal", kind: "kind", origin: "origin", ip: "ip", account: "account",
96
+ visitor_id: "visitorId", request_id: "requestId", cursor: "cursor" }.freeze
97
+
98
+ # Your workspace's identifications, newest first, one page at a time
99
+ # (GET /device-risk/events): a date range (from:/to: YYYY-MM-DD or ISO times; 7 days by
100
+ # default, at most 90, within your retention) and any of the filters. ip: is an address
101
+ # or a CIDR range and is never returned. Pass "nextCursor" back as cursor: until it is
102
+ # nil; do not page by moving the dates. A search that would read too much is a 422
103
+ # search_too_broad: narrow the range or add a filter.
104
+ # { "events" => [...], "nextCursor" => String | nil, "from" => String, "to" => String }.
105
+ def search_events(step_up: nil, limit: nil, **filters)
106
+ unknown = filters.keys - SEARCH_FILTERS.keys
107
+ raise ConfigError, "device_risk.search_events() does not know #{unknown.join(', ')}" unless unknown.empty?
108
+ raise ConfigError, "device_risk.search_events() limit must be 1 to 100" unless limit.nil? || (limit.is_a?(Integer) && limit.between?(1, 100))
109
+
110
+ params = {}
111
+ filters.each { |k, v| params[SEARCH_FILTERS[k]] = v if v.is_a?(String) && !v.empty? }
112
+ params["stepUp"] = step_up ? "true" : "false" if [true, false].include?(step_up)
113
+ params["limit"] = limit.to_s if limit
114
+ raw = @http.get("/device-risk/events", params) || {}
115
+ cursor = raw["nextCursor"]
116
+ { "events" => raw["events"].is_a?(Array) ? raw["events"] : [],
117
+ "nextCursor" => cursor.is_a?(String) && !cursor.empty? ? cursor : nil,
118
+ "from" => raw["from"].to_s, "to" => raw["to"].to_s }
119
+ end
120
+
121
+ # The trusted identification behind a request_id: the visitor, how sure, what the page
122
+ # showed, the risk engine's verdict. Read-only.
123
+ def get_event(request_id)
124
+ raise ConfigError, "device_risk.get_event() needs a request_id (dre_...)" unless request_id.is_a?(String) && REQUEST_ID.match?(request_id)
125
+
126
+ @http.get("/device-risk/events/#{request_id}")
127
+ end
128
+ end
129
+ end
130
+ end