Skip to content

Share the browser half from shared/, and drop templated views - #2

Merged
evsinev merged 1 commit into
mainfrom
shared-frontend-and-config-js
Sep 11, 2026
Merged

Share the browser half from shared/, and drop templated views#2
evsinev merged 1 commit into
mainfrom
shared-frontend-and-config-js

Conversation

@evsinev

@evsinev evsinev commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Groundwork for php-js, python-flask-js, ruby-sinatra-js and java-springboot-js. The
current scheme does not survive them: six apps × five files is thirty copies kept identical by
hand, and the one line that was allowed to differ — the config injection — would need six
template dialects and six exceptions in the CI diff. Both problems are removed here, before any
new app is written.

shared/ is the source of truth

shared/public/styles.css  status.js  checkout.js
shared/views/checkout.html  result.html
scripts/sync-shared.sh

The copies stay committed, so nothing about an app directory changes: cd go-js && go run .
still works with no pre-step and the release archives are the same shape. A frontend change is
now: edit shared/, run the script, commit both.

CI stops diffing pairs of files and instead runs the script and fails on git diff --exit-code.
That check has no per-file list and no exceptions, so adding a language costs one line in the
script and nothing in CI
— the two arrays at the top of sync-shared.sh are the whole
configuration. nextjs/ is in the second array: its scripts and views are React components, so
it takes only the stylesheet, and that one file is what keeps the three from looking different.

Every shared file gained a header line saying where the source of truth is, so it travels into
the copies — which is exactly where someone about to edit the wrong file will read it.

No templated HTML anywhere

checkout.html and result.html are now served byte for byte as they sit on disk. The server
generates config.js instead, and the page loads it with an ordinary <script src>:

Route Body
GET {prefix}/config.js window.CONFIG = {basePath, sdkUrl, endpointId, amount, currency, ephemeralTicket}
GET {prefix}/result-config.js the same, without sdkUrl and without a ticket

Two endpoints rather than one so the result page does not mint a payment ticket it will never
use. Both are Cache-Control: no-store — the ticket is single-use.

go-js loses html/template entirely; nodejs-express-js loses renderPage and the
CONFIG_LINE regex, and with it the footgun its own CLAUDE.md documented, where a plain
replace('__CONFIG__', …) hit the explanatory comment instead of the script tag.

The gateway-failure path moved with it. A failed ticket call used to make GET / answer
502. config.js has to stay valid JavaScript, so it now emits error in place of
ephemeralTicket, and checkout.js treats a missing ticket as terminal: the reason goes to the
console, the payer gets copy they can act on, and the button says "Reload to try again" instead
of inviting a click that cannot work. That last part also fixes a pre-existing bug — a failed
SDK script load used to enable the button.

One 3DS return for every language

redirect_url now points at {prefix}/result/callback in all three apps:

  • POST {prefix}/result/callback verifies sha1(status + orderid + merchant_order + MERCHANT_CONTROL), answers 403 on a mismatch, and otherwise 303s to {prefix}/result
    with those same four parameters in the query;
  • GET {prefix}/result verifies them again with the same function before serving anything.

Forwarding the gateway's own control is what makes this work with no second signing scheme and
no server-side state: the browser carries the identifiers but cannot forge them, and an edited
URL gets a 403 rather than a page that polls somebody else's order. result.html reads the
order from location.search, so it needs nothing injected either.

This also removes the special case nextjs/ had: it drops the httpOnly cookie and
return-cookie.ts and does exactly what the other two do. The one remaining difference is
documented — a React page cannot set a status code without the experimental forbidden(), so a
failed check there renders the empty page instead of a 403. Refusing to show the order is the
part that matters.

nodejs-express-js/deploy/nginx.conf needed a fix that is easy to miss: its \.js$ rule served
assets off the disk and would have swallowed config.js and result-config.js, breaking every
payment. It now names the three real static files one by one, with a comment saying why.

Verification

All three apps were run against the sandbox and compared, not just built.

  • config.js on go-js and nodejs-express-js: 200, text/javascript, no-store, the same
    six keys, a fresh 731-byte ticket per request. The checkout page renders identically to
    before, the three iframes are created from it, the theme toggle still reaches them, and the
    pay button enables on onReady.
  • The 3DS return, on all three: a correctly signed POST gives 303 to the identical URL in all
    three (the Go redirect builds the query by hand rather than with url.Values.Encode, which
    sorts, so the payer sees the same address everywhere); a tampered POST gives 403; a tampered
    GET /result gives 403 in Go and Express and the empty page in Next; a bare GET /result
    shows "nothing to show". Following a valid signed URL polls the real order and renders the
    full approved panel — amount, VISA •••• 4448, ANNA WEBER, reference, approval code.
  • The dead-gateway path, with API_URL pointed at a closed port: config.js still returns
    valid JavaScript with error, the page shows "This page could not be prepared for a payment",
    all three fields take the error ring, the button is disabled, the SDK is never loaded, and the
    console carries no ephemeralTicket: fetch failed.
  • ./scripts/sync-shared.sh && git diff --exit-code on a clean tree — what CI runs — passes.
  • gofmt -l, go vet, go build (in a golang:1.24 container: Go is not installed on this
    machine); npm ci, node --check, npm run build; yarn lint, yarn build.

Two gaps worth stating. Entering a card by hand into the cross-origin iframes stopped working
through the browser automation partway through, so the tokenize → Sale → status leg was not
re-driven by hand on go-js this time; it is unchanged code, and the result panel was verified
against a real approved order from the gateway. And the local .env still has no genuine
MERCHANT_CONTROL, so the callback checks were exercised with a locally computed signature, as
in the previous PR.

@evsinev
evsinev merged commit eca9448 into main Sep 11, 2026
8 checks passed
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