diff --git a/README.md b/README.md index 94a169f..4768241 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Lower-level building blocks are also available: nonce = client.auth.nonce # POST /auth/nonces session = client.auth.verify(message: siwe_msg, signature: sig) # POST /auth client.auth.logout # POST /auth/logout +client.auth.revoke_all # POST /auth/revoke_all ``` `logout` revokes **the token this client carries**, not every session for the address — @@ -187,6 +188,15 @@ denylist **fails open** by design (a store outage must not sign out the whole pl so `{ revoked: false }` means the token is *still usable* until it expires, and the caller should treat its own copy as compromised rather than assume the session is gone. +`revoke_all` is the other question, and `logout` cannot answer it: an address with five +live sessions would need five tokens you do not have. This is per **address** and reaches +the ones you never saw — including any an attacker is holding — which makes it the call +for a key you no longer trust. The gateway records a cutoff **instant** rather than +enumerating tokens, so a session minted a moment before the call is refused by its own +`iat`; that is what makes it durable where a denylist is not. The returned `cutoff` is +the field worth logging: it says exactly which sessions died, which `revoked: true` +cannot. + ## Catalog (public) ```ruby @@ -251,6 +261,11 @@ client.payments.create(params, idempotency_key: nil) # or keyword fields client.payments.get(id) client.payments.list(status: "authorized", disputed: false, chain_id: 84532, sort: "-created_at") client.payments.transactions(id, operation: "capture") +client.payments.redrive(id, transaction_id) # re-enqueue a stuck broadcast +# Offer `redrive` on the row's `redrivable` flag — the same predicate the gateway guards +# the route with — and not on `status == "pending"`: a pending row holding no signed +# transaction is not redrivable, and there the next step is submitting the signature, not +# retrying a send that never happened. # Each row carries the on-chain gas data (gas_used, effective_gas_price, gas_cost) and # `sender`: the address the gateway RECOVERED from the signature at submit. Null for a # report-by-hash submit, where the wallet broadcast it itself and the gateway held no diff --git a/lib/rail0/resources/auth.rb b/lib/rail0/resources/auth.rb index 7209d96..de97c1f 100644 --- a/lib/rail0/resources/auth.rb +++ b/lib/rail0/resources/auth.rb @@ -53,6 +53,25 @@ def logout http.post("/auth/logout", {}) end + # End EVERY session of the calling address (POST /auth/revoke_all). + # + # The answer to a key you no longer trust, and {#logout} cannot be that answer: it + # is per TOKEN, so an address with five live sessions needs five tokens the caller + # does not have. This is per ADDRESS and reaches the ones it never saw — including + # any an attacker is holding. + # + # The gateway records a cutoff INSTANT rather than enumerating tokens, so a session + # minted a moment before the call is refused by its own `iat`. That is what makes it + # durable where a denylist is not: there is nothing to enumerate and nothing to miss. + # + # `cutoff` is the field worth logging. It says exactly which sessions died, which + # `revoked: true` cannot. + # + # @return [Hash] { revoked: true|false, cutoff: "2026-08-27T21:00:00Z" } + def revoke_all + http.post("/auth/revoke_all", {}) + end + # Perform the full SIWE authentication flow: # 1. Fetch a nonce # 2. Build an EIP-4361 message via siwe-rb diff --git a/lib/rail0/resources/payments.rb b/lib/rail0/resources/payments.rb index 58ffd7d..f4eb549 100644 --- a/lib/rail0/resources/payments.rb +++ b/lib/rail0/resources/payments.rb @@ -98,6 +98,29 @@ def transactions(id, operation: nil, status: nil, sort: nil, page: nil, per_page http.get_list("/payments/#{id}/transactions#{query}") end + # Re-enqueue a stuck broadcast + # (POST /payments/{id}/transactions/{transaction_id}/redrive). + # + # For the one shape a retry can fix: a transaction that is `pending` and whose + # SIGNED bytes the gateway already holds — prepared and signed, never landed on the + # chain (a worker that died between the two, a queue drained by hand). Nothing about + # the payment changes; the same bytes go back to the broadcaster. + # + # Offer this on the transaction's `redrivable` flag, which is the same predicate the + # gateway guards the route with — not on `status == "pending"`. A pending row holding + # no signed transaction is NOT redrivable, and there the next step is submitting the + # signature, not retrying a send that never happened. + # + # The transaction id is resolved THROUGH the payment, so one belonging to another + # payment answers 404 rather than redriving someone else's row. + # + # @param id [String] Payment UUID or rail0_id. + # @param transaction_id [String] The transaction row to redrive. + # @return [Hash] The transaction, re-enqueued. + def redrive(id, transaction_id) + http.post("/payments/#{id}/transactions/#{transaction_id}/redrive", {}) + end + # Submit the payer's EIP-712 signature (PUT /payments/{id}/sign). # @param id [String] Payment UUID or rail0_id. # @param params [Hash] { signature: "0x…" } (65-byte 0x-prefixed hex). diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 03e632d..dcf9fa6 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -128,6 +128,33 @@ def stub_patch(path, body, status: 200) end end + describe "auth.revoke_all" do + # The distinction that makes this endpoint worth having: logout ends ONE token, this + # ends every session of the address — including the ones the caller has never seen, + # which is the whole case for a leaked key. + it "POSTs /auth/revoke_all and returns the cutoff" do + stub = stub_post("/auth/revoke_all", { revoked: true, cutoff: "2026-08-27T21:00:00Z" }) + result = client.auth.revoke_all + expect(stub).to have_been_requested + expect(result[:revoked]).to be(true) + # The cutoff, not just the boolean: it says exactly which sessions died. + expect(result[:cutoff]).to eq("2026-08-27T21:00:00Z") + end + end + + describe "payments.redrive" do + # The gateway resolves the transaction id THROUGH the payment, so the path must carry + # both — a client that built it from the transaction id alone would look correct until + # it retried a stranger's broadcast. + it "POSTs the payment-scoped redrive path" do + stub = stub_post("/payments/#{PAYMENT_ID}/transactions/tx-1/redrive", + { id: "tx-1", operation: "capture", status: "pending" }) + result = client.payments.redrive(PAYMENT_ID, "tx-1") + expect(stub).to have_been_requested + expect(result[:id]).to eq("tx-1") + end + end + describe "auth.logout" do it "POSTs /auth/logout and returns the revocation outcome" do stub = stub_post("/auth/logout", { revoked: true })