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
41 changes: 41 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Settings for docker-compose.yml — every service reads this one file.
#
# cp .env.example .env
#
# Note what is NOT here: PORT, LISTEN_ADDR and BASE_PATH. Each example already defaults to its
# own port and its own prefix, and all nine share one environment here — a PORT in this file
# would be handed to all of them at once. Leave them out and the defaults do the right thing.
#
# This is not one of the per-app .env.example files. Do not copy one of those here.

# The port nginx publishes, and the origin the payer's browser reaches the examples at — nginx,
# not any app's own port. Each example appends its own BASE_PATH to it to build the 3DS return
# URL, so the two lines must name the same port. Change both if 8080 is already taken.
HTTP_PORT=8080
PUBLIC_URL=http://localhost:8080

# Both are given to you with your credentials. API_URL is the gateway root, SDK_URL must be on
# that same host: the SDK refuses to run when it is served from the merchant origin.
# API_URL=https://<gateway-host>/paynet
# SDK_URL=https://<gateway-host>/assets/libs/hosted-fields/latest/index.js?segment=paynet
API_URL=
SDK_URL=

# Credentials provided by the gateway on onboarding. Seven of the nine examples refuse to start
# without them, which is deliberate — an example that served a payment page it cannot take a
# payment with would be worse.
ENDPOINT_ID=
MERCHANT_LOGIN=
# Shared secret the gateway signs its callbacks with, checked on /result
MERCHANT_CONTROL=

# The RSA private key is a file, not a variable: put it at ./private_key.pem and compose mounts
# it read-only into every service at the path below. *.pem is git-ignored repository wide.
#
# It must be PKCS#8 — the JDK reads nothing else:
# openssl pkcs8 -topk8 -nocrypt -in your_key.pem -out private_key.pem
PRIVATE_KEY_PATH=/run/secrets/private_key.pem

# Demo order
ORDER_AMOUNT=1.00
ORDER_CURRENCY=USD
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ jobs:
# other three, on a tag, each on the runner native to it
- run: cargo build --release

# And once more on the version Cargo.toml declares, because every step above runs on
# whatever `stable` is today. A language feature stabilised after 1.85 passes all of them
# and then fails for anyone who took "Rust 1.85 or newer" at its word — which is exactly
# what a let chain in src/main.rs did until it was found by building the example in a
# pinned container rather than on a runner.
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.85'
- run: cargo +1.85 build --release

dotnet:
name: dotnet-aspnetcore-js
runs-on: ubuntu-latest
Expand Down
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,40 @@ Five things about it are load-bearing:

Running it rebuilds `nextjs/.next`, because `basePath` is baked in at build time.

## All nine at once, in `docker-compose.yml`

`docker compose up --build` builds nine images and runs them behind one nginx on `:8080`. It
exists for two reasons, and the second is the one to protect:

- no toolchain to install, which is what `e2e-tests/` still needs nine of;
- **it is the only thing that ever executes the nine `deploy/nginx.conf`.** Those files ship in
the release archives and the READMEs tell people to install them, and until this they were
documentation with nothing to check them.

That second reason is what fixes the shape of the file. Every app service joins the nginx
container's network namespace (`network_mode: "service:nginx"`), so the snippets' own
`proxy_pass http://127.0.0.1:300x` is true inside it and they are mounted **unmodified**. Service
names and `LISTEN_ADDR=0.0.0.0` would have been the ordinary answer and would have meant nine
second copies, drifting silently — the one thing `shared/` exists to prevent. Keep the mounts
read-only and keep them pointing at `*/deploy/nginx.conf`.

Three consequences worth knowing before editing it: a service in a shared namespace may not
declare `ports`, `networks` or `hostname`; **`docker compose restart` does not work** — the nine
hold a handle on the namespace nginx owns, so restarting leaves some of them without a network and
`up -d --force-recreate` is the way; and the root `.env` must carry **no `PORT`, `LISTEN_ADDR` or
`BASE_PATH`** — all nine read that one file, and every example already defaults to its own port
and prefix.

`php-js` is the only app whose shipped deploy config cannot be mounted as it is: its pool sets
`clear_env = yes` and names every setting as an `env[]` line, which is right for a system FPM and
blind to compose's environment. Its `Dockerfile` writes a compose pool instead, and says in a
comment exactly which two lines differ and why. `nextjs` is the only one needing a build argument,
because `next build` bakes `basePath` in.

**This is what a ninth language now costs**, on top of the one line in `scripts/sync-shared.sh`:
a `Dockerfile`, a `.dockerignore`, a service in `docker-compose.yml` and a mount line for its
snippet. Images are not built in CI.

## Documentation

Link to `doc.payneteasy.com`, never to internal or staging hosts. The integration reference is
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,27 @@ rather than falling back to a host baked in at some point and forgotten. Go, Exp
Sinatra, Spring Boot, axum and ASP.NET Core check at startup; Next checks on the first request, so
that a build needs no credentials, and PHP on every request, because it has no startup to check at.

## Run
## Run all nine at once

That "behind one nginx" is not a figure of speech, and `docker-compose.yml` is it: nine images,
one nginx, no toolchain to install.

```bash
cp .env.example .env # the gateway URLs and your credentials
cp your_key.pem private_key.pem # PKCS#8 — the JDK reads nothing else
docker compose up --build # http://localhost:8080/
```

The front page lists all nine. Each is routed by **its own [`deploy/nginx.conf`](go-js/deploy/nginx.conf)**,
mounted unmodified — so this is also what checks that the file in every release archive is
correct, which nothing else does.

It is a demo and not a deployment: plain HTTP on a local port. The first build is slow — it
compiles Rust, packages a Spring Boot jar and runs `next build`. Set `HTTP_PORT` in `.env` if
something already has 8080, and restart the stack rather than one service — every app shares the
nginx container's network namespace, which is what lets the shipped snippets be used unchanged.

## Run one on its own

They all mount everything under a URL prefix, so they can sit behind one nginx at once, and they
all listen on `127.0.0.1` by default — they speak plain HTTP and trust `X-Forwarded-For`, so a
Expand Down Expand Up @@ -212,6 +232,8 @@ Each app has its own README with the details — settings, the 3DS return, deplo
```
shared/ the browser half, once
scripts/sync-shared.sh copies it into every app
docker-compose.yml all nine at once, behind one nginx
docker/nginx/ the server block the nine shipped snippets are included into
go-js/ Go + plain JS, assets embedded in the binary
nodejs-express-js/ Node.js + Express + plain JS
php-js/ PHP + plain JS, no Composer
Expand Down
124 changes: 124 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Every example, built and run at once behind one nginx on http://localhost:8080/
#
# cp .env.example .env # the gateway URLs and your credentials
# cp <your key> private_key.pem
# docker compose up --build
#
# Two things about the shape of this file are deliberate.
#
# 1. Every app service joins the nginx container's network namespace. That is what lets each
# app's own deploy/nginx.conf — the file it ships and its README tells you to install — be
# mounted here unchanged: those say `proxy_pass http://127.0.0.1:300x`, and inside one shared
# namespace that is exactly where the app is. The alternative, service names and
# LISTEN_ADDR=0.0.0.0, would mean nine second copies of those files, drifting quietly.
#
# It also means the apps keep their shipped LISTEN_ADDR=127.0.0.1 default, that nginx needs no
# DNS and so starts before the apps do, and that ports 3000-3008 stay free on your machine.
#
# The price, and it is worth knowing before you hit it: a service in a shared namespace may
# not declare `ports`, `networks` or `hostname`, and the nine hold a handle on the namespace
# nginx owns — so `docker compose restart` leaves some of them without a network. Restart the
# stack, never one service:
#
# docker compose up -d --force-recreate # or: down, then up -d
#
# 2. One .env and one private_key.pem for all nine. PUBLIC_URL is the nginx origin rather than
# any app's own port, because it is what builds the 3DS return URL the payer's browser is sent
# to — each app appends its own BASE_PATH to it.
#
# This is a demo, not a deployment. It speaks plain HTTP and publishes nginx on a local port.

# What every app service shares. Spelled once: nine copies of the same five lines is nine places
# for one of them to quietly differ.
x-app: &app
env_file: .env
volumes: [./private_key.pem:/run/secrets/private_key.pem:ro]
# See (1) above — this is what lets the shipped deploy/nginx.conf files be mounted unchanged
network_mode: "service:nginx"
depends_on: [nginx]

services:
nginx:
image: nginx:1.27-alpine
ports:
# HTTP_PORT in .env if something already has 8080. PUBLIC_URL must name the same port:
# it is the origin the payer's browser is sent back to after a 3DS challenge.
- "${HTTP_PORT:-8080}:8080"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./docker/nginx/index.html:/usr/share/nginx/html/index.html:ro

# The nine shipped snippets, unmodified. A diff here is a real bug in a release archive.
- ./go-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/go.conf:ro
- ./nodejs-express-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/nodejs-express-js.conf:ro
- ./php-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/php.conf:ro
- ./python-flask-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/python.conf:ro
- ./ruby-sinatra-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/ruby.conf:ro
- ./java-springboot-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/java.conf:ro
- ./rust-axum-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/rust.conf:ro
- ./dotnet-aspnetcore-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/dotnet.conf:ro
- ./nextjs/deploy/nginx.conf:/etc/nginx/conf.d/apps/nextjs.conf:ro

# Three of those snippets carry an optional block serving the four client assets straight
# off disk. The directories are committed copies of shared/, so mounting them adds no
# second source of truth — and it means that block is exercised rather than assumed.
- ./nodejs-express-js/public:/opt/hosted-fields-examples-nodejs-express-js/public:ro
- ./python-flask-js/public:/opt/hosted-fields-examples-python/public:ro
- ./ruby-sinatra-js/public:/opt/hosted-fields-examples-ruby/public:ro

# The PHP snippet talks to php-fpm over a unix socket rather than a port
- php-socket:/run/php

go:
<<: *app
build: ./go-js

nodejs-express-js:
<<: *app
build: ./nodejs-express-js

php:
<<: *app
build: ./php-js
# The socket deploy/nginx.conf's fastcgi_pass names, shared with the nginx container
volumes:
- ./private_key.pem:/run/secrets/private_key.pem:ro
- php-socket:/run/php

python:
<<: *app
build: ./python-flask-js

ruby:
<<: *app
build: ./ruby-sinatra-js

java:
<<: *app
build: ./java-springboot-js

rust:
<<: *app
build: ./rust-axum-js

dotnet:
<<: *app
build: ./dotnet-aspnetcore-js

nextjs:
<<: *app
build:
context: ./nextjs
# basePath is baked in by `next build`, so it has to be known here as well as at runtime.
# Change it and you have to rebuild this one image; the other eight read BASE_PATH at
# startup.
args:
BASE_PATH: /hosted-fields-examples-nextjs
environment:
# Next reads the interface under this name, not LISTEN_ADDR
HOSTNAME: 127.0.0.1
PORT: "3002"
BASE_PATH: /hosted-fields-examples-nextjs

volumes:
php-socket:
40 changes: 40 additions & 0 deletions docker/nginx/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Hosted Fields examples</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0; padding: 3rem 1.5rem;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
max-width: 44rem; margin-inline: auto;
}
h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
p { margin: 0 0 2rem; opacity: .75; }
ul { list-style: none; margin: 0; padding: 0; }
li { border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
a { display: flex; gap: 1rem; padding: .75rem .25rem; text-decoration: none; color: inherit; }
a:hover { background: color-mix(in srgb, currentColor 6%, transparent); }
b { font-weight: 600; flex: 0 0 7rem; }
span { opacity: .6; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .875rem; }
</style>
</head>
<body>
<h1>Hosted Fields examples</h1>
<p>The same payment, one server per language, all behind this nginx.</p>
<ul>
<li><a href="/hosted-fields-examples-go/"><b>Go</b><span>go-js</span></a></li>
<li><a href="/hosted-fields-examples-nodejs-express-js/"><b>Node.js</b><span>nodejs-express-js</span></a></li>
<li><a href="/hosted-fields-examples-php/"><b>PHP</b><span>php-js</span></a></li>
<li><a href="/hosted-fields-examples-python/"><b>Python</b><span>python-flask-js</span></a></li>
<li><a href="/hosted-fields-examples-ruby/"><b>Ruby</b><span>ruby-sinatra-js</span></a></li>
<li><a href="/hosted-fields-examples-java/"><b>Java</b><span>java-springboot-js</span></a></li>
<li><a href="/hosted-fields-examples-rust/"><b>Rust</b><span>rust-axum-js</span></a></li>
<li><a href="/hosted-fields-examples-dotnet/"><b>.NET</b><span>dotnet-aspnetcore-js</span></a></li>
<li><a href="/hosted-fields-examples-nextjs/"><b>Next.js</b><span>nextjs</span></a></li>
</ul>
</body>
</html>
26 changes: 26 additions & 0 deletions docker/nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# The one server block every example shares, for docker-compose.yml at the repository root.
#
# It deliberately contains no routing of its own. Each app's own deploy/nginx.conf — the file
# that ships in its release archive and that its README tells you to install — is mounted into
# conf.d/apps/ and included here unchanged. That is the point of this setup as much as the
# convenience is: those nine files are otherwise documentation with nothing to check them.
#
# They say `proxy_pass http://127.0.0.1:300x`, and they work here verbatim because every app
# container shares this container's network namespace. See the comment in docker-compose.yml.

server {
listen 8080;
server_name _;

# The nine prefixes, byte for byte as each example ships them
include /etc/nginx/conf.d/apps/*.conf;

# A front door for the demo. Not part of any example: the payment pages all live under a
# prefix, and nothing in shared/ is served from the root.
# try_files rather than index: `index` fires an internal redirect to /index.html, and this
# server has no location that would match it.
location = / {
root /usr/share/nginx/html;
try_files /index.html =404;
}
}
9 changes: 9 additions & 0 deletions dotnet-aspnetcore-js/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide —
# an image is one more place a key does not belong.
.env
*.pem
*.key

# .NET build output
bin/
obj/
5 changes: 5 additions & 0 deletions dotnet-aspnetcore-js/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ bare, and the test project is named explicitly wherever it is needed.
`{prefix}/`, which is the redirect Go's mux, Tomcat and nginx all send by themselves. It is
registered before anything else, so it short-circuits whatever routing has already selected. The
payment page itself is registered at the bare prefix and reached at the trailing-slash form.
- **`MapGet` maps GET and nothing else**, so a `HEAD` would be answered `405` — this example
alone, since Go's `ServeMux`, Express and the rest all serve `HEAD` from their `GET` route.
Every GET route here goes through the one-line `Get` helper in `Program.cs`, which is
`MapMethods(…, ["GET", "HEAD"], …)`. Kestrel drops the body of a HEAD response itself, so the
handlers know nothing about it.
- **No `UseStaticFiles`, no `wwwroot`, no static web assets.** `public/` goes out through the
four-name allowlist in `Program.cs` and nowhere else. Left to itself the framework would serve
the client scripts a second way, at the root rather than under `BASE_PATH` and with caching
Expand Down
22 changes: 22 additions & 0 deletions dotnet-aspnetcore-js/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# The ASP.NET Core example, for docker-compose.yml at the repository root.
#
# Two stages: views/ and public/ are embedded resources, so the runtime image carries the
# published assembly and nothing beside it.

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
WORKDIR /src
COPY . .
# The app itself references no package; only the test project does, and it is not published
RUN dotnet publish HostedFields.csproj -c Release -o /out

# aspnet, not runtime: the publish is framework-dependent and this is an ASP.NET Core app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /opt/hosted-fields-examples-dotnet
COPY --from=build /out .
# `dotnet <dll>`, because UseAppHost is off — the archive is meant to be platform-neutral, so
# no native launcher is built for whichever machine happened to publish it.
#
# The image sets ASPNETCORE_HTTP_PORTS=8080, which has no effect: Program.cs calls UseUrls()
# explicitly from PORT and LISTEN_ADDR, and that wins.
CMD ["dotnet", "hosted-fields-example-dotnet.dll"]
Loading
Loading