Skip to content

Add a Ruby (Sinatra) example - #7

Merged
evsinev merged 5 commits into
mainfrom
feat/ruby-example
Sep 11, 2026
Merged

Add a Ruby (Sinatra) example#7
evsinev merged 5 commits into
mainfrom
feat/ruby-example

Conversation

@evsinev

@evsinev evsinev commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

A sixth example: the same payment, on Sinatra. ruby-sinatra-js/ implements exactly the seven
routes of the server contract, with the same status codes, the same OAuth base string and the same
sha1 callback 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 are net/http, and the tests are minitest — all standard
library.

settings.rb   environment variables, validation and the key
oauth.rb      OAuth 1.0a RSA-SHA256 signing
control.rb    the 3DS callback checksum
paynet.rb     the three gateway calls
app.rb        the Sinatra::Base subclass, the routes, and the generated config.js
config.ru     what puma runs; `ruby app.rb` runs the same class

Port 3005, prefix /hosted-fields-examples-ruby. Sinatra has no route prefix, so BASE_PATH is
interpolated into each pattern rather than mapped in config.ru — that way ruby app.rb and
puma config.ru serve the very same URLs.

What the sixth language cost

  • scripts/sync-shared.sh: one line;
  • e2e-tests/src/apps.ts: one registry entry plus a rubyBinary() locator. No spec changes,
    and playwright.config.ts needed nothing this time — the startTimeout field added with the
    Flask example already covers a slow first start;
  • nothing in the other five apps.

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_origin answers any cross-origin POST with a 403, and the gateway POSTs the 3DS
    return 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 came
    from. This is what the e2e 3DS spec would have caught.
  • absolute_redirects builds the 303's Location from the request's own scheme and host,
    so behind a TLS-terminating proxy the payer comes back from the bank over http://. Every other
    example emits a relative Location; now so does this one.
  • frame_options sends X-Frame-Options: SAMEORIGIN, which contradicts this app's own
    frame-ancestors 'none' and which no other example sends. The CSP directive is the stronger of
    the two and is what the repository standardises on.
  • static serves public/ at the root, outside BASE_PATH, putting every asset on two URLs.
    It is off, and public/ goes out through an allowlist route declared last — which is also what
    keeps views/, .env, Gemfile and the sources unreachable.

Two smaller Ruby traps, both in ruby-sinatra-js/CLAUDE.md: the signature encoder is
ERB::Util.url_encode and never CGI.escape (form encoding, + for a space), and Net::HTTP
needs both open_timeout and read_timeout named or the half left out waits forever. On the
happier side, Net::HTTP does 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 -c over every file and ruby test/all.rb — 13 tests, 33 assertions, on the same OAuth
    base-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 that bundle exec hides, and
    the Gemfile should not name it because the tests touch no gem at all.
  • cd e2e-tests && npm test60 passed, ten per app. The emulator verifies the OAuth
    signature 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/ via BUNDLE_PATH, so the suite still leaves every app directory as it found
    it.
  • ./scripts/sync-shared.sh && git diff --exit-code clean; the five existing apps still build and
    test untouched.
  • By hand against the emulator: a Sale carrying amount, currency and redirect_url in its
    browser object was charged 12.34 USD to the server's own return URL, all three injections
    dropped; the sale-error scenario came back 200 with the gateway's message rather than a
    502; a forged control gave 403 on both /result and /result/callback; and
    {prefix}/app.rb, /.env, /Gemfile, /config.ru, /views/checkout.html and /test/all.rb
    all answered 404.

One note for anyone running this locally: macOS ships Ruby 2.6 as /usr/bin/ruby and Sinatra 4
refuses it, so apps.ts locates a modern Ruby the way it already locates Go (RUBY_BIN, then
~/opt/ruby/bin/ruby, then PATH) and puts it first on PATH for the app — otherwise bundle's
shebang picks up 2.6 and bundler exits 1.

No key, no MERCHANT_CONTROL and no real ENDPOINT_ID anywhere: .env.example and the deploy
template carry placeholders only.

@evsinev
evsinev merged commit fb45b70 into main Sep 11, 2026
14 checks passed
@evsinev
evsinev deleted the feat/ruby-example branch September 11, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant