v0.1.0:24 — Keysat licensing service end-to-end

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+32
View File
@@ -0,0 +1,32 @@
# Copy to `.env` for local development. On StartOS these are set by the
# service manifest.
# Where the HTTP server binds. Inside a container this is usually 0.0.0.0.
LICENSING_BIND=0.0.0.0:8080
# SQLite file location. On StartOS this should live under the persistent
# data volume, typically `/data/licensing.db`.
LICENSING_DB_PATH=./data/licensing.db
# Public base URL of THIS licensing service. Used to build webhook URLs and
# client poll URLs. Should be the clearnet or .onion address users reach.
LICENSING_PUBLIC_URL=http://localhost:8080
# Required. Generate with: openssl rand -hex 32
# Protects /v1/admin/* endpoints. Never commit this value.
LICENSING_ADMIN_API_KEY=changeme-use-openssl-rand-hex-32
# Optional display name shown on the index page.
LICENSING_OPERATOR_NAME=Acme Software
# --- BTCPay Server ---
# On StartOS, BTCPay is reachable at the .embassy hostname once you declare
# the dependency in the service manifest.
BTCPAY_URL=http://btcpayserver.startos:23000
BTCPAY_API_KEY=replace-with-btcpay-greenfield-api-key
BTCPAY_STORE_ID=replace-with-btcpay-store-id
BTCPAY_WEBHOOK_SECRET=replace-with-webhook-shared-secret
# --- Logging ---
# Standard tracing-subscriber filter syntax. Uncomment to tune.
# RUST_LOG=info,sqlx=warn,hyper=warn
+7
View File
@@ -0,0 +1,7 @@
/target
/data
.env
*.db
*.db-journal
*.db-wal
*.db-shm
+102
View File
@@ -0,0 +1,102 @@
[package]
name = "keysat"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Keysat — self-hosted Bitcoin-paid software licensing server for Start9"
license-file = "LICENSE"
publish = false
[[bin]]
name = "keysat"
path = "src/main.rs"
[dependencies]
# HTTP server
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors", "limit"] }
hyper = "1"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Trait objects with async methods. The new `PaymentProvider` trait
# (src/payment/mod.rs) uses `#[async_trait]` for object-safe async fns.
# Could move to native AFIT in Rust 1.75+ but `async_trait` is one
# attribute and behaves identically.
async-trait = "0.1"
# Database (SQLite via sqlx). `macros` is required for the `sqlx::migrate!`
# macro that bakes ./migrations/*.sql into the binary at compile time. We
# only use the macros that don't need a live DB (no `query!` calls in the
# codebase), so enabling `macros` doesn't impose any compile-time database
# requirement.
sqlx = { version = "0.7", default-features = false, features = [
"runtime-tokio-rustls",
"sqlite",
"migrate",
"macros",
"chrono",
"uuid",
] }
# Cryptography. ed25519-dalek 2.x re-exports the `pkcs8` trait module from
# the lightweight `ed25519` crate, which doesn't include the `LineEnding`
# enum we need. So we depend on the underlying `pkcs8` crate directly with
# its `pem` feature for that one type.
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"] }
pkcs8 = { version = "0.10", features = ["pem"] }
rand = "0.8"
sha2 = "0.10"
hmac = "0.12"
subtle = "2"
# Encoding
data-encoding = "2" # Crockford base32 for license keys
base64 = "0.22"
hex = "0.4"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Time & IDs
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
# HTTP client (BTCPay)
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
# Errors
anyhow = "1"
thiserror = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# Config
dotenvy = "0.15"
# URL/HTML helpers (used by BTCPay authorize-flow endpoints)
urlencoding = "2"
url = "2"
html-escape = "0.2"
# Embed the static admin web UI assets into the binary at compile time.
# The web/ directory is bundled directly into the runtime binary via
# rust-embed so that at runtime axum can serve /admin/* without needing
# any files copied alongside the binary.
rust-embed = { version = "8", features = ["mime-guess"] }
mime_guess = "2"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
+40
View File
@@ -0,0 +1,40 @@
Source-Available License
Copyright (c) 2026 Keysat
This source code is made available so that users and operators may audit,
study, and self-host this software. It is NOT open source. The following
terms apply:
1. GRANT. Subject to the restrictions below, you are permitted to:
(a) view, study, and audit the source code;
(b) compile and run the software for your own internal use on a server
you own or control, including operating it to sell licenses to your
own software products;
(c) modify the source code for the purposes of (a) and (b), provided
modified copies remain subject to this license.
2. RESTRICTIONS. You may NOT:
(a) redistribute the source code, compiled binaries, or derivative works
to third parties, whether free of charge or for a fee;
(b) sell, sublicense, lease, rent, or otherwise commercialize the
software itself (as distinguished from selling licenses to your own
software products via an instance of the software);
(c) remove or obscure copyright notices or this license text;
(d) publicly host a copy of this software that is accessible to or
operable by parties other than yourself, your employees, or your
contractors acting on your behalf.
3. CONTRIBUTIONS. Any contributions you submit to the upstream project are
licensed back to the copyright holder under the same terms and may be
incorporated, relicensed, or redistributed by the copyright holder at
its discretion.
4. NO WARRANTY. The software is provided "AS IS", WITHOUT WARRANTY OF ANY
KIND, express or implied. In no event shall the copyright holder be
liable for any claim, damages, or other liability arising from the
software or its use.
5. TERMINATION. Any violation of these terms terminates your rights under
this license automatically.
For commercial redistribution or resale rights, contact licensing@keysat.xyz.
+173
View File
@@ -0,0 +1,173 @@
# Keysat
**Keysat** is a self-hosted Bitcoin-paid software licensing server, designed to run as a [Start9](https://start9.com) 0.4.0.x service alongside [BTCPay Server](https://btcpayserver.org). One instance can sell, issue, validate, and revoke licenses for any number of software products you own.
> The repository directory is still called `licensing-service/` on disk for continuity with earlier revisions. The crate, the binary, the StartOS package id, and all user-visible strings use **Keysat**.
Every developer who uses this runs their own instance on their own hardware. There is no central authority, no shared database, and no dependency on anyone else's servers. Your keys, your products, your customers, your rules.
## What it does
- Exposes a REST API for selling and managing software licenses paid for in Bitcoin via BTCPay Server.
- Issues **Ed25519-signed license keys** that can be verified offline by any client with your server's public key — so downstream software doesn't break if your licensing server is briefly unreachable.
- Supports multiple products per instance, each with independent pricing and license pools.
- Supports closed-source, open-source-for-convenience, and open-core distribution models. The service doesn't care how you distribute source; it only validates keys against products.
- Optional per-license machine fingerprint binding with trust-on-first-use.
- Admin-gated endpoints for product management, manual license issuance (comps/press/testing), and revocation.
## Architecture in two minutes
```
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Buyer's │──────▶│ licensing-service │──────▶│ BTCPay Server│
│ browser │ │ (this program) │ │ (Start9) │
└──────────────┘ └──────────────────────┘ └──────────────┘
▲ │ ▲ │
│ license key │ │ webhook │
│ ▼ │ │
│ ┌──────────────┐ │
└─────────────────│ SQLite │◀──────────────────┘
poll/status │ licensing.db
└──────────────┘
Downstream software (e.g. another Start9 package you sell):
on startup → POST /v1/validate { key, product_slug, fingerprint }
→ caches result, re-checks on reasonable cadence
```
1. Buyer `POST /v1/purchase { product: "my-app" }` → we create a BTCPay invoice, return its checkout URL.
2. Buyer pays via BTCPay. BTCPay fires a signed webhook at `POST /v1/btcpay/webhook` → we mark the invoice settled and issue a license row.
3. Buyer polls `GET /v1/purchase/:invoice_id` → once settled, response contains the signed `license_key` string.
4. Buyer installs the software. On startup the software calls `POST /v1/validate` to check revocation and bind itself to the installation.
## Why Ed25519-signed keys
Each license key is a compact, cryptographically signed envelope:
```
LIC1-<74-byte payload, base32>-<64-byte signature, base32>
```
The payload contains the product id, license id, issue time, an optional fingerprint hash, and a version byte. The server's private key signs it; anyone with the public key can verify it.
The practical benefit: downstream software can verify a key's signature **offline**, using a public key bundled at compile time. It only needs to reach your licensing server to check revocation, and it can cache that check. If your licensing server has an outage, existing installations keep working. If someone tries to forge a key, the signature fails instantly without a database lookup.
See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout.
## Project layout
```
licensing-service/
├── Cargo.toml
├── LICENSE # source-available; no redistribution
├── README.md
├── .env.example # required env vars
├── migrations/
│ └── 0001_initial.sql # SQLite schema
├── src/
│ ├── main.rs # entry point: wires everything
│ ├── config.rs # env-driven config
│ ├── error.rs # unified error → HTTP mapping
│ ├── models.rs # shared domain types
│ ├── crypto/
│ │ ├── mod.rs # license key format + sign/verify
│ │ └── keys.rs # server keypair lifecycle
│ ├── db/
│ │ ├── mod.rs # pool + migrations
│ │ └── repo.rs # all SQL queries
│ ├── btcpay/
│ │ ├── client.rs # Greenfield API client
│ │ └── webhook.rs # HMAC verification + event parsing
│ └── api/
│ ├── mod.rs # router + AppState
│ ├── products.rs # public product endpoints
│ ├── purchase.rs # buy + poll
│ ├── validate.rs # the hot path for downstream software
│ ├── webhook.rs # BTCPay landing
│ └── admin.rs # operator-only actions
└── docs/
├── API.md # full endpoint reference
├── INTEGRATION.md # for developers embedding a client
└── ARCHITECTURE.md # deeper design notes
```
## Running locally
Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted).
```bash
cp .env.example .env
# edit .env — generate admin key with: openssl rand -hex 32
# fill in BTCPay URL, API key, store id, webhook secret
cargo run --release
```
On first boot the server generates a fresh Ed25519 keypair and stores it in the SQLite database. Get the public key anytime from `GET /v1/pubkey` (or from the logs on first boot).
### Creating your first product
```bash
curl -X POST http://localhost:8080/v1/admin/products \
-H "Authorization: Bearer $LICENSING_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "my-app",
"name": "My App",
"description": "A cool Start9 service.",
"price_sats": 50000
}'
```
### Walking through a purchase
```bash
# 1. Buyer starts a purchase
curl -X POST http://localhost:8080/v1/purchase \
-H "Content-Type: application/json" \
-d '{"product": "my-app"}'
# → { "invoice_id": "...", "checkout_url": "https://btcpay.../i/...", ... }
# 2. Buyer opens checkout_url, pays
# 3. Buyer polls
curl http://localhost:8080/v1/purchase/<invoice_id>
# → { "status": "settled", "license_key": "LIC1-...", ... }
# 4. Downstream software validates the key
curl -X POST http://localhost:8080/v1/validate \
-H "Content-Type: application/json" \
-d '{"key": "LIC1-...", "product_slug": "my-app", "fingerprint": "host-abc123"}'
# → { "ok": true, "license_id": "...", "product_id": "..." }
```
## Deploying on Start9
This repository ships the service only. To package as an `.s9pk` for the 0.4.0.x platform you'll need a separate wrapper repository following [docs.start9.com/packaging/0.4.0.x](https://docs.start9.com/packaging/0.4.0.x/). The service is designed to slot in cleanly:
- **Declares a dependency** on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a `.startos` hostname and supply the env vars from the wrapper's action handlers.
- **Persists to `/data`**, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups.
- **Binds to `0.0.0.0:8080`** and expects StartOS to handle Tor/LAN/clearnet exposure.
- **Graceful shutdown** on SIGTERM, as StartOS expects.
- **Environment-driven config**, no config files needed at runtime.
When you're ready to write the manifest, the env vars you need to wire are listed in `.env.example`. The main gotcha is the BTCPay webhook secret: you configure it on the BTCPay side and it must match `BTCPAY_WEBHOOK_SECRET` exactly — we verify HMAC-SHA256 in constant time and reject any mismatch.
## Developer integration
If you're a developer shipping software that should validate against a licensing-service instance, see [`docs/INTEGRATION.md`](docs/INTEGRATION.md). It covers:
- Bundling the server's public key in your client.
- Offline signature verification + online revocation check.
- Graceful handling of server outages (don't brick your users).
- Recommended caching and rate-limiting patterns.
## Source-available licensing
This project is source-available, not open source. You may read, audit, self-host, and modify for your own use, but may not redistribute, resell, or publicly host for others. See [LICENSE](LICENSE) for the full terms.
Commercial redistribution / resale rights: contact licensing@keysat.xyz.
## Status
v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator.
+194
View File
@@ -0,0 +1,194 @@
# API reference
All endpoints are JSON in / JSON out. Errors return a body of the form:
```json
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
```
Admin endpoints require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
---
## Public endpoints
### `GET /`
Service metadata including the Ed25519 public key. Useful for SDKs to fetch the key at build time.
```json
{
"service": "keysat",
"version": "0.1.0",
"operator": "Acme Software",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
"key_algorithm": "ed25519",
"key_format_version": 1
}
```
### `GET /healthz`
Liveness probe. Returns `{"ok": true}`.
### `GET /v1/pubkey`
Just the public key.
### `GET /v1/products`
List all active products.
### `GET /v1/products/:slug`
Single product by slug.
### `POST /v1/purchase`
Start a purchase.
Request:
```json
{
"product": "my-app",
"buyer_email": "alice@example.com",
"buyer_note": "optional",
"redirect_url": "https://myapp.example.com/thanks"
}
```
Response:
```json
{
"invoice_id": "uuid-of-our-row",
"btcpay_invoice_id": "...",
"checkout_url": "https://btcpay.example.com/i/...",
"amount_sats": 50000,
"poll_url": "https://license.example.com/v1/purchase/uuid-of-our-row"
}
```
### `GET /v1/purchase/:invoice_id`
Poll for license delivery.
While pending:
```json
{
"invoice_id": "...",
"status": "pending",
"product_id": "...",
"amount_sats": 50000,
"license_key": null,
"license_id": null
}
```
Once settled:
```json
{
"invoice_id": "...",
"status": "settled",
"product_id": "...",
"amount_sats": 50000,
"license_key": "LIC1-...-...",
"license_id": "..."
}
```
### `POST /v1/validate`
The hot path. Downstream software calls this at startup (and on a cadence) to check revocation.
Request:
```json
{
"key": "LIC1-...-...",
"product_slug": "my-app",
"fingerprint": "sha256-of-some-installation-unique-data"
}
```
`product_slug` and `fingerprint` are optional. If `fingerprint` is provided and the license row has no fingerprint bound yet, the first caller's fingerprint is locked to the license (trust-on-first-use). Later callers presenting a different fingerprint are rejected with `reason: "fingerprint_mismatch"`.
Response (always HTTP 200 so middleware doesn't log these as errors):
```json
{ "ok": true, "license_id": "...", "product_id": "...", "product_slug": "my-app", "issued_at": "..." }
```
On failure:
```json
{ "ok": false, "reason": "revoked" }
```
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `product_mismatch`, `fingerprint_mismatch`.
### `POST /v1/btcpay/webhook`
Landing point for BTCPay Server webhook events. Only BTCPay should call this. We verify `BTCPay-Sig` HMAC before trusting anything.
---
## Admin endpoints
All of these require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
### `POST /v1/admin/products`
```json
{
"slug": "my-app",
"name": "My App",
"description": "...",
"price_sats": 50000,
"metadata": { "anything": "useful" }
}
```
### `PATCH /v1/admin/products/:id/active`
Activate or deactivate a product.
```json
{ "active": false }
```
Deactivated products are hidden from public listings and reject new purchases; existing licenses continue to validate.
### `GET /v1/admin/licenses?product_id=...`
List licenses for a product.
### `POST /v1/admin/licenses`
Manually issue a license outside the purchase flow — for comps, press keys, developer testing.
```json
{ "product_slug": "my-app", "note": "comp for @alice" }
```
Response:
```json
{
"license_id": "...",
"product_id": "...",
"license_key": "LIC1-...-...",
"issued_at": "..."
}
```
### `POST /v1/admin/licenses/:id/revoke`
```json
{ "reason": "chargeback" }
```
Idempotent: revoking an already-revoked license returns 404.
+74
View File
@@ -0,0 +1,74 @@
# Architecture notes
## Design principles
**Decentralized by default.** Every licensing-service instance is independent. No phoning home, no shared state. If we vanish, every developer using this keeps running their own.
**Cryptography before databases.** A license key carries its own proof of legitimacy via an Ed25519 signature. The database is the authority on revocation and binding, but not on authenticity. This means downstream software doesn't break when your server has an outage.
**Idempotent webhooks.** BTCPay may retry a webhook. Settlement logic is designed so duplicate webhooks can't duplicate licenses (uniqueness enforced at the `licenses.invoice_id` column plus an existence check).
**Operator-owned secrets.** The signing key lives in SQLite and is covered by StartOS encrypted backups. The admin API key is env-driven and never logged. BTCPay credentials are env-driven. No secrets in git, no secrets in code.
## Data model
See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables:
- `products` — what's for sale. Independent pricing per product.
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns.
- `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us.
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot, never rotated in v0.1 (rotation is a planned feature).
## License key format
```
LIC1 - <base32(74-byte payload)> - <base32(64-byte signature)>
```
The payload is a fixed binary layout, not JSON, to keep keys short. Details in [`src/crypto/mod.rs`](../src/crypto/mod.rs).
Why base32 Crockford-style (no padding)?
- Uppercase only, unambiguous chars, easy to read aloud or type from a screen.
- Slightly longer than base64 but less error-prone for humans copying keys.
- Case-insensitive accept means users don't get mysteriously rejected keys.
Why include `issued_at` in the signed payload?
- Lets SDKs reject keys issued before a known revocation epoch without contacting the server (future feature).
- Lets admins spot anomalies in key-age distribution when investigating abuse.
Why optional `fingerprint_hash` *inside the signature*?
- If set, the key is cryptographically useless on any other machine even if DB state is somehow lost. Belt-and-suspenders.
- Not required — most commercial licenses use trust-on-first-use via the DB column instead, because hard binding breaks legitimate hardware upgrades.
## Threat model
Who might attack this?
1. **Pirate trying to use software without paying.** Must present a valid signed key. Can't mint one without the server's private key. Can't replay a key across machines if fingerprint-bound. Can't modify a revoked key into a fresh one without breaking the signature.
2. **Someone who compromises the licensing server.** Can mint keys, revoke keys, read the DB. That's the intended failure mode — the server is the trust root. Mitigations: run on a hardened StartOS instance, use encrypted backups, don't expose admin endpoints to the clearnet (use LAN-only or Tor-only exposure in the manifest).
3. **Someone MITM-ing the /v1/validate call.** Can't forge successful responses because legitimate clients also did offline signature verification first. Can serve stale "revoked" responses — denial of service at worst, not a bypass.
4. **BTCPay webhook spoofer.** Must know the shared HMAC secret. We verify in constant time and reject bad signatures with 401.
5. **Chargeback / dispute** (applicable to non-Bitcoin rails, but worth noting). Bitcoin payments are irreversible, so the normal fraud model that motivates software DRM mostly doesn't apply here. Most revocations will be: key leaked publicly, legitimate business decision, mistaken issuance.
## What's deliberately NOT in v0.1
- **Key rotation.** A single static signing key is fine for first launch. Rotation requires SDK multi-key support and a migration strategy; deferred.
- **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app.
- **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured).
- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change.
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
- **Admin UI.** Everything is API-driven. Wrap it in whatever UI you like — or just use `curl`.
## Notes on Start9 dependencies
When you write the s9pk manifest, `btcpayserver` is a declared dependency. StartOS resolves it to a `.startos` hostname that only works on the same server. If you ever want to run licensing-service pointing at a *remote* BTCPay, you can override `BTCPAY_URL` — the client is a plain HTTPS client, not bound to the StartOS mesh.
For webhooks going the other way (BTCPay → licensing), the webhook URL BTCPay calls will be your licensing service's `.local` or `.onion` hostname. Same-server Tor hop works fine.
+222
View File
@@ -0,0 +1,222 @@
# Developer integration guide
This guide is for developers who want their software to validate against a licensing-service instance. It doesn't matter whether your software is a Start9 package, a desktop app, or a server — the flow is the same.
## Core idea: two-phase validation
Licensing-service separates verification into two concerns:
1. **Signature verification** (offline, fast, deterministic) — prove the key was actually issued by the server. Needs only the server's Ed25519 public key, which you ship with your client.
2. **Revocation check** (online, authoritative) — confirm the server hasn't revoked the license. Requires a network call.
For most software, you should do both on startup, then **cache the revocation result** for some period (hours to a day) and fall back to the cached result if the server is briefly unreachable. That way:
- A bad or forged key is rejected instantly, without a network call.
- A legitimately paying user isn't locked out if the licensing server has a 10-minute hiccup.
- A revoked key is detected within your cache window.
## Bundling the public key
When you set up your licensing-service instance, fetch the public key once:
```bash
curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem
```
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
## Reference integration in Rust
This is what a Start9 package written in Rust might look like. No SDK crate yet — that's planned; here's what you'd write by hand:
```rust
use anyhow::{Context, Result};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use ed25519_dalek::pkcs8::DecodePublicKey;
use data_encoding::BASE32_NOPAD;
// Pinned at compile time from the licensing server's /v1/pubkey output.
const SERVER_PUBLIC_KEY_PEM: &str = r#"
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...your-public-key...
-----END PUBLIC KEY-----
"#;
const LICENSING_URL: &str = "https://license.example.com";
const PRODUCT_SLUG: &str = "my-app";
pub struct LicenseCheck {
pub license_id: String,
pub product_id: String,
}
pub fn offline_verify(license_key: &str) -> Result<()> {
let vk = VerifyingKey::from_public_key_pem(SERVER_PUBLIC_KEY_PEM)
.context("bundled public key is invalid")?;
let mut parts = license_key.trim().splitn(3, '-');
let prefix = parts.next().context("empty key")?;
anyhow::ensure!(prefix == "LIC1", "unknown key prefix");
let payload_b32 = parts.next().context("no payload")?;
let sig_b32 = parts.next().context("no signature")?;
let payload = BASE32_NOPAD.decode(payload_b32.to_ascii_uppercase().as_bytes())?;
let sig_bytes = BASE32_NOPAD.decode(sig_b32.to_ascii_uppercase().as_bytes())?;
let sig_array: [u8; 64] = sig_bytes.as_slice().try_into()
.context("signature length != 64")?;
let sig = Signature::from_bytes(&sig_array);
vk.verify(&payload, &sig).context("signature invalid")?;
Ok(())
}
pub async fn validate_online(
license_key: &str,
fingerprint: &str,
) -> Result<LicenseCheck> {
#[derive(serde::Deserialize)]
struct Resp {
ok: bool,
reason: Option<String>,
license_id: Option<String>,
product_id: Option<String>,
}
let resp: Resp = reqwest::Client::new()
.post(format!("{LICENSING_URL}/v1/validate"))
.json(&serde_json::json!({
"key": license_key,
"product_slug": PRODUCT_SLUG,
"fingerprint": fingerprint,
}))
.send()
.await?
.json()
.await?;
if !resp.ok {
anyhow::bail!("license rejected: {}", resp.reason.unwrap_or_default());
}
Ok(LicenseCheck {
license_id: resp.license_id.unwrap(),
product_id: resp.product_id.unwrap(),
})
}
```
## Reference integration in TypeScript
```ts
import { webcrypto } from "node:crypto";
const SERVER_PUBLIC_KEY_PEM = `
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...your-public-key...
-----END PUBLIC KEY-----
`;
const LICENSING_URL = "https://license.example.com";
const PRODUCT_SLUG = "my-app";
function base32NoPadDecode(s: string): Uint8Array {
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const out: number[] = [];
let bits = 0, value = 0;
for (const c of s.toUpperCase()) {
const idx = ALPHABET.indexOf(c);
if (idx < 0) throw new Error("bad base32 char: " + c);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
async function importPubKey(): Promise<CryptoKey> {
const pem = SERVER_PUBLIC_KEY_PEM
.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, "")
.replace(/\s+/g, "");
const der = Uint8Array.from(Buffer.from(pem, "base64"));
return webcrypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
}
export async function offlineVerify(key: string): Promise<void> {
const [prefix, payloadB32, sigB32] = key.trim().split("-");
if (prefix !== "LIC1") throw new Error("bad prefix");
const payload = base32NoPadDecode(payloadB32);
const sig = base32NoPadDecode(sigB32);
const pk = await importPubKey();
const ok = await webcrypto.subtle.verify("Ed25519", pk, sig, payload);
if (!ok) throw new Error("signature invalid");
}
export async function validateOnline(key: string, fingerprint: string) {
const r = await fetch(`${LICENSING_URL}/v1/validate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key, product_slug: PRODUCT_SLUG, fingerprint }),
});
const body = await r.json();
if (!body.ok) throw new Error(`license rejected: ${body.reason}`);
return body;
}
```
## Graceful degradation pattern
```
on startup:
key = read_license_from_storage()
if key is None:
prompt_user_for_license_or_start_trial()
return
try offline_verify(key) # instant; fail closed on bad signature
except BadSignature:
mark_installation_unlicensed()
return
try online_validate(key, fingerprint)
except NetworkError:
cached = read_cache()
if cached is valid and < 7 days old:
proceed()
else:
warn_user("licensing server unreachable for > 7 days")
proceed() # or refuse, if you prefer strict
except Rejected(reason):
handle_rejection(reason)
on every N hours in background:
re-run online_validate, refresh cache
```
Choosing the cache TTL is a business decision: long TTL = better uptime resilience, slower revocation propagation. A day to a week covers most sane cases.
## Fingerprint strategy
A fingerprint is any string that uniquely identifies an installation. Common choices, roughly from stable to less stable:
- A random 256-bit value you generate and persist in your app's data directory on first run. **Recommended** — stable across reboots, you control it, doesn't leak anything about the host.
- On Start9: the service's `TOR_ADDRESS` env var, hashed.
- Machine UUID from `/etc/machine-id` on Linux. Leaks a real identifier but is available without any state.
- Combination of MAC + hostname — avoid; user-visible and changes on network moves.
Whatever you pick, hash it before sending if you want to avoid exposing the underlying identifier in network traffic.
## Reasoning about failure modes
| Scenario | What happens |
|------------------------------------------|------------------------------------------------------------|
| Licensing server down, user has valid key | Your software uses cached result and keeps working. |
| Licensing server down, first-ever startup | Offline verification passes; online validation fails; you decide whether to proceed or block. |
| Forged key | Offline verification rejects instantly, no network call. |
| Valid key but revoked | Online validation returns `reason: "revoked"`; block or downgrade. |
| Valid key but user swaps hardware | Online validation returns `fingerprint_mismatch`; user contacts you to transfer. |
| Network censorship in user's region | Consider shipping a Tor client so they can reach your `.onion`. |
## Tor / `.onion` support
Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-paid and privacy-adjacent.
@@ -0,0 +1,89 @@
-- Initial schema for the licensing service.
--
-- SQLite is used in WAL mode; all tables are intentionally flat and indexed
-- for the common query paths (validate by key_id, list by product, look up by
-- invoice_id from BTCPay webhooks).
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY, -- UUID v4
slug TEXT NOT NULL UNIQUE, -- human-friendly id used in URLs
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price_sats INTEGER NOT NULL, -- price in satoshis
active INTEGER NOT NULL DEFAULT 1, -- boolean; 0 hides from listings
metadata_json TEXT NOT NULL DEFAULT '{}', -- arbitrary developer metadata
created_at TEXT NOT NULL, -- ISO-8601 UTC
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_products_active ON products(active);
-- Invoices track BTCPay payment attempts. One invoice maps to at most one
-- license. If payment never completes, the invoice just sits in 'pending' /
-- 'expired' and no license is ever issued.
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY, -- UUID v4 (our id)
btcpay_invoice_id TEXT NOT NULL UNIQUE, -- id from BTCPay Server
product_id TEXT NOT NULL,
status TEXT NOT NULL, -- pending | settled | expired | invalid
buyer_email TEXT, -- optional, supplied at purchase
buyer_note TEXT, -- optional purchase note
amount_sats INTEGER NOT NULL,
checkout_url TEXT NOT NULL, -- BTCPay checkout URL
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (product_id) REFERENCES products(id)
);
CREATE INDEX IF NOT EXISTS idx_invoices_btcpay_id ON invoices(btcpay_invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
-- Licenses are the issued proofs-of-purchase. The `key_id` is what a client
-- presents when validating; the actual user-facing license key string is a
-- signed envelope containing this id plus metadata (see crypto module).
CREATE TABLE IF NOT EXISTS licenses (
id TEXT PRIMARY KEY, -- UUID v4, also the `license_id` in the signed payload
product_id TEXT NOT NULL,
invoice_id TEXT UNIQUE, -- NULL for manually-issued / comped licenses
status TEXT NOT NULL, -- active | revoked
fingerprint TEXT, -- optional machine fingerprint locked on first validation
bound_identity TEXT, -- optional user identity (email, pubkey, etc.) locked on first use
issued_at TEXT NOT NULL,
revoked_at TEXT,
revocation_reason TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY (product_id) REFERENCES products(id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);
CREATE INDEX IF NOT EXISTS idx_licenses_product ON licenses(product_id);
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
-- Audit log of validation attempts. Useful for abuse detection and for
-- developers building rate-limiting policies on top.
CREATE TABLE IF NOT EXISTS validation_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id TEXT,
product_id TEXT,
fingerprint TEXT,
result TEXT NOT NULL, -- ok | bad_signature | revoked | product_mismatch | fingerprint_mismatch | not_found
client_ip TEXT,
user_agent TEXT,
occurred_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_validation_license ON validation_log(license_id);
CREATE INDEX IF NOT EXISTS idx_validation_time ON validation_log(occurred_at);
-- Server-wide signing key. Stored here (rather than on disk) so a SQLite
-- backup captures the full server state. The private key is PEM-encoded.
-- Generated on first boot if no row exists.
CREATE TABLE IF NOT EXISTS server_keys (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
algorithm TEXT NOT NULL, -- 'ed25519'
public_key_pem TEXT NOT NULL,
private_key_pem TEXT NOT NULL,
created_at TEXT NOT NULL
);
@@ -0,0 +1,28 @@
-- BTCPay connection state.
--
-- Before v0.1 this lived purely in environment variables; now it's persisted
-- in the DB so the operator can connect to BTCPay via the one-click authorize
-- flow instead of pasting an API key into an env file.
--
-- A single row (id = 1). Rows are upserted on connect / reset.
CREATE TABLE IF NOT EXISTS btcpay_config (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
base_url TEXT NOT NULL, -- BTCPay base URL
api_key TEXT NOT NULL, -- issued by authorize flow
store_id TEXT NOT NULL, -- selected store id
webhook_id TEXT, -- BTCPay webhook id (for update/delete)
webhook_secret TEXT NOT NULL, -- HMAC-SHA256 secret shared with BTCPay
connected_at TEXT NOT NULL -- ISO-8601 UTC
);
-- CSRF tokens for an in-flight authorize round trip. The service generates one
-- when the operator clicks "Connect BTCPay", then validates it on the redirect
-- callback. Short-lived; pruned by timestamp.
CREATE TABLE IF NOT EXISTS btcpay_authorize_state (
state_token TEXT PRIMARY KEY,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_btcpay_authorize_state_time
ON btcpay_authorize_state(created_at);
@@ -0,0 +1,178 @@
-- Expanded features: policies, machines, entitlements, expiry + grace,
-- suspension, outbound webhooks, admin audit log, and token-bucket rate
-- limiting. This migration is additive — v1 licenses issued before it was
-- applied still work, because the missing columns get sensible defaults.
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- Policies (Keygen-style license templates)
--
-- A policy encapsulates "how should licenses of this shape behave" so the
-- developer doesn't have to hand-pick values on every issuance. Example
-- policies for a single product: "Pro Perpetual", "Pro Annual",
-- "Pro 14-day Trial".
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS policies (
id TEXT PRIMARY KEY, -- UUID v4
product_id TEXT NOT NULL,
name TEXT NOT NULL, -- human-readable, e.g. "Pro Perpetual"
slug TEXT NOT NULL, -- short machine-id, unique within product
duration_seconds INTEGER NOT NULL DEFAULT 0, -- 0 = perpetual; else seconds from issuance to expiry
grace_seconds INTEGER NOT NULL DEFAULT 0, -- additional seconds after expiry where validate still returns ok with a warning
max_machines INTEGER NOT NULL DEFAULT 1, -- concurrent-activation cap; 1 mimics "one seat", 0 = unlimited
is_trial INTEGER NOT NULL DEFAULT 0, -- 0/1; trials get FLAG_TRIAL in signed payload
price_sats_override INTEGER, -- if set, overrides product.price_sats for invoices using this policy
entitlements_json TEXT NOT NULL DEFAULT '[]', -- JSON array of feature slugs baked into every license
metadata_json TEXT NOT NULL DEFAULT '{}', -- free-form developer metadata
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (product_id) REFERENCES products(id),
UNIQUE (product_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_policies_product ON policies(product_id);
CREATE INDEX IF NOT EXISTS idx_policies_active ON policies(active);
-- ---------------------------------------------------------------------------
-- Licenses — extended
--
-- New columns for expiry, grace, suspension, entitlements cache, seat cap,
-- trial flag, and an optional Nostr npub (we'll use this later for DM-based
-- key delivery / recovery). None of these columns are required; older rows
-- get sensible defaults via DEFAULT clauses.
-- ---------------------------------------------------------------------------
ALTER TABLE licenses ADD COLUMN policy_id TEXT REFERENCES policies(id);
ALTER TABLE licenses ADD COLUMN expires_at TEXT; -- ISO-8601 UTC; NULL = perpetual
ALTER TABLE licenses ADD COLUMN grace_seconds INTEGER NOT NULL DEFAULT 0;
ALTER TABLE licenses ADD COLUMN max_machines INTEGER NOT NULL DEFAULT 1;
ALTER TABLE licenses ADD COLUMN suspended_at TEXT;
ALTER TABLE licenses ADD COLUMN suspension_reason TEXT;
ALTER TABLE licenses ADD COLUMN entitlements_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE licenses ADD COLUMN is_trial INTEGER NOT NULL DEFAULT 0;
ALTER TABLE licenses ADD COLUMN nostr_npub TEXT;
ALTER TABLE licenses ADD COLUMN buyer_email TEXT; -- denormalized from invoice for admin search; NULL for comps without email
CREATE INDEX IF NOT EXISTS idx_licenses_policy ON licenses(policy_id);
CREATE INDEX IF NOT EXISTS idx_licenses_expires ON licenses(expires_at);
CREATE INDEX IF NOT EXISTS idx_licenses_buyer_email ON licenses(buyer_email);
CREATE INDEX IF NOT EXISTS idx_licenses_nostr_npub ON licenses(nostr_npub);
-- ---------------------------------------------------------------------------
-- Machines (multi-seat activation model)
--
-- Replaces the single-column `fingerprint` on licenses for licenses that
-- allow more than one concurrent machine. Older code paths that only look at
-- licenses.fingerprint still work for single-seat licenses, but validate.rs
-- now also consults this table when max_machines != 1.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS machines (
id TEXT PRIMARY KEY, -- UUID v4
license_id TEXT NOT NULL,
fingerprint TEXT NOT NULL, -- raw client-supplied id (we never stored the hash server-side; we store raw to allow rebind)
fingerprint_hash TEXT NOT NULL, -- hex of SHA-256(fingerprint); indexed for fast lookup
hostname TEXT, -- optional human-friendly label the client may supply
platform TEXT, -- optional "linux-x64", "darwin-arm64", etc.
ip_last_seen TEXT,
activated_at TEXT NOT NULL,
last_heartbeat_at TEXT,
deactivated_at TEXT, -- NULL = active
deactivation_reason TEXT,
FOREIGN KEY (license_id) REFERENCES licenses(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_machines_license_fp ON machines(license_id, fingerprint_hash) WHERE deactivated_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_machines_license ON machines(license_id);
CREATE INDEX IF NOT EXISTS idx_machines_heartbeat ON machines(last_heartbeat_at);
-- ---------------------------------------------------------------------------
-- Outbound webhooks
--
-- Mirror of BTCPay's model: an endpoint is a URL + signing secret; each
-- delivery gets logged so admins can debug and retry.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS webhook_endpoints (
id TEXT PRIMARY KEY, -- UUID v4
url TEXT NOT NULL,
secret TEXT NOT NULL, -- HMAC-SHA256 key (random, 32 bytes, hex)
event_types TEXT NOT NULL DEFAULT '["*"]', -- JSON array of subscribed event types; "*" = all
active INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_active ON webhook_endpoints(active);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id TEXT PRIMARY KEY, -- UUID v4
endpoint_id TEXT NOT NULL,
event_type TEXT NOT NULL, -- license.issued, license.revoked, license.suspended, machine.activated, invoice.settled, etc.
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT, -- NULL once delivered or permanently failed
last_status_code INTEGER,
last_error TEXT,
delivered_at TEXT, -- NULL until success
created_at TEXT NOT NULL,
FOREIGN KEY (endpoint_id) REFERENCES webhook_endpoints(id)
);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint ON webhook_deliveries(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_next ON webhook_deliveries(next_attempt_at) WHERE delivered_at IS NULL;
-- ---------------------------------------------------------------------------
-- Admin audit log
--
-- Every mutation initiated through the admin API (product create, license
-- revoke, suspension, policy change, webhook edit, BTCPay reconnect, manual
-- issuance, etc.) writes one row. The API key used is hashed before storage
-- so the log alone can't be used to recover the key.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_kind TEXT NOT NULL, -- 'admin_api_key' | 'system' | 'btcpay_webhook'
actor_hash TEXT, -- SHA-256 of the actor's credential, or NULL for system
action TEXT NOT NULL, -- dotted event name: product.create, license.revoke, etc.
target_kind TEXT, -- 'product' | 'license' | 'policy' | 'machine' | 'webhook' | 'invoice' | NULL
target_id TEXT,
request_ip TEXT,
user_agent TEXT,
details_json TEXT NOT NULL DEFAULT '{}',
occurred_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_occurred ON audit_log(occurred_at);
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_kind, target_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
-- ---------------------------------------------------------------------------
-- Token-bucket rate limiting
--
-- We keep one row per (bucket_kind, bucket_key) so that e.g. per-IP validate
-- buckets and per-license heartbeat buckets are stored in the same table.
-- The refill happens lazily on every hit (classic token-bucket algorithm)
-- so there's no background filler task to worry about.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS rate_buckets (
bucket_kind TEXT NOT NULL, -- 'validate_ip', 'validate_license', 'heartbeat_license', 'admin_ip', ...
bucket_key TEXT NOT NULL, -- the IP, license_id, etc.
tokens_remaining REAL NOT NULL,
capacity REAL NOT NULL,
refill_per_second REAL NOT NULL,
last_refill_at TEXT NOT NULL, -- ISO-8601; refill math runs off this
PRIMARY KEY (bucket_kind, bucket_key)
);
CREATE INDEX IF NOT EXISTS idx_rate_buckets_refill ON rate_buckets(last_refill_at);
-- ---------------------------------------------------------------------------
-- Validation log — extended
--
-- Add columns for the new reject reasons (expired, suspended, too_many_machines)
-- so admins can tell at a glance why a check failed. The `result` column was
-- already TEXT so we just start writing new values to it.
-- ---------------------------------------------------------------------------
ALTER TABLE validation_log ADD COLUMN machine_id TEXT; -- the machines.id that was matched / created, if any
ALTER TABLE validation_log ADD COLUMN reason_detail TEXT; -- optional extra string, e.g. "grace period remaining: 3d"
@@ -0,0 +1,71 @@
-- Discount / referral codes.
--
-- A `discount_code` is a redeemable token (e.g. "FOUNDERS50") that reduces
-- the price of a purchase. A code can be either a percentage off (basis
-- points: 5000 = 50%) or a fixed sats off, can target a specific product
-- or policy or be universal, can have an optional usage cap and expiry,
-- and carries an optional `referrer_label` for tracking purposes (campaign
-- name, partner email, npub — free-form, not a separate user record).
--
-- Atomicity: `used_count` is incremented at purchase-start time via a
-- conditional UPDATE that gates on the cap. A `discount_redemptions` row
-- is inserted with status='pending' alongside the increment. The
-- redemption transitions to 'redeemed' on invoice settlement, or
-- 'cancelled' on invoice expiry/invalid (with a corresponding decrement
-- of used_count so freed slots become available again).
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS discount_codes (
id TEXT PRIMARY KEY, -- UUID v4
code TEXT NOT NULL UNIQUE, -- normalized to UPPERCASE on insert; case-insensitive lookup
kind TEXT NOT NULL, -- 'percent' | 'fixed_sats' | 'free_license'
amount INTEGER NOT NULL, -- basis points if percent, sats if fixed_sats, ignored if free_license (set to 0)
max_uses INTEGER, -- NULL = unlimited
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT, -- ISO-8601 UTC; NULL = never
applies_to_product_id TEXT, -- NULL = any product
applies_to_policy_id TEXT, -- NULL = any policy
referrer_label TEXT, -- optional, e.g. 'twitter-launch', 'alice@example.com'
description TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (applies_to_product_id) REFERENCES products(id),
FOREIGN KEY (applies_to_policy_id) REFERENCES policies(id),
CHECK (kind IN ('percent', 'fixed_sats', 'free_license')),
CHECK (amount >= 0),
CHECK (used_count >= 0)
);
CREATE INDEX IF NOT EXISTS idx_discount_codes_active ON discount_codes(active);
CREATE INDEX IF NOT EXISTS idx_discount_codes_product ON discount_codes(applies_to_product_id);
CREATE INDEX IF NOT EXISTS idx_discount_codes_policy ON discount_codes(applies_to_policy_id);
CREATE INDEX IF NOT EXISTS idx_discount_codes_expires ON discount_codes(expires_at);
CREATE TABLE IF NOT EXISTS discount_redemptions (
id TEXT PRIMARY KEY, -- UUID v4
code_id TEXT NOT NULL,
invoice_id TEXT NOT NULL, -- references invoices(id)
license_id TEXT, -- populated when license is issued
status TEXT NOT NULL, -- 'pending' | 'redeemed' | 'cancelled'
discount_applied_sats INTEGER NOT NULL, -- base - final
base_price_sats INTEGER NOT NULL, -- snapshot of product price at reservation time
final_price_sats INTEGER NOT NULL, -- what BTCPay was actually charged
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (code_id) REFERENCES discount_codes(id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id),
FOREIGN KEY (license_id) REFERENCES licenses(id),
CHECK (status IN ('pending', 'redeemed', 'cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_code ON discount_redemptions(code_id);
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_invoice ON discount_redemptions(invoice_id);
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_license ON discount_redemptions(license_id);
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_status ON discount_redemptions(status);
-- One redemption per invoice — a buyer can apply at most one code per
-- purchase. If they want to layer codes, they'll need a v0.2 feature.
CREATE UNIQUE INDEX IF NOT EXISTS idx_discount_redemptions_one_per_invoice
ON discount_redemptions(invoice_id);
@@ -0,0 +1,16 @@
-- Runtime-mutable settings, intentionally separated from the
-- startup-only env-var config in `Config::from_env`. Anything that
-- should be live-editable through admin actions or the future web UI —
-- and survive a daemon restart — goes here.
--
-- The table is a generic key/value store rather than dedicated columns
-- because the set of settings will grow over time, and the cost of a
-- key/value pattern with at most a few dozen rows is nil.
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT NOT NULL
);
@@ -0,0 +1,43 @@
-- Migration 0006: tip-recipient on policy.
--
-- Lets the operator configure a Lightning recipient + percentage on each
-- policy. When a license issued under that policy settles, the daemon
-- tries to send a Lightning tip of (license_price_sats * tip_pct_bps / 10000)
-- to tip_recipient via the operator's BTCPay Lightning node.
--
-- All three fields are nullable / zero-default. Existing policies are
-- unaffected: with NULL recipient the issuance hook is a no-op.
--
-- Recipient can be a Lightning Address (e.g. tip@keysat.xyz). LNURL-pay
-- support may be added later; the current implementation resolves only
-- Lightning Addresses via the .well-known/lnurlp/<user> endpoint.
ALTER TABLE policies ADD COLUMN tip_recipient TEXT;
ALTER TABLE policies ADD COLUMN tip_pct_bps INTEGER NOT NULL DEFAULT 0;
ALTER TABLE policies ADD COLUMN tip_label TEXT;
-- Audit log for tip attempts. Insert one row per try, success or failure.
-- Operators consult this for accounting and for debugging when a tip
-- doesn't fire as expected.
CREATE TABLE IF NOT EXISTS tip_attempts (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL,
policy_id TEXT NOT NULL,
recipient TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
pct_bps INTEGER NOT NULL,
label TEXT,
-- 'sent' | 'failed' | 'skipped' (e.g. zero amount, no LN node)
status TEXT NOT NULL,
-- Error or success detail message.
detail TEXT,
-- Lightning payment hash on success, null on failure.
payment_hash TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (license_id) REFERENCES licenses(id),
FOREIGN KEY (policy_id) REFERENCES policies(id)
);
CREATE INDEX IF NOT EXISTS idx_tip_attempts_license ON tip_attempts(license_id);
CREATE INDEX IF NOT EXISTS idx_tip_attempts_recipient ON tip_attempts(recipient);
CREATE INDEX IF NOT EXISTS idx_tip_attempts_created ON tip_attempts(created_at);
+566
View File
@@ -0,0 +1,566 @@
//! Admin endpoints — all require `Authorization: Bearer <admin_api_key>`.
//! The operator uses these to manage products and issue/revoke licenses.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap},
Json,
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
/// Guards every admin handler: pulls the bearer token out of the header and
/// compares constant-time against the configured admin key. Returns the
/// SHA-256 hex of the token on success so handlers can write an audit row
/// that identifies *which* credential made the call without logging the raw
/// key.
pub fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult<String> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
Ok(hex::encode(hasher.finalize()))
} else {
Err(AppError::Forbidden)
}
}
/// Pull the best-effort client IP and User-Agent out of the request headers
/// for audit logging.
pub fn request_context(headers: &HeaderMap) -> (Option<String>, Option<String>) {
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string())
.filter(|s| !s.is_empty());
let ua = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
(client_ip, ua)
}
// ---------- Products ----------
#[derive(Debug, Deserialize)]
pub struct CreateProductReq {
pub slug: String,
pub name: String,
#[serde(default)]
pub description: String,
pub price_sats: i64,
#[serde(default)]
pub metadata: Value,
}
pub async fn create_product(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.price_sats <= 0 {
return Err(AppError::BadRequest("price_sats must be positive".into()));
}
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
let product = repo::create_product(
&state.db,
&req.slug,
&req.name,
&req.description,
req.price_sats,
&metadata,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.create",
Some("product"),
Some(&product.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "slug": product.slug, "name": product.name, "price_sats": product.price_sats }),
)
.await;
crate::webhooks::dispatch(
&state,
"product.created",
&json!({ "product": product }),
)
.await;
Ok(Json(json!(product)))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_product_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_product_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"product.set_active",
Some("product"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Licenses ----------
#[derive(Debug, Deserialize)]
pub struct ListLicensesQuery {
pub product_id: String,
}
pub async fn list_licenses(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?;
Ok(Json(json!({ "licenses": licenses })))
}
#[derive(Debug, Deserialize)]
pub struct SearchLicensesQuery {
pub buyer_email: Option<String>,
pub nostr_npub: Option<String>,
pub invoice_id: Option<String>,
}
/// Free-form lookup used by the "lost key recovery" flow. Searches by email,
/// Nostr npub, or invoice id (whichever is supplied), returns up to 100
/// matching licenses.
pub async fn search_licenses(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<SearchLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let licenses = repo::search_licenses(
&state.db,
q.buyer_email.as_deref(),
q.nostr_npub.as_deref(),
q.invoice_id.as_deref(),
)
.await?;
Ok(Json(json!({ "licenses": licenses })))
}
#[derive(Debug, Deserialize)]
pub struct IssueLicenseReq {
pub product_slug: String,
/// Optional policy slug (within the product). When set, the policy's
/// duration, grace, entitlements, trial flag, and machine cap are used.
#[serde(default)]
pub policy_slug: Option<String>,
/// Optional reason for audit — e.g. "comp", "press", "giveaway".
#[serde(default)]
pub note: Option<String>,
/// Override expiry (ISO-8601 UTC). Ignored if `policy_slug` is set.
#[serde(default)]
pub expires_at: Option<String>,
/// Override entitlements. Ignored if `policy_slug` is set.
#[serde(default)]
pub entitlements: Option<Vec<String>>,
#[serde(default)]
pub max_machines: Option<i64>,
#[serde(default)]
pub grace_seconds: Option<i64>,
#[serde(default)]
pub is_trial: Option<bool>,
#[serde(default)]
pub buyer_email: Option<String>,
#[serde(default)]
pub nostr_npub: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct IssueLicenseResp {
pub license_id: String,
pub product_id: String,
pub license_key: String,
pub issued_at: String,
pub expires_at: Option<String>,
pub entitlements: Vec<String>,
pub is_trial: bool,
pub max_machines: i64,
}
/// Manually issue a license outside the purchase flow. Useful for comps,
/// press keys, grandfathered users, trial keys, or developer testing.
pub async fn issue_license(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<IssueLicenseReq>,
) -> AppResult<Json<IssueLicenseResp>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
// Pull the policy (if any) and merge it with per-call overrides.
let policy = if let Some(slug) = &req.policy_slug {
Some(
repo::get_policy_by_slug(&state.db, &product.id, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug
))
})?,
)
} else {
None
};
// Compose effective values: explicit request fields take precedence over
// the policy, which takes precedence over defaults.
let now = Utc::now();
let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
let expires_at = match (req.expires_at.clone(), duration_seconds) {
(Some(explicit), _) => Some(explicit),
(None, 0) => None, // perpetual
(None, secs) => Some((now + Duration::seconds(secs)).to_rfc3339()),
};
let grace_seconds = req
.grace_seconds
.or_else(|| policy.as_ref().map(|p| p.grace_seconds))
.unwrap_or(0);
let max_machines = req
.max_machines
.or_else(|| policy.as_ref().map(|p| p.max_machines))
.unwrap_or(1);
let is_trial = req
.is_trial
.or_else(|| policy.as_ref().map(|p| p.is_trial))
.unwrap_or(false);
let entitlements = req
.entitlements
.clone()
.or_else(|| policy.as_ref().map(|p| p.entitlements.clone()))
.unwrap_or_default();
let license_id = uuid::Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&issued_at,
&json!({
"source": "admin_issue",
"note": req.note,
}),
policy.as_ref().map(|p| p.id.as_str()),
expires_at.as_deref(),
grace_seconds,
max_machines,
&entitlements,
is_trial,
req.buyer_email.as_deref(),
req.nostr_npub.as_deref(),
)
.await?;
// Build v2 signed payload.
let mut flags = 0u8;
if is_trial {
flags |= crate::crypto::FLAG_TRIAL;
}
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&product.id).unwrap(),
license_id: uuid::Uuid::parse_str(&license_id).unwrap(),
issued_at: now.timestamp(),
expires_at: expires_at
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp())
.unwrap_or(0),
fingerprint_hash: [0u8; 32],
entitlements: entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.issue_manual",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({
"product_id": product.id,
"policy_id": policy.as_ref().map(|p| &p.id),
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.issued",
&json!({
"license_id": license_id,
"product_id": product.id,
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
"source": "admin_issue",
}),
)
.await;
Ok(Json(IssueLicenseResp {
license_id,
product_id: product.id,
license_key,
issued_at,
expires_at,
entitlements,
is_trial,
max_machines,
}))
}
#[derive(Debug, Deserialize)]
pub struct RevokeReq {
#[serde(default)]
pub reason: String,
}
pub async fn revoke_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(req): Json<RevokeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin revoke".to_string()
} else {
req.reason
};
repo::revoke_license(&state.db, &license_id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.revoke",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"license.revoked",
&json!({ "license_id": license_id, "reason": reason }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Suspension / un-suspension ----------
#[derive(Debug, Deserialize)]
pub struct SuspendReq {
#[serde(default)]
pub reason: String,
}
pub async fn suspend_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(req): Json<SuspendReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin suspend".to_string()
} else {
req.reason
};
repo::suspend_license(&state.db, &license_id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.suspend",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"license.suspended",
&json!({ "license_id": license_id, "reason": reason }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
pub async fn unsuspend_license(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::unsuspend_license(&state.db, &license_id).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.unsuspend",
Some("license"),
Some(&license_id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.unsuspended",
&json!({ "license_id": license_id }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
// ---------- Audit log viewer ----------
#[derive(Debug, Deserialize)]
pub struct ListAuditQuery {
#[serde(default = "default_audit_limit")]
pub limit: i64,
pub action: Option<String>,
}
fn default_audit_limit() -> i64 {
200
}
pub async fn list_audit(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListAuditQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?;
Ok(Json(json!({ "entries": rows })))
}
// ---------- Settings (live-mutable runtime config) ----------
/// Settings key for the operator's public-facing display name. Read by
/// the `/` index handler on every request, so updates take effect
/// immediately — no daemon restart needed.
pub const SETTING_OPERATOR_NAME: &str = "operator_name";
#[derive(Debug, Deserialize)]
pub struct SetOperatorNameReq {
/// New operator name. Empty string clears the setting (reverts to
/// the daemon's startup-time fallback from KEYSAT_OPERATOR_NAME).
pub name: String,
}
pub async fn set_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<SetOperatorNameReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let trimmed = req.name.trim();
let stored: Option<&str> = if trimmed.is_empty() { None } else { Some(trimmed) };
repo::settings_set(&state.db, SETTING_OPERATOR_NAME, stored).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"operator_name.set",
Some("setting"),
Some(SETTING_OPERATOR_NAME),
ip.as_deref(),
ua.as_deref(),
&json!({ "value": stored }),
)
.await;
Ok(Json(json!({ "ok": true, "operator_name": stored })))
}
pub async fn get_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?;
let effective = stored
.clone()
.or_else(|| state.config.operator_name.clone());
Ok(Json(json!({
"stored": stored,
"effective": effective,
"fallback_env": state.config.operator_name,
})))
}
+86
View File
@@ -0,0 +1,86 @@
//! Embedded admin web UI.
//!
//! At compile time, every file in `licensing-service/web/` is bundled
//! into the binary via `rust-embed`. At runtime, axum serves them under
//! `/admin/*` — no separate static-file deployment, no nginx, no proxy.
//! The whole admin SPA ships in the same `keysat` executable as the
//! daemon.
//!
//! Auth model: NONE at this HTTP layer. The static assets themselves
//! (HTML, CSS, JS) are public — there's nothing secret in them. The
//! actual gating happens client-side: the index page prompts for the
//! operator's admin API key on first load, validates it against any
//! `/v1/admin/*` endpoint, stores it in localStorage, and uses it as
//! `Authorization: Bearer ...` on every subsequent admin call. The
//! admin-scoped endpoints already enforce the key constant-time, so a
//! random visitor can load `/admin/index.html` but cannot do anything
//! useful without the key.
//!
//! v0.2 first cut: this is scaffolding only. The HTML page contains a
//! login form + a placeholder dashboard. Future SPA work just adds
//! more files into `web/` (or replaces index.html with a built React /
//! Svelte bundle); the serving code below doesn't change.
use axum::{
body::Body,
http::{header, StatusCode, Uri},
response::{IntoResponse, Redirect, Response},
};
use rust_embed::RustEmbed;
/// Compile-time-bundled directory of static admin UI assets. Every file
/// under `web/` (relative to the crate root) is embedded byte-for-byte
/// into the binary.
#[derive(RustEmbed)]
#[folder = "web/"]
struct AdminAssets;
/// `GET /admin` — redirect to `/admin/` so the relative paths in the
/// embedded HTML resolve correctly.
pub async fn admin_root_redirect() -> Redirect {
Redirect::permanent("/admin/")
}
/// `GET /admin/` — serve the SPA shell (index.html).
pub async fn admin_index() -> Response {
serve_embedded("index.html")
}
/// `GET /admin/*path` — serve any other embedded static file. Falls
/// through to `index.html` for unknown paths so client-side routing
/// (e.g. /admin/products, /admin/licenses) works without server-side
/// route registration.
pub async fn admin_asset(uri: Uri) -> Response {
// The Uri here will be the FULL path (including the /admin prefix).
// Strip the prefix to look up the asset.
let path = uri.path();
let stripped = path.strip_prefix("/admin/").unwrap_or(path);
if stripped.is_empty() {
return serve_embedded("index.html");
}
if AdminAssets::get(stripped).is_some() {
serve_embedded(stripped)
} else {
// Unknown path — fall through to index.html so the SPA's
// client-side router can take over. This is the canonical
// fallback pattern for SPAs hosted on path prefixes.
serve_embedded("index.html")
}
}
fn serve_embedded(path: &str) -> Response {
match AdminAssets::get(path) {
Some(file) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.to_string())
// Modest caching — these are versioned with the binary,
// so cache for an hour. A binary upgrade rolls the
// service which evicts the cache anyway.
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(file.data.into_owned()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
@@ -0,0 +1,415 @@
//! BTCPay one-click authorize flow.
//!
//! Instead of making the operator generate an API key by hand and paste it
//! into a form, we use BTCPay's "authorize" redirect flow:
//!
//! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action
//! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token)
//! and gets back a BTCPay URL to open in the operator's browser.
//! 2. The operator, already logged into BTCPay on the same box, sees a
//! consent page listing the permissions this service is requesting. They
//! click **Authorize**.
//! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the
//! newly-minted API key and the store(s) it was scoped to.
//! 4. We persist the key, pick the target store, register the webhook (with
//! a freshly-generated secret), and save everything in `btcpay_config`.
//! 5. From that moment on, the `BtcpayProvider` (held as an `Arc<dyn
//! PaymentProvider>` in `AppState.payment`) is populated
//! and purchase / webhook endpoints work.
//!
//! If the callback fails for any reason, the operator is shown an error page
//! and can retry. The admin endpoint requires the admin bearer token; the
//! callback path uses the CSRF `state` token to tie a callback back to the
//! issuing operator session.
use crate::api::{admin::require_admin, AppState};
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
use crate::btcpay::config as btcpay_cfg;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Form, Json,
};
use data_encoding::BASE32_NOPAD;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Permissions we request on the authorize page. Each is namespaced by
/// `btcpay.store.*` which means BTCPay will prompt the operator to pick
/// which store(s) to grant.
const REQUESTED_PERMISSIONS: &[&str] = &[
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifystoresettings", // to register the webhook
"btcpay.store.canviewinvoices",
"btcpay.store.cancreateinvoice",
"btcpay.store.canmodifyinvoices",
];
#[derive(Debug, Serialize)]
pub struct ConnectResp {
/// URL the operator should open in their browser to authorize.
pub authorize_url: String,
/// CSRF state token tied to this round trip.
pub state: String,
}
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
/// URL for the StartOS wrapper action to open in the operator's browser.
pub async fn start_connect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<ConnectResp>> {
require_admin(&state, &headers)?;
// Idempotency: if BTCPay is already connected, refuse to issue a new
// authorize URL. Re-clicking Connect today produces a duplicate
// webhook subscription on BTCPay, which results in every payment
// event being delivered to Keysat twice. Make the operator go
// through Disconnect first if they really want to re-authorize.
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
return Err(AppError::Conflict(format!(
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
existing.store_id,
)));
}
// Random 20-byte token, base32-encoded, for the CSRF `state` parameter.
let mut raw = [0u8; 20];
rand::thread_rng().fill_bytes(&mut raw);
let state_token = BASE32_NOPAD.encode(&raw);
btcpay_cfg::record_authorize_state(&state.db, &state_token)
.await
.map_err(AppError::Internal)?;
// Construct the authorize URL per BTCPay's docs.
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
//
// CSRF state must travel inside the `redirect` URL itself, NOT as a
// separate query param on the outer authorize URL. Empirical
// observation against BTCPay: arbitrary query params on the
// authorize URL are NOT forwarded to the redirect target. The
// redirect URL is preserved verbatim, so any params we encode INTO
// it survive the round-trip.
let redirect = format!(
"{}/v1/btcpay/authorize/callback?state={}",
state.config.public_base_url,
urlencoding::encode(&state_token),
);
let perm_params = REQUESTED_PERMISSIONS
.iter()
.map(|p| format!("permissions={}", urlencoding::encode(p)))
.collect::<Vec<_>>()
.join("&");
// The authorize URL is followed by the operator's BROWSER, so the host
// must be reachable from outside the container. Use the explicit
// `btcpay_browser_url` if the wrapper provided it; fall back to
// `btcpay_url` only for dev/local setups (where they're the same).
let authorize_base = state
.config
.btcpay_browser_url
.as_deref()
.unwrap_or(&state.config.btcpay_url);
let authorize_url = format!(
"{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}",
authorize_base,
urlencoding::encode("Keysat"),
urlencoding::encode("keysat"),
urlencoding::encode(&redirect),
);
Ok(Json(ConnectResp {
authorize_url,
state: state_token,
}))
}
/// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`,
/// `userId`, and `permissions[]` as a form body. It also preserves any
/// query-string parameters on the redirect URL — we use that for `state`.
#[derive(Debug, Deserialize)]
pub struct CallbackForm {
#[serde(rename = "apiKey")]
pub api_key: String,
#[serde(rename = "userId")]
pub user_id: Option<String>,
// BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns
// that into a repeated string. We don't actually need to parse them
// individually — we just re-verify via list_stores.
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub state: String,
}
/// The real callback endpoint — POST form-encoded.
pub async fn callback(
State(state): State<AppState>,
Query(q): Query<CallbackQuery>,
Form(form): Form<CallbackForm>,
) -> AppResult<Response> {
finish_connect(&state, &q.state, &form.api_key).await?;
Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS."))
}
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
/// Handle that too for robustness.
#[derive(Debug, Deserialize)]
pub struct CallbackGetQuery {
pub state: String,
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// Error message if BTCPay declined / operator clicked "Deny".
pub error: Option<String>,
}
pub async fn callback_get(
State(state): State<AppState>,
Query(q): Query<CallbackGetQuery>,
) -> Response {
if let Some(err) = q.error {
return Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&err)
))
.into_response();
}
let Some(api_key) = q.api_key else {
// Some installs POST; in that case a bare GET with no apiKey is
// possible if the operator refreshes the tab. Redirect to root.
return Redirect::to("/").into_response();
};
match finish_connect(&state, &q.state, &api_key).await {
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to StartOS.",
),
Err(e) => Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
))
.into_response(),
}
}
/// Admin endpoint: list payment methods configured on the connected
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
/// Used by the wrapper / future web UI to surface a "no wallet
/// configured" state.
pub async fn payment_methods(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?
.ok_or(AppError::BtcpayNotConfigured)?;
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
// Return both the raw array for callers that want detail, and a
// boolean summary for the common "is anything configured?" check.
let count = methods.len();
Ok(Json(json!({
"store_id": cfg.store_id,
"count": count,
"methods": methods,
})))
}
/// Admin endpoint: report current BTCPay connection status.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
Ok(Json(match cfg {
None => json!({ "connected": false }),
Some(c) => json!({
"connected": true,
"store_id": c.store_id,
"webhook_id": c.webhook_id,
"base_url": c.base_url,
}),
}))
}
// --- internals ---
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let base_url = &state.config.btcpay_url;
// Enumerate stores the key has access to. With `selectiveStores=true`
// the operator picked specific stores during authorize; we pick the
// first one that the key can see.
let stores = btcpay_client::list_stores(base_url, api_key)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
let store = stores
.into_iter()
.next()
.ok_or_else(|| AppError::BadRequest(
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
))?;
// Generate a strong webhook secret, then register the webhook on BTCPay.
let mut raw_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_secret);
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
let created_webhook = btcpay_client::create_webhook(
base_url,
api_key,
&store.id,
&callback_url,
&webhook_secret,
)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
// Persist.
let cfg = btcpay_cfg::BtcpayConfig {
base_url: base_url.clone(),
api_key: api_key.to_string(),
store_id: store.id.clone(),
webhook_id: Some(created_webhook.id.clone()),
webhook_secret: webhook_secret.clone(),
};
btcpay_cfg::save(&state.db, &cfg)
.await
.map_err(AppError::Internal)?;
// Swap runtime — wrap a fresh BtcpayProvider into the
// PaymentProvider trait object held by AppState. Pass the
// public-facing BTCPay URL too so that checkout URLs returned to
// buyers get rewritten from the internal Docker hostname to a
// browser-reachable host.
let client = BtcpayClient::new(base_url, api_key, &store.id);
let provider = Arc::new(
BtcpayProvider::new(client, webhook_secret)
.with_public_base(state.config.btcpay_public_url.clone()),
);
state.set_payment_provider(provider).await;
tracing::info!(
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
"BTCPay connected via authorize flow"
);
Ok(())
}
fn success_page(msg: &str) -> Response {
let body = format!(
r#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
h2{{color:#0a7}}</style></head>
<body><h2>✓ {msg}</h2></body></html>"#,
msg = html_escape::encode_text(msg)
);
(StatusCode::OK, Html(body)).into_response()
}
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
/// webhook + API key on BTCPay's side, then unconditional clear of the
/// local config row. If BTCPay is unreachable, the local state is still
/// cleared and the operator gets a warning to clean up BTCPay manually.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = crate::api::admin::request_context(&headers);
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?;
let Some(cfg) = cfg else {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "BTCPay was not connected; nothing to do.",
})));
};
// Capture metadata for the response BEFORE we clear local state.
let store_id = cfg.store_id.clone();
let webhook_id = cfg.webhook_id.clone();
// Best-effort remote cleanup. We DON'T short-circuit if either of
// these calls fails — the operator's intent is to disconnect, and
// leaving local state pointing at a remote we no longer trust is
// worse than leaving orphan state on the BTCPay side. Any failures
// are surfaced in the response so the operator can manually clean
// up on BTCPay if needed.
let mut warnings: Vec<String> = Vec::new();
if let Some(webhook_id) = webhook_id.as_deref() {
if let Err(e) = btcpay_client::delete_webhook(
&cfg.base_url,
&cfg.api_key,
&cfg.store_id,
webhook_id,
)
.await
{
warnings.push(format!(
"Could not delete BTCPay webhook {webhook_id}: {e}. \
You may want to manually delete it in BTCPay's store webhook settings."
));
}
}
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
warnings.push(format!(
"Could not revoke BTCPay API key: {e}. \
You may want to manually revoke it in BTCPay's account API-keys page."
));
}
btcpay_cfg::clear(&state.db)
.await
.map_err(AppError::Internal)?;
// Replace the runtime payment provider so subsequent purchase
// attempts return BtcpayNotConfigured cleanly.
state.clear_payment_provider().await;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"btcpay.disconnect",
Some("btcpay_config"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"store_id": store_id,
"webhook_id": webhook_id,
"warnings": warnings,
})))
}
+640
View File
@@ -0,0 +1,640 @@
//! Public buyer-facing purchase page at `GET /buy/:slug`.
//!
//! The flow is:
//! 1. Buyer hits `https://<operator-keysat>/buy/<product-slug>` in a browser.
//! 2. We look up the product, render an HTML page showing what they're
//! buying — name, description, price — plus a small form for an
//! optional email (for receipt + license delivery) and an optional
//! discount code.
//! 3. They click "Pay with Bitcoin." Inline JS POSTs to `/v1/purchase`,
//! gets back a BTCPay checkout URL, redirects the browser there.
//! 4. After payment, BTCPay redirects to `/thank-you` (existing handler).
//!
//! Visual language matches the rest of the Keysat design system: navy
//! topbar, cream paper-textured background, gold accent on the price and
//! the CTA, classical type. Inlined CSS so this single file is the whole
//! buyer-facing surface — easy to deploy, no asset hosting required.
use crate::api::AppState;
use crate::db::repo;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Html,
};
pub async fn render(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Html<String>, (StatusCode, Html<String>)> {
// Look up the product. Inactive or missing → 404 with a friendly page.
let product = match repo::get_product_by_slug(&state.db, &slug).await {
Ok(Some(p)) if p.active => p,
_ => return Err((StatusCode::NOT_FOUND, Html(not_found_html(&slug)))),
};
// Live-read operator name (same pattern as thank-you / root).
let live = repo::settings_get(&state.db, crate::api::admin::SETTING_OPERATOR_NAME)
.await
.ok()
.flatten();
let operator_str = live
.as_deref()
.or(state.config.operator_name.as_deref())
.unwrap_or("Keysat");
let operator = html_escape(operator_str);
let product_name = html_escape(&product.name);
let product_slug = html_escape(&product.slug);
let product_description = html_escape(&product.description);
let price_sats_fmt = format_thousands(product.price_sats);
let body = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Buy {product_name} — {operator}</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
--success:#2D7A5F; --success-bg:#E3F0EA;
--danger:#B23A3A; --danger-bg:#F4E0E0;
--border-1:rgba(14,31,51,0.12);
--border-2:rgba(14,31,51,0.20);
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
}}
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
body {{
font-family:var(--font-body); color:var(--ink-900);
background:var(--cream-100);
background-image:
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
background-size:3px 3px, 7px 7px;
-webkit-font-smoothing:antialiased; min-height:100vh;
}}
.topbar {{
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
border-bottom:1px solid var(--border-1);
padding:14px 24px;
}}
.topbar .inner {{
max-width:680px; margin:0 auto;
display:flex; align-items:center; gap:12px;
font-family:var(--font-display); font-weight:500; font-size:14px;
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
}}
.topbar .operator {{
font-family:var(--font-body); font-size:12px;
letter-spacing:0.04em; text-transform:none;
color:var(--ink-500);
margin-left:auto;
}}
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
.eyebrow {{
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
display:inline-flex; align-items:center; gap:10px;
}}
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
h1 {{
font-family:var(--font-display); font-weight:500; font-size:42px;
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950);
margin:0 0 12px;
}}
.product-slug {{
font-family:var(--font-mono); font-size:12.5px; color:var(--ink-500);
margin:0 0 18px;
}}
.description {{
font-size:16px; line-height:1.55; color:var(--ink-700);
margin:0 0 32px;
}}
.cert {{
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
padding:32px 32px 28px;
position:relative;
margin-bottom:24px;
}}
.cert::before, .cert::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.cert::before {{ top:14px; }} .cert::after {{ bottom:14px; }}
.price {{
font-family:var(--font-display); font-weight:700; font-size:36px;
color:var(--navy-950); letter-spacing:-0.025em; margin:8px 0 0;
}}
.price .unit {{
font-family:var(--font-body); font-size:15px; font-weight:600;
color:var(--ink-500); margin-left:8px;
}}
.price-label {{
font-size:11.5px; font-weight:700; letter-spacing:0.14em;
text-transform:uppercase; color:var(--ink-500);
}}
.field {{ margin-bottom:14px; }}
.field label {{
display:block; font-size:12.5px; font-weight:600;
color:var(--ink-700); margin-bottom:6px;
}}
.field input {{
width:100%; padding:11px 13px;
font-family:var(--font-body); font-size:14px;
border:1px solid var(--border-2); border-radius:8px;
background:#fff; color:var(--ink-900);
}}
.field input:focus {{
outline:none; border-color:var(--navy-700);
box-shadow:0 0 0 3px rgba(30,58,95,0.18);
}}
.field .hint {{ font-size:12px; color:var(--ink-500); margin-top:5px; }}
/* Apply-discount cluster: input + button on one row */
.code-row {{ display:flex; gap:8px; align-items:stretch; }}
.code-row input {{ flex:1; }}
.btn-apply {{
background:transparent; color:var(--navy-800);
border:1px solid var(--border-2); border-radius:8px;
padding:0 16px;
font-family:var(--font-body); font-weight:600; font-size:13px;
cursor:pointer; transition:all 120ms;
flex-shrink:0;
}}
.btn-apply:hover {{ background:var(--cream-200); border-color:var(--navy-700); }}
.btn-apply:disabled {{ opacity:0.5; cursor:wait; }}
.code-status {{
margin-top:8px; font-size:13px; padding:8px 12px;
border-radius:7px; display:none;
}}
.code-status.show {{ display:block; }}
.code-status.ok {{ background:var(--success-bg); color:#205c47; border:1px solid rgba(45,122,95,0.25); }}
.code-status.bad {{ background:var(--danger-bg); color:#8a2828; border:1px solid rgba(178,58,58,0.25); }}
/* Price card update animation when discount applied */
.price-strike {{
text-decoration:line-through; color:var(--ink-500);
font-size:18px; font-weight:500; display:block;
margin-bottom:4px;
}}
.price-discount-tag {{
display:inline-block; margin-left:8px;
font-family:var(--font-body); font-size:12px; font-weight:600;
padding:3px 10px; border-radius:999px;
background:var(--success-bg); color:#205c47;
border:1px solid rgba(45,122,95,0.25);
vertical-align:middle;
}}
.btn-pay {{
width:100%; padding:14px;
background:var(--navy-800); color:var(--cream-50);
border:0; border-radius:10px;
font-family:var(--font-body); font-weight:600; font-size:15px;
cursor:pointer; transition:background 120ms;
margin-top:16px;
display:inline-flex; align-items:center; justify-content:center; gap:8px;
}}
.btn-pay:hover {{ background:var(--navy-900); }}
.btn-pay:disabled {{ opacity:0.6; cursor:wait; }}
.btn-pay svg {{ width:18px; height:18px; }}
.error {{
margin-top:14px; padding:10px 14px;
background:var(--danger-bg); color:#8a2828;
border:1px solid rgba(178,58,58,0.25);
border-radius:7px; font-size:13.5px;
display:none;
}}
.error.show {{ display:block; }}
.license-success {{
display:none; margin-top:24px;
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px;
box-shadow:0 0 0 1px var(--gold-500) inset, 0 8px 16px rgba(14,31,51,0.10);
padding:32px 32px 28px; position:relative;
}}
.license-success.show {{ display:block; }}
.license-success::before, .license-success::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.license-success::before {{ top:14px; }}
.license-success::after {{ bottom:14px; }}
.license-success .stamp {{
font-size:10px; font-weight:700; letter-spacing:0.22em;
text-transform:uppercase; color:var(--gold-700);
text-align:center; margin-bottom:16px;
}}
.license-success h3 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em;
text-align:center;
}}
.license-success .subtitle {{
font-size:14px; color:var(--ink-500); text-align:center;
margin:0 0 22px;
}}
.license-success .field-label {{
font-size:11px; font-weight:600; letter-spacing:0.12em;
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
}}
.license-success .key-box {{
background:var(--navy-950); color:var(--cream-50);
padding:14px 16px; border-radius:8px;
font-family:var(--font-mono); font-size:12.5px;
word-break:break-all; line-height:1.5;
display:flex; align-items:flex-start; gap:12px;
}}
.license-success .key-box .key-text {{ flex:1; }}
.license-success .key-box button {{
background:rgba(245,241,232,0.10); color:var(--cream-50);
border:0; padding:6px 10px; border-radius:6px;
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
flex-shrink:0;
}}
.license-success .key-box button:hover {{ background:rgba(245,241,232,0.20); }}
.license-success .save-note {{
margin-top:14px; font-size:13px; color:var(--ink-700);
background:var(--cream-100); border:1px solid var(--border-1);
border-radius:8px; padding:10px 14px;
}}
.license-success .save-note strong {{ color:var(--navy-950); }}
footer.kfooter {{
text-align:center; font-size:12px; color:var(--ink-500);
margin-top:48px; padding:18px;
}}
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
footer.kfooter a:hover {{ color:var(--navy-900); }}
</style>
</head>
<body>
<div class="topbar">
<div class="inner">
<span>Keysat</span>
<span class="operator">Sold by {operator}</span>
</div>
</div>
<div class="wrap">
<div class="eyebrow">Buy a license</div>
<h1>{product_name}</h1>
<div class="product-slug">{product_slug}</div>
<p class="description">{product_description}</p>
<div class="cert">
<div class="price-label">Price</div>
<div class="price" id="price-display">
<span id="price-strike-line" class="price-strike" style="display:none"></span>
<span id="price-current">{price_sats_fmt}</span><span class="unit">sats</span>
<span id="price-discount-tag" class="price-discount-tag" style="display:none"></span>
</div>
</div>
<form id="buy-form">
<div class="field">
<label for="email">Email (for receipt &amp; license)</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required>
<div class="hint">We&rsquo;ll send your license key here after payment confirms.</div>
</div>
<div class="field">
<label for="code">Discount code (optional)</label>
<div class="code-row">
<input type="text" id="code" name="code" placeholder="FOUNDERS50" autocomplete="off">
<button type="button" class="btn-apply" id="btn-apply">Apply</button>
</div>
<div class="code-status" id="code-status" role="status" aria-live="polite"></div>
</div>
<button type="submit" class="btn-pay" id="btn-pay">
<svg id="btn-pay-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.5 8.5h5a2 2 0 010 4h-5m0 0h5a2 2 0 010 4h-5m0-8v8m2-10v2m0 8v2"></path>
</svg>
<span id="btn-pay-label">Pay with Bitcoin</span>
</button>
<div class="error" id="err"></div>
</form>
<div class="license-success" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h3>You&rsquo;re licensed.</h3>
<p class="subtitle">No payment needed for this code. Your signed license is below.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</span>
<button id="license-key-copy">Copy</button>
</div>
<div class="save-note">
<strong>Save this somewhere safe.</strong> The license key is signed at issue time and verifies offline. We&rsquo;ll also send a copy to <span id="license-email-display"></span> for your records.
</div>
</div>
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
</footer>
<script>
(function() {{
const form = document.getElementById('buy-form');
const btn = document.getElementById('btn-pay');
const btnLabel = document.getElementById('btn-pay-label');
const btnIcon = document.getElementById('btn-pay-icon');
const errEl = document.getElementById('err');
const successEl = document.getElementById('license-success');
const keyTextEl = document.getElementById('license-key-text');
const emailDisplayEl = document.getElementById('license-email-display');
const codeInput = document.getElementById('code');
const applyBtn = document.getElementById('btn-apply');
const codeStatus = document.getElementById('code-status');
const priceCurrent = document.getElementById('price-current');
const priceStrike = document.getElementById('price-strike-line');
const priceTag = document.getElementById('price-discount-tag');
const PRODUCT_SLUG = {slug_json};
const BASE_PRICE_FMT = priceCurrent.textContent;
// State of the most recent successful Apply. When set with kind=free_license
// and the same code is still in the input, the submit handler skips the
// "try /v1/redeem then fall through" dance and goes straight to redeem.
let appliedCode = null; // {{ code, kind, is_free, final_price_sats }}
function showError(msg) {{
errEl.textContent = msg;
errEl.classList.add('show');
}}
function clearError() {{ errEl.classList.remove('show'); }}
function showLicense(licenseKey, email) {{
keyTextEl.textContent = licenseKey;
emailDisplayEl.textContent = email || '(no email provided)';
form.style.display = 'none';
successEl.classList.add('show');
successEl.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
}}
function fmtNum(n) {{
return Number(n).toLocaleString('en-US');
}}
function setStatus(kind, text) {{
codeStatus.classList.remove('ok', 'bad');
if (!kind) {{ codeStatus.classList.remove('show'); codeStatus.textContent = ''; return; }}
codeStatus.classList.add(kind === 'ok' ? 'ok' : 'bad', 'show');
codeStatus.textContent = text;
}}
function resetPrice() {{
priceCurrent.textContent = BASE_PRICE_FMT;
priceStrike.style.display = 'none';
priceStrike.textContent = '';
priceTag.style.display = 'none';
priceTag.textContent = '';
}}
function setPaidButton() {{
btnLabel.textContent = 'Pay with Bitcoin';
btnIcon.style.display = '';
}}
function setRedeemButton() {{
btnLabel.textContent = 'Redeem license';
btnIcon.style.display = 'none';
}}
// Reset apply state if the buyer edits the code after a successful Apply.
codeInput.addEventListener('input', function() {{
if (appliedCode && codeInput.value.trim().toUpperCase() !== appliedCode.code.toUpperCase()) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus(null);
}}
}});
applyBtn.addEventListener('click', async function() {{
clearError();
const code = codeInput.value.trim();
if (!code) {{
setStatus('bad', 'Enter a code first.');
return;
}}
applyBtn.disabled = true;
const orig = applyBtn.textContent;
applyBtn.textContent = 'Checking…';
try {{
const url = '/v1/discount-codes/preview?code='
+ encodeURIComponent(code) + '&product=' + encodeURIComponent(PRODUCT_SLUG);
const resp = await fetch(url);
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
try {{ const j = await resp.json(); msg = j.message || j.error || msg; }} catch(_) {{}}
throw new Error(msg);
}}
const j = await resp.json();
if (!j.valid) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus('bad', j.message || 'Code not valid.');
return;
}}
appliedCode = {{
code: j.code,
kind: j.kind,
is_free: !!j.is_free,
final_price_sats: j.final_price_sats,
}};
// Update price card
if (j.kind === 'free_license' || j.final_price_sats === 0) {{
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
priceStrike.style.display = 'block';
priceCurrent.textContent = 'FREE';
priceTag.textContent = '100% off';
priceTag.style.display = 'inline-block';
setRedeemButton();
}} else {{
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
priceStrike.style.display = 'block';
priceCurrent.textContent = fmtNum(j.final_price_sats);
if (j.kind === 'percent') {{
priceTag.textContent = (j.amount_pct || ((j.discount_applied_sats / j.base_price_sats) * 100).toFixed(0)) + '% off';
}} else {{
priceTag.textContent = fmtNum(j.discount_applied_sats) + ' sats off';
}}
priceTag.style.display = 'inline-block';
setPaidButton();
}}
setStatus('ok', j.message || 'Code applied.');
}} catch (err) {{
appliedCode = null;
resetPrice();
setPaidButton();
setStatus('bad', err.message || 'Could not validate code.');
}} finally {{
applyBtn.disabled = false;
applyBtn.textContent = orig;
}}
}});
// Try free-license redemption first if a code was provided. If that
// path returns "this code requires payment", fall through to the
// BTCPay flow with the code applied. Any other error stops here.
async function tryFreeRedeem(code, email) {{
const resp = await fetch('/v1/redeem', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
product: PRODUCT_SLUG,
code,
buyer_email: email || undefined,
}}),
}});
if (resp.ok) {{
const j = await resp.json();
return {{ ok: true, license_key: j.license_key }};
}}
let msg = 'HTTP ' + resp.status;
try {{
const j = await resp.json();
msg = j.message || j.error || msg;
}} catch (_) {{}}
// Distinguish "fall through to paid flow" from real errors.
if (resp.status === 400 && /requires payment/i.test(msg)) {{
return {{ ok: false, fallThrough: true }};
}}
return {{ ok: false, fallThrough: false, msg }};
}}
async function startPaidPurchase(code, email) {{
const body = {{ product: PRODUCT_SLUG, buyer_email: email || undefined }};
if (code) body.code = code;
const resp = await fetch('/v1/purchase', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(body),
}});
if (!resp.ok) {{
let msg = 'HTTP ' + resp.status;
try {{
const j = await resp.json();
msg = j.message || j.error || msg;
}} catch (_) {{}}
throw new Error(msg);
}}
const j = await resp.json();
if (!j.checkout_url) throw new Error('No checkout URL returned by server');
window.location.href = j.checkout_url;
}}
// "Copy" on the license key box.
document.getElementById('license-key-copy').addEventListener('click', async function() {{
try {{
await navigator.clipboard.writeText(keyTextEl.textContent);
this.textContent = 'Copied';
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
}} catch (e) {{}}
}});
form.addEventListener('submit', async function(e) {{
e.preventDefault();
clearError();
btn.disabled = true;
const originalLabel = btnLabel.textContent;
btnLabel.textContent = 'Working…';
const email = document.getElementById('email').value.trim();
const code = codeInput.value.trim();
const codeMatchesApplied = appliedCode &&
code.toUpperCase() === appliedCode.code.toUpperCase();
try {{
// Fast path: a free_license code was already validated via Apply.
if (codeMatchesApplied && appliedCode.is_free) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
// If the server changed its mind, surface the error rather than silently
// routing to a paid flow that the buyer didn't consent to.
throw new Error(r.msg || 'Could not redeem free license.');
}}
// Slower path (no Apply or non-free code): keep the original try-then-fallthrough.
if (code) {{
const r = await tryFreeRedeem(code, email);
if (r.ok) {{ showLicense(r.license_key, email); return; }}
if (!r.fallThrough) {{
throw new Error(r.msg || 'Code rejected');
}}
// else fall through to the BTCPay path with the code applied
}}
btnLabel.textContent = 'Creating invoice…';
await startPaidPurchase(code, email);
}} catch (err) {{
showError('Could not complete: ' + (err.message || err));
btn.disabled = false;
btnLabel.textContent = originalLabel;
}}
}});
}})();
</script>
</body>
</html>
"#,
operator = operator,
product_name = product_name,
product_slug = product_slug,
product_description = product_description,
price_sats_fmt = price_sats_fmt,
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
);
Ok(Html(body))
}
fn not_found_html(slug: &str) -> String {
let slug_safe = html_escape(slug);
format!(
r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Product not found</title>
<style>
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#222;background:#fafafa;line-height:1.55}}
h1{{font-size:1.5rem;margin-top:0}}
code{{background:#eee;padding:0.1em 0.4em;border-radius:4px;font-family:ui-monospace,monospace}}
</style></head>
<body>
<h1>Product not found</h1>
<p>No product is registered under the slug <code>{slug_safe}</code>, or it&rsquo;s currently inactive.</p>
<p>If you arrived here from a link the seller shared, double-check that you&rsquo;ve typed the URL correctly. Otherwise, ask the seller to confirm the product slug.</p>
</body></html>"#,
slug_safe = slug_safe
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_thousands(n: i64) -> String {
// Renders 50000 as "50,000" — visible price legibility for sat amounts.
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(c);
}
out.chars().rev().collect()
}
+354
View File
@@ -0,0 +1,354 @@
//! Admin endpoints for discount / referral codes.
//!
//! Operators create codes, list them with usage stats, and disable them.
//! The public purchase flow consumes codes via the `code` field on
//! `POST /v1/purchase`; that path is handled in `crate::api::purchase`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreateDiscountCodeReq {
/// e.g. "FOUNDERS50". Normalized to uppercase. ASCII alphanumerics + '-' '_'.
pub code: String,
/// 'percent' | 'fixed_sats'.
pub kind: String,
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
pub amount: i64,
#[serde(default)]
pub max_uses: Option<i64>,
/// ISO-8601 RFC3339 UTC timestamp.
#[serde(default)]
pub expires_at: Option<String>,
/// Restrict to a single product (by slug). Omit for any product.
#[serde(default)]
pub product_slug: Option<String>,
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
/// Requires `product_slug` to be set if specified.
#[serde(default)]
pub policy_slug: Option<String>,
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
#[serde(default)]
pub referrer_label: Option<String>,
#[serde(default)]
pub description: String,
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateDiscountCodeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Resolve product/policy slugs to ids if supplied.
let product_id = if let Some(slug) = req.product_slug.as_deref() {
let p = repo::get_product_by_slug(&state.db, slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
Some(p.id)
} else {
None
};
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
let pid = product_id.as_deref().ok_or_else(|| {
AppError::BadRequest("policy_slug requires product_slug".into())
})?;
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug.as_deref().unwrap_or("")
))
})?;
Some(policy.id)
} else {
None
};
let code = repo::create_discount_code(
&state.db,
&req.code,
&req.kind,
req.amount,
req.max_uses,
req.expires_at.as_deref(),
product_id.as_deref(),
policy_id.as_deref(),
req.referrer_label.as_deref(),
&req.description,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.create",
Some("discount_code"),
Some(&code.id),
ip.as_deref(),
ua.as_deref(),
&json!({
"code": code.code,
"kind": code.kind,
"amount": code.amount,
"max_uses": code.max_uses,
"expires_at": code.expires_at,
"product_id": product_id,
"policy_id": policy_id,
"referrer_label": code.referrer_label,
}),
)
.await;
Ok(Json(json!(code)))
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub include_inactive: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?;
Ok(Json(json!({ "codes": codes })))
}
pub async fn get_one(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
let redemptions = repo::list_redemptions_by_code(&state.db, &code.id).await?;
Ok(Json(json!({
"code": code,
"redemptions": redemptions,
})))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_discount_code_active(&state.db, &id, req.active).await?;
let action = if req.active {
"discount_code.enable"
} else {
"discount_code.disable"
};
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
action,
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
/// Hard-delete a discount code. Refuses if any redemptions reference
/// the code — those rows are part of the audit trail and shouldn't be
/// orphaned. For codes that have been used, the operator should
/// disable instead.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Look up the code so we can audit-log meaningful detail.
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code '{id}'")))?;
// Refuse if any redemptions exist (referential integrity + audit
// trail preservation). Operator should use Disable in that case.
let redemption_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_redemptions WHERE code_id = ?",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
if redemption_count > 0 {
return Err(AppError::Conflict(format!(
"cannot delete code '{}' — it has {} redemption(s) on the audit trail. \
Disable it instead (it stops accepting new uses, but the history is kept).",
code.code, redemption_count
)));
}
sqlx::query("DELETE FROM discount_codes WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"discount_code.delete",
Some("discount_code"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "code": code.code, "kind": code.kind }),
)
.await;
Ok(Json(json!({ "ok": true, "deleted": code.code })))
}
#[derive(Debug, Deserialize)]
pub struct PreviewQuery {
pub code: String,
pub product: String,
}
/// PUBLIC endpoint — buyers hit this from the buy page when they click
/// Apply on a discount code. Validates the code (existence, active
/// state, expiry, product applicability) and returns the kind +
/// computed discounted price WITHOUT consuming a slot. The actual
/// purchase / redemption still goes through `/v1/purchase` or
/// `/v1/redeem` and is the real transaction; this is just for showing
/// the buyer what they'll be charged before they commit.
pub async fn preview(
State(state): State<AppState>,
Query(q): Query<PreviewQuery>,
) -> AppResult<Json<Value>> {
let code_str = q.code.trim();
if code_str.is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let product = repo::get_product_by_slug(&state.db, &q.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product)))?;
let code = match repo::get_discount_code_by_code(&state.db, code_str).await? {
Some(c) => c,
None => {
return Ok(Json(json!({
"valid": false,
"reason": "unknown_code",
"message": "Code not found.",
"base_price_sats": product.price_sats,
})));
}
};
if !code.active {
return Ok(Json(json!({
"valid": false,
"reason": "disabled",
"message": "This code has been disabled.",
"base_price_sats": product.price_sats,
})));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Ok(Json(json!({
"valid": false,
"reason": "expired",
"message": "This code has expired.",
"base_price_sats": product.price_sats,
})));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Ok(Json(json!({
"valid": false,
"reason": "wrong_product",
"message": "This code does not apply to this product.",
"base_price_sats": product.price_sats,
})));
}
}
if let Some(max) = code.max_uses {
if code.used_count >= max {
return Ok(Json(json!({
"valid": false,
"reason": "exhausted",
"message": "This code has reached its use limit.",
"base_price_sats": product.price_sats,
})));
}
}
// Compute the discounted price (mirroring purchase.rs's logic).
let base = product.price_sats;
let (final_price, discount_applied) = match code.kind.as_str() {
"free_license" => (0i64, base),
"percent" => {
let bps = (code.amount).clamp(0, 10_000) as i128;
let b = base as i128;
let discount = ((b * bps) / 10_000).max(0).min(b) as i64;
((base - discount).max(1), discount)
}
"fixed_sats" => {
let discount = code.amount.max(0).min(base);
((base - discount).max(1), discount)
}
_ => (base, 0),
};
let amount_pct = if code.kind == "percent" {
Some(code.amount as f64 / 100.0)
} else {
None
};
Ok(Json(json!({
"valid": true,
"code": code.code,
"kind": code.kind,
"is_free": code.kind == "free_license",
"base_price_sats": base,
"discount_applied_sats": discount_applied,
"final_price_sats": if code.kind == "free_license" { 0 } else { final_price },
"amount_pct": amount_pct,
"message": match code.kind.as_str() {
"free_license" => "Free license — no payment required.".to_string(),
"percent" => format!("{}% off applied.", code.amount as f64 / 100.0),
"fixed_sats" => format!("{} sats off applied.", code.amount),
_ => "Code applied.".to_string(),
},
})))
}
+150
View File
@@ -0,0 +1,150 @@
//! Admin-only issuer-key import endpoint.
//!
//! Used exactly once, by exactly one operator: when bootstrapping a
//! "master Keysat" instance (the one that issues licenses for the Keysat
//! package itself). The master operator pre-generated an Ed25519 keypair
//! offline; this endpoint takes the PEM-encoded private half and stores
//! it as the daemon's signing keypair, replacing the auto-generated one
//! that gets created on first boot.
//!
//! ## Why this isn't a StartOS Action
//!
//! 95% of Keysat operators install Keysat to sell their own software.
//! Their auto-generated issuer key is exactly what they want; they never
//! need this endpoint. Surfacing an "import issuer key" button in every
//! operator's StartOS Actions tab would create cognitive load (am I
//! supposed to do this?) for zero benefit. So this lives as an admin
//! API endpoint only — invisible by default, callable via curl during
//! the master-bootstrap procedure documented in
//! `MASTER_KEYPAIR_PROCEDURE.md`.
//!
//! ## Safety guards
//!
//! Replacing the issuer key after licenses have been issued would
//! invalidate every previously-signed customer license. To prevent that
//! footgun, the endpoint refuses if any license rows exist in the
//! database. The master Keysat instance hasn't issued anything when it
//! gets bootstrapped, so this guard never trips during legitimate use
//! and prevents the worst-case mistake.
//!
//! ## After successful import
//!
//! The new keypair lands in the `server_keys` table immediately, but the
//! daemon's in-memory `AppState.keypair` still holds the old one until
//! restart. The endpoint returns a `restart_required: true` so the
//! operator (or their orchestration) knows to bounce the service before
//! the new key takes effect.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use axum::{body::Bytes, extract::State, http::HeaderMap, Json};
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
pub async fn import(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let pem = std::str::from_utf8(&body)
.map_err(|_| AppError::BadRequest("body is not valid UTF-8".into()))?
.trim();
if pem.is_empty() {
return Err(AppError::BadRequest("body is empty".into()));
}
if !pem.contains("-----BEGIN") || !pem.contains("PRIVATE KEY-----") {
return Err(AppError::BadRequest(
"expected a PEM-encoded private key (must contain BEGIN/END PRIVATE KEY)".into(),
));
}
// Parse + validate the supplied PEM.
let signing = SigningKey::from_pkcs8_pem(pem).map_err(|e| {
AppError::BadRequest(format!("could not parse Ed25519 private key: {e}"))
})?;
let verifying = signing.verifying_key();
// Re-encode through pkcs8 so we always store a normalized form. This
// also catches any encoding oddity on the input side that would have
// tripped a future load.
use pkcs8::LineEnding;
let priv_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.map_err(|e| AppError::Internal(anyhow::anyhow!("re-encode private key: {e}")))?
.to_string();
let pub_pem = verifying
.to_public_key_pem(LineEnding::LF)
.map_err(|e| AppError::Internal(anyhow::anyhow!("encode public key: {e}")))?;
// Safety guard: refuse if any licenses have already been issued by
// this Keysat. Replacing the issuer key would invalidate them.
let licenses_exist: bool =
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM licenses LIMIT 1)")
.fetch_one(&state.db)
.await?;
if licenses_exist {
return Err(AppError::Conflict(
"this Keysat has already issued at least one license; importing a new \
issuer key would invalidate every previously-signed license. Refusing. \
Use this endpoint only on a fresh master-Keysat install before any \
licenses have been issued."
.into(),
));
}
// Upsert the keypair into server_keys row id=1. SQLite's INSERT ON
// CONFLICT is the idiomatic way to do this in one statement.
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
VALUES (1, 'ed25519', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
algorithm = excluded.algorithm,
public_key_pem = excluded.public_key_pem,
private_key_pem = excluded.private_key_pem,
created_at = excluded.created_at",
)
.bind(&pub_pem)
.bind(&priv_pem)
.bind(&now)
.execute(&state.db)
.await?;
// Audit-log this prominently. There is no scenario where a regular
// operator should be running this; if it shows up in the audit log
// unexpectedly, that's a red flag worth investigating.
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"issuer_key.import",
Some("server_key"),
None,
ip.as_deref(),
ua.as_deref(),
&json!({
"public_key_pem": pub_pem,
"note": "master-bootstrap import",
}),
)
.await;
tracing::warn!(
public_key = %pub_pem.lines().nth(1).unwrap_or(""),
"issuer key imported via admin endpoint — restart the service for the new key to take effect"
);
Ok(Json(json!({
"ok": true,
"public_key_pem": pub_pem,
"restart_required": true,
"message": "Issuer key imported. Restart the Keysat service for the new \
key to take effect — until then, in-memory state still holds \
the previous keypair."
})))
}
+306
View File
@@ -0,0 +1,306 @@
//! Machines — individual install records bound to a license.
//!
//! In the single-seat case (`licenses.max_machines = 1`), the first
//! successful `/v1/validate` call locks the fingerprint onto the license
//! and creates a `machines` row. Later validations keep heartbeating that
//! row.
//!
//! In the multi-seat case (`max_machines > 1` or `0` for unlimited),
//! validate auto-activates up to the cap. Beyond the cap, the client gets a
//! `too_many_machines` reject and is expected to call
//! `POST /v1/machines/deactivate` with the fingerprint of an old install to
//! free up a slot, then retry.
//!
//! Explicit activation endpoints (`POST /v1/machines/activate`) are offered
//! for apps that want to prompt the user about seat usage before starting up
//! for the first time. They behave identically to `/v1/validate`'s implicit
//! activation, just without requiring the full key check.
//!
//! Admin endpoints let operators look at who's using what and force-kick a
//! machine off a license.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::crypto;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
// ---------- Public endpoints (client-facing) ----------
#[derive(Debug, Deserialize)]
pub struct ActivateReq {
pub key: String,
pub fingerprint: String,
pub hostname: Option<String>,
pub platform: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ActivateResp {
pub ok: bool,
pub machine_id: Option<String>,
pub active_count: i64,
pub max_machines: i64,
pub reason: Option<String>,
}
pub async fn activate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ActivateReq>,
) -> AppResult<Json<ActivateResp>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
let license = repo::get_license_by_id(&state.db, &license_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("license {license_id}")))?;
if license.status != "active" {
return Ok(Json(ActivateResp {
ok: false,
machine_id: None,
active_count: 0,
max_machines: license.max_machines,
reason: Some(license.status),
}));
}
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
let fp_hash = crate::hex_sha256(&req.fingerprint);
if let Some(m) =
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
{
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
let active = repo::list_active_machines(&state.db, &license_id).await?;
return Ok(Json(ActivateResp {
ok: true,
machine_id: Some(m.id),
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: None,
}));
}
let active = repo::list_active_machines(&state.db, &license_id).await?;
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
return Ok(Json(ActivateResp {
ok: false,
machine_id: None,
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: Some("too_many_machines".into()),
}));
}
let m = repo::activate_machine(
&state.db,
&license_id,
&req.fingerprint,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
let active = repo::list_active_machines(&state.db, &license_id).await?;
Ok(Json(ActivateResp {
ok: true,
machine_id: Some(m.id),
active_count: active.len() as i64,
max_machines: license.max_machines,
reason: None,
}))
}
#[derive(Debug, Deserialize)]
pub struct HeartbeatReq {
pub key: String,
pub fingerprint: String,
}
pub async fn heartbeat(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<HeartbeatReq>,
) -> AppResult<Json<Value>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
// Rate-limit heartbeats per-license to 60/hr.
if !crate::rate_limit::consume(
&state.db,
"heartbeat_license",
&license_id,
/* capacity */ 60.0,
/* refill_per_second */ 60.0 / 3600.0,
)
.await?
{
return Ok(Json(json!({ "ok": false, "reason": "rate_limited" })));
}
let fp_hash = crate::hex_sha256(&req.fingerprint);
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
Some(m) => {
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
Ok(Json(json!({ "ok": true, "machine_id": m.id })))
}
None => Ok(Json(json!({ "ok": false, "reason": "not_activated" }))),
}
}
#[derive(Debug, Deserialize)]
pub struct DeactivateReq {
pub key: String,
pub fingerprint: String,
#[serde(default)]
pub reason: Option<String>,
}
pub async fn deactivate(
State(state): State<AppState>,
Json(req): Json<DeactivateReq>,
) -> AppResult<Json<Value>> {
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
let license_id = payload.license_id.to_string();
let fp_hash = crate::hex_sha256(&req.fingerprint);
let m = repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?;
let Some(m) = m else {
return Ok(Json(json!({ "ok": false, "reason": "not_found" })));
};
let reason = req
.reason
.unwrap_or_else(|| "client_requested".to_string());
repo::deactivate_machine(&state.db, &m.id, &reason).await?;
crate::webhooks::dispatch(
&state,
"machine.deactivated",
&json!({
"license_id": license_id,
"machine_id": m.id,
"reason": reason,
}),
)
.await;
// Single-seat legacy: also clear licenses.fingerprint so the next client
// can re-bind cleanly.
let license = repo::get_license_by_id(&state.db, &license_id).await?;
if let Some(lic) = license {
if lic.max_machines == 1 {
let _ = sqlx::query("UPDATE licenses SET fingerprint = NULL WHERE id = ?")
.bind(&license_id)
.execute(&state.db)
.await;
}
}
Ok(Json(json!({ "ok": true })))
}
// ---------- Admin endpoints ----------
#[derive(Debug, Deserialize)]
pub struct AdminListQuery {
pub license_id: String,
#[serde(default)]
pub include_inactive: bool,
}
pub async fn admin_list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AdminListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let machines = if q.include_inactive {
repo::list_all_machines(&state.db, &q.license_id).await?
} else {
repo::list_active_machines(&state.db, &q.license_id).await?
};
Ok(Json(json!({ "machines": machines })))
}
#[derive(Debug, Deserialize)]
pub struct AdminDeactivateReq {
#[serde(default)]
pub reason: String,
}
pub async fn admin_deactivate(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<AdminDeactivateReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin deactivate".to_string()
} else {
req.reason
};
let m = repo::get_machine_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("machine {id}")))?;
repo::deactivate_machine(&state.db, &id, &reason).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"machine.deactivate",
Some("machine"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "license_id": m.license_id, "reason": reason }),
)
.await;
crate::webhooks::dispatch(
&state,
"machine.deactivated",
&json!({
"license_id": m.license_id,
"machine_id": id,
"reason": reason,
}),
)
.await;
Ok(Json(json!({ "ok": true })))
}
+666
View File
@@ -0,0 +1,666 @@
//! HTTP API surface.
//!
//! Route layout (v1):
//!
//! | Method | Path | Purpose |
//! |--------|----------------------------------------|---------------------------------------------|
//! | GET | `/` | service info + public key |
//! | GET | `/healthz` | health check |
//! | GET | `/thank-you` | post-payment landing (BTCPay redirect tgt) |
//! | GET | `/admin/` | embedded admin web UI (SPA, client-gated) |
//! | GET | `/admin/<path>` | static assets for the embedded admin UI |
//! | GET | `/v1/pubkey` | PEM-encoded Ed25519 public key |
//! | GET | `/v1/products` | list active products |
//! | GET | `/v1/products/:slug` | single product |
//! | POST | `/v1/purchase` | start purchase, returns BTCPay URL |
//! | GET | `/v1/purchase/:invoice_id` | poll purchase status + license if ready |
//! | POST | `/v1/redeem` | redeem a 'free_license' code, no BTCPay |
//! | POST | `/v1/validate` | validate a license key |
//! | POST | `/v1/machines/activate` | explicit seat activation |
//! | POST | `/v1/machines/heartbeat` | seat heartbeat |
//! | POST | `/v1/machines/deactivate` | free a seat (client-initiated) |
//! | POST | `/v1/btcpay/webhook` | BTCPay webhook landing |
//! | Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY` |
//! | POST | `/v1/admin/products` | create product |
//! | PATCH | `/v1/admin/products/:id/active` | activate / deactivate |
//! | POST | `/v1/admin/licenses` | manually issue license (comp/dev) |
//! | GET | `/v1/admin/licenses` | list licenses by product |
//! | GET | `/v1/admin/licenses/search` | search by email / npub / invoice |
//! | POST | `/v1/admin/licenses/:id/revoke` | revoke a license |
//! | POST | `/v1/admin/licenses/:id/suspend` | suspend (reversible) |
//! | POST | `/v1/admin/licenses/:id/unsuspend` | unsuspend |
//! | POST | `/v1/admin/policies` | create policy (license template) |
//! | GET | `/v1/admin/policies` | list policies for product |
//! | PATCH | `/v1/admin/policies/:id/active` | activate / deactivate policy |
//! | GET | `/v1/admin/machines` | list machines for a license |
//! | POST | `/v1/admin/machines/:id/deactivate` | force-kick a machine |
//! | POST | `/v1/admin/webhook-endpoints` | register webhook subscriber |
//! | GET | `/v1/admin/webhook-endpoints` | list webhook subscribers |
//! | PATCH | `/v1/admin/webhook-endpoints/:id/active` | enable/disable |
//! | DELETE | `/v1/admin/webhook-endpoints/:id` | delete webhook subscriber |
//! | POST | `/v1/admin/discount-codes` | create discount / referral code |
//! | GET | `/v1/admin/discount-codes` | list discount codes |
//! | GET | `/v1/admin/discount-codes/:id` | one code with redemption history |
//! | PATCH | `/v1/admin/discount-codes/:id/active` | enable / disable code |
//! | DELETE | `/v1/admin/discount-codes/:id` | hard-delete (refused if redeemed) |
//! | GET | `/v1/discount-codes/preview` | PUBLIC: preview discount on a product |
//! | GET | `/v1/admin/audit` | list audit log entries |
pub mod admin;
pub mod admin_ui;
pub mod btcpay_authorize;
pub mod discount_codes;
pub mod machines;
pub mod policies;
pub mod products;
pub mod purchase;
pub mod buy_page;
pub mod issuer_key;
pub mod redeem;
pub mod self_license;
pub mod validate;
pub mod webhook;
pub mod webhook_endpoints;
use crate::btcpay::client::BtcpayClient;
use crate::config::Config;
use crate::crypto::keys::ServerKeypair;
use crate::error::{AppError, AppResult};
use axum::{
extract::FromRef,
routing::{get, patch, post},
Json, Router,
};
use serde_json::json;
use sqlx::SqlitePool;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub keypair: Arc<ServerKeypair>,
/// Active payment provider (BTCPay today, Zaprite eventually).
/// `None` until the operator completes a connect flow. Stored as
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
/// write lock when the operator runs Connect / Disconnect.
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
pub config: Arc<Config>,
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
/// operator activates a fresh license via the admin endpoint.
pub self_tier: Arc<RwLock<crate::license_self::Tier>>,
}
impl AppState {
/// Provider-agnostic accessor. New code should use this; legacy
/// `btcpay_client()` / `btcpay_webhook_secret()` accessors remain
/// for v0.2 compat and will retire as call sites migrate in v0.3.
pub async fn payment_provider(
&self,
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
let guard = self.payment.read().await;
guard
.as_ref()
.cloned()
.ok_or(AppError::BtcpayNotConfigured)
}
/// Compat: returns the BTCPay-specific HTTP client, by clone, when
/// the active provider is BTCPay. Falls back to
/// `BtcpayNotConfigured` either when no provider is connected OR
/// when the active provider isn't BTCPay (so Zaprite-only operators
/// in v0.3 will get a clean error from BTCPay-specific code paths
/// that haven't been migrated yet).
pub async fn btcpay_client(&self) -> AppResult<BtcpayClient> {
let guard = self.payment.read().await;
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
provider
.as_any()
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
.map(|p| p.client().clone())
.ok_or(AppError::BtcpayNotConfigured)
}
/// Compat: returns the BTCPay HMAC webhook secret. See
/// `btcpay_client()` for compat-error semantics.
pub async fn btcpay_webhook_secret(&self) -> AppResult<String> {
let guard = self.payment.read().await;
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
provider
.as_any()
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
.map(|p| p.webhook_secret().to_string())
.ok_or(AppError::BtcpayNotConfigured)
}
/// Swap the active payment provider. Called by `btcpay_authorize`
/// (and, later, `zaprite_authorize`).
pub async fn set_payment_provider(
&self,
provider: Arc<dyn crate::payment::PaymentProvider>,
) {
let mut guard = self.payment.write().await;
*guard = Some(provider);
}
/// Clear the active payment provider (Disconnect flow).
pub async fn clear_payment_provider(&self) {
let mut guard = self.payment.write().await;
*guard = None;
}
}
impl FromRef<AppState> for SqlitePool {
fn from_ref(app: &AppState) -> Self {
app.db.clone()
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(root))
.route("/healthz", get(healthz))
.route("/thank-you", get(thank_you))
// Public buyer-facing purchase page. Server-renders an HTML
// page for a given product slug; the inlined form POSTs to
// /v1/purchase and redirects to BTCPay checkout.
.route("/buy/:slug", get(buy_page::render))
// Admin web UI — embedded into the binary at compile time via
// rust-embed (see api/admin_ui.rs). The HTML page itself is
// public; the SPA gates access client-side using the admin API
// key, which is enforced server-side on every /v1/admin/*
// call.
.route("/admin", get(admin_ui::admin_root_redirect))
.route("/admin/", get(admin_ui::admin_index))
.route("/admin/*path", get(admin_ui::admin_asset))
.route("/v1/pubkey", get(pubkey))
.route("/v1/products", get(products::list))
.route("/v1/products/:slug", get(products::get))
.route("/v1/purchase", post(purchase::start))
.route("/v1/purchase/:invoice_id", get(purchase::status))
.route("/v1/redeem", post(redeem::redeem))
.route("/v1/validate", post(validate::validate))
// Client-facing machine endpoints.
.route("/v1/machines/activate", post(machines::activate))
.route("/v1/machines/heartbeat", post(machines::heartbeat))
.route("/v1/machines/deactivate", post(machines::deactivate))
.route("/v1/btcpay/webhook", post(webhook::handle))
.route(
"/v1/admin/btcpay/connect",
post(btcpay_authorize::start_connect),
)
.route(
"/v1/btcpay/authorize/callback",
post(btcpay_authorize::callback).get(btcpay_authorize::callback_get),
)
.route(
"/v1/admin/btcpay/status",
get(btcpay_authorize::status),
)
.route(
"/v1/admin/btcpay/disconnect",
post(btcpay_authorize::disconnect),
)
.route(
"/v1/admin/btcpay/payment-methods",
get(btcpay_authorize::payment_methods),
)
.route("/v1/admin/products", post(admin::create_product))
.route(
"/v1/admin/products/:id/active",
patch(admin::set_product_active),
)
// Both GET (list) and POST (issue) on the same path — must be chained
// onto a single MethodRouter, because axum's Router::route replaces.
.route(
"/v1/admin/licenses",
get(admin::list_licenses).post(admin::issue_license),
)
.route(
"/v1/admin/licenses/search",
get(admin::search_licenses),
)
.route(
"/v1/admin/licenses/:id/revoke",
post(admin::revoke_license),
)
.route(
"/v1/admin/licenses/:id/suspend",
post(admin::suspend_license),
)
.route(
"/v1/admin/licenses/:id/unsuspend",
post(admin::unsuspend_license),
)
// Policies (license templates).
.route(
"/v1/admin/policies",
get(policies::list).post(policies::create),
)
.route(
"/v1/admin/policies/:id/active",
patch(policies::set_active),
)
.route(
"/v1/admin/policies/:id/tip",
patch(policies::set_tip),
)
.route("/v1/admin/tips", get(policies::list_tips))
// Machines (admin views).
.route("/v1/admin/machines", get(machines::admin_list))
.route(
"/v1/admin/machines/:id/deactivate",
post(machines::admin_deactivate),
)
// Webhook subscribers.
.route(
"/v1/admin/webhook-endpoints",
get(webhook_endpoints::list).post(webhook_endpoints::create),
)
.route(
"/v1/admin/webhook-endpoints/:id/active",
patch(webhook_endpoints::set_active),
)
.route(
"/v1/admin/webhook-endpoints/:id",
axum::routing::delete(webhook_endpoints::delete),
)
// Discount / referral codes.
.route(
"/v1/admin/discount-codes",
get(discount_codes::list).post(discount_codes::create),
)
.route(
"/v1/admin/discount-codes/:id",
get(discount_codes::get_one).delete(discount_codes::delete),
)
.route(
"/v1/admin/discount-codes/:id/active",
patch(discount_codes::set_active),
)
// Public preview — buyer hits this from the buy page when they
// click Apply on a discount code. Returns kind + computed
// discounted price, doesn't consume a redemption slot.
.route(
"/v1/discount-codes/preview",
get(discount_codes::preview),
)
// Audit log.
.route("/v1/admin/audit", get(admin::list_audit))
// Live-mutable settings.
.route(
"/v1/admin/settings/operator-name",
get(admin::get_operator_name).post(admin::set_operator_name),
)
// Keysat self-license (Keysat-licenses-Keysat).
.route(
"/v1/admin/self-license",
get(self_license::status).post(self_license::activate),
)
// Issuer-key import — admin-only, master-bootstrap path. No
// StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md.
.route("/v1/admin/import-issuer-key", post(issuer_key::import))
.with_state(state)
}
async fn root(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
// Live-read the operator name from the settings table so admin
// updates take effect without a daemon restart. Falls back to the
// env-var-loaded config if the DB row hasn't been set yet (fresh
// installs, or installs that pre-date this feature).
let operator = match crate::db::repo::settings_get(
&state.db,
crate::api::admin::SETTING_OPERATOR_NAME,
)
.await
{
Ok(Some(v)) => Some(v),
_ => state.config.operator_name.clone(),
};
Json(json!({
"service": "keysat",
"version": env!("CARGO_PKG_VERSION"),
"operator": operator,
"public_key_pem": state.keypair.public_key_pem,
"key_algorithm": "ed25519",
"key_format_version": crate::crypto::KEY_VERSION,
}))
}
async fn healthz() -> Json<serde_json::Value> {
Json(json!({ "ok": true }))
}
/// HTML "thank you" landing page that BTCPay redirects buyers to after a
/// settled invoice. Reads `?invoice_id=<id>` from the query string,
/// renders a Keysat-branded polling page that calls
/// /v1/purchase/<invoice_id> every few seconds until the response
/// includes a `license_key`, then renders the license inline in a
/// certificate-style card with a Copy button. Same visual language
/// as the buy page's free-license success state.
async fn thank_you(
axum::extract::State(state): axum::extract::State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> axum::response::Html<String> {
let invoice_id = params.get("invoice_id").cloned().unwrap_or_default();
let invoice_id_safe = html_escape(&invoice_id);
let invoice_id_json = serde_json::to_string(&invoice_id).unwrap_or_else(|_| "\"\"".into());
// Live-read operator_name from the settings table; fall back to the
// env-var config; final fallback to a neutral brand name.
let live = crate::db::repo::settings_get(
&state.db,
crate::api::admin::SETTING_OPERATOR_NAME,
)
.await
.ok()
.flatten();
let operator_str = live
.as_deref()
.or(state.config.operator_name.as_deref())
.unwrap_or("Keysat");
let operator = html_escape(operator_str);
let body = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Payment received — {operator}</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
--success:#2D7A5F; --success-bg:#E3F0EA;
--danger:#B23A3A; --danger-bg:#F4E0E0;
--border-1:rgba(14,31,51,0.12);
--border-2:rgba(14,31,51,0.20);
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
}}
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
body {{
font-family:var(--font-body); color:var(--ink-900);
background:var(--cream-100);
background-image:
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
background-size:3px 3px, 7px 7px;
-webkit-font-smoothing:antialiased; min-height:100vh;
}}
.topbar {{
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
border-bottom:1px solid var(--border-1); padding:14px 24px;
}}
.topbar .inner {{
max-width:680px; margin:0 auto;
display:flex; align-items:center; gap:12px;
font-family:var(--font-display); font-weight:500; font-size:14px;
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
}}
.topbar .operator {{
font-family:var(--font-body); font-size:12px;
letter-spacing:0.04em; text-transform:none;
color:var(--ink-500); margin-left:auto;
}}
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
.eyebrow {{
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
display:inline-flex; align-items:center; gap:10px;
}}
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
h1 {{
font-family:var(--font-display); font-weight:500; font-size:38px;
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950); margin:0 0 14px;
}}
.lede {{ font-size:16px; line-height:1.55; color:var(--ink-700); margin:0 0 28px; }}
.pending-card, .license-success, .error-card {{
background:var(--cream-50); border:1px solid var(--border-1);
border-radius:14px; box-shadow:var(--shadow-md);
padding:32px 32px 28px; position:relative;
}}
.license-success, .pending-card {{
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
}}
.license-success::before, .license-success::after,
.pending-card::before, .pending-card::after {{
content:''; position:absolute; left:14px; right:14px;
height:1px; background:var(--gold-500); opacity:0.5;
}}
.license-success::before, .pending-card::before {{ top:14px; }}
.license-success::after, .pending-card::after {{ bottom:14px; }}
.stamp {{
font-size:10px; font-weight:700; letter-spacing:0.22em;
text-transform:uppercase; color:var(--gold-700);
text-align:center; margin-bottom:16px;
}}
.pending-card h2 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
}}
.pending-card .sub, .license-success .sub {{
font-size:14px; color:var(--ink-500); text-align:center; margin:0 0 22px;
}}
.spinner {{
width:32px; height:32px; border-radius:50%;
border:3px solid var(--border-1); border-top-color:var(--gold-500);
animation:spin 1s linear infinite;
margin:18px auto 22px;
}}
@keyframes spin {{ to {{ transform:rotate(360deg); }} }}
.status-detail {{
font-family:var(--font-mono); font-size:12.5px;
background:var(--cream-100); border:1px solid var(--border-1);
border-radius:7px; padding:8px 12px;
color:var(--ink-700); text-align:center;
}}
.license-success h2 {{
font-family:var(--font-display); font-weight:500; font-size:22px;
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
}}
.field-label {{
font-size:11px; font-weight:600; letter-spacing:0.12em;
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
}}
.key-box {{
background:var(--navy-950); color:var(--cream-50);
padding:14px 16px; border-radius:8px;
font-family:var(--font-mono); font-size:12.5px;
word-break:break-all; line-height:1.5;
display:flex; align-items:flex-start; gap:12px;
}}
.key-box .key-text {{ flex:1; }}
.key-box button {{
background:rgba(245,241,232,0.10); color:var(--cream-50);
border:0; padding:6px 10px; border-radius:6px;
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
flex-shrink:0;
}}
.key-box button:hover {{ background:rgba(245,241,232,0.20); }}
.save-note {{
margin-top:14px; font-size:13px; color:var(--ink-700);
background:var(--cream-100); border:1px solid var(--border-1);
border-radius:8px; padding:10px 14px;
}}
.save-note strong {{ color:var(--navy-950); }}
.error-card {{
border-color:rgba(178,58,58,0.3); background:var(--danger-bg);
color:#8a2828; font-size:14px;
}}
.hide {{ display:none !important; }}
footer.kfooter {{
text-align:center; font-size:12px; color:var(--ink-500);
margin-top:48px; padding:18px;
}}
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
footer.kfooter a:hover {{ color:var(--navy-900); }}
</style>
</head>
<body>
<div class="topbar">
<div class="inner">
<span>Keysat</span>
<span class="operator">Sold by {operator}</span>
</div>
</div>
<div class="wrap">
<div class="eyebrow">Payment received</div>
<h1 id="page-title">Issuing your license&hellip;</h1>
<p class="lede" id="page-lede">Your Bitcoin payment was received. We&rsquo;re waiting for it to settle on the network and for the license to be signed. This usually takes under a minute once the next block confirms.</p>
<!-- pending state (default): polling for the license -->
<div class="pending-card" id="pending-card">
<div class="stamp">&mdash; Awaiting confirmation &mdash;</div>
<h2>Hang tight.</h2>
<p class="sub">This page will refresh automatically when your license is ready.</p>
<div class="spinner" aria-hidden="true"></div>
<div class="status-detail" id="status-detail">checking status&hellip;</div>
</div>
<!-- success state: license card -->
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
<div class="stamp">&mdash; License issued &mdash;</div>
<h2>You&rsquo;re licensed.</h2>
<p class="sub">Your signed license is below. We&rsquo;ll also email a copy.</p>
<div class="field-label">License key</div>
<div class="key-box">
<span class="key-text" id="license-key-text">&hellip;</span>
<button id="license-key-copy">Copy</button>
</div>
<div class="save-note">
<strong>Save this somewhere safe.</strong> The key is signed at issue time and verifies offline against the seller&rsquo;s public key. You don&rsquo;t need to come back here.
</div>
</div>
<!-- error state: invoice not found, or unrecoverable -->
<div class="error-card hide" id="error-card" role="alert">
<div id="error-msg">Something went wrong looking up this purchase.</div>
</div>
</div>
<footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span>
</footer>
<script>
(function() {{
const INVOICE_ID = {invoice_id_json};
if (!INVOICE_ID) {{
document.getElementById('pending-card').classList.add('hide');
document.getElementById('error-card').classList.remove('hide');
document.getElementById('error-msg').textContent = 'No invoice id supplied. Looking for your license? Check your email or contact the seller.';
return;
}}
const pendingCard = document.getElementById('pending-card');
const successCard = document.getElementById('license-success');
const errorCard = document.getElementById('error-card');
const statusDetail = document.getElementById('status-detail');
const keyText = document.getElementById('license-key-text');
const errorMsg = document.getElementById('error-msg');
const pageTitle = document.getElementById('page-title');
const pageLede = document.getElementById('page-lede');
// Copy button.
document.getElementById('license-key-copy').addEventListener('click', async function() {{
try {{
await navigator.clipboard.writeText(keyText.textContent);
this.textContent = 'Copied';
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
}} catch (e) {{}}
}});
function showSuccess(licenseKey) {{
pendingCard.classList.add('hide');
errorCard.classList.add('hide');
keyText.textContent = licenseKey;
successCard.classList.remove('hide');
pageTitle.textContent = 'Your license is ready.';
pageLede.textContent = 'Save the key below — it verifies offline against the sellers public key. You can close this tab when youre done.';
}}
function showError(msg) {{
pendingCard.classList.add('hide');
successCard.classList.add('hide');
errorMsg.textContent = msg;
errorCard.classList.remove('hide');
pageTitle.textContent = 'Something went wrong.';
pageLede.textContent = 'See the message below for details.';
}}
let attempt = 0;
const MAX_ATTEMPTS = 240; // 240 * 3s = 12 min total. Most settle inside 1.
async function poll() {{
attempt++;
try {{
const r = await fetch('/v1/purchase/' + encodeURIComponent(INVOICE_ID));
if (r.status === 404) {{
return showError('Invoice not found. The link may have been mistyped.');
}}
if (!r.ok) {{
statusDetail.textContent = 'server returned HTTP ' + r.status + ' (will retry)';
return scheduleNext();
}}
const j = await r.json();
if (j.license_key) {{
return showSuccess(j.license_key);
}}
const status = j.status || 'pending';
statusDetail.textContent = 'invoice status: ' + status + (attempt > 1 ? ' (still polling)' : '');
if (status === 'expired' || status === 'invalid') {{
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller.');
}}
scheduleNext();
}} catch (err) {{
statusDetail.textContent = 'network error (retrying): ' + (err.message || err);
scheduleNext();
}}
}}
function scheduleNext() {{
if (attempt >= MAX_ATTEMPTS) {{
statusDetail.textContent = 'still waiting — refresh the page or come back later.';
return;
}}
setTimeout(poll, 3000);
}}
poll();
}})();
</script>
</body>
</html>"#
);
axum::response::Html(body)
}
/// Minimal HTML escape for the operator name. Keeps this module dependency-free.
fn html_escape(s: &str) -> String {
s.chars()
.map(|c| match c {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
_ => c.to_string(),
})
.collect()
}
async fn pubkey(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
Json(json!({
"algorithm": "ed25519",
"public_key_pem": state.keypair.public_key_pem,
}))
}
+270
View File
@@ -0,0 +1,270 @@
//! Policies — reusable license templates.
//!
//! A policy captures "when I issue a license under this shape, what are the
//! defaults?" (duration, grace period, entitlements, machine cap, trial flag,
//! optional price override). Callers to `/v1/admin/licenses` can reference a
//! policy by slug instead of specifying every field.
//!
//! Policies are per-product. The system looks up a "default" policy for a
//! product when a customer buys it through the normal purchase flow — so most
//! products should have at least one policy slugged `default`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreatePolicyReq {
pub product_slug: String,
pub name: String,
pub slug: String,
/// 0 = perpetual.
#[serde(default)]
pub duration_seconds: i64,
#[serde(default)]
pub grace_seconds: i64,
/// 0 = unlimited, 1 = single-seat, n>1 = n-seat.
#[serde(default = "default_max_machines")]
pub max_machines: i64,
#[serde(default)]
pub is_trial: bool,
#[serde(default)]
pub price_sats_override: Option<i64>,
#[serde(default)]
pub entitlements: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Optional Lightning recipient (e.g. "tip@keysat.xyz") to tip a percentage
/// of each successful issuance to. None = no tipping.
#[serde(default)]
pub tip_recipient: Option<String>,
/// Tip percentage in basis points. 100 = 1%. Capped at 10000 (=100%).
#[serde(default)]
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient (audit/UI).
#[serde(default)]
pub tip_label: Option<String>,
}
fn default_max_machines() -> i64 {
1
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
if req.duration_seconds < 0 {
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
}
if req.grace_seconds < 0 {
return Err(AppError::BadRequest("grace_seconds must be >= 0".into()));
}
if req.max_machines < 0 {
return Err(AppError::BadRequest("max_machines must be >= 0".into()));
}
let metadata = if req.metadata.is_null() {
json!({})
} else {
req.metadata
};
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
"tip_pct_bps must be between 0 and 10000 (100%)".into(),
));
}
let tip_recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
if tip_recipient.is_some() && req.tip_pct_bps == 0 {
return Err(AppError::BadRequest(
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
));
}
if tip_recipient.is_none() && req.tip_pct_bps > 0 {
return Err(AppError::BadRequest(
"tip_recipient must be set when tip_pct_bps > 0".into(),
));
}
let tip_label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
let policy = repo::create_policy(
&state.db,
&product.id,
&req.name,
&req.slug,
req.duration_seconds,
req.grace_seconds,
req.max_machines,
req.is_trial,
req.price_sats_override,
&req.entitlements,
&metadata,
tip_recipient,
req.tip_pct_bps,
tip_label,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.create",
Some("policy"),
Some(&policy.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "product_id": product.id, "slug": policy.slug }),
)
.await;
Ok(Json(json!(policy)))
}
#[derive(Debug, Deserialize)]
pub struct ListPoliciesQuery {
pub product_slug: String,
#[serde(default)]
pub include_inactive: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListPoliciesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
let rows = repo::list_policies_by_product(&state.db, &product.id, !q.include_inactive).await?;
Ok(Json(json!({ "policies": rows })))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_active",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct SetTipReq {
/// Lightning Address (`user@domain`). Pass `null` to disable tipping.
pub tip_recipient: Option<String>,
/// Basis points: 010000. 0 = disabled.
pub tip_pct_bps: i64,
/// Optional free-form label (audit / UI).
#[serde(default)]
pub tip_label: Option<String>,
}
pub async fn set_tip(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetTipReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
"tip_pct_bps must be between 0 and 10000".into(),
));
}
let recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
if recipient.is_some() && req.tip_pct_bps == 0 {
return Err(AppError::BadRequest(
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
));
}
if recipient.is_none() && req.tip_pct_bps > 0 {
return Err(AppError::BadRequest(
"tip_recipient must be set when tip_pct_bps > 0".into(),
));
}
let label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
let updated =
repo::set_policy_tip_config(&state.db, &id, recipient, req.tip_pct_bps, label).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"policy.set_tip",
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({
"tip_recipient": updated.tip_recipient,
"tip_pct_bps": updated.tip_pct_bps,
"tip_label": updated.tip_label,
}),
)
.await;
Ok(Json(json!(updated)))
}
#[derive(Debug, Deserialize)]
pub struct ListTipsQuery {
#[serde(default)]
pub license_id: Option<String>,
#[serde(default)]
pub recipient: Option<String>,
#[serde(default = "default_tip_limit")]
pub limit: i64,
}
fn default_tip_limit() -> i64 {
100
}
pub async fn list_tips(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListTipsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let entries = repo::list_tip_attempts(
&state.db,
q.license_id.as_deref(),
q.recipient.as_deref(),
q.limit,
)
.await?;
Ok(Json(json!({ "tips": entries })))
}
+25
View File
@@ -0,0 +1,25 @@
//! Public product endpoints.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
Json,
};
use serde_json::{json, Value};
pub async fn list(State(state): State<AppState>) -> AppResult<Json<Value>> {
let products = repo::list_products(&state.db, true).await?;
Ok(Json(json!({ "products": products })))
}
pub async fn get(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> AppResult<Json<Value>> {
let product = repo::get_product_by_slug(&state.db, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
Ok(Json(json!(product)))
}
+332
View File
@@ -0,0 +1,332 @@
//! Purchase flow:
//! 1. Client POSTs `/v1/purchase` with a product slug.
//! 2. We create a BTCPay invoice, stash a row, return the checkout URL.
//! 3. Client opens the URL, pays. BTCPay hits our webhook (see
//! [`crate::api::webhook`]) which marks the invoice 'settled' and
//! issues a license.
//! 4. Client polls `/v1/purchase/:invoice_id` until `license_key` is
//! present, then stores it locally.
use crate::api::AppState;
use crate::btcpay::client::BtcpayClient;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct StartPurchaseReq {
/// Product slug to buy.
pub product: String,
/// Optional email for receipt / future contact.
pub buyer_email: Option<String>,
/// Optional free-text note from the buyer.
pub buyer_note: Option<String>,
/// Optional URL the buyer should be returned to after payment.
pub redirect_url: Option<String>,
/// Optional discount / referral code (case-insensitive).
pub code: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct StartPurchaseResp {
pub invoice_id: String, // our internal id
pub btcpay_invoice_id: String, // BTCPay's id (for debugging)
pub checkout_url: String, // URL the user opens to pay
pub amount_sats: i64, // what BTCPay was charged (post-discount)
pub base_price_sats: i64, // product list price (pre-discount)
pub discount_applied_sats: i64, // base - amount_sats; 0 if no code
pub poll_url: String, // where to check status
}
/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
/// 100%-off codes still produce a real BTCPay invoice (and the buyer
/// experiences the purchase flow). 0-sat invoices aren't always supported
/// by BTCPay anyway.
const MIN_INVOICE_SATS: i64 = 1;
pub async fn start(
State(state): State<AppState>,
Json(req): Json<StartPurchaseReq>,
) -> AppResult<Json<StartPurchaseResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for purchase",
req.product
)));
}
let base_price = product.price_sats;
// Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot
// BEFORE we create the BTCPay invoice, so that a code-cap race can't
// result in a buyer holding a discounted live invoice for an
// already-exhausted code.
//
// step A: lookup + eligibility checks (active, expired, applies-to)
// step B: atomically increment used_count (try_reserve_code_slot)
// step C: compute discount, create BTCPay invoice
// step D: persist local invoice
// step E: insert the pending redemption row (record_pending_redemption)
//
// If C, D, or E fail after B succeeded, we call release_code_slot to
// give the slot back.
let (final_price, reservation, discount_applied) = if let Some(raw_code) =
req.code.as_deref().filter(|s| !s.trim().is_empty())
{
let code = repo::get_discount_code_by_code(&state.db, raw_code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown discount code".into()))?;
if !code.active {
return Err(AppError::BadRequest("discount code is disabled".into()));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("discount code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"discount code does not apply to this product".into(),
));
}
}
// Note: applies_to_policy_id is informational in v0.1 — the
// policy used at license-issuance time is the product's default.
// Step B: atomic reserve.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
let discount = compute_discount(&code.kind, code.amount, base_price);
let final_price = (base_price - discount).max(MIN_INVOICE_SATS);
(final_price, Some(code), discount)
} else {
(base_price, None, 0)
};
// Pre-allocate an internal invoice id so we can pass it to BTCPay as
// metadata, letting us correlate webhook events back to our row even
// before we've persisted the BTCPay invoice id.
let internal_id = uuid::Uuid::new_v4().to_string();
// If the caller didn't supply a redirect_url, default to our own
// /thank-you page with the invoice id baked in. After payment
// BTCPay sends the buyer's browser there; the page polls
// /v1/purchase/<invoice_id> until the license is issued, then
// renders it. Internal ID (UUID) goes in the URL so the buyer can
// bookmark it / refresh later if they close the tab.
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_id
);
let redirect_url = req
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&default_redirect);
let metadata = BtcpayClient::invoice_metadata(&product.id, &internal_id);
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(e) => {
// Release the reserved slot if we have one — BTCPay isn't ready.
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step C: BTCPay invoice. On failure, release the slot and bail.
let created = match btcpay
.create_invoice(final_price, metadata, Some(redirect_url))
.await
{
Ok(c) => c,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(AppError::Upstream(format!(
"BTCPay invoice create failed: {e}"
)));
}
};
// BTCPay returns a checkout URL using whatever URL we called its
// API at — for us, the internal Docker hostname (fast). Rewrite
// the host to the configured public URL so the buyer actually
// gets a link they can open. Falls through unchanged if no public
// URL is configured (test/dev only).
let checkout_url = match &state.config.btcpay_public_url {
Some(public_base) => {
let rewritten =
crate::payment::btcpay::rewrite_to_public(&created.checkout_link, public_base);
tracing::info!(
original = %created.checkout_link,
rewritten = %rewritten,
public_base = %public_base,
"purchase: checkout URL rewritten for buyer"
);
rewritten
}
None => {
tracing::warn!(
original = %created.checkout_link,
"purchase: checkout URL NOT rewritten — btcpay_public_url is None"
);
created.checkout_link.clone()
}
};
// Step D: persist local invoice. On failure, release the slot.
// Use internal_id we pre-generated (and baked into the BTCPay
// redirect_url) as the local row id so /v1/purchase/<id> and
// /thank-you?invoice_id=<id> all resolve to the same row.
let invoice = match repo::create_invoice(
&state.db,
&internal_id,
&created.id,
&product.id,
final_price,
&checkout_url,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step E: persist the redemption row tying the slot to the invoice.
if let Some(code) = &reservation {
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
discount_applied,
base_price,
final_price,
)
.await
{
// Slot was reserved but we couldn't record the redemption.
// Release the slot and mark the BTCPay invoice as invalid
// locally so we don't accidentally honour it on settle.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"failed to persist pending redemption; releasing slot \
and invalidating local invoice"
);
let _ = repo::release_code_slot(&state.db, &code.id).await;
let _ = repo::update_invoice_status(&state.db, &created.id, "invalid").await;
return Err(e);
}
}
let poll_url = format!("{}/v1/purchase/{}", state.config.public_base_url, invoice.id);
Ok(Json(StartPurchaseResp {
invoice_id: invoice.id,
btcpay_invoice_id: created.id,
checkout_url,
amount_sats: final_price,
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
}))
}
/// Apply the discount math. Returns the sats to subtract from `base`.
/// Caller is responsible for clamping the result (and for floor enforcement).
fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
match kind {
"percent" => {
// amount is basis points (0..=10000). 5000 == 50%.
// Multiply in i128 to avoid overflow on large sat amounts.
let bps = amount.clamp(0, 10_000) as i128;
let base = base_price_sats as i128;
((base * bps) / 10_000).max(0).min(base) as i64
}
"fixed_sats" => amount.max(0).min(base_price_sats),
_ => 0,
}
}
/// Polling endpoint — returns status; if settled and a license has been
/// issued, returns the signed key string.
pub async fn status(
State(state): State<AppState>,
Path(invoice_id): Path<String>,
) -> AppResult<Json<Value>> {
let invoice = repo::get_invoice_by_id(&state.db, &invoice_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("invoice '{invoice_id}'")))?;
let license = repo::get_license_by_invoice(&state.db, &invoice.id).await?;
let license_key = match &license {
Some(lic) if lic.status == "active" => {
// Re-issue the encoded key deterministically from the stored
// license row. `issued_at` is parseable as RFC3339; we reduce to
// unix seconds. Fingerprint binding isn't done here because the
// key is still unbound at first delivery — it'll be bound the
// first time the app calls /v1/validate or /v1/machines/activate.
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
let expires_at = lic
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&lic.product_id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}"))
})?,
license_id: uuid::Uuid::parse_str(&lic.id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}"))
})?,
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at,
fingerprint_hash: [0u8; 32],
entitlements: lic.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
Some(encode_key(&payload, &sig))
}
_ => None,
};
Ok(Json(json!({
"invoice_id": invoice.id,
"status": invoice.status,
"product_id": invoice.product_id,
"amount_sats": invoice.amount_sats,
"license_key": license_key,
"license_id": license.as_ref().map(|l| l.id.clone()),
})))
}
+192
View File
@@ -0,0 +1,192 @@
//! Free-license code redemption — the no-BTCPay path.
//!
//! Flow for `kind = 'free_license'` codes:
//! 1. Buyer hits POST /v1/redeem with `{product, code, buyer_email?, buyer_note?}`.
//! 2. Server validates the code (active, not expired, applies-to, kind == free_license).
//! 3. Server atomically reserves a slot (try_reserve_code_slot).
//! 4. Server synthesizes a settled invoice with amount_sats = 0
//! (so the rest of the data model — license → invoice — stays uniform).
//! 5. Server records the pending redemption row.
//! 6. Server calls the existing `issue_license_for_invoice` path which:
//! - issues the license,
//! - fires `license.issued`,
//! - finalizes the redemption (pending → redeemed),
//! - fires `code.redeemed`.
//! 7. Response includes the signed license_key so the buyer can paste it
//! directly into your app — no polling, no BTCPay.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct RedeemReq {
/// Product slug.
pub product: String,
/// Redeemable code (case-insensitive).
pub code: String,
/// Optional email — recorded on the synthetic invoice and license for
/// admin search and webhook payloads.
pub buyer_email: Option<String>,
/// Optional free-text note (recorded on invoice).
pub buyer_note: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RedeemResp {
pub license_id: String,
pub license_key: String,
pub invoice_id: String,
pub redemption_id: String,
}
pub async fn redeem(
State(state): State<AppState>,
Json(req): Json<RedeemReq>,
) -> AppResult<Json<RedeemResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for redemption",
req.product
)));
}
if req.code.trim().is_empty() {
return Err(AppError::BadRequest("code is required".into()));
}
let code = repo::get_discount_code_by_code(&state.db, &req.code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown code".into()))?;
if !code.active {
return Err(AppError::BadRequest("code is disabled".into()));
}
if code.kind != "free_license" {
return Err(AppError::BadRequest(
"this code requires payment — use the standard purchase flow with the code applied".into(),
));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"code does not apply to this product".into(),
));
}
}
// Atomic reserve. If reserved succeeds and any subsequent step fails,
// we release the slot so a freed slot becomes available again.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
// Synthesize a settled, zero-amount invoice. Errors release the slot.
let invoice = match repo::create_free_invoice(
&state.db,
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
let _ = repo::release_code_slot(&state.db, &code.id).await;
return Err(e);
}
};
// Record the pending redemption row tying the slot to this invoice.
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
0, // discount_applied (whole price is "free")
0, // base_price_sats (free)
0, // final_price_sats
)
.await
{
let _ = repo::release_code_slot(&state.db, &code.id).await;
return Err(e);
}
// Issue the license. This also finalizes the redemption (pending →
// redeemed) and fires both `license.issued` and `code.redeemed`
// outbound webhooks.
let license_id = match crate::api::webhook::issue_license_for_invoice(&state, &invoice).await {
Ok(id) => id,
Err(e) => {
// The invoice + redemption are persisted but the license
// failed. Cancel the redemption so the slot is released and
// log loudly.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"free redemption: license issuance failed after invoice + redemption \
were persisted"
);
if let Ok(Some(red)) =
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await
{
let _ = repo::cancel_redemption(&state.db, &red.id).await;
}
return Err(e);
}
};
// Re-derive the signed license key so we can return it to the buyer
// directly. Mirrors the math in `purchase::status`.
let license = repo::get_license_by_invoice(&state.db, &invoice.id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("license vanished after issue")))?;
let flags = if license.is_trial { FLAG_TRIAL } else { 0 };
let expires_at_unix = license
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&chrono::Utc).timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&license.product_id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}")))?,
license_id: uuid::Uuid::parse_str(&license.id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}")))?,
issued_at: chrono::DateTime::parse_from_rfc3339(&license.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at: expires_at_unix,
fingerprint_hash: [0u8; 32],
entitlements: license.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
// The redemption row was finalized inside issue_license_for_invoice;
// re-fetch to surface its id in the response.
let redemption_id = repo::list_redemptions_by_code(&state.db, &code.id)
.await
.ok()
.and_then(|rows| rows.into_iter().find(|r| r.invoice_id == invoice.id).map(|r| r.id))
.unwrap_or_default();
Ok(Json(RedeemResp {
license_id,
license_key,
invoice_id: invoice.id,
redemption_id,
}))
}
+172
View File
@@ -0,0 +1,172 @@
//! Admin endpoints for managing the daemon's own self-license
//! (Keysat-licenses-Keysat).
//!
//! - `GET /v1/admin/self-license` — current tier (licensed / unlicensed)
//! - `POST /v1/admin/self-license` — activate a new license. Validates
//! against the embedded master pubkey, writes the file to
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
//!
//! These run *only* when authenticated by the admin API key — same gate
//! as every other `/v1/admin/*` route.
use crate::api::AppState;
use crate::error::AppResult;
use crate::license_self::{self, Tier};
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
#[serde(tag = "tier", rename_all = "snake_case")]
pub enum TierStatus {
Unlicensed {
reason: String,
mode: &'static str,
},
Licensed {
license_id: String,
product_id: String,
/// Unix seconds; 0 means perpetual.
expires_at: i64,
entitlements: Vec<String>,
mode: &'static str,
},
}
fn tier_to_status(tier: &Tier) -> TierStatus {
let mode = match license_self::mode() {
license_self::Mode::Permissive => "permissive",
license_self::Mode::Enforce => "enforce",
};
match tier {
Tier::Unlicensed { reason } => TierStatus::Unlicensed {
reason: reason.clone(),
mode,
},
Tier::Licensed {
license_id,
product_id,
expires_at,
entitlements,
} => TierStatus::Licensed {
license_id: license_id.to_string(),
product_id: product_id.to_string(),
expires_at: *expires_at,
entitlements: entitlements.clone(),
mode,
},
}
}
pub async fn status(State(state): State<AppState>) -> Json<TierStatus> {
let tier = state.self_tier.read().await.clone();
Json(tier_to_status(&tier))
}
#[derive(Deserialize)]
pub struct ActivateBody {
pub license_key: String,
}
#[derive(Serialize)]
pub struct ActivateResponse {
pub ok: bool,
pub tier: TierStatus,
pub message: String,
}
pub async fn activate(
State(state): State<AppState>,
Json(body): Json<ActivateBody>,
) -> AppResult<impl IntoResponse> {
let key = body.license_key.trim().to_string();
if key.is_empty() {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "license_key is required"
})),
)
.into_response());
}
// Verify against the embedded master pubkey before persisting.
let new_tier = match license_self::verify_license(&key) {
Ok(t) => t,
Err(e) => {
tracing::warn!("self-license activation rejected: {e:#}");
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "license_invalid",
"detail": format!("{e:#}"),
})),
)
.into_response());
}
};
// Persist to /data/keysat-license.txt.
if let Err(e) = license_self::write_license_file(&key) {
tracing::error!("self-license file write failed: {e:#}");
return Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "write_failed",
"detail": format!("{e:#}"),
})),
)
.into_response());
}
// Swap the runtime tier.
{
let mut guard = state.self_tier.write().await;
*guard = new_tier.clone();
}
let status_resp = tier_to_status(&new_tier);
let summary = match &status_resp {
TierStatus::Licensed {
license_id,
expires_at,
entitlements,
..
} => {
let exp = if *expires_at == 0 {
"perpetual".to_string()
} else {
format!("expires unix={}", expires_at)
};
let ents = if entitlements.is_empty() {
"(none)".to_string()
} else {
entitlements.join(",")
};
format!(
"License {} verified — {}, entitlements={}.",
license_id, exp, ents
)
}
TierStatus::Unlicensed { .. } => {
// Should be unreachable; verify_license never returns Unlicensed.
"License processed.".to_string()
}
};
tracing::info!("self-license activated: {summary}");
Ok((
StatusCode::OK,
Json(ActivateResponse {
ok: true,
tier: status_resp,
message: summary,
}),
)
.into_response())
}
+451
View File
@@ -0,0 +1,451 @@
//! The single most-hit endpoint: validate a license key.
//!
//! Clients — typically another piece of software starting up — call this
//! with their key and (optionally) the `product_slug` they expect the key
//! to cover and a `fingerprint` identifying the machine/installation.
//!
//! Response shape (HTTP always 200; `ok` + `reason` machine-readable):
//!
//! ```json
//! { "ok": true, "license_id": "...", "product_id": "...", "entitlements": ["pro"], "status": "active" }
//! { "ok": false, "reason": "expired", "grace_until": "..." }
//! ```
//!
//! Machine cap handling:
//!
//! When a license allows more than one concurrent machine (`max_machines != 1`),
//! validate will auto-activate up to the cap. Beyond the cap, the call is
//! rejected with `too_many_machines` — the client is expected to either
//! prompt the user to deactivate another machine or to call
//! `POST /v1/machines/deactivate` first. `max_machines == 0` means unlimited.
use crate::api::AppState;
use crate::crypto::{self, hash_fingerprint};
use crate::db::repo;
use crate::error::AppResult;
use axum::{
extract::State,
http::{header, HeaderMap},
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct ValidateReq {
pub key: String,
/// Optional: the product slug the caller expects this key to cover.
/// Rejects keys issued for a different product even if valid.
pub product_slug: Option<String>,
/// Optional: raw machine fingerprint. First successful validation binds
/// this to the license row (if not already set); later validations
/// succeed only if it matches.
pub fingerprint: Option<String>,
/// Optional client-supplied hostname for machine records.
pub hostname: Option<String>,
/// Optional client-supplied platform descriptor.
pub platform: Option<String>,
}
#[derive(Debug, Serialize, Default)]
pub struct ValidateResp {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grace_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_grace_period: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_trial: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub entitlements: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_machines: Option<i64>,
}
fn reject(reason: &str) -> ValidateResp {
ValidateResp {
ok: false,
reason: Some(reason.to_string()),
..Default::default()
}
}
pub async fn validate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ValidateReq>,
) -> AppResult<Json<ValidateResp>> {
let client_ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Rate limit by client IP if available, else by license key prefix as a
// last-ditch bucket key. Cap at 60 req / minute / bucket.
let bucket_key = client_ip.clone().unwrap_or_else(|| {
req.key
.chars()
.take(24)
.collect::<String>()
});
if !crate::rate_limit::consume(
&state.db,
"validate_ip",
&bucket_key,
/* capacity */ 60.0,
/* refill_per_second */ 1.0,
)
.await?
{
return Ok(Json(reject("rate_limited")));
}
// Step 1: parse & verify signature offline-style, using the server's own
// verifying key (same key the SDK will ship).
let (payload, signature, signed_bytes) = match crypto::parse_key(&req.key) {
Ok(ok) => ok,
Err(e) => {
repo::log_validation(
&state.db,
None,
None,
req.fingerprint.as_deref(),
"bad_format",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&e.to_string()),
)
.await
.ok();
tracing::debug!(error = %e, "rejected malformed key");
return Ok(Json(reject("bad_format")));
}
};
if crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature).is_err() {
repo::log_validation(
&state.db,
Some(&payload.license_id.to_string()),
Some(&payload.product_id.to_string()),
req.fingerprint.as_deref(),
"bad_signature",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("bad_signature")));
}
let license_id = payload.license_id.to_string();
let product_id = payload.product_id.to_string();
// Step 2: look up the license row.
let license = match repo::get_license_by_id(&state.db, &license_id).await? {
Some(l) => l,
None => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"not_found",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("not_found")));
}
};
// Step 3: status checks — authoritative server-side.
match license.status.as_str() {
"active" => {}
"revoked" => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"revoked",
client_ip.as_deref(),
user_agent.as_deref(),
None,
license.revocation_reason.as_deref(),
)
.await
.ok();
return Ok(Json(reject("revoked")));
}
"suspended" => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"suspended",
client_ip.as_deref(),
user_agent.as_deref(),
None,
license.suspension_reason.as_deref(),
)
.await
.ok();
return Ok(Json(reject("suspended")));
}
other => {
tracing::warn!(status = other, license_id, "unknown license status");
return Ok(Json(reject("invalid_state")));
}
}
// Step 4: product match (optional).
let product = repo::get_product_by_id(&state.db, &license.product_id).await?;
if let (Some(expected_slug), Some(p)) = (&req.product_slug, &product) {
if &p.slug != expected_slug {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"product_mismatch",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("product_mismatch")));
}
}
// Step 5: expiry + grace.
let now = Utc::now();
let mut in_grace_period = false;
let mut grace_until: Option<String> = None;
if let Some(exp_str) = &license.expires_at {
if let Ok(exp_dt) = DateTime::parse_from_rfc3339(exp_str) {
let exp_utc = exp_dt.with_timezone(&Utc);
let grace_cutoff = exp_utc + chrono::Duration::seconds(license.grace_seconds);
if now >= grace_cutoff {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"expired",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&format!("expired at {exp_str}")),
)
.await
.ok();
return Ok(Json(ValidateResp {
ok: false,
reason: Some("expired".into()),
license_id: Some(license_id),
product_id: Some(product_id),
expires_at: Some(exp_str.clone()),
..Default::default()
}));
} else if now >= exp_utc {
in_grace_period = true;
grace_until = Some(grace_cutoff.to_rfc3339());
}
}
}
// Step 6: fingerprint + machine binding.
// - Single-seat (max_machines == 1): preserve legacy column-based TOFU
// on `licenses.fingerprint` for backwards compatibility, AND also
// write/update a `machines` row so admins see a consistent view.
// - Multi-seat: look up / auto-activate in the machines table, enforce
// the cap.
let mut machine_id: Option<String> = None;
if let Some(fp) = req.fingerprint.as_deref() {
let fp_hash = crate::hex_sha256(fp);
if license.max_machines == 1 {
match &license.fingerprint {
Some(stored) if stored != fp => {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
Some(fp),
"fingerprint_mismatch",
client_ip.as_deref(),
user_agent.as_deref(),
None,
None,
)
.await
.ok();
return Ok(Json(reject("fingerprint_mismatch")));
}
Some(_) => {
// Already bound and matches — touch heartbeat on any machine row.
if let Some(m) =
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
{
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
machine_id = Some(m.id);
}
}
None => {
repo::bind_fingerprint_if_unset(&state.db, &license_id, fp).await?;
let m = repo::activate_machine(
&state.db,
&license_id,
fp,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&serde_json::json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
machine_id = Some(m.id);
}
}
} else {
// Multi-seat: consult machines table.
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
Some(m) => {
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
machine_id = Some(m.id);
}
None => {
// Count existing active machines. max_machines = 0 means unlimited.
let active = repo::list_active_machines(&state.db, &license_id).await?;
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
Some(fp),
"too_many_machines",
client_ip.as_deref(),
user_agent.as_deref(),
None,
Some(&format!(
"cap {} already reached",
license.max_machines
)),
)
.await
.ok();
return Ok(Json(ValidateResp {
ok: false,
reason: Some("too_many_machines".into()),
license_id: Some(license_id),
product_id: Some(product_id),
max_machines: Some(license.max_machines),
..Default::default()
}));
}
let m = repo::activate_machine(
&state.db,
&license_id,
fp,
&fp_hash,
req.hostname.as_deref(),
req.platform.as_deref(),
client_ip.as_deref(),
)
.await?;
crate::webhooks::dispatch(
&state,
"machine.activated",
&serde_json::json!({
"license_id": license_id,
"machine_id": m.id,
"fingerprint_hash": fp_hash,
}),
)
.await;
machine_id = Some(m.id);
}
}
}
// If the signed payload is itself fingerprint-bound, enforce hash
// match against the signed blob (an extra belt-and-braces check).
if payload.is_fingerprint_bound() && payload.fingerprint_hash != hash_fingerprint(fp) {
return Ok(Json(reject("fingerprint_mismatch")));
}
}
repo::log_validation(
&state.db,
Some(&license_id),
Some(&product_id),
req.fingerprint.as_deref(),
"ok",
client_ip.as_deref(),
user_agent.as_deref(),
machine_id.as_deref(),
if in_grace_period {
Some("in_grace_period")
} else {
None
},
)
.await
.ok();
Ok(Json(ValidateResp {
ok: true,
reason: None,
license_id: Some(license_id),
product_id: Some(product_id),
product_slug: product.map(|p| p.slug),
issued_at: Some(license.issued_at),
expires_at: license.expires_at,
grace_until,
in_grace_period: if in_grace_period { Some(true) } else { None },
is_trial: if license.is_trial { Some(true) } else { None },
entitlements: license.entitlements,
status: Some(license.status),
machine_id,
max_machines: Some(license.max_machines),
}))
}
+305
View File
@@ -0,0 +1,305 @@
//! Payment-provider webhook landing endpoint.
//!
//! Generic over the active `PaymentProvider` (BTCPay today; Zaprite in
//! v0.3). The flow:
//!
//! 1. The provider POSTs an invoice status event here. We hand the raw
//! bytes + headers to the active provider's `validate_webhook` so it
//! can apply its own signature scheme before we trust the body.
//! 2. On `InvoiceSettled`, we mark the invoice settled AND issue a
//! license row (if one doesn't already exist for this invoice —
//! webhooks can be retried). Idempotency is critical.
//! 3. On other events (expired / invalid / refunded), we update status
//! and (for refunds in v0.3) revoke the license.
//!
//! We do **not** sign and return the license key here — the key is
//! lazily re-derived from the stored license row when the buyer polls
//! `/v1/purchase/:invoice_id`. This keeps webhook handling fast and
//! means a dropped webhook response doesn't lose a key.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use crate::payment::ProviderWebhookEvent;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
};
use chrono::Utc;
pub async fn handle(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<StatusCode> {
// Active provider validates its own webhooks (each provider has a
// different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
// Zaprite's TBD). On any verification failure we 401.
let provider = state.payment_provider().await?;
let event = provider
.validate_webhook(&headers, &body)
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
let provider_invoice_id = match event.provider_invoice_id() {
Some(id) => id.to_string(),
None => {
tracing::info!("webhook event without an invoice id; acking");
return Ok(StatusCode::OK);
}
};
let new_status = match &event {
ProviderWebhookEvent::InvoiceSettled { .. } => Some("settled"),
ProviderWebhookEvent::InvoiceExpired { .. } => Some("expired"),
ProviderWebhookEvent::InvoiceInvalid { .. } => Some("invalid"),
// Refunds are a v0.3 surface; for now we treat them as a noop
// and just ack so the provider stops retrying. Once the
// license-revoke-on-refund flow ships, this branch flips to
// doing the revoke + audit-entry work.
ProviderWebhookEvent::InvoiceRefunded { .. } => {
tracing::info!(
provider_invoice_id = %provider_invoice_id,
"refund webhook received; revoke-on-refund flow lands in v0.3"
);
return Ok(StatusCode::OK);
}
ProviderWebhookEvent::Other { kind, .. } => {
tracing::info!(
event_type = %kind,
provider_invoice_id = %provider_invoice_id,
"ignoring non-actionable webhook event"
);
return Ok(StatusCode::OK);
}
};
let new_status = match new_status {
Some(s) => s,
None => return Ok(StatusCode::OK),
};
tracing::info!(
provider = provider.kind().as_str(),
new_status,
provider_invoice_id = %provider_invoice_id,
"webhook event applied"
);
// Persist status.
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
// If the invoice is going to a non-success terminal state, free any
// discount-code slot that was reserved for it. We need the internal
// invoice id (not the provider one) to look up the redemption.
if matches!(new_status, "expired" | "invalid") {
if let Ok(Some(inv)) =
repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await
{
if let Ok(Some(redemption)) =
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
{
if let Err(e) = repo::cancel_redemption(&state.db, &redemption.id).await {
tracing::warn!(
redemption_id = %redemption.id,
error = %e,
"failed to cancel redemption on terminal invoice; counter slot may leak"
);
}
}
}
}
if new_status != "settled" {
return Ok(StatusCode::OK);
}
// Find the invoice and issue a license if not already issued.
let invoice = repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await?;
let Some(invoice) = invoice else {
tracing::warn!(
provider_invoice_id = %provider_invoice_id,
"settled invoice not found in local DB; ignoring"
);
return Ok(StatusCode::OK);
};
// Idempotency: if a license already exists for this invoice, do nothing.
if repo::get_license_by_invoice(&state.db, &invoice.id)
.await?
.is_some()
{
return Ok(StatusCode::OK);
}
let _license_id = issue_license_for_invoice(&state, &invoice).await?;
Ok(StatusCode::OK)
}
/// Shared issuance path — used by both the webhook handler and the reconcile
/// loop. Pulls the invoice's associated policy (if the product has a default
/// one) and materializes a license row with the right expiry / entitlements.
pub async fn issue_license_for_invoice(
state: &AppState,
invoice: &crate::models::Invoice,
) -> AppResult<String> {
// Pick the "default" policy for the product: the first active policy
// whose slug is "default" if present, else the first active policy, else
// none (perpetual, no entitlements, max_machines=1).
let policies = repo::list_policies_by_product(&state.db, &invoice.product_id, true).await?;
let policy = policies
.iter()
.find(|p| p.slug == "default")
.or_else(|| policies.first())
.cloned();
let now = Utc::now();
let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
let expires_at = if duration_seconds == 0 {
None
} else {
Some((now + chrono::Duration::seconds(duration_seconds)).to_rfc3339())
};
let grace_seconds = policy.as_ref().map(|p| p.grace_seconds).unwrap_or(0);
let max_machines = policy.as_ref().map(|p| p.max_machines).unwrap_or(1);
let is_trial = policy.as_ref().map(|p| p.is_trial).unwrap_or(false);
let entitlements = policy
.as_ref()
.map(|p| p.entitlements.clone())
.unwrap_or_default();
let license_id = uuid::Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&invoice.product_id,
Some(&invoice.id),
&issued_at,
&serde_json::json!({
"source": "purchase",
"btcpay_invoice_id": invoice.btcpay_invoice_id,
}),
policy.as_ref().map(|p| p.id.as_str()),
expires_at.as_deref(),
grace_seconds,
max_machines,
&entitlements,
is_trial,
invoice.buyer_email.as_deref(),
None,
)
.await?;
tracing::info!(
license_id = %license_id,
invoice_id = %invoice.id,
policy_id = ?policy.as_ref().map(|p| &p.id),
"license issued for settled invoice"
);
// Fire-and-forget Lightning tip to the policy's configured recipient,
// if any. This never blocks issuance: errors are logged + audited inside
// the spawned task. Skipped silently when the policy has no tip config.
if let Some(p) = policy.as_ref() {
if p.tip_recipient.is_some() && p.tip_pct_bps > 0 {
crate::tipping::spawn_tip(
state.clone(),
license_id.clone(),
p.clone(),
invoice.amount_sats,
);
}
}
crate::webhooks::dispatch(
state,
"license.issued",
&serde_json::json!({
"license_id": license_id,
"product_id": invoice.product_id,
"invoice_id": invoice.id,
"policy_id": policy.as_ref().map(|p| &p.id),
"is_trial": is_trial,
"expires_at": expires_at,
"entitlements": entitlements,
"source": "purchase",
}),
)
.await;
// If this invoice used a discount code, finalize the redemption row
// (transition pending → redeemed, attach license_id) and fire a
// `code.redeemed` webhook. Done here (rather than in the webhook
// handler) so both the webhook path and the reconciler-recovered
// path produce identical effects.
if let Some(redemption) =
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await?
{
if let Err(e) =
repo::mark_redemption_redeemed(&state.db, &redemption.id, &license_id).await
{
tracing::warn!(
redemption_id = %redemption.id,
license_id = %license_id,
error = %e,
"failed to mark redemption as redeemed; continuing"
);
}
let code_payload = match repo::get_discount_code_by_id(&state.db, &redemption.code_id).await
{
Ok(Some(code)) => Some(code),
_ => None,
};
let _ = repo::insert_audit(
&state.db,
"system",
None,
"code.redeemed",
Some("discount_code"),
Some(&redemption.code_id),
None,
None,
&serde_json::json!({
"redemption_id": redemption.id,
"invoice_id": invoice.id,
"license_id": license_id,
"discount_applied_sats": redemption.discount_applied_sats,
"base_price_sats": redemption.base_price_sats,
"final_price_sats": redemption.final_price_sats,
}),
)
.await;
crate::webhooks::dispatch(
state,
"code.redeemed",
&serde_json::json!({
"redemption_id": redemption.id,
"code_id": redemption.code_id,
"code": code_payload.as_ref().map(|c| c.code.clone()),
"license_id": license_id,
"product_id": invoice.product_id,
"invoice_id": invoice.id,
"discount_applied_sats": redemption.discount_applied_sats,
"base_price_sats": redemption.base_price_sats,
"final_price_sats": redemption.final_price_sats,
}),
)
.await;
}
Ok(license_id)
}
// Small helper to attach a log line to an error conversion.
trait TapLog {
fn tap_log(self, msg: String) -> Self;
}
impl TapLog for AppError {
fn tap_log(self, msg: String) -> Self {
tracing::warn!("{msg}");
self
}
}
@@ -0,0 +1,154 @@
//! Admin CRUD for webhook endpoints.
//!
//! Operators register one or more URLs that will receive signed JSON
//! notifications of interesting events (`license.issued`, `license.revoked`,
//! `machine.activated`, etc.). Each endpoint has its own HMAC-SHA256 secret;
//! the delivery worker in [`crate::webhooks`] signs bodies with it.
//!
//! The secret is only returned to the operator in plaintext on create — once
//! they've stored it somewhere safe, later reads return the secret masked.
//! (If they lose it, they can rotate by deleting + recreating the endpoint.)
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::AppResult;
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use rand::RngCore;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct CreateEndpointReq {
pub url: String,
/// Event types this endpoint is interested in. Use `["*"]` to receive all
/// events. Examples: `license.issued`, `license.revoked`,
/// `license.suspended`, `machine.activated`, `machine.deactivated`,
/// `invoice.settled`.
#[serde(default = "default_event_types")]
pub event_types: Vec<String>,
#[serde(default)]
pub description: String,
/// Optional explicit secret (hex, 32+ bytes). If omitted, the server
/// generates a fresh 32-byte secret and returns it in the response.
#[serde(default)]
pub secret: Option<String>,
}
fn default_event_types() -> Vec<String> {
vec!["*".to_string()]
}
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateEndpointReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let secret = req.secret.unwrap_or_else(generate_secret);
let ep = repo::create_webhook_endpoint(
&state.db,
&req.url,
&secret,
&req.event_types,
&req.description,
)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.create",
Some("webhook_endpoint"),
Some(&ep.id),
ip.as_deref(),
ua.as_deref(),
&json!({
"url": ep.url,
"event_types": ep.event_types,
}),
)
.await;
// Return the full endpoint (including the plaintext secret) on create —
// this is the only chance the operator gets to see it.
Ok(Json(json!(ep)))
}
fn generate_secret() -> String {
let mut raw = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw);
hex::encode(raw)
}
#[derive(Debug, Deserialize)]
pub struct ListEndpointsQuery {
#[serde(default)]
pub include_secret: bool,
}
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListEndpointsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?;
Ok(Json(json!({ "endpoints": rows })))
}
#[derive(Debug, Deserialize)]
pub struct SetActiveReq {
pub active: bool,
}
pub async fn set_active(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_webhook_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.set_active",
Some("webhook_endpoint"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "active": req.active }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::delete_webhook_endpoint(&state.db, &id).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"webhook_endpoint.delete",
Some("webhook_endpoint"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true })))
}
+368
View File
@@ -0,0 +1,368 @@
//! Minimal BTCPay Greenfield API client — only the endpoints this service
//! actually calls. Add more as needs grow.
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone)]
pub struct BtcpayClient {
http: Client,
base_url: String,
api_key: String,
store_id: String,
}
/// Response subset from `POST /api/v1/stores/{storeId}/invoices`.
#[derive(Debug, Deserialize)]
pub struct CreatedInvoice {
pub id: String,
#[serde(rename = "checkoutLink")]
pub checkout_link: String,
pub status: String,
}
/// Fields we include when creating an invoice. BTCPay accepts many more; we
/// only send what we need.
#[derive(Debug, Serialize)]
struct CreateInvoiceRequest<'a> {
amount: String,
currency: &'a str,
metadata: serde_json::Value,
checkout: CheckoutOptions<'a>,
}
#[derive(Debug, Serialize)]
struct CheckoutOptions<'a> {
#[serde(rename = "redirectURL")]
redirect_url: Option<&'a str>,
#[serde(rename = "redirectAutomatically")]
redirect_automatically: bool,
}
impl BtcpayClient {
pub fn new(base_url: &str, api_key: &str, store_id: &str) -> Self {
Self {
http: Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest client"),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
}
}
/// Create an invoice priced in satoshis. BTCPay accepts "BTC" currency
/// with decimal amounts; we convert sats → BTC here.
pub async fn create_invoice(
&self,
amount_sats: i64,
metadata: serde_json::Value,
redirect_url: Option<&str>,
) -> Result<CreatedInvoice> {
let url = format!(
"{}/api/v1/stores/{}/invoices",
self.base_url, self.store_id
);
let amount_btc = format!("{:.8}", amount_sats as f64 / 100_000_000.0);
let body = CreateInvoiceRequest {
amount: amount_btc,
currency: "BTC",
metadata,
checkout: CheckoutOptions {
redirect_url,
redirect_automatically: true,
},
};
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay create-invoice")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay create-invoice returned {status}: {text}"
));
}
let invoice: CreatedInvoice = resp
.json()
.await
.context("parsing BTCPay create-invoice response")?;
Ok(invoice)
}
/// Pay a BOLT11 Lightning invoice from the operator's BTCPay node.
/// Used by the tip-recipient flow. Returns the BTCPay payment record so
/// the caller can extract the payment hash and surface it in the audit
/// log. Errors if the store has no internal LN node or the node refuses
/// the payment (insufficient liquidity, invoice already paid, etc.).
///
/// BTCPay endpoint:
/// POST /api/v1/stores/{storeId}/lightning/BTC/invoices/pay
/// { "BOLT11": "<bolt11>" }
///
/// The BTC path-component is the cryptoCode; on BTCPay-Server it's
/// always "BTC" for the Bitcoin Lightning network.
pub async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/stores/{}/lightning/BTC/invoices/pay",
self.base_url, self.store_id
);
let body = json!({ "BOLT11": bolt11 });
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay pay-lightning-invoice")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay pay-lightning-invoice returned {status}: {text}"
));
}
let payment: serde_json::Value = resp
.json()
.await
.context("parsing BTCPay pay-lightning-invoice response")?;
Ok(payment)
}
/// Fetch invoice state for reconciliation on startup / admin queries.
/// Not used in the hot path; webhooks are the source of truth.
pub async fn get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/stores/{}/invoices/{}",
self.base_url, self.store_id, invoice_id
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.api_key))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(
"BTCPay get-invoice returned {}",
resp.status()
));
}
Ok(resp.json().await?)
}
#[allow(dead_code)]
pub fn store_id(&self) -> &str {
&self.store_id
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn api_key(&self) -> &str {
&self.api_key
}
// Helper to quickly construct sample metadata for invoice correlation.
pub fn invoice_metadata(product_id: &str, internal_invoice_id: &str) -> serde_json::Value {
json!({
"orderId": internal_invoice_id,
"productId": product_id,
"source": "keysat",
})
}
}
/// Standalone helpers for the authorize / bootstrap flow. These operate
/// *before* a full `BtcpayClient` exists, since we don't yet know which
/// store the API key is scoped to.
#[derive(Debug, Deserialize)]
pub struct StoreSummary {
pub id: String,
pub name: String,
}
/// List the stores the given API key has access to.
pub async fn list_stores(base_url: &str, api_key: &str) -> Result<Vec<StoreSummary>> {
let url = format!("{}/api/v1/stores", base_url.trim_end_matches('/'));
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.get(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay list-stores")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay list-stores returned {status}: {text}"
));
}
Ok(resp.json::<Vec<StoreSummary>>().await?)
}
#[derive(Debug, Deserialize)]
pub struct CreatedWebhook {
pub id: String,
pub secret: Option<String>,
}
/// Register a webhook on the given store pointing at `callback_url` and
/// subscribing to the three invoice lifecycle events we care about.
pub async fn create_webhook(
base_url: &str,
api_key: &str,
store_id: &str,
callback_url: &str,
secret: &str,
) -> Result<CreatedWebhook> {
let url = format!(
"{}/api/v1/stores/{store_id}/webhooks",
base_url.trim_end_matches('/')
);
let body = json!({
"url": callback_url,
"enabled": true,
"automaticRedelivery": true,
"secret": secret,
"authorizedEvents": {
"everything": false,
"specificEvents": [
"InvoiceSettled",
"InvoiceExpired",
"InvoiceInvalid",
],
},
});
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.post(&url)
.header("Authorization", format!("token {api_key}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("calling BTCPay create-webhook")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay create-webhook returned {status}: {text}"
));
}
Ok(resp.json::<CreatedWebhook>().await?)
}
/// Delete a webhook on the given store. Used by the Disconnect flow so
/// that re-authorizing later doesn't leave behind a duplicate webhook
/// pointing at this Keysat install.
pub async fn delete_webhook(
base_url: &str,
api_key: &str,
store_id: &str,
webhook_id: &str,
) -> Result<()> {
let url = format!(
"{}/api/v1/stores/{store_id}/webhooks/{webhook_id}",
base_url.trim_end_matches('/')
);
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.delete(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay delete-webhook")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay delete-webhook returned {status}: {text}"
));
}
// 404 is treated as success — the webhook is already gone.
Ok(())
}
/// Revoke a BTCPay API key. Best-effort — failures are logged by the
/// caller but don't block the local Disconnect from completing.
pub async fn revoke_api_key(base_url: &str, api_key: &str) -> Result<()> {
let url = format!("{}/api/v1/api-keys/current", base_url.trim_end_matches('/'));
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.delete(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay revoke-api-key")?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay revoke-api-key returned {status}: {text}"
));
}
Ok(())
}
/// List the payment methods configured on a store. Used by the
/// post-connect "missing wallet" detection. Returns the raw JSON array
/// because the per-method shape varies (onchain vs LN, BTC vs altcoins).
/// Empty array → no payment methods configured.
pub async fn list_payment_methods(
base_url: &str,
api_key: &str,
store_id: &str,
) -> Result<Vec<serde_json::Value>> {
let url = format!(
"{}/api/v1/stores/{store_id}/payment-methods",
base_url.trim_end_matches('/')
);
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.get(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay list-payment-methods")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"BTCPay list-payment-methods returned {status}: {text}"
));
}
let raw: serde_json::Value = resp.json().await?;
Ok(raw
.as_array()
.cloned()
.unwrap_or_default())
}
+125
View File
@@ -0,0 +1,125 @@
//! Persistent BTCPay connection state.
//!
//! Runtime credentials (API key, store, webhook secret) live in the DB so that
//! the operator can reconfigure BTCPay from the StartOS dashboard without
//! editing env vars or restarting the container.
//!
//! Written on first connect (via the authorize flow) and on explicit
//! reconnects. Read at startup to construct the `BtcpayClient`.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone)]
pub struct BtcpayConfig {
pub base_url: String,
pub api_key: String,
pub store_id: String,
pub webhook_id: Option<String>,
pub webhook_secret: String,
}
/// Load the current BTCPay config. Returns `None` if the operator has not
/// completed the authorize flow yet.
pub async fn load(pool: &SqlitePool) -> Result<Option<BtcpayConfig>> {
let row = sqlx::query(
"SELECT base_url, api_key, store_id, webhook_id, webhook_secret \
FROM btcpay_config WHERE id = 1",
)
.fetch_optional(pool)
.await
.context("loading btcpay_config")?;
Ok(row.map(|r| BtcpayConfig {
base_url: r.get("base_url"),
api_key: r.get("api_key"),
store_id: r.get("store_id"),
webhook_id: r.get("webhook_id"),
webhook_secret: r.get("webhook_secret"),
}))
}
/// Delete the entire BTCPay config row. Used by the Disconnect flow.
/// Subsequent calls to `load` return `None` until the operator
/// re-authorizes.
pub async fn clear(pool: &SqlitePool) -> Result<()> {
sqlx::query("DELETE FROM btcpay_config WHERE id = 1")
.execute(pool)
.await
.context("clearing btcpay_config")?;
Ok(())
}
/// Upsert the full config. Called by the authorize-callback path after the
/// service has fetched/created everything it needs from BTCPay.
pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_config \
(id, base_url, api_key, store_id, webhook_id, webhook_secret, connected_at) \
VALUES (1, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
base_url = excluded.base_url, \
api_key = excluded.api_key, \
store_id = excluded.store_id, \
webhook_id = excluded.webhook_id, \
webhook_secret = excluded.webhook_secret, \
connected_at = excluded.connected_at",
)
.bind(&cfg.base_url)
.bind(&cfg.api_key)
.bind(&cfg.store_id)
.bind(cfg.webhook_id.as_deref())
.bind(&cfg.webhook_secret)
.bind(&now)
.execute(pool)
.await
.context("saving btcpay_config")?;
Ok(())
}
/// Record a new in-flight authorize state token. The caller has already
/// generated a cryptographically-random token.
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
)
.bind(token)
.bind(&now)
.execute(pool)
.await
.context("recording btcpay authorize state")?;
// Best-effort prune of rows older than 30 minutes.
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let _ = sqlx::query("DELETE FROM btcpay_authorize_state WHERE created_at < ?")
.bind(&cutoff)
.execute(pool)
.await;
Ok(())
}
/// Validate that `token` was issued recently and has not been consumed.
/// Consumes (deletes) the token on success so a replay fails.
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let row = sqlx::query(
"SELECT state_token FROM btcpay_authorize_state \
WHERE state_token = ? AND created_at >= ?",
)
.bind(token)
.bind(&cutoff)
.fetch_optional(pool)
.await?;
if row.is_none() {
return Err(anyhow!("unknown or expired authorize state token"));
}
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
.bind(token)
.execute(pool)
.await?;
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
//! BTCPay Server integration.
//!
//! - [`client`] creates invoices via the BTCPay Greenfield API.
//! - [`webhook`] verifies and parses incoming webhook calls from BTCPay.
//!
//! BTCPay's Greenfield API is documented at
//! <https://docs.btcpayserver.org/API/Greenfield/v1/>.
pub mod client;
pub mod config;
pub mod webhook;
+93
View File
@@ -0,0 +1,93 @@
//! BTCPay webhook handling.
//!
//! BTCPay signs each webhook body with HMAC-SHA256 using the shared secret
//! we configured, and sends the hex digest in the `BTCPay-Sig` header as
//! `sha256=<hex>`. We verify in constant time before trusting anything.
use anyhow::{anyhow, Result};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
/// Verify the `BTCPay-Sig` header matches the raw request body.
///
/// Returns `Ok(())` on success, `Err` on any mismatch. Callers must pass the
/// raw, unmodified body — any reserialization will break the HMAC.
pub fn verify_signature(secret: &str, header_value: &str, raw_body: &[u8]) -> Result<()> {
let expected_hex = header_value
.strip_prefix("sha256=")
.ok_or_else(|| anyhow!("BTCPay-Sig header missing 'sha256=' prefix"))?;
let expected =
hex::decode(expected_hex).map_err(|_| anyhow!("BTCPay-Sig header is not hex"))?;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC takes any key size");
mac.update(raw_body);
let computed = mac.finalize().into_bytes();
if bool::from(computed.as_slice().ct_eq(&expected)) {
Ok(())
} else {
Err(anyhow!("BTCPay webhook signature mismatch"))
}
}
/// The subset of webhook payload fields we care about. BTCPay sends many
/// event types; we key off `invoiceId` and `type` / `status`.
#[derive(Debug, serde::Deserialize)]
pub struct WebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(rename = "invoiceId")]
pub invoice_id: String,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl WebhookEvent {
/// BTCPay fires event types like `InvoiceSettled`, `InvoiceExpired`,
/// `InvoiceInvalid`, `InvoiceProcessing`. We normalize to our internal
/// status vocabulary.
pub fn to_status(&self) -> Option<&'static str> {
match self.event_type.as_str() {
"InvoiceSettled" | "InvoicePaymentSettled" => Some("settled"),
"InvoiceExpired" => Some("expired"),
"InvoiceInvalid" => Some("invalid"),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verifies_correct_signature() {
let secret = "super-secret";
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={sig}");
assert!(verify_signature(secret, &header, body).is_ok());
}
#[test]
fn rejects_tampered_body() {
let secret = "super-secret";
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
let tampered = br#"{"type":"InvoiceSettled","invoiceId":"evil"}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let header = format!("sha256={sig}");
assert!(verify_signature(secret, &header, tampered).is_err());
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Runtime configuration.
//!
//! Loaded once at startup from environment variables. A `.env` file is read
//! if present (via `dotenvy`) so local development is frictionless. In
//! production on StartOS, the same variables are set by the service manifest.
use anyhow::{anyhow, Context, Result};
use std::net::SocketAddr;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
/// Where the HTTP server binds.
pub bind: SocketAddr,
/// Path to the SQLite database file (e.g. `/data/keysat.db` inside a
/// Start9 container; `./data/keysat.db` in dev).
pub db_path: PathBuf,
/// Shared secret required on admin endpoints via `Authorization: Bearer ...`.
/// Generated once by the operator and kept secret.
pub admin_api_key: String,
/// BTCPay Server base URL used for daemon → BTCPay API calls. On
/// StartOS this is the internal-network hostname like
/// `http://btcpayserver.startos:23000`, which is only resolvable from
/// inside other StartOS containers.
pub btcpay_url: String,
/// BTCPay Server base URL used for the OPERATOR'S BROWSER. The
/// authorize flow redirects the operator's browser to BTCPay's
/// consent page; that target must be reachable from the LAN /
/// clearnet, not the internal-network hostname. The wrapper sets
/// this to BTCPay's preferred operator-facing URL — typically
/// mDNS (`https://immense-voyage.local:49347`) since the operator
/// is on the same LAN as the Start9.
pub btcpay_browser_url: Option<String>,
/// BTCPay Server PUBLIC URL used for BUYER-facing redirects.
/// The daemon rewrites checkout URLs returned by BTCPay's API so
/// they point at this URL — random internet buyers can't reach
/// mDNS or LAN URLs, so this needs to be a real clearnet domain
/// like `https://btcpay.your-domain.com`. Falls back to
/// `btcpay_browser_url` if unset (useful for local testing only).
pub btcpay_public_url: Option<String>,
/// Seed BTCPay API key, used only on first boot before the operator has
/// completed the authorize flow. Leave empty in the normal case.
pub btcpay_api_key: Option<String>,
/// Seed BTCPay store id. Same rules as `btcpay_api_key` — empty in the
/// normal case.
pub btcpay_store_id: Option<String>,
/// Seed webhook secret. Only used when bootstrapping from env vars.
pub btcpay_webhook_secret: Option<String>,
/// Public base URL of *this* Keysat instance, used when constructing
/// invoice redirect / webhook URLs (e.g. `https://license.example.com`).
pub public_base_url: String,
/// Optional human-readable operator name shown in `/` index responses.
pub operator_name: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self> {
// Best-effort load of .env in dev. Missing file is not an error.
let _ = dotenvy::dotenv();
// All runtime knobs live under `KEYSAT_*`. For older installs and
// dev shells that predate the rename we still honour the original
// `LICENSING_*` names as a silent fallback.
let bind_str = env_with_fallback("KEYSAT_BIND", "LICENSING_BIND")
.unwrap_or_else(|| "0.0.0.0:8080".to_string());
let bind: SocketAddr = bind_str
.parse()
.with_context(|| format!("KEYSAT_BIND is not a valid socket address: {bind_str}"))?;
let db_path = PathBuf::from(
env_with_fallback("KEYSAT_DB_PATH", "LICENSING_DB_PATH")
.unwrap_or_else(|| "./data/keysat.db".into()),
);
let admin_api_key = required_with_fallback("KEYSAT_ADMIN_API_KEY", "LICENSING_ADMIN_API_KEY")?;
if admin_api_key.len() < 32 {
return Err(anyhow!(
"KEYSAT_ADMIN_API_KEY must be at least 32 characters (use `openssl rand -hex 32`)"
));
}
let btcpay_url = required("BTCPAY_URL")?;
let btcpay_browser_url = optional_nonempty("BTCPAY_BROWSER_URL")
.map(|s| s.trim_end_matches('/').to_string());
let btcpay_public_url = optional_nonempty("BTCPAY_PUBLIC_URL")
.map(|s| s.trim_end_matches('/').to_string())
// Fallback: if no public URL is plumbed, use browser URL.
// Won't work for real customers but is fine for local testing.
.or_else(|| btcpay_browser_url.clone());
let btcpay_api_key = optional_nonempty("BTCPAY_API_KEY");
let btcpay_store_id = optional_nonempty("BTCPAY_STORE_ID");
let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET");
let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?;
let operator_name = env_with_fallback("KEYSAT_OPERATOR_NAME", "LICENSING_OPERATOR_NAME");
Ok(Self {
bind,
db_path,
admin_api_key,
btcpay_url: btcpay_url.trim_end_matches('/').to_string(),
btcpay_browser_url,
btcpay_public_url,
btcpay_api_key,
btcpay_store_id,
btcpay_webhook_secret,
public_base_url: public_base_url.trim_end_matches('/').to_string(),
operator_name,
})
}
}
fn optional_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn required(name: &str) -> Result<String> {
std::env::var(name).map_err(|_| anyhow!("missing required env var: {name}"))
}
/// Look up a var under its current (KEYSAT_*) name, falling back to the
/// pre-rename (LICENSING_*) name if unset.
fn env_with_fallback(primary: &str, fallback: &str) -> Option<String> {
optional_nonempty(primary).or_else(|| optional_nonempty(fallback))
}
fn required_with_fallback(primary: &str, fallback: &str) -> Result<String> {
env_with_fallback(primary, fallback)
.ok_or_else(|| anyhow!("missing required env var: {primary} (or {fallback})"))
}
+77
View File
@@ -0,0 +1,77 @@
//! Server key lifecycle: generate on first boot, load on subsequent boots.
//!
//! Keys are stored in SQLite (rather than on the filesystem) so the same
//! backup mechanism that protects licenses also protects the signing key.
//! On StartOS, the database file lives under the service's encrypted data
//! volume, so at-rest encryption is handled by the OS.
use anyhow::{Context, Result};
use chrono::Utc;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use sqlx::SqlitePool;
/// Both halves of the server keypair.
#[derive(Clone)]
pub struct ServerKeypair {
pub signing: SigningKey,
pub verifying: VerifyingKey,
/// PEM-encoded public key, for display / SDK bundling.
pub public_key_pem: String,
}
/// Load the keypair from the DB, generating and persisting a new one if no
/// row exists. This function is idempotent and safe to call on every boot.
pub async fn load_or_generate(pool: &SqlitePool) -> Result<ServerKeypair> {
// Try to load.
let existing = sqlx::query_as::<_, (String, String)>(
"SELECT public_key_pem, private_key_pem FROM server_keys WHERE id = 1",
)
.fetch_optional(pool)
.await?;
if let Some((pub_pem, priv_pem)) = existing {
let signing = SigningKey::from_pkcs8_pem(&priv_pem)
.context("failed to parse stored private key")?;
let verifying = VerifyingKey::from_public_key_pem(&pub_pem)
.context("failed to parse stored public key")?;
return Ok(ServerKeypair {
signing,
verifying,
public_key_pem: pub_pem,
});
}
// Generate a new keypair.
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
use pkcs8::LineEnding;
let priv_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.context("failed to encode private key to PEM")?
.to_string();
let pub_pem = verifying
.to_public_key_pem(LineEnding::LF)
.context("failed to encode public key to PEM")?;
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
VALUES (1, 'ed25519', ?, ?, ?)",
)
.bind(&pub_pem)
.bind(&priv_pem)
.bind(&now)
.execute(pool)
.await?;
tracing::info!("generated new Ed25519 server signing key");
Ok(ServerKeypair {
signing,
verifying,
public_key_pem: pub_pem,
})
}
+486
View File
@@ -0,0 +1,486 @@
//! License key cryptography.
//!
//! # Key format
//!
//! A license key presented to users looks like:
//!
//! ```text
//! LIC1-<base32 payload>-<base32 signature>
//! ```
//!
//! The base32 alphabet is `BASE32_NOPAD` (RFC 4648, no padding, case-insensitive
//! decode). Signatures are always 64 bytes of Ed25519.
//!
//! ## Payload — version 1 (legacy, still accepted)
//!
//! A fixed 74-byte blob:
//!
//! | offset | size | field |
//! |--------|------|----------------------------------------------|
//! | 0 | 1 | version = 1 |
//! | 1 | 1 | flags (bit 0: fingerprint-bound) |
//! | 2 | 16 | product_id (UUID, big-endian bytes) |
//! | 18 | 16 | license_id (UUID, big-endian bytes) |
//! | 34 | 8 | issued_at (u64 unix seconds, BE) |
//! | 42 | 32 | fingerprint_hash (SHA-256, zero if unbound) |
//!
//! ## Payload — version 2 (current default)
//!
//! Variable-length. The fixed head is 83 bytes, followed by the entitlements
//! table. Every byte here is signed.
//!
//! | offset | size | field |
//! |--------|------|---------------------------------------------------------|
//! | 0 | 1 | version = 2 |
//! | 1 | 1 | flags |
//! | 2 | 16 | product_id |
//! | 18 | 16 | license_id |
//! | 34 | 8 | issued_at (u64 BE, unix seconds) |
//! | 42 | 8 | expires_at (u64 BE, unix seconds; 0 = perpetual) |
//! | 50 | 32 | fingerprint_hash (SHA-256; zero iff flag bit unset) |
//! | 82 | 1 | entitlements_count (N, 0..=255) |
//! | 83.. | ... | entitlements: N × `<len: u8><ascii bytes>` |
//!
//! Each entitlement is a short ASCII string ≤ 255 bytes; the canonical examples
//! are feature slugs (`"pro"`, `"cloud-sync"`, `"multi-seat"`). The list is
//! signed so offline verifiers can gate features without contacting the server.
//!
//! ## Flag bits (shared across versions)
//!
//! | bit | meaning |
//! |-----|------------------------------------------------------------|
//! | 0 | fingerprint-bound |
//! | 1 | trial license (v2 only; best-effort — clients may warn) |
//!
//! # Why versioned
//!
//! v2 adds expiry and entitlements, both of which need to be inside the signed
//! blob if we want offline enforcement (a stripped entitlement or pushed-back
//! expiry would have to match a valid signature, which the attacker can't
//! produce). Keeping the v1 parser in place means any keys already issued with
//! v1 continue to verify forever — the whole point of cryptographic licensing.
//!
//! # Offline verification
//!
//! Third-party clients ship the server's **public key** (not the private
//! key) bundled in their SDK. They can verify signatures, enforce expiry, and
//! gate features on entitlements entirely offline. Revocation, machine binding,
//! and suspension are authoritative server-side — clients that want true
//! strictness should call `/v1/validate` periodically.
pub mod keys;
use anyhow::{anyhow, Context, Result};
use data_encoding::BASE32_NOPAD;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Key format version currently issued by the server.
pub const KEY_VERSION: u8 = 2;
/// v1 format — legacy, still accepted on parse.
pub const KEY_VERSION_V1: u8 = 1;
/// v2 format — current default.
pub const KEY_VERSION_V2: u8 = 2;
/// Fixed-size of the v1 payload (for tests / legacy parsing).
pub const PAYLOAD_V1_LEN: usize = 1 + 1 + 16 + 16 + 8 + 32; // = 74
/// Minimum size of a v2 payload (head only, no entitlements).
pub const PAYLOAD_V2_HEAD_LEN: usize = 1 + 1 + 16 + 16 + 8 + 8 + 32 + 1; // = 83
/// Flag bit indicating the license is bound to a fingerprint hash.
pub const FLAG_FINGERPRINT_BOUND: u8 = 0b0000_0001;
/// Flag bit indicating the license was issued as a trial (comp/paid trial).
/// Clients that care may render a "Trial" badge; enforcement is via expiry.
pub const FLAG_TRIAL: u8 = 0b0000_0010;
/// Prefix that tags our key strings and future-proofs the envelope.
pub const KEY_PREFIX: &str = "LIC1";
/// Parsed, not-yet-verified key payload. This is a unified v1+v2 shape; on a
/// v1 parse we zero-fill the v2-only fields, so downstream code can be
/// version-agnostic as long as it reads `version` before trusting `expires_at`
/// or `entitlements`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LicensePayload {
pub version: u8,
pub flags: u8,
pub product_id: Uuid,
pub license_id: Uuid,
pub issued_at: i64,
/// Unix seconds; `0` means perpetual. Always 0 for v1.
pub expires_at: i64,
/// SHA-256 of the fingerprint, or zeros if `FLAG_FINGERPRINT_BOUND` is unset.
pub fingerprint_hash: [u8; 32],
/// Feature slugs ASCII; empty for v1 or v2 licenses with no entitlements.
pub entitlements: Vec<String>,
}
impl LicensePayload {
pub fn is_fingerprint_bound(&self) -> bool {
self.flags & FLAG_FINGERPRINT_BOUND != 0
}
pub fn is_trial(&self) -> bool {
self.flags & FLAG_TRIAL != 0
}
/// Has this license expired at the given instant? `expires_at == 0` means
/// perpetual and returns `false`.
pub fn is_expired_at(&self, now_unix: i64) -> bool {
self.expires_at != 0 && now_unix >= self.expires_at
}
/// Does this license grant the given entitlement? Comparison is
/// case-sensitive and exact — pick a canonical casing and stick with it.
pub fn has_entitlement(&self, slug: &str) -> bool {
self.entitlements.iter().any(|e| e == slug)
}
/// Serialize to the v2 wire format. Always emits v2 — v1 is parse-only.
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(PAYLOAD_V2_HEAD_LEN + self.entitlements.len() * 16);
buf.push(KEY_VERSION_V2);
buf.push(self.flags);
buf.extend_from_slice(self.product_id.as_bytes());
buf.extend_from_slice(self.license_id.as_bytes());
buf.extend_from_slice(&(self.issued_at as u64).to_be_bytes());
buf.extend_from_slice(&(self.expires_at as u64).to_be_bytes());
buf.extend_from_slice(&self.fingerprint_hash);
// entitlement count — capped at 255 by u8
let n: u8 = self
.entitlements
.len()
.try_into()
.expect("too many entitlements (max 255)");
buf.push(n);
for e in &self.entitlements {
let bytes = e.as_bytes();
let len: u8 = bytes
.len()
.try_into()
.expect("entitlement slug too long (max 255 bytes)");
buf.push(len);
buf.extend_from_slice(bytes);
}
buf
}
/// Parse a payload blob. Dispatches on the first byte (version).
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.is_empty() {
return Err(anyhow!("empty payload"));
}
match bytes[0] {
KEY_VERSION_V1 => Self::from_bytes_v1(bytes),
KEY_VERSION_V2 => Self::from_bytes_v2(bytes),
other => Err(anyhow!("unsupported key version: {other}")),
}
}
fn from_bytes_v1(bytes: &[u8]) -> Result<Self> {
if bytes.len() != PAYLOAD_V1_LEN {
return Err(anyhow!(
"v1 payload length {} != expected {}",
bytes.len(),
PAYLOAD_V1_LEN
));
}
let flags = bytes[1];
let product_id = Uuid::from_slice(&bytes[2..18])?;
let license_id = Uuid::from_slice(&bytes[18..34])?;
let issued_at = u64::from_be_bytes(bytes[34..42].try_into().unwrap()) as i64;
let mut fingerprint_hash = [0u8; 32];
fingerprint_hash.copy_from_slice(&bytes[42..74]);
Ok(Self {
version: KEY_VERSION_V1,
flags,
product_id,
license_id,
issued_at,
expires_at: 0,
fingerprint_hash,
entitlements: Vec::new(),
})
}
fn from_bytes_v2(bytes: &[u8]) -> Result<Self> {
if bytes.len() < PAYLOAD_V2_HEAD_LEN {
return Err(anyhow!(
"v2 payload length {} < head length {}",
bytes.len(),
PAYLOAD_V2_HEAD_LEN
));
}
let flags = bytes[1];
let product_id = Uuid::from_slice(&bytes[2..18])?;
let license_id = Uuid::from_slice(&bytes[18..34])?;
let issued_at = u64::from_be_bytes(bytes[34..42].try_into().unwrap()) as i64;
let expires_at = u64::from_be_bytes(bytes[42..50].try_into().unwrap()) as i64;
let mut fingerprint_hash = [0u8; 32];
fingerprint_hash.copy_from_slice(&bytes[50..82]);
let n = bytes[82] as usize;
let mut entitlements = Vec::with_capacity(n);
let mut cursor = PAYLOAD_V2_HEAD_LEN;
for i in 0..n {
if cursor >= bytes.len() {
return Err(anyhow!(
"truncated entitlement list at index {i} (cursor {cursor}, len {})",
bytes.len()
));
}
let len = bytes[cursor] as usize;
cursor += 1;
if cursor + len > bytes.len() {
return Err(anyhow!(
"entitlement {i} length {len} runs past end of payload"
));
}
let slug = std::str::from_utf8(&bytes[cursor..cursor + len])
.with_context(|| format!("entitlement {i} is not UTF-8"))?;
entitlements.push(slug.to_string());
cursor += len;
}
if cursor != bytes.len() {
return Err(anyhow!(
"trailing bytes after entitlement list ({} unread)",
bytes.len() - cursor
));
}
Ok(Self {
version: KEY_VERSION_V2,
flags,
product_id,
license_id,
issued_at,
expires_at,
fingerprint_hash,
entitlements,
})
}
}
/// Hash a raw fingerprint string. We hash so that the full fingerprint never
/// travels inside the key (only its hash), making keys shorter and hiding
/// information like MAC addresses from anyone who intercepts a key string.
pub fn hash_fingerprint(fp: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(fp.as_bytes());
hasher.finalize().into()
}
/// Encode a payload + signature into a user-facing key string.
pub fn encode_key(payload: &LicensePayload, signature: &Signature) -> String {
let payload_b32 = BASE32_NOPAD.encode(&payload.to_bytes());
let sig_b32 = BASE32_NOPAD.encode(&signature.to_bytes());
format!("{KEY_PREFIX}-{payload_b32}-{sig_b32}")
}
/// Parse a user-provided key string into its payload + signature components
/// (plus the raw signed bytes, which the caller needs to verify against).
/// Does *not* verify the signature — call `verify_key` for that.
pub fn parse_key(s: &str) -> Result<(LicensePayload, Signature, Vec<u8>)> {
let s = s.trim();
let mut parts = s.splitn(3, '-');
let prefix = parts.next().context("key is empty")?;
if prefix != KEY_PREFIX {
return Err(anyhow!("unrecognized key prefix: {prefix}"));
}
let payload_b32 = parts.next().context("missing payload section")?;
let sig_b32 = parts.next().context("missing signature section")?;
let payload_bytes = BASE32_NOPAD
.decode(payload_b32.to_ascii_uppercase().as_bytes())
.context("invalid base32 in payload")?;
let sig_bytes = BASE32_NOPAD
.decode(sig_b32.to_ascii_uppercase().as_bytes())
.context("invalid base32 in signature")?;
let payload = LicensePayload::from_bytes(&payload_bytes)?;
let sig_array: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("signature length != 64"))?;
let signature = Signature::from_bytes(&sig_array);
Ok((payload, signature, payload_bytes))
}
/// Sign a payload with the server's private key.
pub fn sign_payload(signing_key: &SigningKey, payload: &LicensePayload) -> Signature {
signing_key.sign(&payload.to_bytes())
}
/// Verify a parsed payload's signature against a public key.
///
/// For v2 keys, `signed_bytes` is the raw payload blob that was parsed from
/// the wire. For v1 keys it's the 74-byte v1 blob. Always pass the blob you
/// got out of `parse_key` directly — never re-serialize a `LicensePayload`,
/// because we always serialize as v2 and that will break v1 signatures.
pub fn verify_payload(
verifying_key: &VerifyingKey,
signed_bytes: &[u8],
signature: &Signature,
) -> Result<()> {
verifying_key
.verify(signed_bytes, signature)
.context("signature verification failed")
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
fn test_payload() -> LicensePayload {
LicensePayload {
version: KEY_VERSION_V2,
flags: 0,
product_id: Uuid::new_v4(),
license_id: Uuid::new_v4(),
issued_at: 1_700_000_000,
expires_at: 0,
fingerprint_hash: [0u8; 32],
entitlements: Vec::new(),
}
}
#[test]
fn roundtrip_unbound_perpetual_v2() {
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let payload = test_payload();
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
assert_eq!(parsed, payload);
verify_payload(&verifying, &signed_bytes, &parsed_sig).unwrap();
}
#[test]
fn roundtrip_with_entitlements_and_expiry() {
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let payload = LicensePayload {
expires_at: 1_900_000_000,
entitlements: vec![
"pro".to_string(),
"cloud-sync".to_string(),
"multi-seat".to_string(),
],
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
assert_eq!(parsed, payload);
assert!(parsed.has_entitlement("pro"));
assert!(parsed.has_entitlement("cloud-sync"));
assert!(!parsed.has_entitlement("enterprise"));
assert!(!parsed.is_expired_at(1_800_000_000));
assert!(parsed.is_expired_at(1_900_000_000));
verify_payload(&verifying, &signed_bytes, &parsed_sig).unwrap();
}
#[test]
fn tampered_payload_fails_verification() {
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let payload = LicensePayload {
entitlements: vec!["free".to_string()],
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (_, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
// Flip a bit in the signed blob.
let mut tampered = signed_bytes.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert!(verify_payload(&verifying, &tampered, &parsed_sig).is_err());
}
#[test]
fn fingerprint_bound_roundtrip() {
let signing = SigningKey::generate(&mut OsRng);
let fp = "machine-abc-123";
let payload = LicensePayload {
flags: FLAG_FINGERPRINT_BOUND,
fingerprint_hash: hash_fingerprint(fp),
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, _, _) = parse_key(&encoded).unwrap();
assert!(parsed.is_fingerprint_bound());
assert_eq!(parsed.fingerprint_hash, hash_fingerprint(fp));
}
#[test]
fn trial_flag_roundtrip() {
let signing = SigningKey::generate(&mut OsRng);
let payload = LicensePayload {
flags: FLAG_TRIAL,
expires_at: 1_710_000_000,
..test_payload()
};
let sig = sign_payload(&signing, &payload);
let encoded = encode_key(&payload, &sig);
let (parsed, _, _) = parse_key(&encoded).unwrap();
assert!(parsed.is_trial());
}
#[test]
fn v1_parse_still_works() {
// Hand-craft a v1-shaped payload (the wire format that old service
// versions emitted) and confirm we still parse it, zero-filling the
// v2-only fields.
let product_id = Uuid::new_v4();
let license_id = Uuid::new_v4();
let mut v1 = Vec::with_capacity(PAYLOAD_V1_LEN);
v1.push(KEY_VERSION_V1);
v1.push(FLAG_FINGERPRINT_BOUND);
v1.extend_from_slice(product_id.as_bytes());
v1.extend_from_slice(license_id.as_bytes());
v1.extend_from_slice(&1_700_000_000u64.to_be_bytes());
v1.extend_from_slice(&hash_fingerprint("rig-1"));
assert_eq!(v1.len(), PAYLOAD_V1_LEN);
let parsed = LicensePayload::from_bytes(&v1).unwrap();
assert_eq!(parsed.version, KEY_VERSION_V1);
assert!(parsed.is_fingerprint_bound());
assert_eq!(parsed.expires_at, 0);
assert!(parsed.entitlements.is_empty());
assert_eq!(parsed.product_id, product_id);
assert_eq!(parsed.license_id, license_id);
}
#[test]
fn truncated_entitlement_list_is_rejected() {
// v2 payload head claiming 2 entitlements but only 1 supplied.
let mut buf = Vec::new();
buf.push(KEY_VERSION_V2);
buf.push(0);
buf.extend_from_slice(&[0u8; 16]);
buf.extend_from_slice(&[0u8; 16]);
buf.extend_from_slice(&0u64.to_be_bytes()); // issued_at
buf.extend_from_slice(&0u64.to_be_bytes()); // expires_at
buf.extend_from_slice(&[0u8; 32]); // fingerprint
buf.push(2); // count = 2
buf.push(3); // len = 3
buf.extend_from_slice(b"pro");
// missing the second entitlement entirely
assert!(LicensePayload::from_bytes(&buf).is_err());
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Database layer. Runs migrations on startup and provides typed repository
//! helpers for each table. Using `sqlx::query` (not `query!`) keeps the
//! project buildable without a live DB at compile time.
pub mod repo;
use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
/// Opens (or creates) the SQLite database at `path`, applies migrations, and
/// returns a connection pool ready for use.
pub async fn init(path: &Path) -> Result<SqlitePool> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent dir for db at {}", path.display()))?;
}
}
let url = format!("sqlite://{}", path.display());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
// WAL mode is the right default for a read-heavy validation workload.
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(opts)
.await
.with_context(|| format!("opening sqlite at {}", path.display()))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.context("running migrations")?;
tracing::info!(path = %path.display(), "database ready");
Ok(pool)
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
//! Unified error type for the service. Converts into appropriate HTTP
//! responses so handlers can just `?`-propagate.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("conflict: {0}")]
Conflict(String),
#[error("license invalid: {0}")]
LicenseInvalid(String),
#[error("upstream error: {0}")]
Upstream(String),
#[error("BTCPay not configured: connect via the StartOS dashboard first")]
BtcpayNotConfigured,
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
AppError::Database(_) | AppError::Internal(_) => {
tracing::error!(error = %self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
};
let body = Json(json!({
"ok": false,
"error": code,
"message": self.to_string(),
}));
(status, body).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+256
View File
@@ -0,0 +1,256 @@
//! Keysat-licenses-Keysat: dogfooded self-licensing layer.
//!
//! The Keysat package ships with the master public key embedded in
//! `TRUST_ROOT_PUBKEY_PEM` below. On every boot we look for a license
//! at `SELF_LICENSE_PATH` (or the `KEYSAT_LICENSE` env var), parse it
//! using the same wire-format machinery the daemon uses to issue
//! customer licenses, and verify its signature against the master
//! public key.
//!
//! Two modes:
//! - `Permissive` (default for dev builds): missing or invalid
//! licenses log a warning and the daemon starts in
//! `Tier::Unlicensed`. No features are gated yet — that's a
//! future v0.2.x flip.
//! - `Enforce`: missing or invalid licenses cause the daemon to
//! refuse to start. Set at compile time via the
//! `KEYSAT_LICENSE_ENFORCE=1` env var. Marketplace builds set
//! this; local dev builds don't.
//!
//! The master pubkey is the *public* half of an Ed25519 keypair held
//! offline by the keysat.xyz team. It is not secret — embedding it in
//! source on GitHub is fine. Anyone with the *private* half can mint
//! Keysat self-licenses; the private half lives on paper backup +
//! hardware-token storage and never touches a connected machine
//! except briefly when a master Keysat instance is being initialized.
use crate::crypto::{parse_key, verify_payload};
use anyhow::{bail, Context, Result};
use ed25519_dalek::pkcs8::DecodePublicKey;
use ed25519_dalek::VerifyingKey;
use std::time::{SystemTime, UNIX_EPOCH};
/// Master public key for Keysat self-licensing. PEM-encoded Ed25519,
/// SubjectPublicKeyInfo wrapped (the format `openssl pkey -pubout`
/// emits). To rotate this in a future release: replace the const,
/// ship a new build, distribute fresh licenses to existing customers.
/// Existing customers' licenses won't verify against the new key —
/// that's the breaking event. Plan rotations carefully.
pub const TRUST_ROOT_PUBKEY_PEM: &str = "-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
-----END PUBLIC KEY-----";
/// Where the daemon expects a self-license file. Single line, the raw
/// license-key string in `LIC1-…-…` format. Mounted from the
/// persistent data volume so it survives package upgrades.
pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt";
/// Build-time enforcement toggle. `KEYSAT_LICENSE_ENFORCE=1` at
/// `cargo build` time enables enforce mode.
const ENFORCE_FLAG: Option<&str> = option_env!("KEYSAT_LICENSE_ENFORCE");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Missing/invalid license logs a warning and continues. Default.
Permissive,
/// Missing/invalid license refuses to start the daemon.
Enforce,
}
pub fn mode() -> Mode {
match ENFORCE_FLAG {
Some("1") | Some("true") | Some("yes") => Mode::Enforce,
_ => Mode::Permissive,
}
}
#[derive(Debug, Clone)]
pub enum Tier {
/// No license configured, or license verify failed in permissive mode.
Unlicensed { reason: String },
/// Valid license verified against the trust-root.
Licensed {
license_id: uuid::Uuid,
product_id: uuid::Uuid,
/// Unix seconds; 0 means perpetual.
expires_at: i64,
entitlements: Vec<String>,
},
}
impl Tier {
pub fn as_str(&self) -> &'static str {
match self {
Tier::Unlicensed { .. } => "unlicensed",
Tier::Licensed { .. } => "licensed",
}
}
}
/// Boot-time check. In permissive mode this always returns `Ok`; in
/// enforce mode it returns `Err` on missing / invalid / expired
/// licenses, which causes `main` to bail out before we open any
/// network sockets.
pub fn check_at_boot() -> Result<Tier> {
let mode = mode();
tracing::info!(
mode = mode.as_str(),
"Keysat self-license check (mode={})",
mode.as_str()
);
let license_str = match read_license_string() {
Some(s) => s,
None => {
let reason = format!(
"no license at {} or KEYSAT_LICENSE env var",
SELF_LICENSE_PATH
);
return handle_missing_or_invalid(mode, reason, None);
}
};
match verify_license(&license_str) {
Ok(tier) => {
log_licensed(&tier);
Ok(tier)
}
Err(e) => {
let reason = format!("verification failed: {e:#}");
handle_missing_or_invalid(mode, reason, Some(e))
}
}
}
fn handle_missing_or_invalid(
mode: Mode,
reason: String,
err: Option<anyhow::Error>,
) -> Result<Tier> {
match mode {
Mode::Permissive => {
tracing::warn!(
tier = "unlicensed",
"Keysat self-license: {} — running unlicensed (permissive build)",
reason
);
Ok(Tier::Unlicensed { reason })
}
Mode::Enforce => {
tracing::error!(
"Keysat self-license: {} — refusing to start. \
Activate via StartOS → Keysat → Actions → Activate Keysat license.",
reason
);
match err {
Some(e) => Err(e.context("self-license invalid (enforce mode)")),
None => bail!("self-license missing (enforce mode): {reason}"),
}
}
}
}
fn read_license_string() -> Option<String> {
if let Ok(s) = std::env::var("KEYSAT_LICENSE") {
let s = s.trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
let path = std::path::Path::new(SELF_LICENSE_PATH);
if let Ok(s) = std::fs::read_to_string(path) {
let s = s.trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
None
}
/// Verify a license-key string against the embedded trust-root.
/// Returns the parsed `Tier::Licensed` on success.
pub fn verify_license(license_key: &str) -> Result<Tier> {
let trust_key = parse_trust_root_pubkey()?;
let (payload, signature, signed_bytes) =
parse_key(license_key).context("license key parse failed")?;
verify_payload(&trust_key, &signed_bytes, &signature)
.context("license signature does not verify against master pubkey")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if payload.is_expired_at(now) {
bail!(
"license expired at unix={} (now unix={})",
payload.expires_at,
now
);
}
Ok(Tier::Licensed {
license_id: payload.license_id,
product_id: payload.product_id,
expires_at: payload.expires_at,
entitlements: payload.entitlements,
})
}
/// Persist a verified license string to `SELF_LICENSE_PATH`. Caller
/// is expected to have run `verify_license` first.
pub fn write_license_file(license_key: &str) -> Result<()> {
let path = std::path::Path::new(SELF_LICENSE_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent directory {}", parent.display()))?;
}
std::fs::write(path, format!("{}\n", license_key.trim()))
.with_context(|| format!("writing license to {}", path.display()))?;
Ok(())
}
fn parse_trust_root_pubkey() -> Result<VerifyingKey> {
let pem = TRUST_ROOT_PUBKEY_PEM.trim();
if pem.is_empty() {
bail!("trust-root pubkey not embedded in this build");
}
let vk = VerifyingKey::from_public_key_pem(pem)
.context("trust-root pubkey PEM parse failed")?;
Ok(vk)
}
fn log_licensed(tier: &Tier) {
if let Tier::Licensed {
license_id,
product_id,
expires_at,
entitlements,
} = tier
{
let exp = if *expires_at == 0 {
"perpetual".to_string()
} else {
format!("expires_at_unix={expires_at}")
};
let ents = if entitlements.is_empty() {
"(none)".to_string()
} else {
entitlements.join(",")
};
tracing::info!(
tier = "licensed",
license = %license_id,
product = %product_id,
"Keysat self-license: VERIFIED — {exp}, entitlements={ents}"
);
}
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Permissive => "permissive",
Mode::Enforce => "enforce",
}
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Entry point. Wires config → logging → DB → keypair → HTTP server.
mod api;
mod btcpay;
mod config;
mod crypto;
mod db;
mod error;
mod license_self;
mod models;
mod payment;
mod rate_limit;
mod reconcile;
mod tipping;
mod webhooks;
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
/// id from a raw value (machine fingerprints, admin key hashes).
pub fn hex_sha256(s: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
hex::encode(hasher.finalize())
}
use anyhow::Context;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// --- logging ---
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn")),
)
.with(fmt::layer().with_target(false))
.init();
// --- config ---
let cfg = config::Config::from_env().context("loading configuration")?;
tracing::info!(
bind = %cfg.bind,
db = %cfg.db_path.display(),
btcpay_url = %cfg.btcpay_url,
btcpay_browser_url = ?cfg.btcpay_browser_url,
btcpay_public_url = ?cfg.btcpay_public_url,
"starting keysat v{}",
env!("CARGO_PKG_VERSION")
);
// --- self-license tier (Keysat-licenses-Keysat) ---
// Verifies any /data/keysat-license.txt against the embedded master
// pubkey. In permissive builds (default) a missing/invalid license
// logs a warning and we continue. In enforce builds (compiled with
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
// start. Result is held in app state so the admin UI can surface it.
let self_tier = Arc::new(tokio::sync::RwLock::new(
license_self::check_at_boot()
.context("Keysat self-license check failed (enforce mode)")?,
));
// --- database ---
let pool = db::init(&cfg.db_path).await?;
// --- signing key ---
let keypair = crypto::keys::load_or_generate(&pool).await?;
tracing::info!(
"signing key ready; public key:\n{}",
keypair.public_key_pem.trim()
);
// --- payment provider (may be None until operator connects) ---
let provider: Option<Arc<dyn payment::PaymentProvider>> =
load_btcpay_provider(&pool, &cfg).await.map(|p| {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
arc
});
match &provider {
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
None => tracing::warn!(
"no payment provider yet configured — purchases will return 503 until the \
operator completes the 'Connect BTCPay' flow"
),
}
let state = api::AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(tokio::sync::RwLock::new(provider)),
config: Arc::new(cfg.clone()),
self_tier,
};
// Spawn background loops before handing state to the router.
reconcile::spawn(state.clone());
webhooks::spawn_delivery_worker(state.clone());
let app = api::router(state).layer(TraceLayer::new_for_http());
// --- serve ---
let listener = tokio::net::TcpListener::bind(cfg.bind)
.await
.with_context(|| format!("binding to {}", cfg.bind))?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("shutdown complete");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}
/// Load a BtcpayProvider from (in order): DB, then env var seed, then None.
/// Never fails — an unconfigured service simply returns 503 on purchase paths
/// until the operator completes the connect flow. Returns the concrete
/// `BtcpayProvider` so the caller can decide how to wrap it (we wrap as
/// `Arc<dyn PaymentProvider>` in `main`).
async fn load_btcpay_provider(
pool: &sqlx::SqlitePool,
cfg: &config::Config,
) -> Option<payment::btcpay::BtcpayProvider> {
// DB first.
if let Ok(Some(saved)) = btcpay::config::load(pool).await {
let client = btcpay::client::BtcpayClient::new(
&saved.base_url,
&saved.api_key,
&saved.store_id,
);
return Some(
payment::btcpay::BtcpayProvider::new(client, saved.webhook_secret)
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
// Fall back to env seed (useful for dev / legacy installs).
if let (Some(api_key), Some(store_id), Some(secret)) = (
cfg.btcpay_api_key.as_deref(),
cfg.btcpay_store_id.as_deref(),
cfg.btcpay_webhook_secret.as_deref(),
) {
let client =
btcpay::client::BtcpayClient::new(&cfg.btcpay_url, api_key, store_id);
// Persist the seed into DB so it survives env changes.
let _ = btcpay::config::save(
pool,
&btcpay::config::BtcpayConfig {
base_url: cfg.btcpay_url.clone(),
api_key: api_key.to_string(),
store_id: store_id.to_string(),
webhook_id: None,
webhook_secret: secret.to_string(),
},
)
.await;
return Some(
payment::btcpay::BtcpayProvider::new(client, secret.to_string())
.with_public_base(cfg.btcpay_public_url.clone()),
);
}
None
}
+244
View File
@@ -0,0 +1,244 @@
//! Domain models — shared types used by DB, API, and BTCPay layers.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub id: String,
pub slug: String,
pub name: String,
pub description: String,
pub price_sats: i64,
pub active: bool,
/// Arbitrary JSON metadata the developer can attach.
pub metadata: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InvoiceStatus {
Pending,
Settled,
Expired,
Invalid,
}
impl InvoiceStatus {
pub fn as_str(&self) -> &'static str {
match self {
InvoiceStatus::Pending => "pending",
InvoiceStatus::Settled => "settled",
InvoiceStatus::Expired => "expired",
InvoiceStatus::Invalid => "invalid",
}
}
pub fn parse(s: &str) -> Self {
match s {
"settled" => InvoiceStatus::Settled,
"expired" => InvoiceStatus::Expired,
"invalid" => InvoiceStatus::Invalid,
_ => InvoiceStatus::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoice {
pub id: String,
pub btcpay_invoice_id: String,
pub product_id: String,
pub status: String,
pub buyer_email: Option<String>,
pub buyer_note: Option<String>,
pub amount_sats: i64,
pub checkout_url: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseStatus {
Active,
Revoked,
/// Temporarily disabled but recoverable — distinct from revocation, which
/// is terminal. Suspended licenses fail `/v1/validate` with reason
/// `suspended` until an admin un-suspends them.
Suspended,
}
impl LicenseStatus {
pub fn as_str(&self) -> &'static str {
match self {
LicenseStatus::Active => "active",
LicenseStatus::Revoked => "revoked",
LicenseStatus::Suspended => "suspended",
}
}
}
/// Full license row. Older fields are unchanged; v2 columns live behind
/// `Option`s since they were introduced in migration 0003.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct License {
pub id: String,
pub product_id: String,
pub invoice_id: Option<String>,
pub status: String,
pub fingerprint: Option<String>,
pub bound_identity: Option<String>,
pub issued_at: String,
pub revoked_at: Option<String>,
pub revocation_reason: Option<String>,
pub metadata: serde_json::Value,
// v2 / migration 0003 fields
pub policy_id: Option<String>,
pub expires_at: Option<String>,
pub grace_seconds: i64,
pub max_machines: i64,
pub suspended_at: Option<String>,
pub suspension_reason: Option<String>,
pub entitlements: Vec<String>,
pub is_trial: bool,
pub nostr_npub: Option<String>,
pub buyer_email: Option<String>,
}
/// Reusable license template. A policy says "when we issue a license under
/// this slug, set these defaults" (duration, grace, entitlements, machine
/// cap, trial flag, price override, optional tip recipient).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
pub product_id: String,
pub name: String,
pub slug: String,
pub duration_seconds: i64,
pub grace_seconds: i64,
pub max_machines: i64,
pub is_trial: bool,
pub price_sats_override: Option<i64>,
pub entitlements: Vec<String>,
pub metadata: serde_json::Value,
pub active: bool,
/// Lightning Address (user@domain) the daemon tips a percentage of
/// each successful issuance to. None = no tipping. The amount is
/// `license_price_sats * tip_pct_bps / 10000`. Tip failures never
/// block license issuance.
pub tip_recipient: Option<String>,
/// Percentage in basis points (1bps = 0.01%; 100bps = 1%; 10000bps = 100%).
/// 0 = no tipping. Capped at 10000 server-side.
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient — surfaced in the audit log.
pub tip_label: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// A machine activated under a license. One row per active install.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Machine {
pub id: String,
pub license_id: String,
pub fingerprint: String,
pub fingerprint_hash: String,
pub hostname: Option<String>,
pub platform: Option<String>,
pub ip_last_seen: Option<String>,
pub activated_at: String,
pub last_heartbeat_at: Option<String>,
pub deactivated_at: Option<String>,
pub deactivation_reason: Option<String>,
}
impl Machine {
pub fn is_active(&self) -> bool {
self.deactivated_at.is_none()
}
}
/// Outbound webhook subscription.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEndpoint {
pub id: String,
pub url: String,
/// HMAC-SHA256 secret — never returned on list endpoints after creation.
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
pub event_types: Vec<String>,
pub active: bool,
pub description: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookDelivery {
pub id: String,
pub endpoint_id: String,
pub event_type: String,
pub payload_json: String,
pub attempt_count: i64,
pub next_attempt_at: Option<String>,
pub last_status_code: Option<i64>,
pub last_error: Option<String>,
pub delivered_at: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: i64,
pub actor_kind: String,
pub actor_hash: Option<String>,
pub action: String,
pub target_kind: Option<String>,
pub target_id: Option<String>,
pub request_ip: Option<String>,
pub user_agent: Option<String>,
pub details: serde_json::Value,
pub occurred_at: String,
}
/// Discount / referral code. See `migrations/0004_discount_codes.sql`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountCode {
pub id: String,
pub code: String,
/// 'percent' | 'fixed_sats'.
pub kind: String,
/// Basis points if `kind == 'percent'` (0..=10000); sats if `kind == 'fixed_sats'`.
pub amount: i64,
pub max_uses: Option<i64>,
pub used_count: i64,
pub expires_at: Option<String>,
pub applies_to_product_id: Option<String>,
pub applies_to_policy_id: Option<String>,
pub referrer_label: Option<String>,
pub description: String,
pub active: bool,
pub created_at: String,
pub updated_at: String,
}
/// One row per (code, invoice) pair. Status transitions:
/// pending → redeemed (invoice settled, license issued)
/// pending → cancelled (invoice expired or invalidated)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountRedemption {
pub id: String,
pub code_id: String,
pub invoice_id: String,
pub license_id: Option<String>,
/// 'pending' | 'redeemed' | 'cancelled'.
pub status: String,
pub discount_applied_sats: i64,
pub base_price_sats: i64,
pub final_price_sats: i64,
pub created_at: String,
pub updated_at: String,
}
+250
View File
@@ -0,0 +1,250 @@
//! BTCPay implementation of the [`PaymentProvider`] trait.
//!
//! Wraps the existing `BtcpayClient` (in `crate::btcpay::client`) and
//! the existing webhook signature verifier
//! (`crate::btcpay::webhook::verify_signature`). All BTCPay-specific
//! types and HTTP shape stay in `crate::btcpay::*`; this file is just
//! the trait-shaped facade.
use super::{
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use crate::btcpay::client::BtcpayClient;
use crate::btcpay::webhook::{verify_signature, WebhookEvent as BtcpayWebhookEvent};
use anyhow::{anyhow, Context, Result};
use axum::http::HeaderMap;
use serde_json::Value;
use std::any::Any;
const BTCPAY_SIG_HEADER: &str = "BTCPay-Sig";
/// Active BTCPay provider. Wraps the lower-level HTTP client and the
/// HMAC secret that BTCPay signs webhooks with. Constructed by
/// `api::btcpay_authorize` after the operator completes the OAuth flow.
///
/// `public_base` is BTCPay's PUBLIC URL (the StartTunnel / clearnet
/// one). Optional because it may not be known yet during very-first-
/// boot. When set, every checkout URL returned by `create_invoice`
/// gets its host rewritten from the internal `.startos` hostname to
/// this public host, so buyers actually receive a URL they can open
/// in their browser.
pub struct BtcpayProvider {
pub(crate) client: BtcpayClient,
pub(crate) webhook_secret: String,
pub(crate) public_base: Option<String>,
}
impl BtcpayProvider {
pub fn new(client: BtcpayClient, webhook_secret: String) -> Self {
Self {
client,
webhook_secret,
public_base: None,
}
}
pub fn with_public_base(mut self, public_base: Option<String>) -> Self {
self.public_base = public_base.filter(|s| !s.trim().is_empty());
self
}
/// Compat accessor for code paths that haven't yet migrated to the
/// `PaymentProvider` trait. Returns the underlying BTCPay-specific
/// client by clone (the client is `Clone` and stores only an HTTP
/// client + a few strings; cloning is cheap).
pub fn client(&self) -> &BtcpayClient {
&self.client
}
pub fn webhook_secret(&self) -> &str {
&self.webhook_secret
}
}
/// Rewrite the host (scheme + host + port) of `url_in` to that of
/// `public_base`, preserving the path, query, and fragment. Used to
/// turn `http://btcpayserver.startos:23000/i/abc?x=y` into
/// `https://btcpay.keysat.xyz/i/abc?x=y` before handing the URL to a
/// buyer's browser. Returns the input unchanged if either URL fails
/// to parse — bad-URL handling stays in the caller.
///
/// `pub(crate)` so other modules (like `api::purchase`) can apply the
/// same rewrite when they go through the compat-shim BtcpayClient
/// path instead of the PaymentProvider trait.
pub(crate) fn rewrite_to_public(url_in: &str, public_base: &str) -> String {
let parsed_in = match url::Url::parse(url_in) {
Ok(u) => u,
Err(_) => return url_in.to_string(),
};
let parsed_pub = match url::Url::parse(public_base) {
Ok(u) => u,
Err(_) => return url_in.to_string(),
};
let mut out = parsed_pub.clone();
out.set_path(parsed_in.path());
out.set_query(parsed_in.query());
out.set_fragment(parsed_in.fragment());
out.to_string()
}
#[async_trait::async_trait]
impl PaymentProvider for BtcpayProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Btcpay
}
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle> {
// BTCPay invoices in our flow are sat-denominated. If a future
// caller hands us non-sat money for BTCPay, fail loudly — that's
// a programming error, not a runtime condition.
if params.amount.currency != "SAT" {
anyhow::bail!(
"BTCPayProvider.create_invoice expected SAT-denominated amount, got {}",
params.amount.currency
);
}
// The existing BtcpayClient::create_invoice already takes
// (amount_sats, metadata, redirect_url). We pass through.
let metadata = enrich_metadata(params.metadata, params.external_order_id);
let created = self
.client
.create_invoice(params.amount.amount, metadata, Some(params.redirect_url))
.await
.context("BTCPay create-invoice")?;
// Rewrite the checkout URL's host to the public BTCPay URL so
// buyers actually get a link they can open. BTCPay derives the
// checkout URL from whatever URL we used to call its API
// (internal Docker hostname `btcpayserver.startos:23000`) —
// useless to a buyer's browser. If `public_base` is set we
// swap the host; if not, log loudly because that's a misconfig.
let checkout_url = match &self.public_base {
Some(pb) => {
let rewritten = rewrite_to_public(&created.checkout_link, pb);
tracing::info!(
original = %created.checkout_link,
rewritten = %rewritten,
public_base = %pb,
"checkout URL rewritten for buyer-reachability"
);
rewritten
}
None => {
tracing::warn!(
original = %created.checkout_link,
"checkout URL NOT rewritten — public_base is None. \
Set BTCPAY_PUBLIC_URL via the wrapper, or ensure \
BTCPay's interface list includes a clearnet domain. \
Buyer will see the internal Docker hostname which \
is unreachable from outside."
);
created.checkout_link
}
};
Ok(CreatedInvoiceHandle {
provider_invoice_id: created.id,
checkout_url,
})
}
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
let raw = self
.client
.get_invoice(provider_invoice_id)
.await
.context("BTCPay get-invoice")?;
let status = raw
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("Pending");
Ok(match status {
"Settled" | "Complete" => ProviderInvoiceStatus::Settled,
"Expired" => ProviderInvoiceStatus::Expired,
"Invalid" => ProviderInvoiceStatus::Invalid,
// Refunded isn't a top-level BTCPay status; if BTCPay ever
// reports it via metadata we'd handle here. For now it falls
// through to Pending.
_ => ProviderInvoiceStatus::Pending,
})
}
fn validate_webhook(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent> {
let sig = headers
.get(BTCPAY_SIG_HEADER)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("missing {BTCPAY_SIG_HEADER} header"))?;
verify_signature(&self.webhook_secret, sig, body)
.context("BTCPay webhook signature")?;
let parsed: BtcpayWebhookEvent = serde_json::from_slice(body)
.context("malformed BTCPay webhook body")?;
Ok(match parsed.event_type.as_str() {
"InvoiceSettled" | "InvoicePaymentSettled" => ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: parsed.invoice_id,
},
"InvoiceExpired" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: parsed.invoice_id,
},
"InvoiceInvalid" => ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: parsed.invoice_id,
},
other => ProviderWebhookEvent::Other {
kind: other.to_string(),
provider_invoice_id: Some(parsed.invoice_id),
},
})
}
async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<PaymentReceipt> {
let raw = self
.client
.pay_lightning_invoice(bolt11)
.await
.context("BTCPay pay-lightning-invoice")?;
let payment_hash = raw
.get("paymentHash")
.and_then(|v| v.as_str())
.map(String::from);
Ok(PaymentReceipt { payment_hash, raw })
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Helper: ensure the provider-side metadata always includes our
/// internal invoice id so webhook events are correlatable. BTCPay
/// preserves arbitrary metadata fields and returns them on
/// `get_invoice` and on webhook deliveries.
fn enrich_metadata(mut metadata: Value, external_order_id: &str) -> Value {
if !metadata.is_object() {
metadata = serde_json::json!({});
}
if let Some(obj) = metadata.as_object_mut() {
// BTCPay's checkout displays `orderId` if present.
obj.entry("orderId")
.or_insert_with(|| Value::String(external_order_id.to_string()));
obj.entry("source")
.or_insert_with(|| Value::String("keysat".to_string()));
}
metadata
}
/// Money helper for callers translating from `i64` sat amounts.
pub fn sats(amount: i64) -> Money {
Money::sats(amount)
}
+215
View File
@@ -0,0 +1,215 @@
//! Payment-provider abstraction.
//!
//! Today there's exactly one provider, BTCPay. v0.3 adds Zaprite. The
//! daemon stores the active provider as a trait object so adding new
//! providers is a single-impl drop-in.
//!
//! ## Why a trait
//!
//! Pre-v0.2 the daemon hard-coded BTCPay assumptions in `webhook.rs`,
//! `purchase.rs`, `reconcile.rs`, and `tipping.rs`. Adding Zaprite would
//! have meant either parallel code paths (gross) or post-hoc retrofitting
//! (worse). The `PaymentProvider` trait is a one-time refactor that lets
//! every later provider slot in cleanly.
//!
//! ## Trait surface
//!
//! Just the operations the rest of the daemon actually needs:
//!
//! - `kind()` — provider identity, for logs / audit / admin UI
//! - `create_invoice` — make a hosted-checkout session, return a URL
//! - `get_invoice_status` — for the reconcile loop (webhook misses)
//! - `validate_webhook` — provider-specific signature scheme + parse
//! - `pay_lightning_invoice` — for the tip-recipient flow; default impl
//! returns a "not supported" error so providers without a Lightning
//! payout capability can stay silent.
//!
//! ## What stays out of the trait
//!
//! Provider-specific setup (OAuth-style consent flows, webhook
//! registration, store enumeration) lives in provider-specific modules
//! like `api::btcpay_authorize`. Those modules are responsible for
//! constructing a provider impl and handing it to
//! `AppState::set_payment_provider`.
use anyhow::Result;
use axum::http::HeaderMap;
use serde::{Deserialize, Serialize};
use std::any::Any;
pub mod btcpay;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderKind {
Btcpay,
Zaprite,
}
impl ProviderKind {
pub fn as_str(&self) -> &'static str {
match self {
ProviderKind::Btcpay => "btcpay",
ProviderKind::Zaprite => "zaprite",
}
}
}
/// A monetary amount + the unit it's denominated in.
///
/// We carry currency through the system because v0.3 adds USD/EUR for
/// card payments via Zaprite. v0.2 still emits everything as `SAT`
/// since BTCPay invoices are sat-denominated for our flow.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Money {
/// The currency code. ISO 4217 for fiat; `SAT` and `BTC` for Bitcoin.
pub currency: String,
/// The amount in the currency's smallest indivisible unit (sats for
/// BTC, cents for USD, etc.). Using i64 because integer math is
/// cheaper than decimals and we never need fractional sats.
pub amount: i64,
}
impl Money {
pub fn sats(amount: i64) -> Self {
Money {
currency: "SAT".to_string(),
amount,
}
}
}
/// Inputs for `create_invoice`. Bundled into a struct so the trait
/// signature stays stable as we add fields.
pub struct CreateInvoiceParams<'a> {
pub amount: Money,
/// Where the buyer is sent after a successful payment. The provider
/// appends its own status fragments / query params as needed.
pub redirect_url: &'a str,
/// Arbitrary metadata pinned to the invoice on the provider's side.
/// Used by Keysat to round-trip its internal invoice id back through
/// webhook events (`metadata.orderId` for BTCPay; `externalOrderId`
/// for Zaprite).
pub metadata: serde_json::Value,
/// Keysat's internal invoice id (UUID). Passed back in webhook
/// events to correlate with the local row.
pub external_order_id: &'a str,
/// Buyer email if known. Some providers use this for receipts.
pub buyer_email: Option<&'a str>,
}
/// Result of `create_invoice`. Whatever the provider returned, narrowed
/// to the two things the rest of Keysat actually needs.
#[derive(Debug, Clone)]
pub struct CreatedInvoiceHandle {
/// Provider-side invoice id. BTCPay invoice id today; Zaprite order
/// id later. Stored on the invoice row so we can reconcile.
pub provider_invoice_id: String,
/// Public URL the buyer is redirected to to pay.
pub checkout_url: String,
}
/// Provider-agnostic invoice status used by the reconcile loop. Maps to
/// the daemon's existing `InvoiceStatus` model but stays decoupled so
/// the trait doesn't pull in domain types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderInvoiceStatus {
Pending,
Settled,
Expired,
Refunded,
Invalid,
}
/// Parsed webhook event. Only the kinds Keysat actually acts on are
/// modeled; everything else falls into `Other` and is ignored.
#[derive(Debug, Clone)]
pub enum ProviderWebhookEvent {
InvoiceSettled {
provider_invoice_id: String,
},
InvoiceExpired {
provider_invoice_id: String,
},
InvoiceInvalid {
provider_invoice_id: String,
},
InvoiceRefunded {
provider_invoice_id: String,
refunded_amount: Option<Money>,
},
/// Anything else the provider sent. We log + 200 it so the provider
/// stops retrying.
Other {
kind: String,
provider_invoice_id: Option<String>,
},
}
impl ProviderWebhookEvent {
pub fn provider_invoice_id(&self) -> Option<&str> {
match self {
ProviderWebhookEvent::InvoiceSettled { provider_invoice_id }
| ProviderWebhookEvent::InvoiceExpired { provider_invoice_id }
| ProviderWebhookEvent::InvoiceInvalid { provider_invoice_id }
| ProviderWebhookEvent::InvoiceRefunded {
provider_invoice_id, ..
} => Some(provider_invoice_id),
ProviderWebhookEvent::Other {
provider_invoice_id,
..
} => provider_invoice_id.as_deref(),
}
}
}
/// Result of paying a Lightning invoice via the provider's LN node.
#[derive(Debug, Clone)]
pub struct PaymentReceipt {
pub payment_hash: Option<String>,
/// Raw provider response, for the audit log.
pub raw: serde_json::Value,
}
/// The trait every payment provider implements.
///
/// Object-safe (uses `&dyn`/`Box<dyn>`) thanks to `#[async_trait]`. The
/// `Any` supertrait lets call sites that still need provider-specific
/// types (e.g., the BTCPay-specific authorize flow) downcast.
#[async_trait::async_trait]
pub trait PaymentProvider: Send + Sync + Any {
fn kind(&self) -> ProviderKind;
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle>;
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus>;
/// Verify and parse a webhook delivery. Implementations are
/// responsible for reading whatever signature header their provider
/// uses, computing the expected HMAC, and constant-time comparing.
fn validate_webhook(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent>;
/// Pay a BOLT11 Lightning invoice via the provider's LN node.
/// Default impl returns a "not supported" error so providers
/// without LN payout capability don't have to override.
async fn pay_lightning_invoice(&self, _bolt11: &str) -> Result<PaymentReceipt> {
anyhow::bail!(
"pay_lightning_invoice not supported by this payment provider"
)
}
/// Hatch for compat-era downcasting. Lets `AppState`'s legacy
/// `btcpay_client()` accessor reach the inner BTCPay-specific
/// client. v0.3 will retire the compat accessors and remove this.
fn as_any(&self) -> &dyn Any;
}
+81
View File
@@ -0,0 +1,81 @@
//! Token-bucket rate limiting backed by SQLite.
//!
//! The state for each bucket lives in `rate_buckets` (bucket_kind, bucket_key).
//! Each incoming request refills the bucket based on wall-clock elapsed time
//! since last refill, then tries to spend one token. Returns `true` if the
//! request is allowed, `false` if it's rate-limited.
//!
//! Why store in SQLite instead of in-memory? Because the service is
//! single-tenant and small, and persisting lets us survive restarts without
//! giving attackers a "just bounce the process" bypass. The overhead of one
//! extra SQLite write per hit is negligible at our expected traffic.
use crate::error::AppResult;
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
/// Try to spend one token from the given bucket. Returns `Ok(true)` if the
/// request is allowed, `Ok(false)` if rate-limited, or `Err` on a DB error.
///
/// - `capacity`: maximum tokens the bucket can hold (and what it starts at)
/// - `refill_per_second`: how many tokens to add per wall-clock second
pub async fn consume(
pool: &SqlitePool,
bucket_kind: &str,
bucket_key: &str,
capacity: f64,
refill_per_second: f64,
) -> AppResult<bool> {
let now = Utc::now();
// Pull existing bucket, if any.
let row = sqlx::query_as::<_, (f64, f64, f64, String)>(
"SELECT tokens_remaining, capacity, refill_per_second, last_refill_at
FROM rate_buckets WHERE bucket_kind = ? AND bucket_key = ?",
)
.bind(bucket_kind)
.bind(bucket_key)
.fetch_optional(pool)
.await?;
let (new_tokens, allowed) = match row {
Some((prev_tokens, _cap, _refill, last_refill_at)) => {
let last = DateTime::parse_from_rfc3339(&last_refill_at)
.map(|t| t.with_timezone(&Utc))
.unwrap_or(now);
let elapsed_s = (now - last).num_milliseconds() as f64 / 1000.0;
let mut tokens = (prev_tokens + elapsed_s * refill_per_second).min(capacity);
if tokens >= 1.0 {
tokens -= 1.0;
(tokens, true)
} else {
(tokens, false)
}
}
None => {
// Start with a full bucket minus the current request.
(capacity - 1.0, true)
}
};
let now_str = now.to_rfc3339();
sqlx::query(
"INSERT INTO rate_buckets
(bucket_kind, bucket_key, tokens_remaining, capacity, refill_per_second, last_refill_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bucket_kind, bucket_key) DO UPDATE SET
tokens_remaining = excluded.tokens_remaining,
capacity = excluded.capacity,
refill_per_second = excluded.refill_per_second,
last_refill_at = excluded.last_refill_at",
)
.bind(bucket_kind)
.bind(bucket_key)
.bind(new_tokens)
.bind(capacity)
.bind(refill_per_second)
.bind(&now_str)
.execute(pool)
.await?;
Ok(allowed)
}
+146
View File
@@ -0,0 +1,146 @@
//! Invoice reconciliation background task.
//!
//! Webhooks are the primary signal from BTCPay to us — fast, push-based, and
//! authenticated with HMAC. But webhooks can be dropped (network blips, our
//! service restarting during a burst, BTCPay retry-budget exhaustion on a
//! long outage). If we only ever reacted to webhooks, a dropped settle
//! notification would mean a buyer paid and never got their license.
//!
//! Reconciliation closes that gap. Every N seconds we scan our own table
//! for invoices still in `pending` status that were created recently, ask
//! BTCPay directly what their real state is, and reconcile:
//!
//! - BTCPay says `Settled` → mark settled AND issue a license if one
//! doesn't exist yet (idempotency enforced by the UNIQUE index on
//! `licenses.invoice_id`).
//! - BTCPay says `Expired` / `Invalid` → mark accordingly, don't issue.
//! - BTCPay still says `New` / `Processing` → leave it alone.
//!
//! The task is cheap — one DB query and at most N HTTP calls per tick —
//! and bounded (we only look at invoices younger than MAX_AGE_HOURS).
use crate::api::AppState;
use crate::db::repo;
use std::time::Duration;
use tokio::time::sleep;
const TICK: Duration = Duration::from_secs(60);
const MAX_AGE_HOURS: i64 = 72;
pub fn spawn(state: AppState) {
tokio::spawn(async move {
// Small initial delay so we don't race startup logs.
sleep(Duration::from_secs(15)).await;
loop {
if let Err(e) = tick(&state).await {
tracing::warn!(error = %e, "reconciliation tick failed");
}
sleep(TICK).await;
}
});
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(_) => return Ok(()), // not configured yet — skip silently
};
let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS)
.await
.map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?;
if pending.is_empty() {
return Ok(());
}
tracing::debug!(count = pending.len(), "reconciling pending invoices");
for inv in pending {
match btcpay.get_invoice(&inv.btcpay_invoice_id).await {
Ok(raw) => {
let status_str = raw
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let normalized = match status_str.as_str() {
"Settled" | "Complete" => Some("settled"),
"Expired" => Some("expired"),
"Invalid" => Some("invalid"),
// still in flight
_ => None,
};
let Some(new_status) = normalized else { continue };
if new_status == inv.status.as_str() {
continue; // no-op
}
if let Err(e) = repo::update_invoice_status(
&state.db,
&inv.btcpay_invoice_id,
new_status,
)
.await
{
tracing::warn!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to update invoice status"
);
continue;
}
// Free any reserved discount-code slot if the invoice
// entered a terminal failure state.
if matches!(new_status, "expired" | "invalid") {
if let Ok(Some(redemption)) =
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
{
let _ = repo::cancel_redemption(&state.db, &redemption.id).await;
}
}
if new_status == "settled" {
if let Err(e) = ensure_license(state, &inv).await {
tracing::warn!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to issue license after recovered settle"
);
} else {
tracing::info!(
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler issued license for recovered settled invoice"
);
}
}
}
Err(e) => {
tracing::debug!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to fetch invoice from BTCPay"
);
}
}
}
Ok(())
}
async fn ensure_license(
state: &AppState,
invoice: &crate::models::Invoice,
) -> anyhow::Result<()> {
if repo::get_license_by_invoice(&state.db, &invoice.id)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?
.is_some()
{
return Ok(());
}
crate::api::webhook::issue_license_for_invoice(state, invoice)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
Ok(())
}
+353
View File
@@ -0,0 +1,353 @@
//! Tip-recipient-on-policy: fire a Lightning tip after every successful
//! license issuance under a tip-enabled policy.
//!
//! Flow:
//! 1. License is issued (existing path; this module is called from the
//! reconcile/webhook layer once that completes).
//! 2. Look up the policy. If `tip_recipient` is set and `tip_pct_bps > 0`,
//! compute `amount_sats = paid_sats * tip_pct_bps / 10000`.
//! 3. Resolve the Lightning Address. We support exactly the Lightning
//! Address scheme `user@domain`, which maps to
//! `https://domain/.well-known/lnurlp/user`. Plain LNURL-pay bech32
//! strings are not supported in v0.1; can add later.
//! 4. Fetch the LNURL-pay metadata, verify the amount fits in
//! `[minSendable, maxSendable]`, request a BOLT11 invoice for our
//! amount via the `callback` URL.
//! 5. Pay the BOLT11 via the operator's BTCPay Lightning node.
//! 6. Record success/failure in the `tip_attempts` audit table.
//!
//! Failure semantics: this module **never** propagates errors back to the
//! issuance path. A tip failing is a logged + audited concern, not a reason
//! to fail a customer's purchase. Operators set up tipping voluntarily;
//! they accept the trade-off that an occasional tip will fail and can be
//! retried manually.
use crate::api::AppState;
use crate::db::repo;
use crate::models::Policy;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
/// Maximum amount in millisats we'll send via a single tip. Defense in
/// depth — a misconfigured `tip_pct_bps` shouldn't be able to drain the
/// wallet on a single sale.
const MAX_TIP_MSAT: u64 = 5_000_000_000; // 50,000,000 sats; 0.5 BTC
#[derive(Debug, Deserialize)]
struct LnurlPayMetadata {
callback: String,
#[serde(rename = "minSendable")]
min_sendable: u64,
#[serde(rename = "maxSendable")]
max_sendable: u64,
#[serde(default)]
tag: String,
}
#[derive(Debug, Deserialize)]
struct LnurlPayInvoice {
pr: String, // BOLT11
}
/// Spawn a tip in the background. Caller fires this after issuance and
/// returns immediately — the customer's purchase response doesn't wait for
/// the tip to complete.
pub fn spawn_tip(
state: AppState,
license_id: String,
policy: Policy,
paid_sats: i64,
) {
tokio::spawn(async move {
if let Err(e) = run_tip(&state, &license_id, &policy, paid_sats).await {
tracing::warn!(
license = %license_id,
policy = %policy.id,
"tip flow ended with error: {e:#}"
);
// run_tip records its own audit entries; this is just the catch-all log.
}
});
}
async fn run_tip(
state: &AppState,
license_id: &str,
policy: &Policy,
paid_sats: i64,
) -> Result<()> {
let recipient = match &policy.tip_recipient {
Some(r) if !r.trim().is_empty() => r.trim().to_string(),
_ => return Ok(()), // no tip configured; not an error
};
let pct = policy.tip_pct_bps;
if pct <= 0 {
return Ok(());
}
let label = policy.tip_label.clone();
// Compute tip amount. Round down (floor); we never tip more than the
// configured percentage of what the buyer paid.
let tip_sats = paid_sats.saturating_mul(pct) / 10_000;
if tip_sats <= 0 {
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
0,
pct,
label.as_deref(),
"skipped",
Some("tip_sats <= 0 after percentage applied"),
None,
)
.await
.ok();
return Ok(());
}
let tip_msat = (tip_sats as u64).saturating_mul(1000);
if tip_msat > MAX_TIP_MSAT {
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"skipped",
Some(&format!(
"tip exceeds safety cap ({} msat > {} msat)",
tip_msat, MAX_TIP_MSAT
)),
None,
)
.await
.ok();
return Ok(());
}
// Resolve Lightning Address → LNURL-pay metadata.
let metadata = match resolve_lightning_address(&recipient).await {
Ok(m) => m,
Err(e) => {
let detail = format!("address resolution failed: {e:#}");
tracing::warn!(license = %license_id, recipient = %recipient, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
if tip_msat < metadata.min_sendable || tip_msat > metadata.max_sendable {
let detail = format!(
"tip amount {tip_msat} msat outside recipient bounds [{}, {}]",
metadata.min_sendable, metadata.max_sendable
);
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
// Request a BOLT11 invoice from the recipient for our amount.
let invoice = match request_lnurl_invoice(&metadata.callback, tip_msat).await {
Ok(b) => b,
Err(e) => {
let detail = format!("invoice request failed: {e:#}");
tracing::warn!(license = %license_id, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
// Pay it via the operator's BTCPay Lightning node.
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
Err(e) => {
let detail = format!("BTCPay client unavailable: {e:?}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
match btcpay.pay_lightning_invoice(&invoice).await {
Ok(payment) => {
let payment_hash = payment
.get("paymentHash")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
tracing::info!(
license = %license_id,
recipient = %recipient,
amount_sats = tip_sats,
payment_hash = ?payment_hash,
"tip sent"
);
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"sent",
Some(&format!("paid via BTCPay LN node ({} sats)", tip_sats)),
payment_hash.as_deref(),
)
.await
.ok();
}
Err(e) => {
let detail = format!("BTCPay pay-LN-invoice failed: {e:#}");
tracing::warn!(license = %license_id, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
}
}
Ok(())
}
/// Parse `user@domain` and fetch the LNURL-pay metadata document at
/// `https://domain/.well-known/lnurlp/user`. Returns the parsed metadata.
async fn resolve_lightning_address(addr: &str) -> Result<LnurlPayMetadata> {
let (user, domain) = addr
.split_once('@')
.ok_or_else(|| anyhow!("not a Lightning Address (expected user@domain)"))?;
if user.is_empty() || domain.is_empty() {
bail!("Lightning Address has empty user or domain");
}
// Reasonable charset check — LN addresses are user-input-safe alphanum + dash + underscore + dot.
let charset_ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
if !user.chars().all(charset_ok) || !domain.chars().all(charset_ok) {
bail!("Lightning Address contains disallowed characters");
}
let url = format!("https://{domain}/.well-known/lnurlp/{user}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building HTTP client")?;
let resp = client.get(&url).send().await.context("LNURL-pay GET")?;
if !resp.status().is_success() {
bail!("LNURL-pay endpoint returned {}", resp.status());
}
let metadata: LnurlPayMetadata = resp
.json()
.await
.context("parsing LNURL-pay metadata response")?;
if !metadata.tag.is_empty() && metadata.tag != "payRequest" {
bail!(
"expected LNURL-pay metadata tag='payRequest', got '{}'",
metadata.tag
);
}
if !metadata.callback.starts_with("https://") {
bail!(
"LNURL-pay callback must be HTTPS, got: {}",
metadata.callback
);
}
Ok(metadata)
}
/// Hit the recipient's `callback` URL with `?amount=<msat>` and return the
/// resulting BOLT11 invoice string.
async fn request_lnurl_invoice(callback: &str, amount_msat: u64) -> Result<String> {
let sep = if callback.contains('?') { '&' } else { '?' };
let url = format!("{callback}{sep}amount={amount_msat}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building HTTP client")?;
let resp = client.get(&url).send().await.context("LNURL-pay invoice GET")?;
if !resp.status().is_success() {
bail!(
"LNURL-pay invoice endpoint returned {}",
resp.status()
);
}
// The response can be either { pr, ... } on success or
// { status: "ERROR", reason: "..." } on failure.
let body: serde_json::Value = resp
.json()
.await
.context("parsing LNURL-pay invoice response")?;
if let Some("ERROR") = body.get("status").and_then(|s| s.as_str()) {
let reason = body
.get("reason")
.and_then(|s| s.as_str())
.unwrap_or("unknown");
bail!("LNURL-pay invoice error: {reason}");
}
let parsed: LnurlPayInvoice = serde_json::from_value(body)
.context("LNURL-pay response missing 'pr' field")?;
Ok(parsed.pr)
}
+221
View File
@@ -0,0 +1,221 @@
//! Outbound webhooks.
//!
//! When interesting things happen (a license is issued, revoked, suspended,
//! a machine activates, an invoice settles), the service can POST a signed
//! JSON payload to one or more URLs configured by the operator.
//!
//! Design:
//!
//! - Each endpoint has its own HMAC-SHA256 secret (32 random bytes, hex).
//! - Each delivery is a row in `webhook_deliveries`. Deliveries that fail are
//! retried with exponential backoff up to 10 attempts.
//! - Deliveries are dispatched by a single background task that polls the
//! table every 5 seconds for rows whose `next_attempt_at` is due.
//! - The signature scheme is the same shape as BTCPay's webhook signing
//! (`sha256=<hex>`), so integrators who've already written BTCPay webhook
//! receivers can adapt their code trivially.
use crate::api::AppState;
use crate::db::repo;
use crate::models::WebhookEndpoint;
use chrono::{Duration as ChronoDuration, Utc};
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use std::time::Duration;
use tokio::time::sleep;
type HmacSha256 = Hmac<Sha256>;
/// Signature header we attach to every outbound delivery. Receivers verify by
/// recomputing `HMAC-SHA256(body, secret)` and comparing in constant time.
pub const SIG_HEADER: &str = "X-Keysat-Signature";
/// Event-type header, mirrors `event_type` in the payload for convenience.
pub const EVENT_HEADER: &str = "X-Keysat-Event";
/// Idempotency key header — the delivery id, stable across retries.
pub const DELIVERY_HEADER: &str = "X-Keysat-Delivery";
/// Fire off a logical event. Persists one `webhook_deliveries` row per
/// active subscribed endpoint; the delivery worker handles the HTTP.
///
/// Infallible from the caller's perspective: any DB error is logged and
/// swallowed so event dispatch never blocks the main mutation.
pub async fn dispatch(state: &AppState, event_type: &str, data: &Value) {
let envelope = serde_json::json!({
"event_type": event_type,
"timestamp": Utc::now().to_rfc3339(),
"data": data,
});
let envelope_json = match serde_json::to_string(&envelope) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "webhook dispatch: failed to serialize envelope");
return;
}
};
let endpoints = match repo::list_active_webhook_endpoints(&state.db).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = ?e, "webhook dispatch: failed to list endpoints");
return;
}
};
for ep in endpoints {
if !ep_wants(&ep, event_type) {
continue;
}
if let Err(e) = repo::enqueue_delivery(&state.db, &ep.id, event_type, &envelope_json).await
{
tracing::warn!(error = ?e, endpoint = %ep.id, "failed to enqueue delivery");
}
}
}
fn ep_wants(ep: &WebhookEndpoint, event_type: &str) -> bool {
ep.event_types.iter().any(|t| t == "*" || t == event_type)
}
/// Background task: every 5s, pick up to 25 deliveries whose `next_attempt_at`
/// is due, POST them, update the row.
pub fn spawn_delivery_worker(state: AppState) {
tokio::spawn(async move {
// Stagger startup slightly to avoid racing the initial reconcile loop.
sleep(Duration::from_secs(5)).await;
loop {
if let Err(e) = tick(&state).await {
tracing::warn!(error = %e, "webhook delivery tick failed");
}
sleep(Duration::from_secs(5)).await;
}
});
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
let due = repo::list_ready_deliveries(&state.db, 25)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
if due.is_empty() {
return Ok(());
}
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
for d in due {
// Look up endpoint + secret.
let ep = match repo::get_webhook_endpoint_by_id(&state.db, &d.endpoint_id, true).await {
Ok(Some(ep)) if ep.active => ep,
_ => {
// Endpoint gone or disabled — mark delivery permanently failed.
repo::mark_delivery_failure(
&state.db,
&d.id,
None,
"endpoint deleted or disabled",
None,
)
.await
.ok();
continue;
}
};
let secret = ep.secret.as_deref().unwrap_or("");
// Compute HMAC signature of the raw body.
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(e) => {
repo::mark_delivery_failure(
&state.db,
&d.id,
None,
&format!("bad HMAC key: {e}"),
None,
)
.await
.ok();
continue;
}
};
mac.update(d.payload_json.as_bytes());
let sig_hex = hex::encode(mac.finalize().into_bytes());
let sig_header_val = format!("sha256={sig_hex}");
let req = http
.post(&ep.url)
.header("content-type", "application/json")
.header(SIG_HEADER, &sig_header_val)
.header(EVENT_HEADER, &d.event_type)
.header(DELIVERY_HEADER, &d.id)
.body(d.payload_json.clone());
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16() as i64;
if resp.status().is_success() {
repo::mark_delivery_success(&state.db, &d.id, status).await.ok();
} else {
let backoff = backoff_for(d.attempt_count + 1);
let next = backoff.map(|bs| (Utc::now() + bs).to_rfc3339());
let body_preview = resp.text().await.unwrap_or_default();
let trimmed: String = body_preview.chars().take(200).collect();
repo::mark_delivery_failure(
&state.db,
&d.id,
Some(status),
&format!("non-2xx response: {trimmed}"),
next.as_deref(),
)
.await
.ok();
}
}
Err(e) => {
let backoff = backoff_for(d.attempt_count + 1);
let next = backoff.map(|bs| (Utc::now() + bs).to_rfc3339());
repo::mark_delivery_failure(
&state.db,
&d.id,
None,
&format!("request error: {e}"),
next.as_deref(),
)
.await
.ok();
}
}
}
Ok(())
}
/// Exponential backoff for delivery retries, capped at 10 attempts. Returns
/// `None` when the max is reached (meaning: do not reschedule).
fn backoff_for(attempts_after: i64) -> Option<ChronoDuration> {
const MAX_ATTEMPTS: i64 = 10;
if attempts_after >= MAX_ATTEMPTS {
return None;
}
// 5s, 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 6h
let minutes = match attempts_after {
1 => 0,
2 => 0,
3 => 0,
4 => 1,
5 => 5,
6 => 15,
7 => 30,
8 => 60,
9 => 120,
_ => 360,
};
let seconds = match attempts_after {
1 => 5,
2 => 10,
3 => 30,
_ => 0,
};
Some(ChronoDuration::seconds(seconds) + ChronoDuration::minutes(minutes))
}
File diff suppressed because it is too large Load Diff