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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6955277b3bc3e32deb8f97a7d137f02f5d3517b7897801a47f2ea7450025459c
4
+ data.tar.gz: cbbe23f464baba65fc927cd7d34ec609be4bdc1a33d2841e07a53b1f66a0c61c
5
+ SHA512:
6
+ metadata.gz: 89047ee9495e0d3f2871feda126bea65fc520db8a4300eb9e49a9c0e167e2a803c34407dbb052101c1104ddc03be71141dfa3bebb818319ebf3058bb74d4370e
7
+ data.tar.gz: 20ce50cfbc756507d749bc4c404755b74f561708bd7c88f8845d77a4a11115f5df2226b1e2c61a3c324b8f1f908440badc97def0a783567c8a4275d11d776afb
data/CHANGELOG.md ADDED
@@ -0,0 +1,105 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. The
4
+ format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [5.0.0] — 2026-11-12
9
+
10
+ ### Breaking
11
+ - **Every name is spelled Touchque instead of TouchQue.** Code that uses the old names must be updated; nothing else changes. Old → new:
12
+ - The module: `TouchQue` → `Touchque` (`TouchQue.client`, `.configure`, `.logger`, `.replay_cache`, `.login_options`, `.login_secret` and `.hosted_login` → `Touchque.client`, `Touchque.configure`...).
13
+ - The client class: `TouchQue::Client` → `Touchque::Client`.
14
+ - The error classes: `TouchQue::Error`, `TouchQue::APIError`, `TouchQue::NetworkError`, `TouchQue::RejectedError`, `TouchQue::TimeoutError`, `TouchQue::PasskeyRequiredError`, `TouchQue::ConfigError`, `TouchQue::WebhookSignatureError`, `TouchQue::WebhookReplayError`, `TouchQue::ApprovalError`, `TouchQue::ApprovalMismatchError`, `TouchQue::FeatureError` (and `FeatureNotInPlanError`, `FeatureReadOnlyError`, `FeatureOffError`), `TouchQue::HostedLoginError`, `TouchQue::MyAccountError`, `TouchQue::McpAuthError`, `TouchQue::IdJagError`, `TouchQue::AgentCardError` and `TouchQue::SecurityEventError` → `Touchque::Error`, `Touchque::APIError`, `Touchque::NetworkError` and so on.
15
+ - Every other class and module under it: `TouchQue::Resources::*`, `TouchQue::Guard`, `TouchQue::Agent`, `TouchQue::OrgAgent`, `TouchQue::McpAuth`, `TouchQue::HostedLogin`, `TouchQue::SafeApproval`, `TouchQue::CodeReply`... → `Touchque::Resources::*`, `Touchque::Guard`, `Touchque::Agent`...
16
+ - Rails: `TouchQue::Rails::Guard`, `TouchQue::Rails::Webhook`, `TouchQue::Rails::HostedLogin`, `TouchQue::Rails.login_routes` and `TouchQue::SessionsController` → `Touchque::Rails::Guard`, `Touchque::Rails::Webhook`, `Touchque::Rails::HostedLogin`, `Touchque::Rails.login_routes` and `Touchque::SessionsController`. `login_routes`' default controller is now `"touchque/sessions"` (was `"touch_que/sessions"`, the route name Rails derived from the old class name); change it if you passed it yourself.
17
+ - Rack: `TouchQue::Rack::Relay` → `Touchque::Rack::Relay`.
18
+ - The guard's header names (`Touchque::Guard::TOKEN_HEADER`, `OFFLINE_HEADER`, `CODE_HEADER`, `CODE_TYPE_HEADER`, `BROWSER_HEADER`): `X-TouchQue-Token`, `X-TouchQue-Offline`, `X-TouchQue-Code`, `X-TouchQue-Code-Type`, `X-TouchQue-Browser` → `X-Touchque-Token`, `X-Touchque-Offline`, `X-Touchque-Code`, `X-Touchque-Code-Type`, `X-Touchque-Browser`. HTTP header names are case-insensitive, so servers are unaffected.
19
+ - The SDK's log lines start with `[Touchque]` (was `[TouchQue]`) and its error messages with `Touchque:`.
20
+ - The same as before: the gem name `touchque`, `require "touchque"`, `config.touchque` and `credentials.touchque`, the `/touchque` mount path, the cookies, the JSON the API sends and receives, the `TQ_*` environment variables, and the signed EIP-712 type string `TouchQueGovernance(...)` that the Safe Guard contract hashes.
21
+
22
+ ### Changed
23
+ - The README has an "Action types" section: define a type once (dashboard → Developers → Action types, or from code); the guard answers 500 `misconfigured` for an undefined name and 403 `action_disabled` for one turned off.
24
+
25
+ ## [4.2.0] — not published (these changes ship in 5.0.0)
26
+
27
+ ### Security
28
+ - **The offline approval-token check refuses another workspace's token.** `GET /agents/jwks.json` is one key set for every Touchque workspace, and the check used to compare `aud` only when you passed an audience, so a token Touchque signed for someone else's workspace (their own phone, any person's name, any details) could pass your executor. Now:
29
+ - `agents.verify_approval_token(token, audience: nil, ...)` uses this workspace's own audience by default: `agents.approval_audience` asks `GET /agents/approval-audience` once (`touchque:workspace:<id>`) and keeps it.
30
+ - `ApprovalToken.verify_with(token, jwks, audience:, ...)` requires the audience. A missing or empty one throws Touchque::ApprovalMismatchError (`audience is required`).
31
+ - A token without `aud` is refused (`the approval token names no workspace`), as is one for another workspace (`the approval token is for another workspace`).
32
+ - Breaking for callers of the low-level function who passed no audience: pass agents.approval_audience's value.
33
+ - `redeemApproval` was already safe: Touchque compares `aud` with your workspace there.
34
+
35
+ ### Added
36
+ - **`device_risk.search_events(...)`: search your workspace's Device risk identifications**, newest first, one page at a time (`GET /device-risk/events`): `from:`/`to:` (7 days by default, at most 90, within your retention), `signal:`, `step_up:`, `kind:`, `origin:`, `ip:` (an address or a CIDR range; never returned), `account:`, `visitor_id:`, `request_id:`, `limit:` (1 to 100) and the opaque `cursor:` from the previous page's `"nextCursor"`.
37
+ - `device_risk.check` returns `"engine"` (String or nil): the engine's state for this check; `"unavailable"` when the risk engine could not be reached and the verdict is a safe default (`"stepUp"` true), so an outage can be told apart from risk.
38
+ - **`webauthn.primary_availability`: is passkey sign-in on for your workspace?** `GET /webauthn/authenticate/primary/availability` answers `{ "enabled" => bool, "userVerification" => "required" | "preferred" | nil }`. Ask before your sign-in page shows its passkey button and passkey autofill; when it is off, `primary_options` answers 403 `passwordless_login_disabled`.
39
+ - `agents.get_workspace_policy`, the name every other Touchque SDK uses, as an alias of `agents.workspace_policy` (which stays; `set_workspace_policy` was already there).
40
+ - **Webhook events of Never Locked Out and the help desk**, the names the API sends: `Webhook::EVENT_RECOVERY_STARTED`, `EVENT_RECOVERY_DEVICE_BOUND`, `EVENT_RECOVERY_COOLING`, `EVENT_RECOVERY_COMPLETED`, `EVENT_RECOVERY_VETOED`, `EVENT_RECOVERY_EXPIRED`, `EVENT_RECOVERY_FAILED` and `EVENT_ASSIST_OPENED`, `EVENT_ASSIST_ENDED`, `EVENT_ASSIST_CHECK_PASSED`, `EVENT_ASSIST_CHECK_FAILED`, `EVENT_ASSIST_UNFROZEN`, `EVENT_ASSIST_CLAIM_CONFIRMED`, `EVENT_ASSIST_CLAIM_UNVERIFIED`.
41
+ - `SecurityEvents.poll` sends `Accept: application/json`, like the Node SDK (it sent Net::HTTP's `*/*`).
42
+ - README: "Shared Signals (security events from Touchque SSO)", verifying a pushed SET and polling; tests for the keys found through `/.well-known/ssf-configuration` and for a poll, against a local transmitter.
43
+ - **Shared Signals: receive the security events an identity provider sends after sign-in.** Touchque SSO sends Security Event Tokens (OpenID Shared Signals Framework 1.0) for an organization's members to the receivers its admin adds (Organization > Continuous access): CAEP `session-revoked`, `credential-change`, `device-compliance-change`, RISC `account-disabled`, `credential-compromise`; `txn` carries the case code of Touchque's decision. `Touchque::SecurityEventVerifier.new(issuer:, audience:, jwks_uri:, jwks:, clock_skew:).verify(token)` returns `{ "jti", "issuer", "audience", "issued_at", "txn", "subject", "type", "name", "claims" }` or raises `Touchque::SecurityEventError` with the RFC 8935 code to answer in `error` (`invalid_request`, `invalid_key`, `invalid_issuer`, `invalid_audience`); `Touchque::SecurityEvents.poll(url:, token:, ack:, set_errs:, max_events:)` for poll delivery (RFC 8936); `SecurityEvents::TYPES`. The keys come from the issuer's `/.well-known/ssf-configuration`; a SET does not expire, so keep the jtis you handled. Tested against the shared vectors (`fixtures/security-event-vectors.json`).
44
+ - **Safe Guard: Safe transactions approved on the approver's phone, for TouchqueSafeGuard.** `tq.safe_guard.request(external_username, type, safe_tx, reference_id:, expires_in:)` sends the raw transaction (the phone decodes it itself and signs with a key only its biometrics unlock), `status(request_id)` and `wait_for_approval(request_id)` hand back the 105-byte approval. `Touchque::SafeApproval.verify(target, approval, approver_key, purpose:, approver_index:, rows: | display_digest:, now:)` checks it the way the guard does, without trusting Touchque: the Safe transaction hash (EIP-712, Keccak-256 here) and the governance hash computed here, the 149-byte message, low `s`, the one-day expiry window, the P-256 signature. `SafeApproval.with_approvals(owner_signatures, approvals)` for `execTransaction`, `SafeApproval.schedule_call(safe, governance_hash, approvals)` for a governance change; `safe_tx_hash`, `governance_hash`, `message`, `display_digest`. Tested against the shared vectors (`fixtures/safe-guard-vectors.json`).
45
+
46
+ ### Fixed
47
+ - An empty `reference_id` is no reference, as for the API: `login.request` no longer sends `"referenceId": ""`, and `complete` (and so the guard) no longer raises `ApprovalMismatchError` after using up an approval started with `reference_id: ""`.
48
+ - The step-up guard (`Guard.run`, `require_touchque`, the relay's `/login`) answers the contract's 503 `unavailable` for any failure, an approval for another transaction included, instead of a bare 500. Every connection failure and garbled answer (`ENETUNREACH`, `EPIPE`, a bad status line...) now raises `Touchque::NetworkError`.
49
+ - Transaction details are trimmed like the API trims them (JavaScript's trim: no-break and other Unicode spaces too), so `complete` and `ApprovalProof.verify` no longer refuse details that start or end with such a space.
50
+ - `login.verify` passes `expires_in:` and `client_signals:` on to the request instead of dropping them.
51
+ - `Touchque::Rack::Relay` loads on first use: the documented Sinatra / plain Rack mount works without Rails and without `require "touchque/rack/relay"`.
52
+ - The relay answers 400 `invalid_credential_id` to `DELETE /passkey/credentials/{id}` with a malformed %-encoding instead of a 500.
53
+
54
+ ### Security
55
+ - **Hosted login: an empty `iss` is refused too (RFC 9207).** `handle_callback` (and so the Rails `/callback`) already refused a callback whose `iss` names another issuer; one with an empty `iss=` now raises `issuer_mismatch` as well, before the code is exchanged. A callback without `iss` (a server that does not send it yet, or a nil value) is still accepted.
56
+ - `inspect` (and `pp`) of `Touchque::Client`, its `webhook`, `HostedLogin` and `MyAccount` no longer show the API key, the API secret, the client secret or the access token.
57
+ - `SafeApproval.verify` without `purpose:` accepts only an ordinary approval (`"approve"`). A schedule approval covers no Safe nonce or gas fields, so it is accepted only with `purpose: "schedule"`, never as permission to execute.
58
+ - Touchque Login (`HostedLogin`, `MyAccount`) refuses a plaintext `http://` domain except on this machine (`localhost`, `127.0.0.1`, `::1`, `*.localhost`), like `base_url`: codes, PKCE verifiers, the client secret and tokens never travel in the clear.
59
+
60
+ ## [4.1.0] — not published (these changes ship in 5.0.0)
61
+
62
+ ### Added
63
+ - **Device risk events like a device-intelligence event, without raw traits:** `deviceRisk.check` and `getEvent` answers carry `network`, `velocity` and `bot`: the address as the risk engine saw it (country, region, city, never coordinates; the operator; `type` isp, hosting or tor; `timezoneMismatch` when the browser's clock does not fit the address), the visitor's counts (accounts, sign-ups, countries, stored-id resets, identifications in the last hour and day) and one answer to "a bot?" (`detected`, `suspected` or `not_detected`, with reasons). The field the script tag fills may now carry its pointer summary after the requestId: pass it as it came.
64
+
65
+ ### Changed
66
+ - **Silent by default.** Nothing goes to `$stderr` (`Kernel#warn`) on its own any more: the SDK's own problems (a misconfigured guard, a failed `on_challenge`, a relay error or a relay without `get_login_user`) go to `Touchque.logger` (`Touchque.logger = Rails.logger`); nil, the default, logs nothing.
67
+ - `ApprovalMismatchError` no longer names the other person in its message.
68
+
69
+ ### Added
70
+ - **A code typed back on the phone:** `tq.login.request(..., reply: { kind: "code", key:, channel:, target: })`; `Touchque::CodeReply.new_key` and `Touchque::CodeReply.open(private_key, request_id, ct)` open `status(id)["reply"]["ct"]`.
71
+ - **Feature errors:** a feature your organization cannot use here (403 `feature_not_in_plan`, `feature_read_only`, `feature_off`) raises `FeatureNotInPlanError`, `FeatureReadOnlyError` or `FeatureOffError`, all `FeatureError` (`feature`, `feature_reason`) and still `APIError`.
72
+
73
+ First release of the Ruby SDK, at the same surface as the Node, Python, PHP, Go and .NET SDKs 4.1.0.
74
+
75
+ ### Added
76
+ - **Webhook events `DEVICE_RISK_SIGNAL` and `DEVICE_RISK_ALERT`** (`Webhook::EVENT_DEVICE_RISK_*`): a Device risk signal that asks for more (identifiers, signals and the engine's verdict, never a trait), and an alert rule of the workspace that went off. Signed like every event.
77
+ - **Device risk in one line: `tq.device_risk.check(params[:touchque_request_id], event: "sign_in", external_username: email)`** with the script tag (`device.js`, `@touchque/web` 1.6.0), which fills that field. Pass it as it came: nil, empty, made up or expired is still a verdict (`report` missing, unknown, expired, invalid; the signal `device_identification_missing`, a number-matched step-up at most), never an exception. `Touchque::Resources::DeviceRisk::REPORTS`.
78
+ - **Device risk: `tq.device_risk.check(request_id, event:, external_username:, client_ip:)` and `tq.device_risk.get_event(request_id)`** (a paid add-on; 409 `device_risk_off` until the workspace turns it on), and `device_risk_request_id:` (or `device_risk:`) on `login.request` and `risk.check`. The page's requestId comes from `@touchque/web` `deviceRisk.identify`. Every signal only ever asks for more (`"stepUp"`, never a refusal); signals start in shadow.
79
+ - **Pairing a browser by QR code: `tq.qr_pairings`.** `create(key_thumbprint, client_ip:, user_agent:, browser:, os:, ttl_seconds:)` registers a pairing for the browser key your page holds (`@touchque/web` `browserKey.thumbprint()`) and returns `"uri"` (`touchque://pair-browser?...`) to show as a QR code; the nonce in it stays with you (Touchque keeps its hash). `get(pairing_id)` says who scanned it and how near the phone was (`"proximity"`), `refuse(pairing_id, reason:, message:)` tells the phone why your own checks said no. `login.request(..., qr_pairing:)` asks the phone that scanned, once, with number matching. `Resources::QrPairings.uri`, `REFUSAL_REASONS`.
80
+ - **Actions: the `agent.approval` trigger**, before an AI agent's request reaches the phone (`trigger: "agent.approval"`); only tightening counts: `api.deny`, `api.requirePasskey`, `api.stepUp`. See [Actions](https://support.touchque.com/actions).
81
+ - **Webhook reason `user_blocked_agent`** (`Webhook::REASON_USER_BLOCKED_AGENT`): the person rejected an AI agent's request with Block this agent on their phone; the agent may not ask them again, and 2FA is not frozen. `agents.list_blocks` can return the block reason `"phone"`.
82
+ - Request options and resources at the 4.1.0 surface: `challenge_digits:`, `expires_in:`, `client_signals:`, `security_changes:`, `destination:` and `locale:` on login requests; `client_signals:` on risk checks; the linked phone's posture on `auth.get_user`; `offline.totp_verifiers` + `Offline.match_totp_verifier` for a Touchque outage; Touchque Login email and SMS providers (`identity.set_delivery`) and attack protection; Mobile SDK settings with your own APNs key / FCM service account, `androidCerts` and `mobile.test_push`.
83
+ - **AI agents: events for a SIEM.** `agents.list_events(after:, since:, limit:)`: the workspace's agent events (`AGENT_*`, `AGENTS_*`) oldest first, each with its agent, person, approval and case code; keep `"next"` and pass it as `after:`.
84
+ - **AI agents: the workspace's policy and a passkey line.** `agents.workspace_policy` / `set_workspace_policy(policy)`: every agent's floor (an agent's own policy can only be stricter, field by field). `"requirePasskeyAbove" => { "TRY" => "10000.00" }` in any policy: over it the person approves with a passkey on your page (`"requiresPasskey"` on the approval), never on the phone or with a mandate; a background request gets 409 `interactive_required`. `Webhook::EVENT_AGENT_WORKSPACE_POLICY_CHANGED`. A second approver who answers sooner than a person can gets no token (status `blocked`, reason `inhuman_approval`).
85
+ - **Touchque Login: the approving app's name and custom-domain-only sign-in.** `identity.set_approver_app_name(name)`: the phone app the hosted pages tell customers to approve on ("Approve on the Acme app"; `nil` = your brand when the Mobile SDK is set up, else Touchque). `identity.set_custom_domain_only(true)`: serve sign-in only on the primary custom domain ({slug}.login.touchque.com redirects page loads there and refuses other calls; needs a verified primary domain with a working certificate). `identity.check_domain_certificate(domain_id)`: the domain's certificate at the sign-in edge now (`certificate`: pending, active or error).
86
+ - **Cross App Access (ID-JAG) and A2A agent cards.** `CrossAppAccess.request_id_jag` trades a member's ID token for an ID-JAG at the organization's identity provider, `redeem_id_jag` trades it for an access token at the other service (jwt-bearer); `IdJagVerifier` checks one at your own authorization server (typ, issuer, single audience, client, expiry, used once; `first_use` for a shared store). `AgentCard.verify` checks an A2A card's signatures (JWS over the RFC 8785 canonical card; `jku` only on the card's host or `trusted_jku_hosts`), `sign`, `canonical_json`.
87
+ - **`Touchque::Client`** (Ruby 3.1+, standard library only): `auth`, `login` (request, status,
88
+ `verify` with an `on_challenge` block, `consume`, `report_fraud`, recovery codes), `webauthn`,
89
+ `offline` (QR and typed codes, outage verifiers), `risk` (silent sign-ins), `action_types`
90
+ (Second Thought questions), `actions` (Touchque Actions and their secrets), `recovery` (Never
91
+ Locked Out), `assist` (help desk), `agents` (with the token vault, mandates, blocks, the kill
92
+ switch, approval tokens), `identity` (the Touchque Login Management API), `mobile` (the Mobile
93
+ SDK's server side, `push_from_webhook`) and `webhook` (signature, timestamp window, replay cache).
94
+ - `start` / `check` / `complete` and `Touchque::Guard.run`: the two-phase step-up contract shared
95
+ by every Touchque server SDK, with offline codes, silent sign-ins, Vishing Shield, Mirror Alarm
96
+ canaries and "Why?" case codes.
97
+ - `Touchque::RelayHandler` and `Touchque::Rack::Relay`: the routes `@touchque/web` calls, with rate limits.
98
+ - `Touchque::ApprovalProof`, `ApprovalToken`, `AgentKeys` (ES256 and EdDSA), `Touchque::Agent`
99
+ (private_key_jwt + DPoP), `Touchque::OrgAgent` (Touchque SSO agents) and `Touchque::McpAuth`.
100
+ - `Touchque::HostedLogin` (Touchque Login OpenID Connect client) and `Touchque::MyAccount`;
101
+ `Touchque::LoginSession` (encrypted, chunked cookies, readable by the Node SDK's sealSession).
102
+ - **Rails**: a Railtie (`config.touchque`, credentials), `Touchque::Rails::Guard`
103
+ (`require_touchque`), `Touchque::Rails::Webhook` (`verify_touchque_webhook`),
104
+ `Touchque::Rails::HostedLogin` (`/login`, `/callback`, `/logout`, `require_touchque_sign_in`)
105
+ and `Touchque::Rails.login_routes`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Touchque
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # touchque (Ruby)
2
+
3
+ The official Ruby server SDK for [Touchque](https://touchque.com): push approvals with number
4
+ matching, passkeys, silent sign-ins, offline codes, verified webhooks, Touchque Actions, AI agent
5
+ approvals, Touchque Login (hosted sign-in and the Management API) and the server side of the
6
+ Mobile SDK. Rails integration included (a Railtie and controller concerns), plus a Rack relay for
7
+ Sinatra and any Rack app.
8
+
9
+ Ruby 3.1+, no runtime dependencies beyond the standard library (`net/http`, `openssl`, `json`).
10
+
11
+ 📘 Full docs: **[support.touchque.com](https://support.touchque.com)**
12
+
13
+ ## Install
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "touchque"
18
+ ```
19
+
20
+ > **Not on RubyGems yet.** Until it is, take it from GitHub:
21
+ > `gem "touchque", git: "https://github.com/Touchque/touchque-ruby"`.
22
+
23
+ ## Setup
24
+
25
+ Get an API key and secret from your [Touchque dashboard](https://dashboard.touchque.com):
26
+
27
+ ```bash
28
+ export TQ_API_KEY=tq_auth_your_key
29
+ export TQ_API_SECRET=your_api_secret
30
+ ```
31
+
32
+ The secret signs every request: it belongs on your server only, never in a browser or an app.
33
+
34
+ ```ruby
35
+ tq = Touchque.client # from the environment, shared
36
+ tq = Touchque::Client.new(api_key: "tq_...", api_secret: "...") # explicit
37
+ ```
38
+
39
+ In Rails the Railtie reads `config.touchque` (or `credentials.touchque`, then the environment):
40
+
41
+ ```ruby
42
+ # config/application.rb
43
+ config.touchque.api_key = Rails.application.credentials.dig(:touchque, :api_key)
44
+ config.touchque.api_secret = Rails.application.credentials.dig(:touchque, :api_secret)
45
+ ```
46
+
47
+ ## Quick start (Rails)
48
+
49
+ One line per protected action:
50
+
51
+ ```ruby
52
+ class TransfersController < ApplicationController
53
+ include Touchque::Rails::Guard
54
+ require_touchque "SEND_MONEY", only: :create,
55
+ details: -> { { "Amount" => params.require(:amount), "To" => params.require(:to) } }
56
+
57
+ def create
58
+ touchque_approval # only reached once the user approved on their phone
59
+ end
60
+ end
61
+ ```
62
+
63
+ Until the user approves on their phone the action answers
64
+ **`202 {"touchque": step, "token": "..."}`**. Your page shows `step` in its own design (the
65
+ matching number, or a QR code the first time the user links the app) and sends the same request
66
+ again with header `X-Touchque-Token: <token>`; [`@touchque/web`](https://www.npmjs.com/package/@touchque/web)
67
+ does this loop for you. Once approved the action runs **once**. Refusals answer 403 (rejected,
68
+ blocked), 408 (expired), 423 (frozen) or 429 (rate limited) with `Retry-After`.
69
+
70
+ The approving user is `current_user`'s email (else id, else username). For your login action
71
+ (nobody is signed in yet) pass `user: -> { session[:password_verified_user] }`.
72
+
73
+ **The end user's address.** The risk checks (Tor, location, impossible travel) and the location
74
+ shown on the phone use `request.remote_ip`: configure `config.action_dispatch.trusted_proxies`
75
+ behind a load balancer.
76
+
77
+ ## Action types
78
+
79
+ `"SEND_MONEY"` above is an action type: one kind of approval, with the title the phone shows and
80
+ whether it is critical. Define each one once, in the dashboard (**Developers → Action types**)
81
+ or from code:
82
+
83
+ ```ruby
84
+ tq.action_types.define("SEND_MONEY", name: "Send money", critical: true)
85
+ ```
86
+
87
+ The guard refuses what is not ready. A name that is not defined is a setup error
88
+ (`Touchque::ConfigError`, "Unknown action …"): the guard answers 500 with `reason: "misconfigured"`. A type
89
+ turned off in the dashboard answers 403 with `reason: "action_disabled"`, which stops that kind of
90
+ approval at once, without a deploy.
91
+
92
+ ## The three primitives (any framework)
93
+
94
+ ```ruby
95
+ step = tq.start("SEND_MONEY", "jane@acme.com", details: { "Amount" => "250 EUR" })
96
+ # step["state"]: waiting (show step["number"]) | enroll (show step["enroll"]["qrCodeDataUrl"]) | passkey_required | blocked | frozen | rate_limited
97
+ now = tq.check(step["requestId"])
98
+ approval = tq.complete(step["requestId"], "jane@acme.com", "SEND_MONEY", details: { "Amount" => "250 EUR" })
99
+ # uses the approval once, and only for this user, action and amount
100
+ ```
101
+
102
+ `Touchque::Guard.run(tq, user, action, token:, offline:, code:, ...)` is the whole two-phase
103
+ contract for Sinatra, Hanami or plain Rack.
104
+
105
+ ## Waiting in one call
106
+
107
+ ```ruby
108
+ result = tq.login.verify("jane@acme.com", type: "LOGIN", client_ip: request.remote_ip, user_agent: request.user_agent) do |c|
109
+ # Number matching: show the number now; the phone asks for it.
110
+ notify("Pick #{c["challengeCode"]} on your phone") if c["challengeCode"]
111
+ end
112
+ ```
113
+
114
+ `verify` raises `Touchque::RejectedError`, `Touchque::TimeoutError` or
115
+ `Touchque::PasskeyRequiredError` (`reason` "policy": approve with a passkey; "step_up": the user
116
+ tapped Approve during a call and the request now waits for a passkey).
117
+
118
+ ## Relay routes for `@touchque/web`
119
+
120
+ ```ruby
121
+ # config/routes.rb
122
+ mount Touchque::Rack::Relay.new(
123
+ get_user_id: ->(req) { req.session["user_email"] },
124
+ # The user who already passed your password step. Binds /login and the offline routes to them.
125
+ get_login_user: ->(req) { req.session["password_verified_user"] },
126
+ on_authenticated: ->(req, response, info) { req.session["user_email"] = info["externalUsername"] },
127
+ silent_sign_in: true # a known browser with nothing new skips the push
128
+ ) => "/touchque"
129
+ ```
130
+
131
+ Routes: `POST /login`, `/enroll/start`, `GET /enroll/status`, `/offline/challenge|verify|totp`,
132
+ `/passkey/authenticate/options|verify`, `/passkey/login/options|verify`,
133
+ `/passkey/register/options|verify`, `GET /passkey/credentials`, `DELETE /passkey/credentials/:id`.
134
+ Pre-auth routes are rate limited (20 per IP, 5 per username a minute; pass `rate_limit: { store: }`
135
+ for Redis across processes). The offline routes stay off until `get_login_user` is set. Adding a
136
+ passkey for a user with a linked phone waits for the phone first.
137
+
138
+ ## Webhooks
139
+
140
+ ```ruby
141
+ class TouchqueWebhooksController < ActionController::API
142
+ include Touchque::Rails::Webhook
143
+ verify_touchque_webhook
144
+
145
+ def create
146
+ EndSession.call(touchque_webhook["requestId"]) if touchque_webhook["event"] == Touchque::Resources::Webhook::EVENT_APPROVAL_REVOKED
147
+ head :ok
148
+ end
149
+ end
150
+ ```
151
+
152
+ The signature (your API secret over the canonical JSON), the signed timestamp (5 minutes) and the
153
+ `jti` are checked first: a bad one answers 401, a replay 200 without running your action again.
154
+ `Touchque.replay_cache` is in-process by default; with several Puma workers or hosts set one over
155
+ Redis (`check_and_set(jti, ttl_seconds)` with `SET NX EX`). Anywhere else:
156
+ `tq.webhook.verify(raw_body, signature_header, replay_cache: cache)`.
157
+
158
+ ## Silent sign-ins
159
+
160
+ ```ruby
161
+ risk = tq.risk.check(user, browser_proof: request.headers["X-Touchque-Browser"], client_ip: request.remote_ip)
162
+ sign_in(user) if risk["prompt"] == false # a known browser, nothing new: no push
163
+ ```
164
+
165
+ Fails closed: only an answer of exactly `prompt: false` skips the phone. `tq.risk.browsers(user)`
166
+ lists a user's known browsers; `forget_browsers(user)` forgets them (after a password reset).
167
+
168
+ ## Passkeys, offline codes, recovery, help desk
169
+
170
+ - `tq.webauthn`: registration, second factor and passwordless sign-in options/verify, list and delete passkeys.
171
+ - `tq.offline`: QR challenge and typed codes with the phone offline; `totp_verifiers` +
172
+ `Touchque::Resources::Offline.match_totp_verifier` check time-based codes while Touchque is unreachable.
173
+ - `tq.recovery` (Never Locked Out): vouchers, `start` (show `bindUri` as a QR), `status`, `veto`.
174
+ - `tq.assist` (help desk): `open`, `verify` (a 2-digit code the agent reads to the person), `spend`, `unfreeze`, `end`.
175
+ - `Touchque::ApprovalProof.verify(proof, request_id:, type:, details:, expected_public_key:)`: the
176
+ phone's own signature over what it showed ("what you see is what you sign").
177
+
178
+ ## Touchque Actions
179
+
180
+ ```ruby
181
+ tq.actions.deploy("night-alert", trigger: "approval.decided",
182
+ template: { id: "alert", params: { channel: "slack", when: { countries: ["TR"] } } })
183
+ replay = tq.actions.dry_run(trigger: "login.request", code: code, limit: 500)
184
+ ```
185
+
186
+ Plus `test`, `rollback`, `logs`, `metrics`, `templates`, `secrets`. Action code is JavaScript run
187
+ by Touchque in a sandbox, whatever language your server is in. Action types:
188
+ `tq.action_types.define("SEND_MONEY", name: "Send money", critical: true)`.
189
+
190
+ ## AI agents
191
+
192
+ Your server registers agents (public key only) and checks their approvals; the agent signs in with its own key:
193
+
194
+ ```ruby
195
+ keys = Touchque::AgentKeys.generate # where the agent runs; "ES256" or "EdDSA"
196
+ agent = tq.agents.create("Invoice bot", ["PAY_INVOICE"], public_key: keys["publicJwk"])
197
+
198
+ # The agent (TQ_AGENT_ID, TQ_AGENT_KEY): private_key_jwt + DPoP, never your API key.
199
+ bot = Touchque::Agent.new
200
+ approved = bot.require_approval("ayse@acme.com", "PAY_INVOICE", [payment]) { |s| say "Type #{s["challengeCode"]} on your phone" }
201
+
202
+ # Your server, right before it pays:
203
+ tq.agents.redeem_approval(approved["approvalToken"], user: "ayse@acme.com", authorization_details: payment)
204
+ ```
205
+
206
+ Also: mandates (`request_mandate`), the token vault (`tq.agents.vault`, `bot.get_connection_token`),
207
+ policies, blocks, the kill switch, offline token checks (`verify_approval_token`).
208
+ `Touchque::OrgAgent` is an organization's agent on Touchque SSO (DPoP tokens for one app, token
209
+ exchange, CIBA, the token vault, shared accounts; GitHub Actions and Kubernetes tokens instead of
210
+ a stored key). `Touchque::McpAuth` protects an MCP server whose authorization server is Touchque SSO.
211
+
212
+ ### Cross App Access (ID-JAG) and A2A agent cards
213
+
214
+ An organization's identity provider (Touchque SSO) can let one of its apps reach another service for a member with no consent screen (Cross App Access, ID-JAG, an open IETF draft): the app trades the member's ID token for an ID-JAG, then the ID-JAG for an access token at the other service. If you run an authorization server, the verifier checks an ID-JAG (issuer, single audience, your client, expiry, `typ`) and uses it once. Before your agent trusts another agent's A2A card, check its signatures (A2A 1.0 §8.4: JWS over the RFC 8785 canonical card); keys named by `jku` are only fetched from the card's own host or hosts you trust.
215
+
216
+ ```ruby
217
+ jag = Touchque::CrossAppAccess.request_id_jag(issuer: "https://sso.touchque.com", client_id: client_id, client_secret: secret,
218
+ id_token: id_token, audience: "https://auth.files.example")
219
+ tokens = Touchque::CrossAppAccess.redeem_id_jag(token_endpoint: "https://auth.files.example/token", client_id: "acme",
220
+ client_secret: files_secret, assertion: jag["assertion"])
221
+
222
+ # Your own authorization server (jwt-bearer grant):
223
+ claims = Touchque::IdJagVerifier.new(issuer: "https://sso.touchque.com", audience: "https://auth.files.example")
224
+ .verify(params[:assertion], client_id: authenticated_client_id)
225
+
226
+ Touchque::AgentCard.verify(card_json, card_url: "https://agent.example/.well-known/agent-card.json")
227
+ ```
228
+
229
+ ## Shared Signals (security events from Touchque SSO)
230
+
231
+ When something happens to a member's account after sign-in (sessions revoked, credentials changed or compromised, a device out of compliance, the account disabled), Touchque SSO sends a Security Event Token (OpenID Shared Signals Framework 1.0) to the receivers the organization's admin adds (Organization > Continuous access). Your app ends the person's sessions or asks them to sign in again. The verifier reads the keys from the issuer's `/.well-known/ssf-configuration` and checks the signature, `typ`, issuer, audience and the one event. A SET does not expire: keep the `jti`s you handled and skip a repeat.
232
+
233
+ ```ruby
234
+ SSF = Touchque::SecurityEventVerifier.new(issuer: "https://sso.touchque.com", audience: "https://app.example.com")
235
+
236
+ # Push (RFC 8935): the SET is the request body (application/secevent+jwt).
237
+ def create
238
+ event = SSF.verify(request.raw_post) # "name": session-revoked, credential-change, account-disabled...
239
+ end_sessions_of(event["subject"]["email"]) if event["name"] == "session-revoked"
240
+ head :accepted
241
+ rescue Touchque::SecurityEventError => e
242
+ render json: { err: e.error, description: e.message }, status: e.error == "temporarily_unavailable" ? 503 : 400
243
+ end
244
+
245
+ # Poll (RFC 8936): acknowledge what you handled on the next call.
246
+ ack = []
247
+ loop do
248
+ out = Touchque::SecurityEvents.poll(url: poll_url, token: stream_token, ack: ack, max_events: 50, return_immediately: true)
249
+ ack = out["sets"].map do |jti, set|
250
+ handle(SSF.verify(set))
251
+ jti
252
+ end
253
+ break if ack.empty? && !out["more_available"]
254
+ end
255
+ ```
256
+
257
+ `verify` returns `"jti"`, `"issuer"`, `"audience"`, `"issued_at"`, `"txn"` (the case code of Touchque's decision), `"subject"`, `"type"`, `"name"` and `"claims"`, or raises `Touchque::SecurityEventError` whose `error` is the RFC 8935 code to answer (`invalid_request`, `invalid_key`, `invalid_issuer`, `invalid_audience`; `temporarily_unavailable` when the issuer's keys could not be read). Report a SET you refuse while polling with `set_errs: { jti => { "err" => e.error, "description" => e.message } }`.
258
+
259
+ ## Touchque Login (hosted sign-in)
260
+
261
+ ```ruby
262
+ # config/application.rb
263
+ config.touchque.login = { domain: "acme.login.touchque.com", client_id: "...", client_secret: "...",
264
+ scope: "openid profile email offline_access" }
265
+
266
+ # config/routes.rb
267
+ Touchque::Rails.login_routes(self) # GET /login, /callback, /logout
268
+
269
+ class AccountController < ApplicationController
270
+ include Touchque::Rails::HostedLogin
271
+ before_action :require_touchque_sign_in
272
+ def show = render(json: touchque_session["user"])
273
+ end
274
+ ```
275
+
276
+ The session lives in encrypted cookies (AES-256-GCM, split when large, no session store needed;
277
+ keyed from `config.touchque.session_secret` or `secret_key_base`) and is refreshed before the
278
+ access token expires. `Touchque::HostedLogin` is the OpenID Connect client underneath (PKCE, ID
279
+ and access token checks, refresh, token exchange, CIBA, the token vault, client credentials,
280
+ logout); your API checks access tokens with `verify_access_token(token, audience:, required_scopes:)`.
281
+ `login.my_account(access_token)` is the customer's own account API. The Management API is
282
+ `tq.identity` (users, roles, organizations, apps, connections, imports and exports, attack
283
+ protection, email and SMS providers).
284
+
285
+ ## Mobile SDK (your own app approves)
286
+
287
+ ```ruby
288
+ e = tq.mobile.create_enrollment(user_id) # the app spends e["enrollmentToken"]
289
+ push = Touchque::Resources::Mobile.push_from_webhook(event) # a MOBILE_PUSH webhook → APNs payload / FCM data
290
+ ```
291
+
292
+ Or give Touchque your APNs key / FCM service account (`update_settings(apns:, fcm:)`) and it pushes for you.
293
+
294
+ ## Pairing a browser by QR code
295
+
296
+ Your page shows a QR code, the person scans it with the Touchque app, and the phone that scanned is the one asked to approve, with number matching. Your page sends the thumbprint of its browser key (`@touchque/web` `browserKey.thumbprint()`); the nonce in the QR never leaves your server (Touchque keeps its hash).
297
+
298
+ ```ruby
299
+ p = tq.qr_pairings.create(key_thumbprint, client_ip: request.remote_ip, user_agent: request.user_agent)
300
+ # show p["uri"] (touchque://pair-browser?...) as a QR code; poll:
301
+ now = tq.qr_pairings.get(p["pairingId"]) # waiting -> scanned -> confirmed
302
+ tq.login.request(now["externalUsername"], qr_pairing: p["pairingId"]) if now["state"] == "confirmed"
303
+ # or: tq.qr_pairings.refuse(p["pairingId"], reason: "device_not_approved", message: "Ask IT to approve this computer.")
304
+ ```
305
+
306
+ `get` says `scanned` / `confirmed` once a phone holding one of your users' accounts read it (with `proximity`: same network, same country), `reported` when the person said someone told them to scan it (their 2FA freezes). `refuse` tells the phone why your own checks said no.
307
+
308
+ ## A code typed back on the phone
309
+
310
+ When a sign-in needs a code another service sent the person (a text message, WhatsApp, e-mail), ask them to type it on the phone instead. The phone encrypts it to a one-time key you made for this request: Touchque only ever holds the ciphertext. Such a request lives 5 minutes.
311
+
312
+ ```ruby
313
+ key = Touchque::CodeReply.new_key # keep key["privateKey"] for this request
314
+ sent = tq.login.request(user, reply: { kind: "code", key: key["publicKey"], channel: "sms", target: "+**********26" })
315
+ status = tq.login.status(sent["requestId"]) # once CONFIRMED
316
+ code = status["reply"] && Touchque::CodeReply.open(key["privateKey"], sent["requestId"], status["reply"]["ct"])
317
+ ```
318
+
319
+ ## Safe Guard (Safe transactions approved on the phone)
320
+
321
+ TouchqueSafeGuard is a transaction guard for a Safe: a transaction runs only with approvals signed by the P-256 keys on its approvers' phones. You send the raw transaction; the phone decodes it itself, shows its own rows and signs what it computed with a key only its biometrics unlock. Delegatecalls, changes to the Safe and proxy upgrades are governance: scheduled, time-locked and vetoable. A phone without that key yet gets `409 crypto_key_missing`.
322
+
323
+ ```ruby
324
+ target = { "chainId" => 1, "safe" => safe, "guard" => guard,
325
+ "tx" => { "to" => to, "value" => 0, "data" => data, "operation" => 0, "nonce" => 42 } }
326
+ sent = tq.safe_guard.request("treasurer@acme.com", "safe_payment", target.merge( # an action type from your dashboard
327
+ "config" => { "threshold" => 2, "govThreshold" => 3, "govDelay" => 172_800, "exitDelay" => 259_200 },
328
+ "batchTargets" => [multi_send_call_only],
329
+ "approverIndex" => 0 # this approver's index in the guard
330
+ ))
331
+ done = tq.safe_guard.wait_for_approval(sent["requestId"]) # 5 minutes at most
332
+ approval = done["safeTx"]["approval"]
333
+
334
+ # Check it yourself: your own transaction, the approver's key from the guard on chain, and what
335
+ # you are about to do with it: "approve" runs this transaction, "schedule" starts a governance change.
336
+ purpose = done["safeTx"]["purpose"] == "schedule" ? "schedule" : "approve"
337
+ check = Touchque::SafeApproval.verify(target, approval, approver_key, purpose: purpose, approver_index: 0)
338
+ raise "approval refused: #{check["reason"]}" unless check["valid"]
339
+
340
+ if purpose == "approve"
341
+ signatures = Touchque::SafeApproval.with_approvals(owner_signatures, [approval]) # execTransaction(..., signatures)
342
+ else
343
+ data = Touchque::SafeApproval.schedule_call(safe, check["governanceHash"], [approval]) # a call to the guard; runs after govDelay
344
+ end
345
+ ```
346
+
347
+ `Touchque::SafeApproval.verify` makes the guard's check off chain, so a co-signer or your own server never has to trust Touchque: it computes the Safe transaction hash (EIP-712) and the governance hash from the raw fields, rebuilds the 149-byte message the phone signed and checks the P-256 signature. `purpose:` defaults to `"approve"`: a schedule approval covers no Safe nonce or gas fields, so it is accepted only when you pass `purpose: "schedule"`, never as permission to execute. It refuses a high `s`, an approval that expired or is valid for more than a day, another approver index, and, when you pass `rows:` or `display_digest:` from your own decoding, rows the phone did not show. `"reason"` is `approval_malformed`, `public_key_invalid`, `approver_index_mismatch`, `approval_expired`, `rows_differ`, `high_s` or `signature_invalid`. `safe_tx_hash`, `governance_hash`, `message` and `display_digest` are public too.
348
+
349
+ ## Errors, case codes and retries
350
+
351
+ | Error | When |
352
+ | --- | --- |
353
+ | `Touchque::APIError` | Touchque answered with an error: `status`, `code` (`device_not_linked`, `already_used`...), `case_code`, `retry_after`, `data` |
354
+ | `Touchque::NetworkError` | the request never reached Touchque (safe to retry) |
355
+ | `Touchque::RejectedError`, `TimeoutError`, `PasskeyRequiredError` | `verify` outcomes |
356
+ | `Touchque::WebhookSignatureError`, `WebhookReplayError` | webhooks |
357
+ | `Touchque::ApprovalError`, `ApprovalMismatchError` | agent approvals |
358
+ | `Touchque::ConfigError` | a missing key, an unknown action type, a wrong argument |
359
+
360
+ A feature your organization cannot use here raises a `Touchque::FeatureError` (still an `APIError`, status 403) with `feature` (`device_risk`, `ai_agents`…) and `feature_reason`: `FeatureNotInPlanError`, `FeatureReadOnlyError` or `FeatureOffError` — not in its plan (an owner or admin adds it in the Touchque dashboard), being turned off (reads work for 14 days, changes are refused) or switched off on this Touchque deployment.
361
+
362
+ **"Why?" case codes.** Refusals and pushes carry a 6-character case code (`K7P-2QX`): show it
363
+ ("Tell your support team: K7P-2QX"); your team opens the whole decision by it in the dashboard.
364
+
365
+ **Retries.** Every request is signed with a one-time nonce, so the SDK never replays one. Retry
366
+ `Touchque::NetworkError` at the call site; on 429 / 423 wait `retry_after` seconds. The client
367
+ never follows redirects and refuses plain `http://` except for localhost.
368
+
369
+ ## Logging
370
+
371
+ The SDK writes nothing to `$stderr` on its own. To see its own problems (a misconfigured guard, a failed `on_challenge`, a relay without `get_login_user`), set a logger: `Touchque.logger = Rails.logger` (or any `Logger`). Lines carry an error's class and message, never the request.
372
+
373
+ ## Development
374
+
375
+ ```bash
376
+ bundle install
377
+ bundle exec rake test # needs Node.js: the tests run against testing/fake-touchque-api.mjs and fixtures/
378
+ gem build touchque.gemspec
379
+ ```
380
+
381
+ ## License
382
+
383
+ MIT