diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d050caa
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,32 @@
+# Build context is now this wrapper directory itself. Exclude things
+# that bloat the context and aren't needed by the Dockerfile.
+#
+# The Dockerfile only consumes `licensing-service/` (a symlink to the
+# sibling Rust crate) plus its own multi-stage build inputs.
+
+# Wrapper's own Node deps and bundle output — irrelevant to Rust build.
+node_modules/
+javascript/
+
+# Local backup / temp files
+*.bak
+*.tmp
+*.ts.tmp
+
+# Build artifacts
+*.s9pk
+
+# Editor / OS cruft
+.DS_Store
+*.swp
+.vscode/
+.idea/
+
+# Don't ship the symlinked Rust target/ output that may exist in the
+# upstream working tree.
+licensing-service/target/
+licensing-service/docs/
+
+# Git internals (we keep .git/HEAD + .git/index for the gitHash field via
+# start-cli's own scan; Docker doesn't need them).
+.git/
diff --git a/.gitignore b/.gitignore
index c6c9cf8..8069d9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,8 +5,30 @@ javascript/
# Node
node_modules/
+# Rust / Cargo
+licensing-service/target/
+licensing-service/Cargo.lock.bak
+
# macOS / editor cruft
.DS_Store
*.swp
.vscode/
.idea/
+
+# Local backup / temp files
+*.bak
+*.tmp
+*.ts.tmp
+*.old-backup
+
+# Env / secrets — never commit a real .env
+.env
+.env.local
+.env.*.local
+
+# Local SQLite DB if anyone runs the daemon out-of-tree
+licensing-service/data/
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
diff --git a/Dockerfile b/Dockerfile
index f7080af..54d418e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,13 @@
# syntax=docker/dockerfile:1.6
-ARG RUST_VERSION=1.75
+# Build toolchain pinned at a recent stable. We bumped past 1.85 because
+# transitive deps in the ICU family (icu_collections, icu_normalizer,
+# icu_properties, icu_provider, idna_adapter) require rustc >= 1.86.
+# 1.88 gives a couple of minor versions of headroom against newer deps.
+# The Keysat crate itself still declares MSRV 1.75 in its Cargo.toml;
+# we just need a newer toolchain to compile the dep tree.
+ARG RUST_VERSION=1.88
# -------- builder --------
FROM rust:${RUST_VERSION}-slim-bookworm AS builder
@@ -44,6 +50,11 @@ RUN mkdir -p licensing-service/src && \
# Copy the actual source.
COPY licensing-service/src ./licensing-service/src
+# Copy the embedded admin web UI assets. rust-embed reads this directory
+# at compile time and bundles every file into the binary, so this layer
+# must exist before the cargo build step.
+COPY licensing-service/web ./licensing-service/web
+
# Build.
ARG TARGETARCH
RUN case "${TARGETARCH}" in \
@@ -62,10 +73,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tini \
&& rm -rf /var/lib/apt/lists/*
-# Non-root user to avoid running as root even though we're in a container.
-RUN useradd --system --create-home --uid 10001 keysat
-USER keysat
-WORKDIR /home/keysat
+# Run as root inside the container. StartOS containers are isolated by
+# the platform's namespacing, so root-in-container is not root-on-host.
+# Dropping privileges to a non-root user here causes two real problems:
+# 1. The persistent volume mounted at /data is owned by root by default;
+# a non-root user gets "unable to open database file" (SQLite 14).
+# 2. Tini (which we use as the entrypoint init for proper signal
+# handling) emits a warning when not running as PID 1, which it
+# can't be after a USER switch in this layout.
+WORKDIR /data
COPY --from=builder /keysat /usr/local/bin/keysat
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..274ff6b
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
index bd866ac..397e92f 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,282 @@
-# keysat-startos
+
+
+
-StartOS 0.4.0.x wrapper package for [Keysat](../licensing-service) (the Rust daemon in `../licensing-service/`). This directory turns the upstream Rust daemon into an installable `.s9pk`.
+
Keysat
-The source directory is still called `licensing-service/` on disk for continuity; the binary it produces, the manifest id, and all operator-visible strings use the new name **Keysat**.
+
+ Self-hosted, Bitcoin-paid software-licensing service for Start9.
+
-## Prerequisites
+> **About this README.** Keysat is a from-scratch service authored for
+> StartOS — there is no upstream project to differ from. The canonical
+> implementation is this package and the Rust daemon it wraps
+> (`licensing-service/`). Where this README would normally explain
+> "differences from upstream," it instead documents the architecture
+> directly. Anything that isn't documented here matches the source.
-- A working StartOS 0.4.0.x development environment (see [docs.start9.com](https://docs.start9.com)).
-- `start-cli` installed, with `~/.startos/developer.key.pem` initialized.
-- Node.js and npm (the StartOS SDK is TypeScript).
-- Docker (via buildx) for the multi-arch image build.
+## Table of Contents
-## Getting the shared build logic
+- [What Keysat is](#what-keysat-is)
+- [Image and Container Runtime](#image-and-container-runtime)
+- [Volume and Data Layout](#volume-and-data-layout)
+- [Installation and First-Run Flow](#installation-and-first-run-flow)
+- [Configuration Management](#configuration-management)
+- [Network Access and Interfaces](#network-access-and-interfaces)
+- [Actions (StartOS UI)](#actions-startos-ui)
+- [Backups and Restore](#backups-and-restore)
+- [Health Checks](#health-checks)
+- [Dependencies](#dependencies)
+- [Limitations and Differences](#limitations-and-differences)
+- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream)
+- [Contributing](#contributing)
+- [YAML Quick Reference](#yaml-quick-reference)
-The `Makefile` includes `s9pk.mk`, which is shared build boilerplate maintained by the Start9 team. Fetch it once:
+## What Keysat is
-```bash
-curl -o s9pk.mk https://raw.githubusercontent.com/Start9Labs/hello-world-startos/master/s9pk.mk
+Keysat lets a software seller issue, validate, and revoke license keys for
+their own product, with payment in Bitcoin via BTCPay Server. The seller
+runs Keysat on their own Start9, declares one or more products, and shares
+a public purchase URL with their customers. Buyers pay in Bitcoin and
+receive a signed license key whose authenticity their software can verify
+offline against the seller's embedded public key. Keys can be capped to
+specific machines, time-limited, suspended, revoked, or marked as trial.
+
+Discount and referral codes (paid and free-license) are first-class
+primitives. Free-license codes bypass BTCPay entirely and issue a key
+directly via a public redemption endpoint — useful for press passes,
+comp keys, beta access, or "first N users free" launch promos.
+
+## Image and Container Runtime
+
+Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
+with build context set to the parent directory so the Dockerfile can
+`COPY` from the sibling `licensing-service/` source tree. The Rust binary
+is statically linked against musl (target
+`*-unknown-linux-musl`) so the runtime image is a `scratch`-based final
+stage with no shared-library dependencies. Architectures: `x86_64` and
+`aarch64`.
+
+`start-cli s9pk pack` ingests the resulting OCI image, converts it to a
+squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
+StartOS extracts the squashfs and runs the service in its own container
+runtime.
+
+## Volume and Data Layout
+
+Keysat declares a single persistent volume:
+
+| Volume | Mount | Contents |
+|--------|--------|---------------------------------------------------------|
+| `main` | `/data`| SQLite database (`keysat.db`); contains the Ed25519 signing keypair, products, policies, licenses, machines, invoices, redemptions, audit log, and BTCPay credentials. |
+
+Loss of this volume invalidates every issued license, since the signing
+keypair is regenerated on first boot. Treat StartOS-managed backups as
+mandatory.
+
+## Installation and First-Run Flow
+
+1. Install Keysat via the marketplace (or sideload the `.s9pk`).
+2. Resolve the auto-created **critical task** "Connect BTCPay" by
+ running the **Connect BTCPay** action. This opens a one-click
+ authorize page on your local BTCPay; after approval, Keysat
+ auto-detects your store and registers an inbound webhook. No API
+ keys to copy.
+3. Run **Check BTCPay connection** to confirm — the install task clears
+ automatically.
+4. Set your **operator name** (shown on the public homepage and in
+ buyer-facing receipts).
+5. Create one or more **products** — each represents something you sell.
+6. Create at least one **policy** per product. The policy slugged
+ `default` is consumed by the standard public purchase flow; other
+ slugs are used for manual issuance. Policies define duration, grace
+ period, seat cap, entitlements, trial flag, and price overrides.
+7. Optionally create **discount / referral / free-license codes** (see
+ `Create discount code` action).
+8. Share the public service URL with buyers.
+
+## Configuration Management
+
+All configuration is performed through StartOS actions; there is no
+on-disk config file the operator should edit. Environment variables
+passed to the daemon at startup (`main.ts`) are derived from the
+package-local store (operator name, admin API key) and from the
+declared BTCPay dependency hostname.
+
+For advanced operators, the `/v1/admin/*` HTTP API exposes everything
+the actions do plus bulk-list operations not yet surfaced in the UI.
+Retrieve the admin API key via the **Show admin credentials** action.
+
+## Network Access and Interfaces
+
+Keysat exposes one logical port (8080 HTTP) split across two service
+interfaces for clarity:
+
+| Interface | Type | Path prefix | Purpose |
+|-----------|------|-------------|------------------------------------------------------------------------------|
+| `api` | api | `/` | Public REST API for buyers (purchase, redeem) and licensed apps (validate, machine activation). Bake the URL into your software builds as the licensing endpoint. |
+| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically during Connect BTCPay; not for human use. |
+
+StartOS terminates TLS at the platform edge. Inside the container every
+request arrives as plain HTTP. For browser-facing URLs (e.g., the public
+purchase page) hardcode `https://`.
+
+## Actions (StartOS UI)
+
+Grouped as displayed in the dashboard.
+
+**General**
+- *Set operator name* — your public-facing brand.
+
+**BTCPay**
+- *Connect BTCPay* — one-click authorize against your BTCPay; auto-detects store and registers webhook.
+- *Check BTCPay connection* — confirm BTCPay state; clears the install task on success.
+
+**Credentials**
+- *Show admin credentials* — admin API key for direct `/v1/admin/*` access.
+
+**Products + Policies**
+- *Create product* — declare something to sell.
+- *Create policy* — license template for a product (duration, grace, seat cap, entitlements, trial flag, price override).
+
+**Discount codes**
+- *Create discount code* — percent-off / fixed-sats-off / free-license.
+- *List discount codes* — usage stats.
+- *Disable / enable discount code*.
+
+**Licenses**
+- *Issue license manually* — comp / press / grandfathered keys.
+- *Search licenses* — by email, Nostr npub, or BTCPay invoice id.
+- *Suspend license* — reversible lockout.
+- *Unsuspend license*.
+- *Revoke license* — terminal kill.
+
+**Machines**
+- *List machines* — installs bound to a license.
+- *Deactivate machine* — free a seat.
+
+**Webhooks (outbound)**
+- *Register webhook endpoint* — POST signed events to your URL.
+- *List webhook endpoints*.
+
+**Diagnostics**
+- *View audit log* — admin mutation history, filterable.
+
+## Backups and Restore
+
+Keysat opts into StartOS's default volume backup via `setupBackups` /
+`Backups.ofVolumes('main')`. The single `main` volume contains all
+state — signing key included — so a backup is sufficient to fully
+recover the service. On restore, the install-time **Connect BTCPay**
+task re-surfaces in case the BTCPay credentials in the restored DB are
+stale.
+
+Treat backups as mandatory: losing the signing keypair invalidates every
+key Keysat ever issued, with no recovery path.
+
+## Health Checks
+
+A single port-listening check on port 8080 (`sdk.healthCheck.checkPortListening`).
+StartOS reports the service as healthy once the daemon is binding the
+port. The daemon exposes `GET /healthz` for richer external monitoring.
+
+## Dependencies
+
+| Dependency | Version range | Required | Purpose |
+|-------------|---------------|----------|---------------------------------------------------------------|
+| `btcpayserver` | `>=1.11.0` | Yes | Required to receive Bitcoin payments and confirm settlement. |
+
+The dependency is `kind: 'running'`, so Keysat will not start until
+BTCPay is running. The `btcpayserver.startos` hostname is provided to
+the container automatically.
+
+## Limitations and Differences
+
+Known v0.1 limitations:
+
+- **No buyer self-service portal.** Buyers cannot log in to view their licenses, transfer to a new machine, or recover a lost key without contacting the operator. Use *Search licenses* to recover.
+- **No recurring subscriptions.** Time-limited licenses expire and require a manual repurchase. BTCPay supports recurring billing but Keysat does not yet model auto-renewal.
+- **No license tier upgrade in place.** A buyer who got Standard cannot be upgraded to Pro on the existing key — they need a new key.
+- **No bulk / volume licensing.** "Buy 10 keys at once with discount" is not built in.
+- **No in-dashboard list views.** Operators query large datasets via the admin API key rather than a paginated UI.
+- **Webhook delivery retries are bounded.** A subscriber down past the retry window will miss events. BTCPay invoice reconciliation runs as a background poll so dropped *payment* webhooks are recovered.
+- **Hardware fingerprinting is client-supplied.** Keysat does not derive fingerprints itself; the buyer-side SDK passes whatever the integrator chose.
+
+## What Is Unchanged from Upstream
+
+Not applicable — Keysat is authored fresh for Start9 and has no upstream.
+The canonical implementation IS this package + the Rust daemon at
+`licensing-service/`.
+
+## Contributing
+
+For commercial redistribution or resale rights, or to discuss white-label
+deployment, contact `licensing@keysat.xyz`. Source-available license
+terms are in the package's `LICENSE` file: you may run, audit, modify
+for self-hosting; you may not redistribute, resell, or publicly host for
+others.
+
+## YAML Quick Reference
+
+Structured summary for AI consumers and automated package introspection.
+
+```yaml
+service:
+ id: keysat
+ title: Keysat
+ category: bitcoin
+ license: source-available (LicenseRef-Proprietary)
+ marketingUrl: https://keysat.xyz
+image:
+ source: dockerBuild
+ baseImage: scratch (musl-static Rust binary)
+ arches: [x86_64, aarch64]
+volumes:
+ - id: main
+ mountpoint: /data
+ contents: SQLite DB + Ed25519 signing keypair
+network:
+ interfaces:
+ - id: api
+ type: api
+ port: 8080
+ protocol: http
+ pathPrefix: /
+ audience: public
+ - id: webhook
+ type: api
+ port: 8080
+ protocol: http
+ pathPrefix: /btcpay
+ audience: btcpay
+dependencies:
+ btcpayserver:
+ required: true
+ versionRange: ">=1.11.0"
+ kind: running
+healthChecks:
+ - id: api
+ method: portListening
+ port: 8080
+backups:
+ mode: full-volume
+ volumes: [main]
+firstRun:
+ tasks:
+ - id: btcpay-initial-setup
+ severity: critical
+ runs: configureBtcpay
+features:
+ paymentRail: btcpay-server
+ signing: ed25519
+ offlineVerification: true
+ multiSeat: true
+ trialFlag: true
+ expiry: true
+ gracePeriod: true
+ entitlements: true
+ discountCodes: [percent, fixed_sats, free_license]
+ outboundWebhooks: true
+ auditLog: true
+ selfLicensingTier: stub-v0.1
```
-
-(Or copy it from any other 0.4.0.x package you have locally.)
-
-## Installing dependencies
-
-```bash
-npm install
-```
-
-## Building and installing
-
-```bash
-# Build for all supported architectures
-make
-
-# Or just the architecture of your dev StartOS box
-make arm # for Raspberry Pi / Apple Silicon StartOS
-make x86 # for an x86 StartOS server
-
-# Push to your StartOS server (requires the developer key)
-make install
-```
-
-`make install` will prompt for your StartOS password the first time; subsequent installs use the cached session.
-
-## Project layout
-
-This follows the standard 0.4.0.x layout:
-
-```
-keysat-startos/
-├── Dockerfile # multi-stage Rust build
-├── Makefile # delegates to s9pk.mk
-├── s9pk.mk # (fetch from hello-world-startos)
-├── package.json / tsconfig.json
-├── icon.png # 512×512 StartOS tile
-├── assets/
-│ ├── ABOUT.md
-│ └── keysat-thumbnail.png # 1024×1024 marketing hero
-└── startos/
- ├── manifest/index.ts # setupManifest()
- ├── manifest/i18n.ts # descriptions, translatable
- ├── main.ts # daemon definition
- ├── interfaces.ts # network exposure (API on 8080)
- ├── dependencies.ts # requires BTCPay Server
- ├── actions/ # user-facing StartOS buttons
- │ ├── configureBtcpay.ts # one-click BTCPay authorize
- │ ├── createPolicy.ts # reusable license template
- │ ├── createProduct.ts
- │ ├── deactivateMachine.ts # force-kick an install
- │ ├── issueLicense.ts # comp / press keys
- │ ├── listMachines.ts # inspect a license's seats
- │ ├── listWebhooks.ts
- │ ├── registerWebhook.ts # outbound event subscriber
- │ ├── revokeLicense.ts # one-way permanent block
- │ ├── searchLicenses.ts # lost-key recovery
- │ ├── setOperatorName.ts
- │ ├── showCredentials.ts
- │ ├── suspendLicense.ts # reversible lockout
- │ ├── unsuspendLicense.ts
- │ └── viewAuditLog.ts
- ├── fileModels/store.ts # persistent wrapper state
- ├── init/index.ts # first-boot setup
- ├── versions/ # migration history
- │ ├── index.ts
- │ └── v0.1.0.ts
- ├── backups.ts # volume backup declaration
- ├── sdk.ts # manifest-bound SDK instance
- ├── utils.ts # small helpers
- └── index.ts # ties everything together
-```
-
-## Dockerfile notes
-
-The Dockerfile expects the `licensing-service/` source to be available at the parent directory (`..`). The manifest sets `images.main.source.dockerBuild.workdir` to `'..'` so `start-cli s9pk pack` runs `docker build` with the parent `Licensing/` directory as the context — Docker then sees the licensing-service source alongside this wrapper. A `.dockerignore` at the parent level keeps the uploaded context small.
-
-If you're laying out the repositories differently — e.g., separate GitHub repos for service and wrapper — you'll want to add a git submodule or adjust the `workdir`/`COPY` paths accordingly.
-
-## Operator workflow after install
-
-1. Open the service in your StartOS dashboard.
-2. **Set operator name** → your display name, shown on the public homepage.
-3. **Connect BTCPay** → one-click authorize flow. Opens BTCPay's consent page in your browser; after you approve, the daemon auto-detects your store and registers its inbound webhook. No API keys to copy.
-4. **Check BTCPay connection** to confirm the authorize succeeded.
-5. **Create product** once per thing you want to sell.
-6. **Create policy** at least once per product, slugged `default`, to set the shape of keys issued through the public purchase flow (duration, grace period, entitlements, seat cap).
-7. Share the public service URL with buyers. That's enough for the standard purchase flow.
-
-### Customer support
-
-- **Search licenses** — look up a buyer by email, Nostr npub, or BTCPay invoice id.
-- **Suspend license** / **Unsuspend license** — reversible lockout (e.g., for payment disputes).
-- **Revoke license** — permanent, one-way kill.
-- **Issue license manually** — comp / press / grandfathered keys.
-- **List machines** — see which installs are bound to a license.
-- **Deactivate machine** — force-kick a specific install, freeing a seat.
-
-### Integrations & operations
-
-- **Register webhook endpoint** — POST signed event notifications to an HTTPS URL you control (license.issued, license.revoked, machine.activated, etc.). HMAC-SHA256 in `X-Keysat-Signature: sha256=`.
-- **List webhook endpoints** — see what's subscribed.
-- **View audit log** — most recent admin mutations, filterable by action slug. Useful for compliance and debugging.
-- **Show admin API key** — only needed if you want to script against `/v1/admin/*` from outside the box; every built-in action already carries the key for you.
-
-## Limitations in v0.1
-
-- No in-dashboard list view for invoices/products/licenses — use `/v1/admin/...` via the admin API key if you need a bulk view beyond what the built-in actions surface.
-- Webhook delivery retries are bounded; if a subscriber is down past the retry window, the event is dropped. Invoice reconciliation runs as a background task so dropped BTCPay webhooks get replayed.
diff --git a/icon.png b/icon.png
index c9a1355..d930b67 100644
Binary files a/icon.png and b/icon.png differ
diff --git a/licensing-service/.env.example b/licensing-service/.env.example
new file mode 100644
index 0000000..08e3a15
--- /dev/null
+++ b/licensing-service/.env.example
@@ -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
diff --git a/licensing-service/.gitignore b/licensing-service/.gitignore
new file mode 100644
index 0000000..cca89df
--- /dev/null
+++ b/licensing-service/.gitignore
@@ -0,0 +1,7 @@
+/target
+/data
+.env
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
diff --git a/licensing-service/Cargo.toml b/licensing-service/Cargo.toml
new file mode 100644
index 0000000..83d211d
--- /dev/null
+++ b/licensing-service/Cargo.toml
@@ -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
diff --git a/licensing-service/LICENSE b/licensing-service/LICENSE
new file mode 100644
index 0000000..274ff6b
--- /dev/null
+++ b/licensing-service/LICENSE
@@ -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.
diff --git a/licensing-service/README.md b/licensing-service/README.md
new file mode 100644
index 0000000..c9ec601
--- /dev/null
+++ b/licensing-service/README.md
@@ -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/
+# → { "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.
diff --git a/licensing-service/docs/API.md b/licensing-service/docs/API.md
new file mode 100644
index 0000000..0a9be62
--- /dev/null
+++ b/licensing-service/docs/API.md
@@ -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.
diff --git a/licensing-service/docs/ARCHITECTURE.md b/licensing-service/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..97d0dd4
--- /dev/null
+++ b/licensing-service/docs/ARCHITECTURE.md
@@ -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 - -
+```
+
+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.
diff --git a/licensing-service/docs/INTEGRATION.md b/licensing-service/docs/INTEGRATION.md
new file mode 100644
index 0000000..5251239
--- /dev/null
+++ b/licensing-service/docs/INTEGRATION.md
@@ -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 {
+ #[derive(serde::Deserialize)]
+ struct Resp {
+ ok: bool,
+ reason: Option,
+ license_id: Option,
+ product_id: Option,
+ }
+
+ 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 {
+ 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 {
+ 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.
diff --git a/licensing-service/migrations/0001_initial.sql b/licensing-service/migrations/0001_initial.sql
new file mode 100644
index 0000000..b4a0be6
--- /dev/null
+++ b/licensing-service/migrations/0001_initial.sql
@@ -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
+);
diff --git a/licensing-service/migrations/0002_btcpay_config.sql b/licensing-service/migrations/0002_btcpay_config.sql
new file mode 100644
index 0000000..bce4a4f
--- /dev/null
+++ b/licensing-service/migrations/0002_btcpay_config.sql
@@ -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);
diff --git a/licensing-service/migrations/0003_expanded_features.sql b/licensing-service/migrations/0003_expanded_features.sql
new file mode 100644
index 0000000..393cd45
--- /dev/null
+++ b/licensing-service/migrations/0003_expanded_features.sql
@@ -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"
diff --git a/licensing-service/migrations/0004_discount_codes.sql b/licensing-service/migrations/0004_discount_codes.sql
new file mode 100644
index 0000000..f1339fd
--- /dev/null
+++ b/licensing-service/migrations/0004_discount_codes.sql
@@ -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);
diff --git a/licensing-service/migrations/0005_settings.sql b/licensing-service/migrations/0005_settings.sql
new file mode 100644
index 0000000..c91a8de
--- /dev/null
+++ b/licensing-service/migrations/0005_settings.sql
@@ -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
+);
diff --git a/licensing-service/migrations/0006_policy_tip_recipient.sql b/licensing-service/migrations/0006_policy_tip_recipient.sql
new file mode 100644
index 0000000..e030394
--- /dev/null
+++ b/licensing-service/migrations/0006_policy_tip_recipient.sql
@@ -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/ 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);
diff --git a/licensing-service/src/api/admin.rs b/licensing-service/src/api/admin.rs
new file mode 100644
index 0000000..443a9b4
--- /dev/null
+++ b/licensing-service/src/api/admin.rs
@@ -0,0 +1,566 @@
+//! Admin endpoints — all require `Authorization: Bearer `.
+//! 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 {
+ 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, Option) {
+ 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,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ pub nostr_npub: Option,
+ pub invoice_id: Option,
+}
+
+/// 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,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ /// Optional reason for audit — e.g. "comp", "press", "giveaway".
+ #[serde(default)]
+ pub note: Option,
+ /// Override expiry (ISO-8601 UTC). Ignored if `policy_slug` is set.
+ #[serde(default)]
+ pub expires_at: Option,
+ /// Override entitlements. Ignored if `policy_slug` is set.
+ #[serde(default)]
+ pub entitlements: Option>,
+ #[serde(default)]
+ pub max_machines: Option,
+ #[serde(default)]
+ pub grace_seconds: Option,
+ #[serde(default)]
+ pub is_trial: Option,
+ #[serde(default)]
+ pub buyer_email: Option,
+ #[serde(default)]
+ pub nostr_npub: Option,
+}
+
+#[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,
+ pub entitlements: Vec,
+ 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,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(license_id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(license_id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(license_id): Path,
+) -> AppResult> {
+ 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,
+}
+
+fn default_audit_limit() -> i64 {
+ 200
+}
+
+pub async fn list_audit(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+) -> AppResult> {
+ 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,
+ })))
+}
diff --git a/licensing-service/src/api/admin_ui.rs b/licensing-service/src/api/admin_ui.rs
new file mode 100644
index 0000000..ddceebc
--- /dev/null
+++ b/licensing-service/src/api/admin_ui.rs
@@ -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(),
+ }
+}
diff --git a/licensing-service/src/api/btcpay_authorize.rs b/licensing-service/src/api/btcpay_authorize.rs
new file mode 100644
index 0000000..b0859fd
--- /dev/null
+++ b/licensing-service/src/api/btcpay_authorize.rs
@@ -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` 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,
+ headers: HeaderMap,
+) -> AppResult> {
+ 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::>()
+ .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,
+ // 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,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CallbackQuery {
+ pub state: String,
+}
+
+/// The real callback endpoint — POST form-encoded.
+pub async fn callback(
+ State(state): State,
+ Query(q): Query,
+ Form(form): Form,
+) -> AppResult {
+ 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,
+ /// Error message if BTCPay declined / operator clicked "Deny".
+ pub error: Option,
+}
+
+pub async fn callback_get(
+ State(state): State,
+ Query(q): Query,
+) -> Response {
+ if let Some(err) = q.error {
+ return Html(format!(
+ "
BTCPay authorization failed
{}
",
+ 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!(
+ "
BTCPay authorization failed
{}
",
+ 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,
+ headers: HeaderMap,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+) -> AppResult> {
+ 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#"BTCPay connected
+
+
✓ {msg}
"#,
+ 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,
+ headers: HeaderMap,
+) -> AppResult> {
+ 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 = 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,
+ })))
+}
diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs
new file mode 100644
index 0000000..d5249d6
--- /dev/null
+++ b/licensing-service/src/api/buy_page.rs
@@ -0,0 +1,640 @@
+//! Public buyer-facing purchase page at `GET /buy/:slug`.
+//!
+//! The flow is:
+//! 1. Buyer hits `https:///buy/` 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,
+ Path(slug): Path,
+) -> Result, (StatusCode, Html)> {
+ // 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#"
+
+
+
+
+Buy {product_name} — {operator}
+
+
+
+
+
+
+
+ Keysat
+ Sold by {operator}
+
+
+
+
+
Buy a license
+
{product_name}
+
{product_slug}
+
{product_description}
+
+
+
Price
+
+
+ {price_sats_fmt}sats
+
+
+
+
+
+
+
+
— License issued —
+
You’re licensed.
+
No payment needed for this code. Your signed license is below.
+
License key
+
+ …
+
+
+
+ Save this somewhere safe. The license key is signed at issue time and verifies offline. We’ll also send a copy to for your records.
+
No product is registered under the slug {slug_safe}, or it’s currently inactive.
+
If you arrived here from a link the seller shared, double-check that you’ve typed the URL correctly. Otherwise, ask the seller to confirm the product slug.
+"#,
+ slug_safe = slug_safe
+ )
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+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()
+}
diff --git a/licensing-service/src/api/discount_codes.rs b/licensing-service/src/api/discount_codes.rs
new file mode 100644
index 0000000..b295692
--- /dev/null
+++ b/licensing-service/src/api/discount_codes.rs
@@ -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,
+ /// ISO-8601 RFC3339 UTC timestamp.
+ #[serde(default)]
+ pub expires_at: Option,
+ /// Restrict to a single product (by slug). Omit for any product.
+ #[serde(default)]
+ pub product_slug: Option,
+ /// 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,
+ /// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
+ #[serde(default)]
+ pub referrer_label: Option,
+ #[serde(default)]
+ pub description: String,
+}
+
+pub async fn create(
+ State(state): State,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+) -> AppResult> {
+ 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,
+ Query(q): Query,
+) -> AppResult> {
+ 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(),
+ },
+ })))
+}
diff --git a/licensing-service/src/api/issuer_key.rs b/licensing-service/src/api/issuer_key.rs
new file mode 100644
index 0000000..2447a8a
--- /dev/null
+++ b/licensing-service/src/api/issuer_key.rs
@@ -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,
+ headers: HeaderMap,
+ body: Bytes,
+) -> AppResult> {
+ 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."
+ })))
+}
diff --git a/licensing-service/src/api/machines.rs b/licensing-service/src/api/machines.rs
new file mode 100644
index 0000000..bbad24b
--- /dev/null
+++ b/licensing-service/src/api/machines.rs
@@ -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,
+ pub platform: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ActivateResp {
+ pub ok: bool,
+ pub machine_id: Option,
+ pub active_count: i64,
+ pub max_machines: i64,
+ pub reason: Option,
+}
+
+pub async fn activate(
+ State(state): State,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+}
+
+pub async fn deactivate(
+ State(state): State,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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 })))
+}
diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs
new file mode 100644
index 0000000..4589a95
--- /dev/null
+++ b/licensing-service/src/api/mod.rs
@@ -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/` | 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,
+ /// Active payment provider (BTCPay today, Zaprite eventually).
+ /// `None` until the operator completes a connect flow. Stored as
+ /// `Arc` so call sites get cheap clones; swapped under a
+ /// write lock when the operator runs Connect / Disconnect.
+ pub payment: Arc>>>,
+ pub config: Arc,
+ /// Keysat-licenses-Keysat tier. Read at boot, swapped when the
+ /// operator activates a fresh license via the admin endpoint.
+ pub self_tier: Arc>,
+}
+
+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> {
+ 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 {
+ let guard = self.payment.read().await;
+ let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
+ provider
+ .as_any()
+ .downcast_ref::()
+ .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 {
+ let guard = self.payment.read().await;
+ let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
+ provider
+ .as_any()
+ .downcast_ref::()
+ .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,
+ ) {
+ 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 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,
+) -> Json {
+ // 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 {
+ Json(json!({ "ok": true }))
+}
+
+/// HTML "thank you" landing page that BTCPay redirects buyers to after a
+/// settled invoice. Reads `?invoice_id=` from the query string,
+/// renders a Keysat-branded polling page that calls
+/// /v1/purchase/ 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,
+ axum::extract::Query(params): axum::extract::Query>,
+) -> axum::response::Html {
+ 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#"
+
+
+
+
+Payment received — {operator}
+
+
+
+
+
+
+
+ Keysat
+ Sold by {operator}
+
+
+
+
+
Payment received
+
Issuing your license…
+
Your Bitcoin payment was received. We’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.
+
+
+
+
— Awaiting confirmation —
+
Hang tight.
+
This page will refresh automatically when your license is ready.
+
+
checking status…
+
+
+
+
+
— License issued —
+
You’re licensed.
+
Your signed license is below. We’ll also email a copy.
+
License key
+
+ …
+
+
+
+ Save this somewhere safe. The key is signed at issue time and verifies offline against the seller’s public key. You don’t need to come back here.
+
+
+
+
+
+
Something went wrong looking up this purchase.
+
+
+
+
+
+
+
+
+"#
+ );
+ 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 {
+ '&' => "&".to_string(),
+ '<' => "<".to_string(),
+ '>' => ">".to_string(),
+ '"' => """.to_string(),
+ '\'' => "'".to_string(),
+ _ => c.to_string(),
+ })
+ .collect()
+}
+
+async fn pubkey(
+ axum::extract::State(state): axum::extract::State,
+) -> Json {
+ Json(json!({
+ "algorithm": "ed25519",
+ "public_key_pem": state.keypair.public_key_pem,
+ }))
+}
diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs
new file mode 100644
index 0000000..25f078c
--- /dev/null
+++ b/licensing-service/src/api/policies.rs
@@ -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,
+ #[serde(default)]
+ pub entitlements: Vec,
+ #[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,
+ /// 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,
+}
+
+fn default_max_machines() -> i64 {
+ 1
+}
+
+pub async fn create(
+ State(state): State,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ /// Basis points: 0–10000. 0 = disabled.
+ pub tip_pct_bps: i64,
+ /// Optional free-form label (audit / UI).
+ #[serde(default)]
+ pub tip_label: Option,
+}
+
+pub async fn set_tip(
+ State(state): State,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ #[serde(default)]
+ pub recipient: Option,
+ #[serde(default = "default_tip_limit")]
+ pub limit: i64,
+}
+
+fn default_tip_limit() -> i64 {
+ 100
+}
+
+pub async fn list_tips(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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 })))
+}
diff --git a/licensing-service/src/api/products.rs b/licensing-service/src/api/products.rs
new file mode 100644
index 0000000..0170ccc
--- /dev/null
+++ b/licensing-service/src/api/products.rs
@@ -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) -> AppResult> {
+ let products = repo::list_products(&state.db, true).await?;
+ Ok(Json(json!({ "products": products })))
+}
+
+pub async fn get(
+ State(state): State,
+ Path(slug): Path,
+) -> AppResult> {
+ let product = repo::get_product_by_slug(&state.db, &slug)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
+ Ok(Json(json!(product)))
+}
diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs
new file mode 100644
index 0000000..a60b526
--- /dev/null
+++ b/licensing-service/src/api/purchase.rs
@@ -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,
+ /// Optional free-text note from the buyer.
+ pub buyer_note: Option,
+ /// Optional URL the buyer should be returned to after payment.
+ pub redirect_url: Option,
+ /// Optional discount / referral code (case-insensitive).
+ pub code: Option,
+}
+
+#[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,
+ Json(req): Json,
+) -> AppResult> {
+ 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/ 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/ and
+ // /thank-you?invoice_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,
+ Path(invoice_id): Path,
+) -> AppResult> {
+ 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()),
+ })))
+}
diff --git a/licensing-service/src/api/redeem.rs b/licensing-service/src/api/redeem.rs
new file mode 100644
index 0000000..9520b5e
--- /dev/null
+++ b/licensing-service/src/api/redeem.rs
@@ -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,
+ /// Optional free-text note (recorded on invoice).
+ pub buyer_note: Option,
+}
+
+#[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,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ }))
+}
diff --git a/licensing-service/src/api/self_license.rs b/licensing-service/src/api/self_license.rs
new file mode 100644
index 0000000..1f68469
--- /dev/null
+++ b/licensing-service/src/api/self_license.rs
@@ -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,
+ 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) -> Json {
+ 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,
+ Json(body): Json,
+) -> AppResult {
+ 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())
+}
diff --git a/licensing-service/src/api/validate.rs b/licensing-service/src/api/validate.rs
new file mode 100644
index 0000000..863cfc7
--- /dev/null
+++ b/licensing-service/src/api/validate.rs
@@ -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,
+ /// 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,
+ /// Optional client-supplied hostname for machine records.
+ pub hostname: Option,
+ /// Optional client-supplied platform descriptor.
+ pub platform: Option,
+}
+
+#[derive(Debug, Serialize, Default)]
+pub struct ValidateResp {
+ pub ok: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub product_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub product_slug: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub issued_at: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub expires_at: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub grace_until: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub in_grace_period: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub is_trial: Option,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
+ pub entitlements: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub status: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub machine_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub max_machines: Option,
+}
+
+fn reject(reason: &str) -> ValidateResp {
+ ValidateResp {
+ ok: false,
+ reason: Some(reason.to_string()),
+ ..Default::default()
+ }
+}
+
+pub async fn validate(
+ State(state): State,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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::()
+ });
+ 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 = 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 = 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),
+ }))
+}
diff --git a/licensing-service/src/api/webhook.rs b/licensing-service/src/api/webhook.rs
new file mode 100644
index 0000000..12d65e4
--- /dev/null
+++ b/licensing-service/src/api/webhook.rs
@@ -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,
+ headers: HeaderMap,
+ body: Bytes,
+) -> AppResult {
+ // 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 {
+ // 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
+ }
+}
diff --git a/licensing-service/src/api/webhook_endpoints.rs b/licensing-service/src/api/webhook_endpoints.rs
new file mode 100644
index 0000000..7ddb8ca
--- /dev/null
+++ b/licensing-service/src/api/webhook_endpoints.rs
@@ -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,
+ #[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,
+}
+
+fn default_event_types() -> Vec {
+ vec!["*".to_string()]
+}
+
+pub async fn create(
+ State(state): State,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path,
+ Json(req): Json,
+) -> AppResult> {
+ 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,
+ headers: HeaderMap,
+ Path(id): Path