mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 16:28:21 +00:00
* core: cancel the chat callback on interrupt * badges: add multi-use codes * badges: add the managed group * badges: document multi-use codes and the group * core: sign test badges with whole-second expiry * badges: rework tracker and reply texts
314 lines
20 KiB
Markdown
314 lines
20 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` in its code table,
|
|
- does not accept contact requests: the address is for RPC only,
|
|
- in service mode with `[group]` in the ini, manages one SimpleX group and serves `/issue`, `/bulk`
|
|
and `/revoke` in it (see [Issuing codes](#issuing-codes)),
|
|
- 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 serves no group commands, whatever
|
|
`--service-config` says. It still updates a code's group message when the code is redeemed or
|
|
revoked.
|
|
- `--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), an optional `[group]` section (omitting it
|
|
turns off the group's commands, though a group created earlier still has its code messages updated)
|
|
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.
|
|
|
|
Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are
|
|
on. The poller lists every invoice and payment intent of the last three days or so, and one it did
|
|
not create and cannot read is reported in a warning once an hour until it falls out of that window.
|
|
|
|
### 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`.
|
|
|
|
## Issuing codes
|
|
|
|
Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the
|
|
managed group in service mode. Both are commands to a running process, so no second process
|
|
touches the service's database.
|
|
|
|
### From the command line
|
|
|
|
The command is sent to the running service in `--run-cli` mode, not a way to start it:
|
|
|
|
```
|
|
//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. A client that redeemed it before the revoke still gets its own badge
|
|
back when it asks again. Revoking it again answers "already revoked" and fixes its group
|
|
message if the first revoke didn't. A code with no uses left can't be revoked, because its badges
|
|
were already given out, and the command answers with an error. A multi-use code with uses left can
|
|
be revoked, which stops the uses that remain.
|
|
|
|
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.
|
|
|
|
### From the group
|
|
|
|
With `[group]` in `badge_service.ini`, the service manages one group and serves three commands in
|
|
it: `/issue <type> [months <M>] [uses <N>]` and `/bulk <type> [months <M>] count <B>` for moderators
|
|
and above, `/revoke <code>` for admins and owners. `months` is 1 to 255, `uses` 1 to 1000 and
|
|
`count` 1 to 100; a value outside these gets the usage reply. A member's role is checked as the
|
|
service last saw it, so a command sent by a moderator just demoted or removed can still run if it
|
|
reaches the service first; revoke any code the service posts for them after the change. `uses`
|
|
above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time
|
|
of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member,
|
|
since the group has no private lane, so a code issued there is only as private as its least trusted
|
|
member.
|
|
Those replies are also kept as plain text in the service's chat database, so a copy of the database
|
|
holds every code issued in the group. Keep the group's visible history off: with it on, each new
|
|
member receives recent messages, and the codes in them, when they join. A multi-use code's message
|
|
carries the code, and every redemption edits it or, after a day, posts it again; either way every
|
|
current member receives it, so a member who joined after the code was issued gets the code while it
|
|
still has uses left. A message replaced by a new post stays in the group with its old count. Keep
|
|
disappearing messages off in the group and set no message TTL for the service's chats: a code's
|
|
message that expires is treated as deleted and never posted again, so its counter stops.
|
|
Every member can see when each use of a multi-use code was redeemed: the message shows the time of
|
|
the last one, and its edit times show the rest.
|
|
`/revoke <code>` names the code in an ordinary group message, so every member holds it before the
|
|
service reads the command, and the code stays redeemable until the service acts on it — for the
|
|
whole of any downtime. Revoke a code that is not already public in the group, a refunded one above
|
|
all, with `//revoke` in `--run-cli` mode. A `/revoke <code>` with nothing after the code, from a
|
|
member below admin, is answered that the code was not revoked and is now visible to the group. A
|
|
group command the service received but had not run when it stopped, or received while it ran in
|
|
`--run-cli` mode, is dropped with no reply, so resend it, or use `//revoke`.
|
|
|
|
The first member to join through the link is promoted to owner, so the operator joins before sharing
|
|
it. Keep the service an owner too: below owner it cannot update the group's command menu, and below
|
|
author it cannot post codes or replies. A failed promotion is logged at once, and an owner who left
|
|
is logged at the next start or join. Then make the member you choose owner with the `/mr` command
|
|
that the log line names, in `--run-cli` mode; the service never promotes anyone once the first
|
|
promotion was attempted.
|
|
|
|
The join link logged when the group is created stays valid: anyone who has it can join later, as a
|
|
member, and read every code posted or edited from then on. That includes a removed member, who can
|
|
rejoin through it, so removing a member does not stop them seeing new codes. Keep the log that holds
|
|
it private. The link is also stored in the `group_link` column of `sx_badge_service_group`, where it
|
|
can be read again.
|
|
|
|
If an owner deletes the group, or removes the service from it, the service logs an error on start
|
|
and stops serving the group. To create a new group, stop the service, delete the row, and start it
|
|
again: with SQLite, run `DELETE FROM sx_badge_service_group;` on the `<prefix>_chat.db` file
|
|
(`~/.simplex/simplex_badge_service_chat.db` by default), opened with `sqlcipher` and the database key
|
|
if one is set; with PostgreSQL, run
|
|
`DELETE FROM <schema-prefix>_chat_schema.sx_badge_service_group;` (`simplex_v1_chat_schema` by default).
|
|
Multi-use codes issued in the old group stay redeemable, but their messages there are no longer
|
|
updated, so revoke with `//revoke` any that should not stay live.
|
|
|
|
The group is identified by the single `sx_badge_service_group` row. Rolling back past the
|
|
`20260918_badge_group_ops` migration drops that table, so a later re-upgrade creates a second group
|
|
and orphans the first one with its members and roles; multi-use codes come back single-use with
|
|
their claims re-derived, and outstanding trackers come back unanchored. Redeemed credentials are
|
|
preserved and no code becomes redeemable again, though while the old version runs, only the holder
|
|
whose credential ends last gets it back on a retry, and any other holder of a multi-use code gets
|
|
`code_used`; every holder of a revoked code gets `code_invalid`. Rolling back means re-creating and
|
|
re-sharing the group; delete the orphaned one with `/d #'<old local name>'` in `--run-cli` mode, as its
|
|
join link still works and its messages hold every code posted there.
|
|
|
|
The configured `display_name` and `description` apply only to the group the service creates. Editing
|
|
them later is logged as not applied and changes nothing.
|