Add a Ruby (Sinatra) example - #7
Merged
Merged
Conversation
added 5 commits
September 11, 2026 10:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A sixth example: the same payment, on Sinatra.
ruby-sinatra-js/implements exactly the sevenroutes of the server contract, with the same status codes, the same OAuth base string and the same
sha1callback checksum as the other five.Three gems and none of them for the integration: sinatra is the subject, puma and
rackup are what serve it (Sinatra 4 runs on Rack 3 and brings no handler of its own). The
signing is
openssl, the calls arenet/http, and the tests areminitest— all standardlibrary.
Port 3005, prefix
/hosted-fields-examples-ruby. Sinatra has no route prefix, soBASE_PATHisinterpolated into each pattern rather than
mapped inconfig.ru— that wayruby app.rbandpuma config.ruserve the very same URLs.What the sixth language cost
scripts/sync-shared.sh: one line;e2e-tests/src/apps.ts: one registry entry plus arubyBinary()locator. No spec changes,and
playwright.config.tsneeded nothing this time — thestartTimeoutfield added with theFlask example already covers a slow first start;
Four defaults that had to be turned off, each of which fails quietly
Sinatra and rack-protection are sensible in general and wrong for this page in four specific ways.
Every one of them was found by running the thing, not by reading:
http_originanswers any cross-origin POST with a403, and the gateway POSTs the 3DSreturn from its own origin — so the callback would have been rejected before its checksum was
ever read. The callback is authenticated by
control, a shared secret, not by where it camefrom. This is what the e2e 3DS spec would have caught.
absolute_redirectsbuilds the303'sLocationfrom the request's own scheme and host,so behind a TLS-terminating proxy the payer comes back from the bank over
http://. Every otherexample emits a relative
Location; now so does this one.frame_optionssendsX-Frame-Options: SAMEORIGIN, which contradicts this app's ownframe-ancestors 'none'and which no other example sends. The CSP directive is the stronger ofthe two and is what the repository standardises on.
staticservespublic/at the root, outsideBASE_PATH, putting every asset on two URLs.It is off, and
public/goes out through an allowlist route declared last — which is also whatkeeps
views/,.env,Gemfileand the sources unreachable.Two smaller Ruby traps, both in
ruby-sinatra-js/CLAUDE.md: the signature encoder isERB::Util.url_encodeand neverCGI.escape(form encoding,+for a space), andNet::HTTPneeds both
open_timeoutandread_timeoutnamed or the half left out waits forever. On thehappier side,
Net::HTTPdoes not raise on a 4xx, so the "a 4xx with a JSON body is a decline"rule needed no special handling here — unlike Python's
urlopen.Checks
ruby -cover every file andruby test/all.rb— 13 tests, 33 assertions, on the same OAuthbase-string and callback-checksum vectors the Go, Node, PHP and Python suites pin. Added as a CI
job. Deliberately not
bundle exec: minitest is a bundled gem thatbundle exechides, andthe
Gemfileshould not name it because the tests touch no gem at all.cd e2e-tests && npm test— 60 passed, ten per app. The emulator verifies the OAuthsignature against the generated public key and rejects a Sale whose amount is not the server's,
so the Ruby signing and the server-owned amount are checked on the wire. Gems land in
e2e-tests/.tmp/viaBUNDLE_PATH, so the suite still leaves every app directory as it foundit.
./scripts/sync-shared.sh && git diff --exit-codeclean; the five existing apps still build andtest untouched.
amount,currencyandredirect_urlin itsbrowserobject was charged 12.34 USD to the server's own return URL, all three injectionsdropped; the
sale-errorscenario came back200with the gateway's message rather than a502; a forgedcontrolgave403on both/resultand/result/callback; and{prefix}/app.rb,/.env,/Gemfile,/config.ru,/views/checkout.htmland/test/all.rball answered
404.One note for anyone running this locally: macOS ships Ruby 2.6 as
/usr/bin/rubyand Sinatra 4refuses it, so
apps.tslocates a modern Ruby the way it already locates Go (RUBY_BIN, then~/opt/ruby/bin/ruby, thenPATH) and puts it first onPATHfor the app — otherwisebundle'sshebang picks up 2.6 and bundler exits 1.
No key, no
MERCHANT_CONTROLand no realENDPOINT_IDanywhere:.env.exampleand the deploytemplate carry placeholders only.