Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/rail0/resources/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions lib/rail0/resources/payments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
27 changes: 27 additions & 0 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading