mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-07 03:18:40 +00:00
* badges: webapp (#7433) * badges: service migrations, store and catalog * badges: BTCPay provider and settlement poller * badges: web listener and /api endpoints * web: checkout single-page app * badges: tests and BTCPay fixtures * badges: README and ini reference * badges: fix hex16 build on GHC 8.10.7 * badges: Stripe card lane * badges: fix Stripe card checkout, add theming * badges: add a discount row to the order summary * badges: site navbar, embedding, theme, Forget move * badges: use SB code prefix in web checkout * badges: rename sxb app namespace to sb * badges: embed checkout nav via site; keep original app navbar * badges: post iframe height, apply site background when embedded * badges: embed dark surfaces, steadier iframe height * badges: hide app footer when embedded * badges: size embedded body to content, not viewport * badges: declare color-scheme to stop reload flash * badges: fade shell in on load, no reload blank * badges: prerender app shell into index.html * badges: pre-paint theme, hide shell on deep reload * badges: logo returns to landing client-side * badges: embedded wizard back, buy-a-code, resume * badges: signal app-managed screens, resume across reload * badges: rebuild wizard history on deep load so Back walks it * badges: carry welcome-page height as the iframe floor * badges: keep selection on Buy a code; rename to Your codes * badges: read web shell as UTF-8, not locale * badges: resume the exact paid order after Stripe card redirect * badges: move docker deploy under scripts * badges: add serve_webapp toggle and webapp export * badges: wire split webapp deploy in docker config * badges: quiet agent logs by default * badges: resume card redirect in the embedded frame * badges: migrate Stripe adapter to PaymentIntents * badges: correct Stripe restricted key scopes in ini example * badges: card via Payment Element and PaymentIntents * badges: fix stale Checkout Session wording in Stripe adapter * badges: fix stale CheckoutActions reference in card comment * badges: order shell stylesheet before bootstrap script * badges: remove development card stand-in * badges: theme the Stripe card form with the site palette * badges: exclude web from the Haskell build stage * badges: unify invoice cancel and mark canceled * badges: default log level to info * badges: unify closed-invoice buy-again button * badges: mute agent connection logs at info level * badges: show purchase time in local timezone in Your codes * badges: log service events on own channel, quiet agent * badges: fold service migrations into one baseline * badges: run compose on postgres over host network * badges: use high-res hero art * badges: add web CI to catch stale builds * badges: rebuild web shell from committed source * badges: normalize invoice-code link and columns * badges: drop unused columns, rename index * badges: note deferred receipt_hash in migrations * badges: apply code-review fixes * badges: reduce comments across service and web --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: improve web page (#7546) * badges: improve web page * improve layout * improve layout * fix * small changes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: read one issuer key from the ini * badges: move and group the service tests * badges: service fixes (#7567) * badges: match the redeem error wording in tests * badges: drop unused imports in the bot tests * badges: cancel Stripe orders when they expire * badges: correct the Stripe config and docs * badges: refuse to revoke a redeemed code * badges: make the fake Stripe cancel like Stripe * badges: limit replayed webhook deliveries --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum <github.shum@liber.li> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
249 lines
15 KiB
Markdown
249 lines
15 KiB
Markdown
# SimpleX badge service
|
|
|
|
`simplex-badge-service` runs the whole supporter-badge service as one process, two
|
|
lanes: a SimpleX chat bot that answers service RPC over a double-ratchet contact
|
|
address, and — only when `--service-config` names a `badge_service.ini` — the badge-codes
|
|
web checkout (BTCPay and Stripe payment, code issuance, the poller). **Without
|
|
`--service-config` the web listener does not start at all**; the process still runs the
|
|
chat side and nothing else.
|
|
|
|
The wire protocol for the RPC side is
|
|
[`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md). The web checkout is
|
|
specified in
|
|
[`plans/badges-codes/2026-08-27-badge-codes.md`](../../plans/badges-codes/2026-08-27-badge-codes.md),
|
|
whose §9 is the `badge_service.ini` reference, with the implementation plan in
|
|
[`plans/badges-codes/2026-08-31-service-btcpay.md`](../../plans/badges-codes/2026-08-31-service-btcpay.md).
|
|
Earlier plans (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`,
|
|
`2026-08-04-badges-mvp-scope.md`) predate the web checkout and describe the RPC-only
|
|
scaffold this service started as.
|
|
|
|
At this stage the service:
|
|
|
|
- creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)),
|
|
- listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`,
|
|
- issues redemption codes, storing only their `SHA-256` and printing each code once,
|
|
- does not accept contact requests unless `[dev] chat_redeem` is on: the address is for RPC only,
|
|
- in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start,
|
|
- owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`).
|
|
|
|
Every other command answers `unsupported_version`, or `unknown_purchase_key` when the key that
|
|
signed it is not one the service has stored — every command but `redeemBadgeCode` needs a
|
|
purchase that already exists.
|
|
|
|
## Build
|
|
|
|
Build prerequisites and the general contribution flow are in [`docs/CONTRIBUTING.md`](../../docs/CONTRIBUTING.md).
|
|
|
|
```
|
|
cabal build exe:simplex-badge-service
|
|
```
|
|
|
|
## Run
|
|
|
|
```
|
|
simplex-badge-service --help
|
|
```
|
|
|
|
- default (no `--run-cli`): background service mode, no interactive terminal.
|
|
- `--run-cli`: interactive CLI that also processes service requests (mirrors
|
|
`simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands
|
|
below: it starts no web listener and no poller, and `[dev] chat_redeem` does not apply to it,
|
|
whatever `--service-config` says.
|
|
- `--no-address`: skip address creation on start-up (for operators who provision the address themselves).
|
|
The service cannot sign credentials without an issuer key and refuses to start without one:
|
|
|
|
- `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`).
|
|
- `--issuer-secret SECRET` — the issuer secret from `simplex-chat badge keygen`.
|
|
|
|
The service checks the secret against the configured public key at that index and refuses to start
|
|
if they disagree: credentials signed with the wrong key cannot be verified by any client, and the
|
|
codes redeemed against them would be spent for nothing.
|
|
|
|
The key can come from `badge_service.ini` instead:
|
|
|
|
```ini
|
|
[issuer]
|
|
index = 1
|
|
private_key = <secret from `simplex-chat badge keygen`>
|
|
```
|
|
|
|
`index` is the index clients verify against (`badgePublicKeys` in `ChatConfig`), and `private_key`
|
|
is the secret that signs. Both are required, and the key is checked at startup like the flags.
|
|
Rotating is a change to both and a restart, once clients trust the new index.
|
|
|
|
The command line wins over the file when both `--issuer-key-idx` and `--issuer-secret` are given.
|
|
Note that a secret passed as a flag is visible to every user on the machine through `ps`, where one
|
|
in the ini is only as readable as the file: `badge_service.ini` is gitignored and already holds the
|
|
provider API keys and webhook secrets.
|
|
|
|
Other options:
|
|
|
|
- `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side
|
|
only; the process never starts a web listener without it, and never starts one under
|
|
`--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but
|
|
uses only its `[issuer]` section. An issuer key is still required either way.
|
|
- `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`).
|
|
- `--client-service`: use the client service certificate.
|
|
- also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers,
|
|
`--socks-proxy`, `--log-level`/`-l`, and the rest — run `simplex-badge-service --help`
|
|
for the complete list.
|
|
|
|
### Running the web checkout
|
|
|
|
`badge_service.ini` holds the listener bind address and `static_dir`, an optional
|
|
`[btcpay]` section (omitting it disables Bitcoin and Monero), an optional `[stripe]`
|
|
section (omitting it disables card payments) and the poll cadence.
|
|
`badge_service.ini.example` is the committed template; `badge_service.ini` itself is
|
|
gitignored, since a real one holds API keys and webhook secrets.
|
|
|
|
The full walkthrough is in
|
|
[`web/README.md`](web/README.md#running-the-real-service-against-this-build). Short version:
|
|
|
|
```
|
|
cd apps/simplex-badge-service/web && npm install && npm run build && cd ../../..
|
|
cp apps/simplex-badge-service/badge_service.ini.example apps/simplex-badge-service/badge_service.ini
|
|
cabal run simplex-badge-service -- \
|
|
--issuer-key-idx IDX --issuer-secret SECRET \
|
|
--service-config apps/simplex-badge-service/badge_service.ini
|
|
```
|
|
|
|
`IDX` and `SECRET` must be one of the issuer keys clients already ship, not a fresh
|
|
`simplex-chat badge keygen` pair: startup refuses a key no client could verify against.
|
|
|
|
`trust_forwarded_for` decides what the rate limiter counts. Behind a reverse proxy it must be
|
|
`on`, or every request keys on the proxy's address and the whole service shares one bucket of 60
|
|
reads and 5 checkouts a minute. Where the listener is reached directly it must be `off`, because
|
|
then the header is whatever the caller wrote. The last entry is the one read, which is the one a
|
|
proxy appends.
|
|
|
|
The BTCPay API key needs four permissions, each scoped to the one store:
|
|
`cancreateinvoice` and `canviewinvoices` for checkout and the poller,
|
|
`canviewstoresettings` to log the store's live payment methods at startup, and
|
|
`canmodifyinvoices` so `POST /api/invoice/:id/cancel` can invalidate an invoice at BTCPay
|
|
rather than only in this store.
|
|
|
|
### Card payments (Stripe)
|
|
|
|
An optional `[stripe]` section enables the card lane; omitting it disables card payments
|
|
and `POST /api/invoice` answers `provider_unavailable` for a card order, the same as an
|
|
absent `[btcpay]` does for Bitcoin and Monero. Its keys:
|
|
|
|
- `secret_key` — a **restricted** key (`rk_...`), never a full secret key (`sk_...`). Grant it
|
|
**PaymentIntents: write**, to create and cancel intents, plus **Charges: read** — the poller
|
|
reads an intent with `expand[]=latest_charge`, and Stripe rejects the whole read if the key
|
|
lacks read on an expanded object (settlement then falls back to the slower list pass).
|
|
- `publishable_key` (`pk_...`) — the public key the browser mounts the Payment Element with.
|
|
- `webhook_secret` (`whsec_...`) — the signing secret of the `/webhooks/stripe` endpoint,
|
|
configured in the Stripe Dashboard alongside it.
|
|
- `session_minutes` — how many minutes an unpaid card order stays open (1 to 1440, default 60).
|
|
Ten minutes after that, the service cancels the payment at Stripe, and retries if that fails.
|
|
If the order is over 72 hours old and the cancel has kept failing for an hour, the service marks
|
|
it expired and logs an error. Check such orders in the Stripe Dashboard. After a restart, the
|
|
hour starts again.
|
|
|
|
The service fills `publishable_key` into the shell at boot: the built `index.html` ships
|
|
with an empty `<meta id="stripe-publishable-key" name="stripe-publishable-key" content="">`
|
|
(`web/public/index.html`), and the listener serves a copy with the configured key
|
|
substituted in, so the ini is the only place the key is set. With no `[stripe]` section the
|
|
pristine (empty) shell is served and the page shows its development stand-in card form. The
|
|
dev mock (`web/mock/server.py`) does the same substitution from `$STRIPE_PUBLISHABLE_KEY`,
|
|
since it runs without an ini.
|
|
|
|
`POST /webhooks/stripe` verifies `Stripe-Signature` against `webhook_secret` and queues a
|
|
read, the same hint-only role as `POST /webhooks/btcpay`: the poller carries authority, so
|
|
an unverified or unreadable delivery costs nothing but a log line. A delivery signed more than
|
|
15 minutes away from the server's clock is refused with a warning, so keep the clock synced (NTP).
|
|
|
|
The reverse proxy in front of this service must send a Content-Security-Policy that
|
|
allows Stripe.js and its iframes, since Stripe forbids bundling or self-hosting its
|
|
script:
|
|
|
|
```
|
|
script-src 'self' https://js.stripe.com https://*.js.stripe.com;
|
|
frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com
|
|
```
|
|
|
|
**Stripe Link must be disabled in the Dashboard.** Left on, it needs `link.com` in the
|
|
CSP above and reintroduces an email prompt of its own, which this design otherwise avoids.
|
|
|
|
**Enable card only; do not enable redirect-based methods** (iDEAL, Bancontact, PayPal, and
|
|
the rest) in the Dashboard. The embedded checkout sends no `return_url`, which Stripe requires
|
|
the moment a redirect-based method is offered, so enabling one makes every checkout fail to
|
|
create. The service pins the Stripe API version it was built against, so the account's default
|
|
version does not affect the card lane.
|
|
|
|
### The checkout endpoints
|
|
|
|
The browser in `web/` is the only client of the `/api` routes; the two webhooks below are the
|
|
providers', one each for BTCPay and Stripe.
|
|
The invoice id is the only credential for reading or cancelling an order: it is 16 random bytes,
|
|
it travels in the path, and anything that logs request paths logs it. It is also what the buyer
|
|
is shown as their reference and asked to quote, so support sees it: losing it lets someone cancel
|
|
an invoice, which is why the code, which lets them take the badge, is never printed until the
|
|
service says the invoice is paid. The service keeps it out of its own logs, which name the
|
|
provider's reference instead.
|
|
|
|
| Route | Answers |
|
|
|---|---|
|
|
| `POST /api/invoice` | `{invoiceId, badgeType, months, amount, currency, expiresAt}` plus a destination: `clientSecret` for a card, or `address`, `cryptoAmount`, `cryptoCurrency`. Refuses with `code_conflict`, `catalog_changed`, `bad_request`, `provider_unavailable` or `rate_limited`. |
|
|
| `GET /api/invoice/:id` | `{status, badgeType, months, amount, currency, expiresAt}` and the same destination — no `invoiceId`, since the caller already has it — plus `amountPaid`, `cryptoAmountPaid`, `cryptoAmountDue`, `paidInFull`, `settledAt` and `requiredConfirmations` once each has a value. Never the code, which this service has never seen. |
|
|
| `GET /api/invoice/:id?wait=<status>&seenPaid=<figure>&seenFull=<0\|1>` | The same, held for up to 30 seconds while the invoice's status is still `<status>` **and** its payment is the one the caller says it has rendered. `wait=paid` and any value that is not a status answer at once, since neither can change. A status the caller has not seen, or a payment it has not seen, answers at once — the provider's verdict counts as much as the figure, because Monero reports an invoice as confirming while its figures are still zero. A request that omits `seenPaid` holds on the status alone. |
|
|
| `POST /api/invoice/:id/cancel` | Invalidates an open invoice at the provider and expires it here. Refuses a settled or expired one with `not_open`, and one that already holds a payment with `funded`. The provider is told first, and a payment landing in between does not keep the invoice open: nothing can reach that address any more, so the row is expired either way and the poller settles or reports what arrived. |
|
|
| `POST /webhooks/btcpay` | Verifies `BTCPay-Sig` over the bytes as received and queues a read. A hint only: the poller is what carries authority, so an unverified or unreadable delivery costs nothing but a log line. |
|
|
| `POST /webhooks/stripe` | Verifies `Stripe-Signature` over the bytes as received and queues a read. The card lane's equivalent of the row above, and a hint just the same: the poller carries authority, so an unverified or unreadable delivery costs nothing but a log line. |
|
|
|
|
Every `/api` refusal is `{"error": "<code>"}`. Besides the codes above, any of them can answer
|
|
`internal`, an unknown id answers `not_found`, and a wrong verb answers `method_not_allowed` —
|
|
ten codes in all, which is what the browser's `WIRE_ERROR_CODES` lists. The two webhook routes are
|
|
the exception: each answers 200, 400 or 413 with an empty body, because its provider is the only
|
|
caller and nothing it could read would change what the route does. A wrong verb on any route, those
|
|
two included, answers `method_not_allowed`.
|
|
|
|
### Redeeming over chat, for local testing
|
|
|
|
```ini
|
|
[dev]
|
|
chat_redeem = on
|
|
```
|
|
|
|
With this on, the service accepts contact requests and answers `/redeem <code>` from a contact
|
|
with the credential as one-line JSON, ready to paste into a client as `/badge add <json>`. Off by
|
|
default, and only `on`/`off` parse, so a typo cannot silently arm it. It applies to the service
|
|
mode only; `--run-cli` ignores it.
|
|
|
|
Keep it off anywhere real. The service RPC signs over a master key only the client holds; here
|
|
there is no client key, so the service generates one and hands it over with the credential, which
|
|
means it can link every badge it issues this way. `simplex-chat badge sign` has the same property
|
|
and is the offline equivalent.
|
|
|
|
## Issuing codes
|
|
|
|
Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way
|
|
to start it — so codes are issued without a second process touching the service's database:
|
|
|
|
```
|
|
//issue <badge_type> [months] [paid|unpaid|free]
|
|
//issue supporter 12
|
|
```
|
|
|
|
`months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records
|
|
whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator
|
|
(`free`). Redemption refuses an `unpaid` code with `payment_pending`: the web checkout writes the
|
|
code row when the invoice is created, and settlement is what marks it paid.
|
|
|
|
The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is
|
|
shown cannot be recovered.
|
|
|
|
A code that leaked, or that was refunded, is withdrawn the same way:
|
|
|
|
```
|
|
//revoke <code>
|
|
```
|
|
|
|
A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder
|
|
learns nothing from trying. Revoking is not repeatable: the second attempt says so. A code that
|
|
was already redeemed cannot be revoked: its badge was issued, and the command answers with an error.
|
|
|
|
Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is
|
|
why issuing codes lives in the service rather than in core.
|