v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -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/
|
||||
+22
@@ -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
|
||||
|
||||
+21
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,128 +1,282 @@
|
||||
# keysat-startos
|
||||
<p align="center">
|
||||
<img src="icon.png" alt="Keysat" width="128" />
|
||||
</p>
|
||||
|
||||
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`.
|
||||
<h1 align="center">Keysat</h1>
|
||||
|
||||
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**.
|
||||
<p align="center">
|
||||
Self-hosted, Bitcoin-paid software-licensing service for Start9.
|
||||
</p>
|
||||
|
||||
## 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=<hex>`.
|
||||
- **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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
/data
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Keysat
|
||||
|
||||
**Keysat** is a self-hosted Bitcoin-paid software licensing server, designed to run as a [Start9](https://start9.com) 0.4.0.x service alongside [BTCPay Server](https://btcpayserver.org). One instance can sell, issue, validate, and revoke licenses for any number of software products you own.
|
||||
|
||||
> The repository directory is still called `licensing-service/` on disk for continuity with earlier revisions. The crate, the binary, the StartOS package id, and all user-visible strings use **Keysat**.
|
||||
|
||||
Every developer who uses this runs their own instance on their own hardware. There is no central authority, no shared database, and no dependency on anyone else's servers. Your keys, your products, your customers, your rules.
|
||||
|
||||
## What it does
|
||||
|
||||
- Exposes a REST API for selling and managing software licenses paid for in Bitcoin via BTCPay Server.
|
||||
- Issues **Ed25519-signed license keys** that can be verified offline by any client with your server's public key — so downstream software doesn't break if your licensing server is briefly unreachable.
|
||||
- Supports multiple products per instance, each with independent pricing and license pools.
|
||||
- Supports closed-source, open-source-for-convenience, and open-core distribution models. The service doesn't care how you distribute source; it only validates keys against products.
|
||||
- Optional per-license machine fingerprint binding with trust-on-first-use.
|
||||
- Admin-gated endpoints for product management, manual license issuance (comps/press/testing), and revocation.
|
||||
|
||||
## Architecture in two minutes
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
|
||||
│ Buyer's │──────▶│ licensing-service │──────▶│ BTCPay Server│
|
||||
│ browser │ │ (this program) │ │ (Start9) │
|
||||
└──────────────┘ └──────────────────────┘ └──────────────┘
|
||||
▲ │ ▲ │
|
||||
│ license key │ │ webhook │
|
||||
│ ▼ │ │
|
||||
│ ┌──────────────┐ │
|
||||
└─────────────────│ SQLite │◀──────────────────┘
|
||||
poll/status │ licensing.db
|
||||
└──────────────┘
|
||||
|
||||
Downstream software (e.g. another Start9 package you sell):
|
||||
on startup → POST /v1/validate { key, product_slug, fingerprint }
|
||||
→ caches result, re-checks on reasonable cadence
|
||||
```
|
||||
|
||||
1. Buyer `POST /v1/purchase { product: "my-app" }` → we create a BTCPay invoice, return its checkout URL.
|
||||
2. Buyer pays via BTCPay. BTCPay fires a signed webhook at `POST /v1/btcpay/webhook` → we mark the invoice settled and issue a license row.
|
||||
3. Buyer polls `GET /v1/purchase/:invoice_id` → once settled, response contains the signed `license_key` string.
|
||||
4. Buyer installs the software. On startup the software calls `POST /v1/validate` to check revocation and bind itself to the installation.
|
||||
|
||||
## Why Ed25519-signed keys
|
||||
|
||||
Each license key is a compact, cryptographically signed envelope:
|
||||
|
||||
```
|
||||
LIC1-<74-byte payload, base32>-<64-byte signature, base32>
|
||||
```
|
||||
|
||||
The payload contains the product id, license id, issue time, an optional fingerprint hash, and a version byte. The server's private key signs it; anyone with the public key can verify it.
|
||||
|
||||
The practical benefit: downstream software can verify a key's signature **offline**, using a public key bundled at compile time. It only needs to reach your licensing server to check revocation, and it can cache that check. If your licensing server has an outage, existing installations keep working. If someone tries to forge a key, the signature fails instantly without a database lookup.
|
||||
|
||||
See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
licensing-service/
|
||||
├── Cargo.toml
|
||||
├── LICENSE # source-available; no redistribution
|
||||
├── README.md
|
||||
├── .env.example # required env vars
|
||||
├── migrations/
|
||||
│ └── 0001_initial.sql # SQLite schema
|
||||
├── src/
|
||||
│ ├── main.rs # entry point: wires everything
|
||||
│ ├── config.rs # env-driven config
|
||||
│ ├── error.rs # unified error → HTTP mapping
|
||||
│ ├── models.rs # shared domain types
|
||||
│ ├── crypto/
|
||||
│ │ ├── mod.rs # license key format + sign/verify
|
||||
│ │ └── keys.rs # server keypair lifecycle
|
||||
│ ├── db/
|
||||
│ │ ├── mod.rs # pool + migrations
|
||||
│ │ └── repo.rs # all SQL queries
|
||||
│ ├── btcpay/
|
||||
│ │ ├── client.rs # Greenfield API client
|
||||
│ │ └── webhook.rs # HMAC verification + event parsing
|
||||
│ └── api/
|
||||
│ ├── mod.rs # router + AppState
|
||||
│ ├── products.rs # public product endpoints
|
||||
│ ├── purchase.rs # buy + poll
|
||||
│ ├── validate.rs # the hot path for downstream software
|
||||
│ ├── webhook.rs # BTCPay landing
|
||||
│ └── admin.rs # operator-only actions
|
||||
└── docs/
|
||||
├── API.md # full endpoint reference
|
||||
├── INTEGRATION.md # for developers embedding a client
|
||||
└── ARCHITECTURE.md # deeper design notes
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env — generate admin key with: openssl rand -hex 32
|
||||
# fill in BTCPay URL, API key, store id, webhook secret
|
||||
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
On first boot the server generates a fresh Ed25519 keypair and stores it in the SQLite database. Get the public key anytime from `GET /v1/pubkey` (or from the logs on first boot).
|
||||
|
||||
### Creating your first product
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/admin/products \
|
||||
-H "Authorization: Bearer $LICENSING_ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"slug": "my-app",
|
||||
"name": "My App",
|
||||
"description": "A cool Start9 service.",
|
||||
"price_sats": 50000
|
||||
}'
|
||||
```
|
||||
|
||||
### Walking through a purchase
|
||||
|
||||
```bash
|
||||
# 1. Buyer starts a purchase
|
||||
curl -X POST http://localhost:8080/v1/purchase \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"product": "my-app"}'
|
||||
# → { "invoice_id": "...", "checkout_url": "https://btcpay.../i/...", ... }
|
||||
|
||||
# 2. Buyer opens checkout_url, pays
|
||||
|
||||
# 3. Buyer polls
|
||||
curl http://localhost:8080/v1/purchase/<invoice_id>
|
||||
# → { "status": "settled", "license_key": "LIC1-...", ... }
|
||||
|
||||
# 4. Downstream software validates the key
|
||||
curl -X POST http://localhost:8080/v1/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "LIC1-...", "product_slug": "my-app", "fingerprint": "host-abc123"}'
|
||||
# → { "ok": true, "license_id": "...", "product_id": "..." }
|
||||
```
|
||||
|
||||
## Deploying on Start9
|
||||
|
||||
This repository ships the service only. To package as an `.s9pk` for the 0.4.0.x platform you'll need a separate wrapper repository following [docs.start9.com/packaging/0.4.0.x](https://docs.start9.com/packaging/0.4.0.x/). The service is designed to slot in cleanly:
|
||||
|
||||
- **Declares a dependency** on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a `.startos` hostname and supply the env vars from the wrapper's action handlers.
|
||||
- **Persists to `/data`**, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups.
|
||||
- **Binds to `0.0.0.0:8080`** and expects StartOS to handle Tor/LAN/clearnet exposure.
|
||||
- **Graceful shutdown** on SIGTERM, as StartOS expects.
|
||||
- **Environment-driven config**, no config files needed at runtime.
|
||||
|
||||
When you're ready to write the manifest, the env vars you need to wire are listed in `.env.example`. The main gotcha is the BTCPay webhook secret: you configure it on the BTCPay side and it must match `BTCPAY_WEBHOOK_SECRET` exactly — we verify HMAC-SHA256 in constant time and reject any mismatch.
|
||||
|
||||
## Developer integration
|
||||
|
||||
If you're a developer shipping software that should validate against a licensing-service instance, see [`docs/INTEGRATION.md`](docs/INTEGRATION.md). It covers:
|
||||
|
||||
- Bundling the server's public key in your client.
|
||||
- Offline signature verification + online revocation check.
|
||||
- Graceful handling of server outages (don't brick your users).
|
||||
- Recommended caching and rate-limiting patterns.
|
||||
|
||||
## Source-available licensing
|
||||
|
||||
This project is source-available, not open source. You may read, audit, self-host, and modify for your own use, but may not redistribute, resell, or publicly host for others. See [LICENSE](LICENSE) for the full terms.
|
||||
|
||||
Commercial redistribution / resale rights: contact licensing@keysat.xyz.
|
||||
|
||||
## Status
|
||||
|
||||
v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Architecture notes
|
||||
|
||||
## Design principles
|
||||
|
||||
**Decentralized by default.** Every licensing-service instance is independent. No phoning home, no shared state. If we vanish, every developer using this keeps running their own.
|
||||
|
||||
**Cryptography before databases.** A license key carries its own proof of legitimacy via an Ed25519 signature. The database is the authority on revocation and binding, but not on authenticity. This means downstream software doesn't break when your server has an outage.
|
||||
|
||||
**Idempotent webhooks.** BTCPay may retry a webhook. Settlement logic is designed so duplicate webhooks can't duplicate licenses (uniqueness enforced at the `licenses.invoice_id` column plus an existence check).
|
||||
|
||||
**Operator-owned secrets.** The signing key lives in SQLite and is covered by StartOS encrypted backups. The admin API key is env-driven and never logged. BTCPay credentials are env-driven. No secrets in git, no secrets in code.
|
||||
|
||||
## Data model
|
||||
|
||||
See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables:
|
||||
|
||||
- `products` — what's for sale. Independent pricing per product.
|
||||
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
|
||||
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns.
|
||||
- `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us.
|
||||
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot, never rotated in v0.1 (rotation is a planned feature).
|
||||
|
||||
## License key format
|
||||
|
||||
```
|
||||
LIC1 - <base32(74-byte payload)> - <base32(64-byte signature)>
|
||||
```
|
||||
|
||||
The payload is a fixed binary layout, not JSON, to keep keys short. Details in [`src/crypto/mod.rs`](../src/crypto/mod.rs).
|
||||
|
||||
Why base32 Crockford-style (no padding)?
|
||||
|
||||
- Uppercase only, unambiguous chars, easy to read aloud or type from a screen.
|
||||
- Slightly longer than base64 but less error-prone for humans copying keys.
|
||||
- Case-insensitive accept means users don't get mysteriously rejected keys.
|
||||
|
||||
Why include `issued_at` in the signed payload?
|
||||
|
||||
- Lets SDKs reject keys issued before a known revocation epoch without contacting the server (future feature).
|
||||
- Lets admins spot anomalies in key-age distribution when investigating abuse.
|
||||
|
||||
Why optional `fingerprint_hash` *inside the signature*?
|
||||
|
||||
- If set, the key is cryptographically useless on any other machine even if DB state is somehow lost. Belt-and-suspenders.
|
||||
- Not required — most commercial licenses use trust-on-first-use via the DB column instead, because hard binding breaks legitimate hardware upgrades.
|
||||
|
||||
## Threat model
|
||||
|
||||
Who might attack this?
|
||||
|
||||
1. **Pirate trying to use software without paying.** Must present a valid signed key. Can't mint one without the server's private key. Can't replay a key across machines if fingerprint-bound. Can't modify a revoked key into a fresh one without breaking the signature.
|
||||
|
||||
2. **Someone who compromises the licensing server.** Can mint keys, revoke keys, read the DB. That's the intended failure mode — the server is the trust root. Mitigations: run on a hardened StartOS instance, use encrypted backups, don't expose admin endpoints to the clearnet (use LAN-only or Tor-only exposure in the manifest).
|
||||
|
||||
3. **Someone MITM-ing the /v1/validate call.** Can't forge successful responses because legitimate clients also did offline signature verification first. Can serve stale "revoked" responses — denial of service at worst, not a bypass.
|
||||
|
||||
4. **BTCPay webhook spoofer.** Must know the shared HMAC secret. We verify in constant time and reject bad signatures with 401.
|
||||
|
||||
5. **Chargeback / dispute** (applicable to non-Bitcoin rails, but worth noting). Bitcoin payments are irreversible, so the normal fraud model that motivates software DRM mostly doesn't apply here. Most revocations will be: key leaked publicly, legitimate business decision, mistaken issuance.
|
||||
|
||||
## What's deliberately NOT in v0.1
|
||||
|
||||
- **Key rotation.** A single static signing key is fine for first launch. Rotation requires SDK multi-key support and a migration strategy; deferred.
|
||||
- **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app.
|
||||
- **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured).
|
||||
- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change.
|
||||
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
|
||||
- **Admin UI.** Everything is API-driven. Wrap it in whatever UI you like — or just use `curl`.
|
||||
|
||||
## Notes on Start9 dependencies
|
||||
|
||||
When you write the s9pk manifest, `btcpayserver` is a declared dependency. StartOS resolves it to a `.startos` hostname that only works on the same server. If you ever want to run licensing-service pointing at a *remote* BTCPay, you can override `BTCPAY_URL` — the client is a plain HTTPS client, not bound to the StartOS mesh.
|
||||
|
||||
For webhooks going the other way (BTCPay → licensing), the webhook URL BTCPay calls will be your licensing service's `.local` or `.onion` hostname. Same-server Tor hop works fine.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Developer integration guide
|
||||
|
||||
This guide is for developers who want their software to validate against a licensing-service instance. It doesn't matter whether your software is a Start9 package, a desktop app, or a server — the flow is the same.
|
||||
|
||||
## Core idea: two-phase validation
|
||||
|
||||
Licensing-service separates verification into two concerns:
|
||||
|
||||
1. **Signature verification** (offline, fast, deterministic) — prove the key was actually issued by the server. Needs only the server's Ed25519 public key, which you ship with your client.
|
||||
2. **Revocation check** (online, authoritative) — confirm the server hasn't revoked the license. Requires a network call.
|
||||
|
||||
For most software, you should do both on startup, then **cache the revocation result** for some period (hours to a day) and fall back to the cached result if the server is briefly unreachable. That way:
|
||||
|
||||
- A bad or forged key is rejected instantly, without a network call.
|
||||
- A legitimately paying user isn't locked out if the licensing server has a 10-minute hiccup.
|
||||
- A revoked key is detected within your cache window.
|
||||
|
||||
## Bundling the public key
|
||||
|
||||
When you set up your licensing-service instance, fetch the public key once:
|
||||
|
||||
```bash
|
||||
curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem
|
||||
```
|
||||
|
||||
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
|
||||
|
||||
## Reference integration in Rust
|
||||
|
||||
This is what a Start9 package written in Rust might look like. No SDK crate yet — that's planned; here's what you'd write by hand:
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::pkcs8::DecodePublicKey;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
|
||||
// Pinned at compile time from the licensing server's /v1/pubkey output.
|
||||
const SERVER_PUBLIC_KEY_PEM: &str = r#"
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA...your-public-key...
|
||||
-----END PUBLIC KEY-----
|
||||
"#;
|
||||
|
||||
const LICENSING_URL: &str = "https://license.example.com";
|
||||
const PRODUCT_SLUG: &str = "my-app";
|
||||
|
||||
pub struct LicenseCheck {
|
||||
pub license_id: String,
|
||||
pub product_id: String,
|
||||
}
|
||||
|
||||
pub fn offline_verify(license_key: &str) -> Result<()> {
|
||||
let vk = VerifyingKey::from_public_key_pem(SERVER_PUBLIC_KEY_PEM)
|
||||
.context("bundled public key is invalid")?;
|
||||
|
||||
let mut parts = license_key.trim().splitn(3, '-');
|
||||
let prefix = parts.next().context("empty key")?;
|
||||
anyhow::ensure!(prefix == "LIC1", "unknown key prefix");
|
||||
let payload_b32 = parts.next().context("no payload")?;
|
||||
let sig_b32 = parts.next().context("no signature")?;
|
||||
|
||||
let payload = BASE32_NOPAD.decode(payload_b32.to_ascii_uppercase().as_bytes())?;
|
||||
let sig_bytes = BASE32_NOPAD.decode(sig_b32.to_ascii_uppercase().as_bytes())?;
|
||||
let sig_array: [u8; 64] = sig_bytes.as_slice().try_into()
|
||||
.context("signature length != 64")?;
|
||||
let sig = Signature::from_bytes(&sig_array);
|
||||
|
||||
vk.verify(&payload, &sig).context("signature invalid")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_online(
|
||||
license_key: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<LicenseCheck> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Resp {
|
||||
ok: bool,
|
||||
reason: Option<String>,
|
||||
license_id: Option<String>,
|
||||
product_id: Option<String>,
|
||||
}
|
||||
|
||||
let resp: Resp = reqwest::Client::new()
|
||||
.post(format!("{LICENSING_URL}/v1/validate"))
|
||||
.json(&serde_json::json!({
|
||||
"key": license_key,
|
||||
"product_slug": PRODUCT_SLUG,
|
||||
"fingerprint": fingerprint,
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if !resp.ok {
|
||||
anyhow::bail!("license rejected: {}", resp.reason.unwrap_or_default());
|
||||
}
|
||||
Ok(LicenseCheck {
|
||||
license_id: resp.license_id.unwrap(),
|
||||
product_id: resp.product_id.unwrap(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Reference integration in TypeScript
|
||||
|
||||
```ts
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
const SERVER_PUBLIC_KEY_PEM = `
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA...your-public-key...
|
||||
-----END PUBLIC KEY-----
|
||||
`;
|
||||
const LICENSING_URL = "https://license.example.com";
|
||||
const PRODUCT_SLUG = "my-app";
|
||||
|
||||
function base32NoPadDecode(s: string): Uint8Array {
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const out: number[] = [];
|
||||
let bits = 0, value = 0;
|
||||
for (const c of s.toUpperCase()) {
|
||||
const idx = ALPHABET.indexOf(c);
|
||||
if (idx < 0) throw new Error("bad base32 char: " + c);
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
async function importPubKey(): Promise<CryptoKey> {
|
||||
const pem = SERVER_PUBLIC_KEY_PEM
|
||||
.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
const der = Uint8Array.from(Buffer.from(pem, "base64"));
|
||||
return webcrypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
|
||||
}
|
||||
|
||||
export async function offlineVerify(key: string): Promise<void> {
|
||||
const [prefix, payloadB32, sigB32] = key.trim().split("-");
|
||||
if (prefix !== "LIC1") throw new Error("bad prefix");
|
||||
const payload = base32NoPadDecode(payloadB32);
|
||||
const sig = base32NoPadDecode(sigB32);
|
||||
const pk = await importPubKey();
|
||||
const ok = await webcrypto.subtle.verify("Ed25519", pk, sig, payload);
|
||||
if (!ok) throw new Error("signature invalid");
|
||||
}
|
||||
|
||||
export async function validateOnline(key: string, fingerprint: string) {
|
||||
const r = await fetch(`${LICENSING_URL}/v1/validate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ key, product_slug: PRODUCT_SLUG, fingerprint }),
|
||||
});
|
||||
const body = await r.json();
|
||||
if (!body.ok) throw new Error(`license rejected: ${body.reason}`);
|
||||
return body;
|
||||
}
|
||||
```
|
||||
|
||||
## Graceful degradation pattern
|
||||
|
||||
```
|
||||
on startup:
|
||||
key = read_license_from_storage()
|
||||
if key is None:
|
||||
prompt_user_for_license_or_start_trial()
|
||||
return
|
||||
|
||||
try offline_verify(key) # instant; fail closed on bad signature
|
||||
except BadSignature:
|
||||
mark_installation_unlicensed()
|
||||
return
|
||||
|
||||
try online_validate(key, fingerprint)
|
||||
except NetworkError:
|
||||
cached = read_cache()
|
||||
if cached is valid and < 7 days old:
|
||||
proceed()
|
||||
else:
|
||||
warn_user("licensing server unreachable for > 7 days")
|
||||
proceed() # or refuse, if you prefer strict
|
||||
except Rejected(reason):
|
||||
handle_rejection(reason)
|
||||
|
||||
on every N hours in background:
|
||||
re-run online_validate, refresh cache
|
||||
```
|
||||
|
||||
Choosing the cache TTL is a business decision: long TTL = better uptime resilience, slower revocation propagation. A day to a week covers most sane cases.
|
||||
|
||||
## Fingerprint strategy
|
||||
|
||||
A fingerprint is any string that uniquely identifies an installation. Common choices, roughly from stable to less stable:
|
||||
|
||||
- A random 256-bit value you generate and persist in your app's data directory on first run. **Recommended** — stable across reboots, you control it, doesn't leak anything about the host.
|
||||
- On Start9: the service's `TOR_ADDRESS` env var, hashed.
|
||||
- Machine UUID from `/etc/machine-id` on Linux. Leaks a real identifier but is available without any state.
|
||||
- Combination of MAC + hostname — avoid; user-visible and changes on network moves.
|
||||
|
||||
Whatever you pick, hash it before sending if you want to avoid exposing the underlying identifier in network traffic.
|
||||
|
||||
## Reasoning about failure modes
|
||||
|
||||
| Scenario | What happens |
|
||||
|------------------------------------------|------------------------------------------------------------|
|
||||
| Licensing server down, user has valid key | Your software uses cached result and keeps working. |
|
||||
| Licensing server down, first-ever startup | Offline verification passes; online validation fails; you decide whether to proceed or block. |
|
||||
| Forged key | Offline verification rejects instantly, no network call. |
|
||||
| Valid key but revoked | Online validation returns `reason: "revoked"`; block or downgrade. |
|
||||
| Valid key but user swaps hardware | Online validation returns `fingerprint_mismatch`; user contacts you to transfer. |
|
||||
| Network censorship in user's region | Consider shipping a Tor client so they can reach your `.onion`. |
|
||||
|
||||
## Tor / `.onion` support
|
||||
|
||||
Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-paid and privacy-adjacent.
|
||||
@@ -0,0 +1,89 @@
|
||||
-- Initial schema for the licensing service.
|
||||
--
|
||||
-- SQLite is used in WAL mode; all tables are intentionally flat and indexed
|
||||
-- for the common query paths (validate by key_id, list by product, look up by
|
||||
-- invoice_id from BTCPay webhooks).
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
slug TEXT NOT NULL UNIQUE, -- human-friendly id used in URLs
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price_sats INTEGER NOT NULL, -- price in satoshis
|
||||
active INTEGER NOT NULL DEFAULT 1, -- boolean; 0 hides from listings
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}', -- arbitrary developer metadata
|
||||
created_at TEXT NOT NULL, -- ISO-8601 UTC
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_active ON products(active);
|
||||
|
||||
-- Invoices track BTCPay payment attempts. One invoice maps to at most one
|
||||
-- license. If payment never completes, the invoice just sits in 'pending' /
|
||||
-- 'expired' and no license is ever issued.
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY, -- UUID v4 (our id)
|
||||
btcpay_invoice_id TEXT NOT NULL UNIQUE, -- id from BTCPay Server
|
||||
product_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- pending | settled | expired | invalid
|
||||
buyer_email TEXT, -- optional, supplied at purchase
|
||||
buyer_note TEXT, -- optional purchase note
|
||||
amount_sats INTEGER NOT NULL,
|
||||
checkout_url TEXT NOT NULL, -- BTCPay checkout URL
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_btcpay_id ON invoices(btcpay_invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||
|
||||
-- Licenses are the issued proofs-of-purchase. The `key_id` is what a client
|
||||
-- presents when validating; the actual user-facing license key string is a
|
||||
-- signed envelope containing this id plus metadata (see crypto module).
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id TEXT PRIMARY KEY, -- UUID v4, also the `license_id` in the signed payload
|
||||
product_id TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE, -- NULL for manually-issued / comped licenses
|
||||
status TEXT NOT NULL, -- active | revoked
|
||||
fingerprint TEXT, -- optional machine fingerprint locked on first validation
|
||||
bound_identity TEXT, -- optional user identity (email, pubkey, etc.) locked on first use
|
||||
issued_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
revocation_reason TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_product ON licenses(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
|
||||
|
||||
-- Audit log of validation attempts. Useful for abuse detection and for
|
||||
-- developers building rate-limiting policies on top.
|
||||
CREATE TABLE IF NOT EXISTS validation_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id TEXT,
|
||||
product_id TEXT,
|
||||
fingerprint TEXT,
|
||||
result TEXT NOT NULL, -- ok | bad_signature | revoked | product_mismatch | fingerprint_mismatch | not_found
|
||||
client_ip TEXT,
|
||||
user_agent TEXT,
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_validation_license ON validation_log(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_validation_time ON validation_log(occurred_at);
|
||||
|
||||
-- Server-wide signing key. Stored here (rather than on disk) so a SQLite
|
||||
-- backup captures the full server state. The private key is PEM-encoded.
|
||||
-- Generated on first boot if no row exists.
|
||||
CREATE TABLE IF NOT EXISTS server_keys (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
|
||||
algorithm TEXT NOT NULL, -- 'ed25519'
|
||||
public_key_pem TEXT NOT NULL,
|
||||
private_key_pem TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- BTCPay connection state.
|
||||
--
|
||||
-- Before v0.1 this lived purely in environment variables; now it's persisted
|
||||
-- in the DB so the operator can connect to BTCPay via the one-click authorize
|
||||
-- flow instead of pasting an API key into an env file.
|
||||
--
|
||||
-- A single row (id = 1). Rows are upserted on connect / reset.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS btcpay_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
|
||||
base_url TEXT NOT NULL, -- BTCPay base URL
|
||||
api_key TEXT NOT NULL, -- issued by authorize flow
|
||||
store_id TEXT NOT NULL, -- selected store id
|
||||
webhook_id TEXT, -- BTCPay webhook id (for update/delete)
|
||||
webhook_secret TEXT NOT NULL, -- HMAC-SHA256 secret shared with BTCPay
|
||||
connected_at TEXT NOT NULL -- ISO-8601 UTC
|
||||
);
|
||||
|
||||
-- CSRF tokens for an in-flight authorize round trip. The service generates one
|
||||
-- when the operator clicks "Connect BTCPay", then validates it on the redirect
|
||||
-- callback. Short-lived; pruned by timestamp.
|
||||
CREATE TABLE IF NOT EXISTS btcpay_authorize_state (
|
||||
state_token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_btcpay_authorize_state_time
|
||||
ON btcpay_authorize_state(created_at);
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Expanded features: policies, machines, entitlements, expiry + grace,
|
||||
-- suspension, outbound webhooks, admin audit log, and token-bucket rate
|
||||
-- limiting. This migration is additive — v1 licenses issued before it was
|
||||
-- applied still work, because the missing columns get sensible defaults.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Policies (Keygen-style license templates)
|
||||
--
|
||||
-- A policy encapsulates "how should licenses of this shape behave" so the
|
||||
-- developer doesn't have to hand-pick values on every issuance. Example
|
||||
-- policies for a single product: "Pro Perpetual", "Pro Annual",
|
||||
-- "Pro 14-day Trial".
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS policies (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
product_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- human-readable, e.g. "Pro Perpetual"
|
||||
slug TEXT NOT NULL, -- short machine-id, unique within product
|
||||
duration_seconds INTEGER NOT NULL DEFAULT 0, -- 0 = perpetual; else seconds from issuance to expiry
|
||||
grace_seconds INTEGER NOT NULL DEFAULT 0, -- additional seconds after expiry where validate still returns ok with a warning
|
||||
max_machines INTEGER NOT NULL DEFAULT 1, -- concurrent-activation cap; 1 mimics "one seat", 0 = unlimited
|
||||
is_trial INTEGER NOT NULL DEFAULT 0, -- 0/1; trials get FLAG_TRIAL in signed payload
|
||||
price_sats_override INTEGER, -- if set, overrides product.price_sats for invoices using this policy
|
||||
entitlements_json TEXT NOT NULL DEFAULT '[]', -- JSON array of feature slugs baked into every license
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}', -- free-form developer metadata
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||
UNIQUE (product_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_product ON policies(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_active ON policies(active);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Licenses — extended
|
||||
--
|
||||
-- New columns for expiry, grace, suspension, entitlements cache, seat cap,
|
||||
-- trial flag, and an optional Nostr npub (we'll use this later for DM-based
|
||||
-- key delivery / recovery). None of these columns are required; older rows
|
||||
-- get sensible defaults via DEFAULT clauses.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE licenses ADD COLUMN policy_id TEXT REFERENCES policies(id);
|
||||
ALTER TABLE licenses ADD COLUMN expires_at TEXT; -- ISO-8601 UTC; NULL = perpetual
|
||||
ALTER TABLE licenses ADD COLUMN grace_seconds INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE licenses ADD COLUMN max_machines INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE licenses ADD COLUMN suspended_at TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN suspension_reason TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN entitlements_json TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE licenses ADD COLUMN is_trial INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE licenses ADD COLUMN nostr_npub TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN buyer_email TEXT; -- denormalized from invoice for admin search; NULL for comps without email
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_policy ON licenses(policy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_expires ON licenses(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_buyer_email ON licenses(buyer_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_nostr_npub ON licenses(nostr_npub);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Machines (multi-seat activation model)
|
||||
--
|
||||
-- Replaces the single-column `fingerprint` on licenses for licenses that
|
||||
-- allow more than one concurrent machine. Older code paths that only look at
|
||||
-- licenses.fingerprint still work for single-seat licenses, but validate.rs
|
||||
-- now also consults this table when max_machines != 1.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
license_id TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL, -- raw client-supplied id (we never stored the hash server-side; we store raw to allow rebind)
|
||||
fingerprint_hash TEXT NOT NULL, -- hex of SHA-256(fingerprint); indexed for fast lookup
|
||||
hostname TEXT, -- optional human-friendly label the client may supply
|
||||
platform TEXT, -- optional "linux-x64", "darwin-arm64", etc.
|
||||
ip_last_seen TEXT,
|
||||
activated_at TEXT NOT NULL,
|
||||
last_heartbeat_at TEXT,
|
||||
deactivated_at TEXT, -- NULL = active
|
||||
deactivation_reason TEXT,
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_machines_license_fp ON machines(license_id, fingerprint_hash) WHERE deactivated_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_license ON machines(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_heartbeat ON machines(last_heartbeat_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Outbound webhooks
|
||||
--
|
||||
-- Mirror of BTCPay's model: an endpoint is a URL + signing secret; each
|
||||
-- delivery gets logged so admins can debug and retry.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL, -- HMAC-SHA256 key (random, 32 bytes, hex)
|
||||
event_types TEXT NOT NULL DEFAULT '["*"]', -- JSON array of subscribed event types; "*" = all
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_active ON webhook_endpoints(active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
endpoint_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- license.issued, license.revoked, license.suspended, machine.activated, invoice.settled, etc.
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT, -- NULL once delivered or permanently failed
|
||||
last_status_code INTEGER,
|
||||
last_error TEXT,
|
||||
delivered_at TEXT, -- NULL until success
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (endpoint_id) REFERENCES webhook_endpoints(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint ON webhook_deliveries(endpoint_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_next ON webhook_deliveries(next_attempt_at) WHERE delivered_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Admin audit log
|
||||
--
|
||||
-- Every mutation initiated through the admin API (product create, license
|
||||
-- revoke, suspension, policy change, webhook edit, BTCPay reconnect, manual
|
||||
-- issuance, etc.) writes one row. The API key used is hashed before storage
|
||||
-- so the log alone can't be used to recover the key.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_kind TEXT NOT NULL, -- 'admin_api_key' | 'system' | 'btcpay_webhook'
|
||||
actor_hash TEXT, -- SHA-256 of the actor's credential, or NULL for system
|
||||
action TEXT NOT NULL, -- dotted event name: product.create, license.revoke, etc.
|
||||
target_kind TEXT, -- 'product' | 'license' | 'policy' | 'machine' | 'webhook' | 'invoice' | NULL
|
||||
target_id TEXT,
|
||||
request_ip TEXT,
|
||||
user_agent TEXT,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_occurred ON audit_log(occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_kind, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Token-bucket rate limiting
|
||||
--
|
||||
-- We keep one row per (bucket_kind, bucket_key) so that e.g. per-IP validate
|
||||
-- buckets and per-license heartbeat buckets are stored in the same table.
|
||||
-- The refill happens lazily on every hit (classic token-bucket algorithm)
|
||||
-- so there's no background filler task to worry about.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS rate_buckets (
|
||||
bucket_kind TEXT NOT NULL, -- 'validate_ip', 'validate_license', 'heartbeat_license', 'admin_ip', ...
|
||||
bucket_key TEXT NOT NULL, -- the IP, license_id, etc.
|
||||
tokens_remaining REAL NOT NULL,
|
||||
capacity REAL NOT NULL,
|
||||
refill_per_second REAL NOT NULL,
|
||||
last_refill_at TEXT NOT NULL, -- ISO-8601; refill math runs off this
|
||||
PRIMARY KEY (bucket_kind, bucket_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_buckets_refill ON rate_buckets(last_refill_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Validation log — extended
|
||||
--
|
||||
-- Add columns for the new reject reasons (expired, suspended, too_many_machines)
|
||||
-- so admins can tell at a glance why a check failed. The `result` column was
|
||||
-- already TEXT so we just start writing new values to it.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE validation_log ADD COLUMN machine_id TEXT; -- the machines.id that was matched / created, if any
|
||||
ALTER TABLE validation_log ADD COLUMN reason_detail TEXT; -- optional extra string, e.g. "grace period remaining: 3d"
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Discount / referral codes.
|
||||
--
|
||||
-- A `discount_code` is a redeemable token (e.g. "FOUNDERS50") that reduces
|
||||
-- the price of a purchase. A code can be either a percentage off (basis
|
||||
-- points: 5000 = 50%) or a fixed sats off, can target a specific product
|
||||
-- or policy or be universal, can have an optional usage cap and expiry,
|
||||
-- and carries an optional `referrer_label` for tracking purposes (campaign
|
||||
-- name, partner email, npub — free-form, not a separate user record).
|
||||
--
|
||||
-- Atomicity: `used_count` is incremented at purchase-start time via a
|
||||
-- conditional UPDATE that gates on the cap. A `discount_redemptions` row
|
||||
-- is inserted with status='pending' alongside the increment. The
|
||||
-- redemption transitions to 'redeemed' on invoice settlement, or
|
||||
-- 'cancelled' on invoice expiry/invalid (with a corresponding decrement
|
||||
-- of used_count so freed slots become available again).
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discount_codes (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
code TEXT NOT NULL UNIQUE, -- normalized to UPPERCASE on insert; case-insensitive lookup
|
||||
kind TEXT NOT NULL, -- 'percent' | 'fixed_sats' | 'free_license'
|
||||
amount INTEGER NOT NULL, -- basis points if percent, sats if fixed_sats, ignored if free_license (set to 0)
|
||||
max_uses INTEGER, -- NULL = unlimited
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT, -- ISO-8601 UTC; NULL = never
|
||||
applies_to_product_id TEXT, -- NULL = any product
|
||||
applies_to_policy_id TEXT, -- NULL = any policy
|
||||
referrer_label TEXT, -- optional, e.g. 'twitter-launch', 'alice@example.com'
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (applies_to_product_id) REFERENCES products(id),
|
||||
FOREIGN KEY (applies_to_policy_id) REFERENCES policies(id),
|
||||
CHECK (kind IN ('percent', 'fixed_sats', 'free_license')),
|
||||
CHECK (amount >= 0),
|
||||
CHECK (used_count >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_active ON discount_codes(active);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_product ON discount_codes(applies_to_product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_policy ON discount_codes(applies_to_policy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_expires ON discount_codes(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discount_redemptions (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
code_id TEXT NOT NULL,
|
||||
invoice_id TEXT NOT NULL, -- references invoices(id)
|
||||
license_id TEXT, -- populated when license is issued
|
||||
status TEXT NOT NULL, -- 'pending' | 'redeemed' | 'cancelled'
|
||||
discount_applied_sats INTEGER NOT NULL, -- base - final
|
||||
base_price_sats INTEGER NOT NULL, -- snapshot of product price at reservation time
|
||||
final_price_sats INTEGER NOT NULL, -- what BTCPay was actually charged
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (code_id) REFERENCES discount_codes(id),
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id),
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||
CHECK (status IN ('pending', 'redeemed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_code ON discount_redemptions(code_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_invoice ON discount_redemptions(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_license ON discount_redemptions(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_status ON discount_redemptions(status);
|
||||
|
||||
-- One redemption per invoice — a buyer can apply at most one code per
|
||||
-- purchase. If they want to layer codes, they'll need a v0.2 feature.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_discount_redemptions_one_per_invoice
|
||||
ON discount_redemptions(invoice_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Runtime-mutable settings, intentionally separated from the
|
||||
-- startup-only env-var config in `Config::from_env`. Anything that
|
||||
-- should be live-editable through admin actions or the future web UI —
|
||||
-- and survive a daemon restart — goes here.
|
||||
--
|
||||
-- The table is a generic key/value store rather than dedicated columns
|
||||
-- because the set of settings will grow over time, and the cost of a
|
||||
-- key/value pattern with at most a few dozen rows is nil.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migration 0006: tip-recipient on policy.
|
||||
--
|
||||
-- Lets the operator configure a Lightning recipient + percentage on each
|
||||
-- policy. When a license issued under that policy settles, the daemon
|
||||
-- tries to send a Lightning tip of (license_price_sats * tip_pct_bps / 10000)
|
||||
-- to tip_recipient via the operator's BTCPay Lightning node.
|
||||
--
|
||||
-- All three fields are nullable / zero-default. Existing policies are
|
||||
-- unaffected: with NULL recipient the issuance hook is a no-op.
|
||||
--
|
||||
-- Recipient can be a Lightning Address (e.g. tip@keysat.xyz). LNURL-pay
|
||||
-- support may be added later; the current implementation resolves only
|
||||
-- Lightning Addresses via the .well-known/lnurlp/<user> endpoint.
|
||||
|
||||
ALTER TABLE policies ADD COLUMN tip_recipient TEXT;
|
||||
ALTER TABLE policies ADD COLUMN tip_pct_bps INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE policies ADD COLUMN tip_label TEXT;
|
||||
|
||||
-- Audit log for tip attempts. Insert one row per try, success or failure.
|
||||
-- Operators consult this for accounting and for debugging when a tip
|
||||
-- doesn't fire as expected.
|
||||
CREATE TABLE IF NOT EXISTS tip_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
license_id TEXT NOT NULL,
|
||||
policy_id TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
pct_bps INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
-- 'sent' | 'failed' | 'skipped' (e.g. zero amount, no LN node)
|
||||
status TEXT NOT NULL,
|
||||
-- Error or success detail message.
|
||||
detail TEXT,
|
||||
-- Lightning payment hash on success, null on failure.
|
||||
payment_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||
FOREIGN KEY (policy_id) REFERENCES policies(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_attempts_license ON tip_attempts(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_attempts_recipient ON tip_attempts(recipient);
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_attempts_created ON tip_attempts(created_at);
|
||||
@@ -0,0 +1,566 @@
|
||||
//! Admin endpoints — all require `Authorization: Bearer <admin_api_key>`.
|
||||
//! The operator uses these to manage products and issue/revoke licenses.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::crypto::{encode_key, sign_payload, LicensePayload, KEY_VERSION_V2};
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap},
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// Guards every admin handler: pulls the bearer token out of the header and
|
||||
/// compares constant-time against the configured admin key. Returns the
|
||||
/// SHA-256 hex of the token on success so handlers can write an audit row
|
||||
/// that identifies *which* credential made the call without logging the raw
|
||||
/// key.
|
||||
pub fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult<String> {
|
||||
let header_val = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
let token = header_val
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
if bool::from(
|
||||
token
|
||||
.as_bytes()
|
||||
.ct_eq(state.config.admin_api_key.as_bytes()),
|
||||
) {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
} else {
|
||||
Err(AppError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the best-effort client IP and User-Agent out of the request headers
|
||||
/// for audit logging.
|
||||
pub fn request_context(headers: &HeaderMap) -> (Option<String>, Option<String>) {
|
||||
let client_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("").trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let ua = headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
(client_ip, ua)
|
||||
}
|
||||
|
||||
// ---------- Products ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProductReq {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub price_sats: i64,
|
||||
#[serde(default)]
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
pub async fn create_product(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateProductReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
if req.price_sats <= 0 {
|
||||
return Err(AppError::BadRequest("price_sats must be positive".into()));
|
||||
}
|
||||
let metadata = if req.metadata.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
req.metadata
|
||||
};
|
||||
let product = repo::create_product(
|
||||
&state.db,
|
||||
&req.slug,
|
||||
&req.name,
|
||||
&req.description,
|
||||
req.price_sats,
|
||||
&metadata,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"product.create",
|
||||
Some("product"),
|
||||
Some(&product.id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "slug": product.slug, "name": product.name, "price_sats": product.price_sats }),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"product.created",
|
||||
&json!({ "product": product }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!(product)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetActiveReq {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub async fn set_product_active(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_product_active(&state.db, &id, req.active).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"product.set_active",
|
||||
Some("product"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "active": req.active }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------- Licenses ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListLicensesQuery {
|
||||
pub product_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_licenses(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListLicensesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?;
|
||||
Ok(Json(json!({ "licenses": licenses })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchLicensesQuery {
|
||||
pub buyer_email: Option<String>,
|
||||
pub nostr_npub: Option<String>,
|
||||
pub invoice_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Free-form lookup used by the "lost key recovery" flow. Searches by email,
|
||||
/// Nostr npub, or invoice id (whichever is supplied), returns up to 100
|
||||
/// matching licenses.
|
||||
pub async fn search_licenses(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<SearchLicensesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let licenses = repo::search_licenses(
|
||||
&state.db,
|
||||
q.buyer_email.as_deref(),
|
||||
q.nostr_npub.as_deref(),
|
||||
q.invoice_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "licenses": licenses })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IssueLicenseReq {
|
||||
pub product_slug: String,
|
||||
/// Optional policy slug (within the product). When set, the policy's
|
||||
/// duration, grace, entitlements, trial flag, and machine cap are used.
|
||||
#[serde(default)]
|
||||
pub policy_slug: Option<String>,
|
||||
/// Optional reason for audit — e.g. "comp", "press", "giveaway".
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// Override expiry (ISO-8601 UTC). Ignored if `policy_slug` is set.
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
/// Override entitlements. Ignored if `policy_slug` is set.
|
||||
#[serde(default)]
|
||||
pub entitlements: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub max_machines: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub grace_seconds: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub is_trial: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub buyer_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub nostr_npub: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueLicenseResp {
|
||||
pub license_id: String,
|
||||
pub product_id: String,
|
||||
pub license_key: String,
|
||||
pub issued_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub entitlements: Vec<String>,
|
||||
pub is_trial: bool,
|
||||
pub max_machines: i64,
|
||||
}
|
||||
|
||||
/// Manually issue a license outside the purchase flow. Useful for comps,
|
||||
/// press keys, grandfathered users, trial keys, or developer testing.
|
||||
pub async fn issue_license(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<IssueLicenseReq>,
|
||||
) -> AppResult<Json<IssueLicenseResp>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
|
||||
|
||||
// Pull the policy (if any) and merge it with per-call overrides.
|
||||
let policy = if let Some(slug) = &req.policy_slug {
|
||||
Some(
|
||||
repo::get_policy_by_slug(&state.db, &product.id, slug)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"policy '{slug}' for product '{}'",
|
||||
req.product_slug
|
||||
))
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Compose effective values: explicit request fields take precedence over
|
||||
// the policy, which takes precedence over defaults.
|
||||
let now = Utc::now();
|
||||
let issued_at = now.to_rfc3339();
|
||||
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
|
||||
let expires_at = match (req.expires_at.clone(), duration_seconds) {
|
||||
(Some(explicit), _) => Some(explicit),
|
||||
(None, 0) => None, // perpetual
|
||||
(None, secs) => Some((now + Duration::seconds(secs)).to_rfc3339()),
|
||||
};
|
||||
let grace_seconds = req
|
||||
.grace_seconds
|
||||
.or_else(|| policy.as_ref().map(|p| p.grace_seconds))
|
||||
.unwrap_or(0);
|
||||
let max_machines = req
|
||||
.max_machines
|
||||
.or_else(|| policy.as_ref().map(|p| p.max_machines))
|
||||
.unwrap_or(1);
|
||||
let is_trial = req
|
||||
.is_trial
|
||||
.or_else(|| policy.as_ref().map(|p| p.is_trial))
|
||||
.unwrap_or(false);
|
||||
let entitlements = req
|
||||
.entitlements
|
||||
.clone()
|
||||
.or_else(|| policy.as_ref().map(|p| p.entitlements.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let license_id = uuid::Uuid::new_v4().to_string();
|
||||
repo::create_license(
|
||||
&state.db,
|
||||
&license_id,
|
||||
&product.id,
|
||||
None,
|
||||
&issued_at,
|
||||
&json!({
|
||||
"source": "admin_issue",
|
||||
"note": req.note,
|
||||
}),
|
||||
policy.as_ref().map(|p| p.id.as_str()),
|
||||
expires_at.as_deref(),
|
||||
grace_seconds,
|
||||
max_machines,
|
||||
&entitlements,
|
||||
is_trial,
|
||||
req.buyer_email.as_deref(),
|
||||
req.nostr_npub.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Build v2 signed payload.
|
||||
let mut flags = 0u8;
|
||||
if is_trial {
|
||||
flags |= crate::crypto::FLAG_TRIAL;
|
||||
}
|
||||
let payload = LicensePayload {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
product_id: uuid::Uuid::parse_str(&product.id).unwrap(),
|
||||
license_id: uuid::Uuid::parse_str(&license_id).unwrap(),
|
||||
issued_at: now.timestamp(),
|
||||
expires_at: expires_at
|
||||
.as_deref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0),
|
||||
fingerprint_hash: [0u8; 32],
|
||||
entitlements: entitlements.clone(),
|
||||
};
|
||||
let sig = sign_payload(&state.keypair.signing, &payload);
|
||||
let license_key = encode_key(&payload, &sig);
|
||||
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"license.issue_manual",
|
||||
Some("license"),
|
||||
Some(&license_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({
|
||||
"product_id": product.id,
|
||||
"policy_id": policy.as_ref().map(|p| &p.id),
|
||||
"is_trial": is_trial,
|
||||
"expires_at": expires_at,
|
||||
"entitlements": entitlements,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"license.issued",
|
||||
&json!({
|
||||
"license_id": license_id,
|
||||
"product_id": product.id,
|
||||
"is_trial": is_trial,
|
||||
"expires_at": expires_at,
|
||||
"entitlements": entitlements,
|
||||
"source": "admin_issue",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(IssueLicenseResp {
|
||||
license_id,
|
||||
product_id: product.id,
|
||||
license_key,
|
||||
issued_at,
|
||||
expires_at,
|
||||
entitlements,
|
||||
is_trial,
|
||||
max_machines,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RevokeReq {
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub async fn revoke_license(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(license_id): Path<String>,
|
||||
Json(req): Json<RevokeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = if req.reason.is_empty() {
|
||||
"admin revoke".to_string()
|
||||
} else {
|
||||
req.reason
|
||||
};
|
||||
repo::revoke_license(&state.db, &license_id, &reason).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"license.revoke",
|
||||
Some("license"),
|
||||
Some(&license_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "reason": reason }),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"license.revoked",
|
||||
&json!({ "license_id": license_id, "reason": reason }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------- Suspension / un-suspension ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuspendReq {
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub async fn suspend_license(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(license_id): Path<String>,
|
||||
Json(req): Json<SuspendReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = if req.reason.is_empty() {
|
||||
"admin suspend".to_string()
|
||||
} else {
|
||||
req.reason
|
||||
};
|
||||
repo::suspend_license(&state.db, &license_id, &reason).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"license.suspend",
|
||||
Some("license"),
|
||||
Some(&license_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "reason": reason }),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"license.suspended",
|
||||
&json!({ "license_id": license_id, "reason": reason }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn unsuspend_license(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(license_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::unsuspend_license(&state.db, &license_id).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"license.unsuspend",
|
||||
Some("license"),
|
||||
Some(&license_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({}),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"license.unsuspended",
|
||||
&json!({ "license_id": license_id }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------- Audit log viewer ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAuditQuery {
|
||||
#[serde(default = "default_audit_limit")]
|
||||
pub limit: i64,
|
||||
pub action: Option<String>,
|
||||
}
|
||||
|
||||
fn default_audit_limit() -> i64 {
|
||||
200
|
||||
}
|
||||
|
||||
pub async fn list_audit(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListAuditQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?;
|
||||
Ok(Json(json!({ "entries": rows })))
|
||||
}
|
||||
|
||||
// ---------- Settings (live-mutable runtime config) ----------
|
||||
|
||||
/// Settings key for the operator's public-facing display name. Read by
|
||||
/// the `/` index handler on every request, so updates take effect
|
||||
/// immediately — no daemon restart needed.
|
||||
pub const SETTING_OPERATOR_NAME: &str = "operator_name";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetOperatorNameReq {
|
||||
/// New operator name. Empty string clears the setting (reverts to
|
||||
/// the daemon's startup-time fallback from KEYSAT_OPERATOR_NAME).
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn set_operator_name(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<SetOperatorNameReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let trimmed = req.name.trim();
|
||||
let stored: Option<&str> = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
repo::settings_set(&state.db, SETTING_OPERATOR_NAME, stored).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"operator_name.set",
|
||||
Some("setting"),
|
||||
Some(SETTING_OPERATOR_NAME),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "value": stored }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true, "operator_name": stored })))
|
||||
}
|
||||
|
||||
pub async fn get_operator_name(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?;
|
||||
let effective = stored
|
||||
.clone()
|
||||
.or_else(|| state.config.operator_name.clone());
|
||||
Ok(Json(json!({
|
||||
"stored": stored,
|
||||
"effective": effective,
|
||||
"fallback_env": state.config.operator_name,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Embedded admin web UI.
|
||||
//!
|
||||
//! At compile time, every file in `licensing-service/web/` is bundled
|
||||
//! into the binary via `rust-embed`. At runtime, axum serves them under
|
||||
//! `/admin/*` — no separate static-file deployment, no nginx, no proxy.
|
||||
//! The whole admin SPA ships in the same `keysat` executable as the
|
||||
//! daemon.
|
||||
//!
|
||||
//! Auth model: NONE at this HTTP layer. The static assets themselves
|
||||
//! (HTML, CSS, JS) are public — there's nothing secret in them. The
|
||||
//! actual gating happens client-side: the index page prompts for the
|
||||
//! operator's admin API key on first load, validates it against any
|
||||
//! `/v1/admin/*` endpoint, stores it in localStorage, and uses it as
|
||||
//! `Authorization: Bearer ...` on every subsequent admin call. The
|
||||
//! admin-scoped endpoints already enforce the key constant-time, so a
|
||||
//! random visitor can load `/admin/index.html` but cannot do anything
|
||||
//! useful without the key.
|
||||
//!
|
||||
//! v0.2 first cut: this is scaffolding only. The HTML page contains a
|
||||
//! login form + a placeholder dashboard. Future SPA work just adds
|
||||
//! more files into `web/` (or replaces index.html with a built React /
|
||||
//! Svelte bundle); the serving code below doesn't change.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, StatusCode, Uri},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
/// Compile-time-bundled directory of static admin UI assets. Every file
|
||||
/// under `web/` (relative to the crate root) is embedded byte-for-byte
|
||||
/// into the binary.
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "web/"]
|
||||
struct AdminAssets;
|
||||
|
||||
/// `GET /admin` — redirect to `/admin/` so the relative paths in the
|
||||
/// embedded HTML resolve correctly.
|
||||
pub async fn admin_root_redirect() -> Redirect {
|
||||
Redirect::permanent("/admin/")
|
||||
}
|
||||
|
||||
/// `GET /admin/` — serve the SPA shell (index.html).
|
||||
pub async fn admin_index() -> Response {
|
||||
serve_embedded("index.html")
|
||||
}
|
||||
|
||||
/// `GET /admin/*path` — serve any other embedded static file. Falls
|
||||
/// through to `index.html` for unknown paths so client-side routing
|
||||
/// (e.g. /admin/products, /admin/licenses) works without server-side
|
||||
/// route registration.
|
||||
pub async fn admin_asset(uri: Uri) -> Response {
|
||||
// The Uri here will be the FULL path (including the /admin prefix).
|
||||
// Strip the prefix to look up the asset.
|
||||
let path = uri.path();
|
||||
let stripped = path.strip_prefix("/admin/").unwrap_or(path);
|
||||
if stripped.is_empty() {
|
||||
return serve_embedded("index.html");
|
||||
}
|
||||
if AdminAssets::get(stripped).is_some() {
|
||||
serve_embedded(stripped)
|
||||
} else {
|
||||
// Unknown path — fall through to index.html so the SPA's
|
||||
// client-side router can take over. This is the canonical
|
||||
// fallback pattern for SPAs hosted on path prefixes.
|
||||
serve_embedded("index.html")
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_embedded(path: &str) -> Response {
|
||||
match AdminAssets::get(path) {
|
||||
Some(file) => {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, mime.to_string())
|
||||
// Modest caching — these are versioned with the binary,
|
||||
// so cache for an hour. A binary upgrade rolls the
|
||||
// service which evicts the cache anyway.
|
||||
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||
.body(Body::from(file.data.into_owned()))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! BTCPay one-click authorize flow.
|
||||
//!
|
||||
//! Instead of making the operator generate an API key by hand and paste it
|
||||
//! into a form, we use BTCPay's "authorize" redirect flow:
|
||||
//!
|
||||
//! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action
|
||||
//! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token)
|
||||
//! and gets back a BTCPay URL to open in the operator's browser.
|
||||
//! 2. The operator, already logged into BTCPay on the same box, sees a
|
||||
//! consent page listing the permissions this service is requesting. They
|
||||
//! click **Authorize**.
|
||||
//! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the
|
||||
//! newly-minted API key and the store(s) it was scoped to.
|
||||
//! 4. We persist the key, pick the target store, register the webhook (with
|
||||
//! a freshly-generated secret), and save everything in `btcpay_config`.
|
||||
//! 5. From that moment on, the `BtcpayProvider` (held as an `Arc<dyn
|
||||
//! PaymentProvider>` in `AppState.payment`) is populated
|
||||
//! and purchase / webhook endpoints work.
|
||||
//!
|
||||
//! If the callback fails for any reason, the operator is shown an error page
|
||||
//! and can retry. The admin endpoint requires the admin bearer token; the
|
||||
//! callback path uses the CSRF `state` token to tie a callback back to the
|
||||
//! issuing operator session.
|
||||
|
||||
use crate::api::{admin::require_admin, AppState};
|
||||
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
|
||||
use crate::btcpay::config as btcpay_cfg;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::btcpay::BtcpayProvider;
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
Form, Json,
|
||||
};
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Permissions we request on the authorize page. Each is namespaced by
|
||||
/// `btcpay.store.*` which means BTCPay will prompt the operator to pick
|
||||
/// which store(s) to grant.
|
||||
const REQUESTED_PERMISSIONS: &[&str] = &[
|
||||
"btcpay.store.canviewstoresettings",
|
||||
"btcpay.store.canmodifystoresettings", // to register the webhook
|
||||
"btcpay.store.canviewinvoices",
|
||||
"btcpay.store.cancreateinvoice",
|
||||
"btcpay.store.canmodifyinvoices",
|
||||
];
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConnectResp {
|
||||
/// URL the operator should open in their browser to authorize.
|
||||
pub authorize_url: String,
|
||||
/// CSRF state token tied to this round trip.
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
|
||||
/// URL for the StartOS wrapper action to open in the operator's browser.
|
||||
pub async fn start_connect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ConnectResp>> {
|
||||
require_admin(&state, &headers)?;
|
||||
|
||||
// Idempotency: if BTCPay is already connected, refuse to issue a new
|
||||
// authorize URL. Re-clicking Connect today produces a duplicate
|
||||
// webhook subscription on BTCPay, which results in every payment
|
||||
// event being delivered to Keysat twice. Make the operator go
|
||||
// through Disconnect first if they really want to re-authorize.
|
||||
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
|
||||
existing.store_id,
|
||||
)));
|
||||
}
|
||||
|
||||
// Random 20-byte token, base32-encoded, for the CSRF `state` parameter.
|
||||
let mut raw = [0u8; 20];
|
||||
rand::thread_rng().fill_bytes(&mut raw);
|
||||
let state_token = BASE32_NOPAD.encode(&raw);
|
||||
|
||||
btcpay_cfg::record_authorize_state(&state.db, &state_token)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Construct the authorize URL per BTCPay's docs.
|
||||
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
|
||||
//
|
||||
// CSRF state must travel inside the `redirect` URL itself, NOT as a
|
||||
// separate query param on the outer authorize URL. Empirical
|
||||
// observation against BTCPay: arbitrary query params on the
|
||||
// authorize URL are NOT forwarded to the redirect target. The
|
||||
// redirect URL is preserved verbatim, so any params we encode INTO
|
||||
// it survive the round-trip.
|
||||
let redirect = format!(
|
||||
"{}/v1/btcpay/authorize/callback?state={}",
|
||||
state.config.public_base_url,
|
||||
urlencoding::encode(&state_token),
|
||||
);
|
||||
let perm_params = REQUESTED_PERMISSIONS
|
||||
.iter()
|
||||
.map(|p| format!("permissions={}", urlencoding::encode(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
// The authorize URL is followed by the operator's BROWSER, so the host
|
||||
// must be reachable from outside the container. Use the explicit
|
||||
// `btcpay_browser_url` if the wrapper provided it; fall back to
|
||||
// `btcpay_url` only for dev/local setups (where they're the same).
|
||||
let authorize_base = state
|
||||
.config
|
||||
.btcpay_browser_url
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.btcpay_url);
|
||||
let authorize_url = format!(
|
||||
"{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}",
|
||||
authorize_base,
|
||||
urlencoding::encode("Keysat"),
|
||||
urlencoding::encode("keysat"),
|
||||
urlencoding::encode(&redirect),
|
||||
);
|
||||
|
||||
Ok(Json(ConnectResp {
|
||||
authorize_url,
|
||||
state: state_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`,
|
||||
/// `userId`, and `permissions[]` as a form body. It also preserves any
|
||||
/// query-string parameters on the redirect URL — we use that for `state`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackForm {
|
||||
#[serde(rename = "apiKey")]
|
||||
pub api_key: String,
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: Option<String>,
|
||||
// BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns
|
||||
// that into a repeated string. We don't actually need to parse them
|
||||
// individually — we just re-verify via list_stores.
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// The real callback endpoint — POST form-encoded.
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<CallbackQuery>,
|
||||
Form(form): Form<CallbackForm>,
|
||||
) -> AppResult<Response> {
|
||||
finish_connect(&state, &q.state, &form.api_key).await?;
|
||||
Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS."))
|
||||
}
|
||||
|
||||
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
|
||||
/// Handle that too for robustness.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackGetQuery {
|
||||
pub state: String,
|
||||
#[serde(rename = "apiKey")]
|
||||
pub api_key: Option<String>,
|
||||
/// Error message if BTCPay declined / operator clicked "Deny".
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn callback_get(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<CallbackGetQuery>,
|
||||
) -> Response {
|
||||
if let Some(err) = q.error {
|
||||
return Html(format!(
|
||||
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
||||
html_escape::encode_text(&err)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let Some(api_key) = q.api_key else {
|
||||
// Some installs POST; in that case a bare GET with no apiKey is
|
||||
// possible if the operator refreshes the tab. Redirect to root.
|
||||
return Redirect::to("/").into_response();
|
||||
};
|
||||
match finish_connect(&state, &q.state, &api_key).await {
|
||||
Ok(()) => success_page(
|
||||
"BTCPay connected successfully. You can close this tab and return to StartOS.",
|
||||
),
|
||||
Err(e) => Html(format!(
|
||||
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
||||
html_escape::encode_text(&e.to_string())
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin endpoint: list payment methods configured on the connected
|
||||
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
|
||||
/// Used by the wrapper / future web UI to surface a "no wallet
|
||||
/// configured" state.
|
||||
pub async fn payment_methods(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let cfg = btcpay_cfg::load(&state.db)
|
||||
.await
|
||||
.map_err(AppError::Internal)?
|
||||
.ok_or(AppError::BtcpayNotConfigured)?;
|
||||
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
|
||||
|
||||
// Return both the raw array for callers that want detail, and a
|
||||
// boolean summary for the common "is anything configured?" check.
|
||||
let count = methods.len();
|
||||
Ok(Json(json!({
|
||||
"store_id": cfg.store_id,
|
||||
"count": count,
|
||||
"methods": methods,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Admin endpoint: report current BTCPay connection status.
|
||||
pub async fn status(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
|
||||
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
|
||||
Ok(Json(match cfg {
|
||||
None => json!({ "connected": false }),
|
||||
Some(c) => json!({
|
||||
"connected": true,
|
||||
"store_id": c.store_id,
|
||||
"webhook_id": c.webhook_id,
|
||||
"base_url": c.base_url,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
|
||||
btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
let base_url = &state.config.btcpay_url;
|
||||
|
||||
// Enumerate stores the key has access to. With `selectiveStores=true`
|
||||
// the operator picked specific stores during authorize; we pick the
|
||||
// first one that the key can see.
|
||||
let stores = btcpay_client::list_stores(base_url, api_key)
|
||||
.await
|
||||
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
|
||||
let store = stores
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::BadRequest(
|
||||
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
|
||||
))?;
|
||||
|
||||
// Generate a strong webhook secret, then register the webhook on BTCPay.
|
||||
let mut raw_secret = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut raw_secret);
|
||||
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
|
||||
|
||||
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
|
||||
|
||||
let created_webhook = btcpay_client::create_webhook(
|
||||
base_url,
|
||||
api_key,
|
||||
&store.id,
|
||||
&callback_url,
|
||||
&webhook_secret,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
|
||||
|
||||
// Persist.
|
||||
let cfg = btcpay_cfg::BtcpayConfig {
|
||||
base_url: base_url.clone(),
|
||||
api_key: api_key.to_string(),
|
||||
store_id: store.id.clone(),
|
||||
webhook_id: Some(created_webhook.id.clone()),
|
||||
webhook_secret: webhook_secret.clone(),
|
||||
};
|
||||
btcpay_cfg::save(&state.db, &cfg)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Swap runtime — wrap a fresh BtcpayProvider into the
|
||||
// PaymentProvider trait object held by AppState. Pass the
|
||||
// public-facing BTCPay URL too so that checkout URLs returned to
|
||||
// buyers get rewritten from the internal Docker hostname to a
|
||||
// browser-reachable host.
|
||||
let client = BtcpayClient::new(base_url, api_key, &store.id);
|
||||
let provider = Arc::new(
|
||||
BtcpayProvider::new(client, webhook_secret)
|
||||
.with_public_base(state.config.btcpay_public_url.clone()),
|
||||
);
|
||||
state.set_payment_provider(provider).await;
|
||||
|
||||
tracing::info!(
|
||||
store = %store.id,
|
||||
store_name = %store.name,
|
||||
webhook_id = %created_webhook.id,
|
||||
"BTCPay connected via authorize flow"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn success_page(msg: &str) -> Response {
|
||||
let body = format!(
|
||||
r#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
|
||||
h2{{color:#0a7}}</style></head>
|
||||
<body><h2>✓ {msg}</h2></body></html>"#,
|
||||
msg = html_escape::encode_text(msg)
|
||||
);
|
||||
(StatusCode::OK, Html(body)).into_response()
|
||||
}
|
||||
|
||||
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
|
||||
/// webhook + API key on BTCPay's side, then unconditional clear of the
|
||||
/// local config row. If BTCPay is unreachable, the local state is still
|
||||
/// cleared and the operator gets a warning to clean up BTCPay manually.
|
||||
pub async fn disconnect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = crate::api::admin::request_context(&headers);
|
||||
|
||||
let cfg = btcpay_cfg::load(&state.db)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
let Some(cfg) = cfg else {
|
||||
return Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": true,
|
||||
"message": "BTCPay was not connected; nothing to do.",
|
||||
})));
|
||||
};
|
||||
|
||||
// Capture metadata for the response BEFORE we clear local state.
|
||||
let store_id = cfg.store_id.clone();
|
||||
let webhook_id = cfg.webhook_id.clone();
|
||||
|
||||
// Best-effort remote cleanup. We DON'T short-circuit if either of
|
||||
// these calls fails — the operator's intent is to disconnect, and
|
||||
// leaving local state pointing at a remote we no longer trust is
|
||||
// worse than leaving orphan state on the BTCPay side. Any failures
|
||||
// are surfaced in the response so the operator can manually clean
|
||||
// up on BTCPay if needed.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
if let Some(webhook_id) = webhook_id.as_deref() {
|
||||
if let Err(e) = btcpay_client::delete_webhook(
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.store_id,
|
||||
webhook_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warnings.push(format!(
|
||||
"Could not delete BTCPay webhook {webhook_id}: {e}. \
|
||||
You may want to manually delete it in BTCPay's store webhook settings."
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
|
||||
warnings.push(format!(
|
||||
"Could not revoke BTCPay API key: {e}. \
|
||||
You may want to manually revoke it in BTCPay's account API-keys page."
|
||||
));
|
||||
}
|
||||
|
||||
btcpay_cfg::clear(&state.db)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Replace the runtime payment provider so subsequent purchase
|
||||
// attempts return BtcpayNotConfigured cleanly.
|
||||
state.clear_payment_provider().await;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"btcpay.disconnect",
|
||||
Some("btcpay_config"),
|
||||
None,
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": false,
|
||||
"store_id": store_id,
|
||||
"webhook_id": webhook_id,
|
||||
"warnings": warnings,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! Public buyer-facing purchase page at `GET /buy/:slug`.
|
||||
//!
|
||||
//! The flow is:
|
||||
//! 1. Buyer hits `https://<operator-keysat>/buy/<product-slug>` in a browser.
|
||||
//! 2. We look up the product, render an HTML page showing what they're
|
||||
//! buying — name, description, price — plus a small form for an
|
||||
//! optional email (for receipt + license delivery) and an optional
|
||||
//! discount code.
|
||||
//! 3. They click "Pay with Bitcoin." Inline JS POSTs to `/v1/purchase`,
|
||||
//! gets back a BTCPay checkout URL, redirects the browser there.
|
||||
//! 4. After payment, BTCPay redirects to `/thank-you` (existing handler).
|
||||
//!
|
||||
//! Visual language matches the rest of the Keysat design system: navy
|
||||
//! topbar, cream paper-textured background, gold accent on the price and
|
||||
//! the CTA, classical type. Inlined CSS so this single file is the whole
|
||||
//! buyer-facing surface — easy to deploy, no asset hosting required.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Html,
|
||||
};
|
||||
|
||||
pub async fn render(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Html<String>, (StatusCode, Html<String>)> {
|
||||
// Look up the product. Inactive or missing → 404 with a friendly page.
|
||||
let product = match repo::get_product_by_slug(&state.db, &slug).await {
|
||||
Ok(Some(p)) if p.active => p,
|
||||
_ => return Err((StatusCode::NOT_FOUND, Html(not_found_html(&slug)))),
|
||||
};
|
||||
|
||||
// Live-read operator name (same pattern as thank-you / root).
|
||||
let live = repo::settings_get(&state.db, crate::api::admin::SETTING_OPERATOR_NAME)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let operator_str = live
|
||||
.as_deref()
|
||||
.or(state.config.operator_name.as_deref())
|
||||
.unwrap_or("Keysat");
|
||||
let operator = html_escape(operator_str);
|
||||
|
||||
let product_name = html_escape(&product.name);
|
||||
let product_slug = html_escape(&product.slug);
|
||||
let product_description = html_escape(&product.description);
|
||||
let price_sats_fmt = format_thousands(product.price_sats);
|
||||
|
||||
let body = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Buy {product_name} — {operator}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {{
|
||||
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
|
||||
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
|
||||
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
|
||||
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
|
||||
--success:#2D7A5F; --success-bg:#E3F0EA;
|
||||
--danger:#B23A3A; --danger-bg:#F4E0E0;
|
||||
--border-1:rgba(14,31,51,0.12);
|
||||
--border-2:rgba(14,31,51,0.20);
|
||||
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
|
||||
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
|
||||
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
|
||||
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
|
||||
}}
|
||||
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
|
||||
body {{
|
||||
font-family:var(--font-body); color:var(--ink-900);
|
||||
background:var(--cream-100);
|
||||
background-image:
|
||||
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
|
||||
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
|
||||
background-size:3px 3px, 7px 7px;
|
||||
-webkit-font-smoothing:antialiased; min-height:100vh;
|
||||
}}
|
||||
.topbar {{
|
||||
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
|
||||
border-bottom:1px solid var(--border-1);
|
||||
padding:14px 24px;
|
||||
}}
|
||||
.topbar .inner {{
|
||||
max-width:680px; margin:0 auto;
|
||||
display:flex; align-items:center; gap:12px;
|
||||
font-family:var(--font-display); font-weight:500; font-size:14px;
|
||||
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
|
||||
}}
|
||||
.topbar .operator {{
|
||||
font-family:var(--font-body); font-size:12px;
|
||||
letter-spacing:0.04em; text-transform:none;
|
||||
color:var(--ink-500);
|
||||
margin-left:auto;
|
||||
}}
|
||||
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
|
||||
.eyebrow {{
|
||||
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
|
||||
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
|
||||
display:inline-flex; align-items:center; gap:10px;
|
||||
}}
|
||||
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
|
||||
h1 {{
|
||||
font-family:var(--font-display); font-weight:500; font-size:42px;
|
||||
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950);
|
||||
margin:0 0 12px;
|
||||
}}
|
||||
.product-slug {{
|
||||
font-family:var(--font-mono); font-size:12.5px; color:var(--ink-500);
|
||||
margin:0 0 18px;
|
||||
}}
|
||||
.description {{
|
||||
font-size:16px; line-height:1.55; color:var(--ink-700);
|
||||
margin:0 0 32px;
|
||||
}}
|
||||
.cert {{
|
||||
background:var(--cream-50); border:1px solid var(--border-1);
|
||||
border-radius:14px;
|
||||
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
|
||||
padding:32px 32px 28px;
|
||||
position:relative;
|
||||
margin-bottom:24px;
|
||||
}}
|
||||
.cert::before, .cert::after {{
|
||||
content:''; position:absolute; left:14px; right:14px;
|
||||
height:1px; background:var(--gold-500); opacity:0.5;
|
||||
}}
|
||||
.cert::before {{ top:14px; }} .cert::after {{ bottom:14px; }}
|
||||
.price {{
|
||||
font-family:var(--font-display); font-weight:700; font-size:36px;
|
||||
color:var(--navy-950); letter-spacing:-0.025em; margin:8px 0 0;
|
||||
}}
|
||||
.price .unit {{
|
||||
font-family:var(--font-body); font-size:15px; font-weight:600;
|
||||
color:var(--ink-500); margin-left:8px;
|
||||
}}
|
||||
.price-label {{
|
||||
font-size:11.5px; font-weight:700; letter-spacing:0.14em;
|
||||
text-transform:uppercase; color:var(--ink-500);
|
||||
}}
|
||||
.field {{ margin-bottom:14px; }}
|
||||
.field label {{
|
||||
display:block; font-size:12.5px; font-weight:600;
|
||||
color:var(--ink-700); margin-bottom:6px;
|
||||
}}
|
||||
.field input {{
|
||||
width:100%; padding:11px 13px;
|
||||
font-family:var(--font-body); font-size:14px;
|
||||
border:1px solid var(--border-2); border-radius:8px;
|
||||
background:#fff; color:var(--ink-900);
|
||||
}}
|
||||
.field input:focus {{
|
||||
outline:none; border-color:var(--navy-700);
|
||||
box-shadow:0 0 0 3px rgba(30,58,95,0.18);
|
||||
}}
|
||||
.field .hint {{ font-size:12px; color:var(--ink-500); margin-top:5px; }}
|
||||
|
||||
/* Apply-discount cluster: input + button on one row */
|
||||
.code-row {{ display:flex; gap:8px; align-items:stretch; }}
|
||||
.code-row input {{ flex:1; }}
|
||||
.btn-apply {{
|
||||
background:transparent; color:var(--navy-800);
|
||||
border:1px solid var(--border-2); border-radius:8px;
|
||||
padding:0 16px;
|
||||
font-family:var(--font-body); font-weight:600; font-size:13px;
|
||||
cursor:pointer; transition:all 120ms;
|
||||
flex-shrink:0;
|
||||
}}
|
||||
.btn-apply:hover {{ background:var(--cream-200); border-color:var(--navy-700); }}
|
||||
.btn-apply:disabled {{ opacity:0.5; cursor:wait; }}
|
||||
.code-status {{
|
||||
margin-top:8px; font-size:13px; padding:8px 12px;
|
||||
border-radius:7px; display:none;
|
||||
}}
|
||||
.code-status.show {{ display:block; }}
|
||||
.code-status.ok {{ background:var(--success-bg); color:#205c47; border:1px solid rgba(45,122,95,0.25); }}
|
||||
.code-status.bad {{ background:var(--danger-bg); color:#8a2828; border:1px solid rgba(178,58,58,0.25); }}
|
||||
|
||||
/* Price card update animation when discount applied */
|
||||
.price-strike {{
|
||||
text-decoration:line-through; color:var(--ink-500);
|
||||
font-size:18px; font-weight:500; display:block;
|
||||
margin-bottom:4px;
|
||||
}}
|
||||
.price-discount-tag {{
|
||||
display:inline-block; margin-left:8px;
|
||||
font-family:var(--font-body); font-size:12px; font-weight:600;
|
||||
padding:3px 10px; border-radius:999px;
|
||||
background:var(--success-bg); color:#205c47;
|
||||
border:1px solid rgba(45,122,95,0.25);
|
||||
vertical-align:middle;
|
||||
}}
|
||||
|
||||
.btn-pay {{
|
||||
width:100%; padding:14px;
|
||||
background:var(--navy-800); color:var(--cream-50);
|
||||
border:0; border-radius:10px;
|
||||
font-family:var(--font-body); font-weight:600; font-size:15px;
|
||||
cursor:pointer; transition:background 120ms;
|
||||
margin-top:16px;
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:8px;
|
||||
}}
|
||||
.btn-pay:hover {{ background:var(--navy-900); }}
|
||||
.btn-pay:disabled {{ opacity:0.6; cursor:wait; }}
|
||||
.btn-pay svg {{ width:18px; height:18px; }}
|
||||
.error {{
|
||||
margin-top:14px; padding:10px 14px;
|
||||
background:var(--danger-bg); color:#8a2828;
|
||||
border:1px solid rgba(178,58,58,0.25);
|
||||
border-radius:7px; font-size:13.5px;
|
||||
display:none;
|
||||
}}
|
||||
.error.show {{ display:block; }}
|
||||
.license-success {{
|
||||
display:none; margin-top:24px;
|
||||
background:var(--cream-50); border:1px solid var(--border-1);
|
||||
border-radius:14px;
|
||||
box-shadow:0 0 0 1px var(--gold-500) inset, 0 8px 16px rgba(14,31,51,0.10);
|
||||
padding:32px 32px 28px; position:relative;
|
||||
}}
|
||||
.license-success.show {{ display:block; }}
|
||||
.license-success::before, .license-success::after {{
|
||||
content:''; position:absolute; left:14px; right:14px;
|
||||
height:1px; background:var(--gold-500); opacity:0.5;
|
||||
}}
|
||||
.license-success::before {{ top:14px; }}
|
||||
.license-success::after {{ bottom:14px; }}
|
||||
.license-success .stamp {{
|
||||
font-size:10px; font-weight:700; letter-spacing:0.22em;
|
||||
text-transform:uppercase; color:var(--gold-700);
|
||||
text-align:center; margin-bottom:16px;
|
||||
}}
|
||||
.license-success h3 {{
|
||||
font-family:var(--font-display); font-weight:500; font-size:22px;
|
||||
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em;
|
||||
text-align:center;
|
||||
}}
|
||||
.license-success .subtitle {{
|
||||
font-size:14px; color:var(--ink-500); text-align:center;
|
||||
margin:0 0 22px;
|
||||
}}
|
||||
.license-success .field-label {{
|
||||
font-size:11px; font-weight:600; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
|
||||
}}
|
||||
.license-success .key-box {{
|
||||
background:var(--navy-950); color:var(--cream-50);
|
||||
padding:14px 16px; border-radius:8px;
|
||||
font-family:var(--font-mono); font-size:12.5px;
|
||||
word-break:break-all; line-height:1.5;
|
||||
display:flex; align-items:flex-start; gap:12px;
|
||||
}}
|
||||
.license-success .key-box .key-text {{ flex:1; }}
|
||||
.license-success .key-box button {{
|
||||
background:rgba(245,241,232,0.10); color:var(--cream-50);
|
||||
border:0; padding:6px 10px; border-radius:6px;
|
||||
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
|
||||
flex-shrink:0;
|
||||
}}
|
||||
.license-success .key-box button:hover {{ background:rgba(245,241,232,0.20); }}
|
||||
.license-success .save-note {{
|
||||
margin-top:14px; font-size:13px; color:var(--ink-700);
|
||||
background:var(--cream-100); border:1px solid var(--border-1);
|
||||
border-radius:8px; padding:10px 14px;
|
||||
}}
|
||||
.license-success .save-note strong {{ color:var(--navy-950); }}
|
||||
footer.kfooter {{
|
||||
text-align:center; font-size:12px; color:var(--ink-500);
|
||||
margin-top:48px; padding:18px;
|
||||
}}
|
||||
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
|
||||
footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="inner">
|
||||
<span>Keysat</span>
|
||||
<span class="operator">Sold by {operator}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">Buy a license</div>
|
||||
<h1>{product_name}</h1>
|
||||
<div class="product-slug">{product_slug}</div>
|
||||
<p class="description">{product_description}</p>
|
||||
|
||||
<div class="cert">
|
||||
<div class="price-label">Price</div>
|
||||
<div class="price" id="price-display">
|
||||
<span id="price-strike-line" class="price-strike" style="display:none"></span>
|
||||
<span id="price-current">{price_sats_fmt}</span><span class="unit">sats</span>
|
||||
<span id="price-discount-tag" class="price-discount-tag" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="buy-form">
|
||||
<div class="field">
|
||||
<label for="email">Email (for receipt & license)</label>
|
||||
<input type="email" id="email" name="email" placeholder="you@example.com" required>
|
||||
<div class="hint">We’ll send your license key here after payment confirms.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="code">Discount code (optional)</label>
|
||||
<div class="code-row">
|
||||
<input type="text" id="code" name="code" placeholder="FOUNDERS50" autocomplete="off">
|
||||
<button type="button" class="btn-apply" id="btn-apply">Apply</button>
|
||||
</div>
|
||||
<div class="code-status" id="code-status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn-pay" id="btn-pay">
|
||||
<svg id="btn-pay-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9.5 8.5h5a2 2 0 010 4h-5m0 0h5a2 2 0 010 4h-5m0-8v8m2-10v2m0 8v2"></path>
|
||||
</svg>
|
||||
<span id="btn-pay-label">Pay with Bitcoin</span>
|
||||
</button>
|
||||
<div class="error" id="err"></div>
|
||||
</form>
|
||||
|
||||
<div class="license-success" id="license-success" role="region" aria-label="License issued">
|
||||
<div class="stamp">— License issued —</div>
|
||||
<h3>You’re licensed.</h3>
|
||||
<p class="subtitle">No payment needed for this code. Your signed license is below.</p>
|
||||
<div class="field-label">License key</div>
|
||||
<div class="key-box">
|
||||
<span class="key-text" id="license-key-text">…</span>
|
||||
<button id="license-key-copy">Copy</button>
|
||||
</div>
|
||||
<div class="save-note">
|
||||
<strong>Save this somewhere safe.</strong> The license key is signed at issue time and verifies offline. We’ll also send a copy to <span id="license-email-display"></span> for your records.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="kfooter">
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-paid software licensing</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const form = document.getElementById('buy-form');
|
||||
const btn = document.getElementById('btn-pay');
|
||||
const btnLabel = document.getElementById('btn-pay-label');
|
||||
const btnIcon = document.getElementById('btn-pay-icon');
|
||||
const errEl = document.getElementById('err');
|
||||
const successEl = document.getElementById('license-success');
|
||||
const keyTextEl = document.getElementById('license-key-text');
|
||||
const emailDisplayEl = document.getElementById('license-email-display');
|
||||
const codeInput = document.getElementById('code');
|
||||
const applyBtn = document.getElementById('btn-apply');
|
||||
const codeStatus = document.getElementById('code-status');
|
||||
const priceCurrent = document.getElementById('price-current');
|
||||
const priceStrike = document.getElementById('price-strike-line');
|
||||
const priceTag = document.getElementById('price-discount-tag');
|
||||
const PRODUCT_SLUG = {slug_json};
|
||||
const BASE_PRICE_FMT = priceCurrent.textContent;
|
||||
|
||||
// State of the most recent successful Apply. When set with kind=free_license
|
||||
// and the same code is still in the input, the submit handler skips the
|
||||
// "try /v1/redeem then fall through" dance and goes straight to redeem.
|
||||
let appliedCode = null; // {{ code, kind, is_free, final_price_sats }}
|
||||
|
||||
function showError(msg) {{
|
||||
errEl.textContent = msg;
|
||||
errEl.classList.add('show');
|
||||
}}
|
||||
function clearError() {{ errEl.classList.remove('show'); }}
|
||||
function showLicense(licenseKey, email) {{
|
||||
keyTextEl.textContent = licenseKey;
|
||||
emailDisplayEl.textContent = email || '(no email provided)';
|
||||
form.style.display = 'none';
|
||||
successEl.classList.add('show');
|
||||
successEl.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
|
||||
}}
|
||||
|
||||
function fmtNum(n) {{
|
||||
return Number(n).toLocaleString('en-US');
|
||||
}}
|
||||
|
||||
function setStatus(kind, text) {{
|
||||
codeStatus.classList.remove('ok', 'bad');
|
||||
if (!kind) {{ codeStatus.classList.remove('show'); codeStatus.textContent = ''; return; }}
|
||||
codeStatus.classList.add(kind === 'ok' ? 'ok' : 'bad', 'show');
|
||||
codeStatus.textContent = text;
|
||||
}}
|
||||
|
||||
function resetPrice() {{
|
||||
priceCurrent.textContent = BASE_PRICE_FMT;
|
||||
priceStrike.style.display = 'none';
|
||||
priceStrike.textContent = '';
|
||||
priceTag.style.display = 'none';
|
||||
priceTag.textContent = '';
|
||||
}}
|
||||
function setPaidButton() {{
|
||||
btnLabel.textContent = 'Pay with Bitcoin';
|
||||
btnIcon.style.display = '';
|
||||
}}
|
||||
function setRedeemButton() {{
|
||||
btnLabel.textContent = 'Redeem license';
|
||||
btnIcon.style.display = 'none';
|
||||
}}
|
||||
|
||||
// Reset apply state if the buyer edits the code after a successful Apply.
|
||||
codeInput.addEventListener('input', function() {{
|
||||
if (appliedCode && codeInput.value.trim().toUpperCase() !== appliedCode.code.toUpperCase()) {{
|
||||
appliedCode = null;
|
||||
resetPrice();
|
||||
setPaidButton();
|
||||
setStatus(null);
|
||||
}}
|
||||
}});
|
||||
|
||||
applyBtn.addEventListener('click', async function() {{
|
||||
clearError();
|
||||
const code = codeInput.value.trim();
|
||||
if (!code) {{
|
||||
setStatus('bad', 'Enter a code first.');
|
||||
return;
|
||||
}}
|
||||
applyBtn.disabled = true;
|
||||
const orig = applyBtn.textContent;
|
||||
applyBtn.textContent = 'Checking…';
|
||||
try {{
|
||||
const url = '/v1/discount-codes/preview?code='
|
||||
+ encodeURIComponent(code) + '&product=' + encodeURIComponent(PRODUCT_SLUG);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {{
|
||||
let msg = 'HTTP ' + resp.status;
|
||||
try {{ const j = await resp.json(); msg = j.message || j.error || msg; }} catch(_) {{}}
|
||||
throw new Error(msg);
|
||||
}}
|
||||
const j = await resp.json();
|
||||
if (!j.valid) {{
|
||||
appliedCode = null;
|
||||
resetPrice();
|
||||
setPaidButton();
|
||||
setStatus('bad', j.message || 'Code not valid.');
|
||||
return;
|
||||
}}
|
||||
appliedCode = {{
|
||||
code: j.code,
|
||||
kind: j.kind,
|
||||
is_free: !!j.is_free,
|
||||
final_price_sats: j.final_price_sats,
|
||||
}};
|
||||
// Update price card
|
||||
if (j.kind === 'free_license' || j.final_price_sats === 0) {{
|
||||
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
|
||||
priceStrike.style.display = 'block';
|
||||
priceCurrent.textContent = 'FREE';
|
||||
priceTag.textContent = '100% off';
|
||||
priceTag.style.display = 'inline-block';
|
||||
setRedeemButton();
|
||||
}} else {{
|
||||
priceStrike.textContent = fmtNum(j.base_price_sats) + ' sats';
|
||||
priceStrike.style.display = 'block';
|
||||
priceCurrent.textContent = fmtNum(j.final_price_sats);
|
||||
if (j.kind === 'percent') {{
|
||||
priceTag.textContent = (j.amount_pct || ((j.discount_applied_sats / j.base_price_sats) * 100).toFixed(0)) + '% off';
|
||||
}} else {{
|
||||
priceTag.textContent = fmtNum(j.discount_applied_sats) + ' sats off';
|
||||
}}
|
||||
priceTag.style.display = 'inline-block';
|
||||
setPaidButton();
|
||||
}}
|
||||
setStatus('ok', j.message || 'Code applied.');
|
||||
}} catch (err) {{
|
||||
appliedCode = null;
|
||||
resetPrice();
|
||||
setPaidButton();
|
||||
setStatus('bad', err.message || 'Could not validate code.');
|
||||
}} finally {{
|
||||
applyBtn.disabled = false;
|
||||
applyBtn.textContent = orig;
|
||||
}}
|
||||
}});
|
||||
|
||||
// Try free-license redemption first if a code was provided. If that
|
||||
// path returns "this code requires payment", fall through to the
|
||||
// BTCPay flow with the code applied. Any other error stops here.
|
||||
async function tryFreeRedeem(code, email) {{
|
||||
const resp = await fetch('/v1/redeem', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify({{
|
||||
product: PRODUCT_SLUG,
|
||||
code,
|
||||
buyer_email: email || undefined,
|
||||
}}),
|
||||
}});
|
||||
if (resp.ok) {{
|
||||
const j = await resp.json();
|
||||
return {{ ok: true, license_key: j.license_key }};
|
||||
}}
|
||||
let msg = 'HTTP ' + resp.status;
|
||||
try {{
|
||||
const j = await resp.json();
|
||||
msg = j.message || j.error || msg;
|
||||
}} catch (_) {{}}
|
||||
// Distinguish "fall through to paid flow" from real errors.
|
||||
if (resp.status === 400 && /requires payment/i.test(msg)) {{
|
||||
return {{ ok: false, fallThrough: true }};
|
||||
}}
|
||||
return {{ ok: false, fallThrough: false, msg }};
|
||||
}}
|
||||
|
||||
async function startPaidPurchase(code, email) {{
|
||||
const body = {{ product: PRODUCT_SLUG, buyer_email: email || undefined }};
|
||||
if (code) body.code = code;
|
||||
const resp = await fetch('/v1/purchase', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify(body),
|
||||
}});
|
||||
if (!resp.ok) {{
|
||||
let msg = 'HTTP ' + resp.status;
|
||||
try {{
|
||||
const j = await resp.json();
|
||||
msg = j.message || j.error || msg;
|
||||
}} catch (_) {{}}
|
||||
throw new Error(msg);
|
||||
}}
|
||||
const j = await resp.json();
|
||||
if (!j.checkout_url) throw new Error('No checkout URL returned by server');
|
||||
window.location.href = j.checkout_url;
|
||||
}}
|
||||
|
||||
// "Copy" on the license key box.
|
||||
document.getElementById('license-key-copy').addEventListener('click', async function() {{
|
||||
try {{
|
||||
await navigator.clipboard.writeText(keyTextEl.textContent);
|
||||
this.textContent = 'Copied';
|
||||
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
|
||||
}} catch (e) {{}}
|
||||
}});
|
||||
|
||||
form.addEventListener('submit', async function(e) {{
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
btn.disabled = true;
|
||||
const originalLabel = btnLabel.textContent;
|
||||
btnLabel.textContent = 'Working…';
|
||||
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const code = codeInput.value.trim();
|
||||
const codeMatchesApplied = appliedCode &&
|
||||
code.toUpperCase() === appliedCode.code.toUpperCase();
|
||||
|
||||
try {{
|
||||
// Fast path: a free_license code was already validated via Apply.
|
||||
if (codeMatchesApplied && appliedCode.is_free) {{
|
||||
const r = await tryFreeRedeem(code, email);
|
||||
if (r.ok) {{ showLicense(r.license_key, email); return; }}
|
||||
// If the server changed its mind, surface the error rather than silently
|
||||
// routing to a paid flow that the buyer didn't consent to.
|
||||
throw new Error(r.msg || 'Could not redeem free license.');
|
||||
}}
|
||||
|
||||
// Slower path (no Apply or non-free code): keep the original try-then-fallthrough.
|
||||
if (code) {{
|
||||
const r = await tryFreeRedeem(code, email);
|
||||
if (r.ok) {{ showLicense(r.license_key, email); return; }}
|
||||
if (!r.fallThrough) {{
|
||||
throw new Error(r.msg || 'Code rejected');
|
||||
}}
|
||||
// else fall through to the BTCPay path with the code applied
|
||||
}}
|
||||
|
||||
btnLabel.textContent = 'Creating invoice…';
|
||||
await startPaidPurchase(code, email);
|
||||
}} catch (err) {{
|
||||
showError('Could not complete: ' + (err.message || err));
|
||||
btn.disabled = false;
|
||||
btnLabel.textContent = originalLabel;
|
||||
}}
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
operator = operator,
|
||||
product_name = product_name,
|
||||
product_slug = product_slug,
|
||||
product_description = product_description,
|
||||
price_sats_fmt = price_sats_fmt,
|
||||
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
|
||||
);
|
||||
Ok(Html(body))
|
||||
}
|
||||
|
||||
fn not_found_html(slug: &str) -> String {
|
||||
let slug_safe = html_escape(slug);
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Product not found</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#222;background:#fafafa;line-height:1.55}}
|
||||
h1{{font-size:1.5rem;margin-top:0}}
|
||||
code{{background:#eee;padding:0.1em 0.4em;border-radius:4px;font-family:ui-monospace,monospace}}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>Product not found</h1>
|
||||
<p>No product is registered under the slug <code>{slug_safe}</code>, or it’s currently inactive.</p>
|
||||
<p>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.</p>
|
||||
</body></html>"#,
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Admin endpoints for discount / referral codes.
|
||||
//!
|
||||
//! Operators create codes, list them with usage stats, and disable them.
|
||||
//! The public purchase flow consumes codes via the `code` field on
|
||||
//! `POST /v1/purchase`; that path is handled in `crate::api::purchase`.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDiscountCodeReq {
|
||||
/// e.g. "FOUNDERS50". Normalized to uppercase. ASCII alphanumerics + '-' '_'.
|
||||
pub code: String,
|
||||
/// 'percent' | 'fixed_sats'.
|
||||
pub kind: String,
|
||||
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
|
||||
pub amount: i64,
|
||||
#[serde(default)]
|
||||
pub max_uses: Option<i64>,
|
||||
/// ISO-8601 RFC3339 UTC timestamp.
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
/// Restrict to a single product (by slug). Omit for any product.
|
||||
#[serde(default)]
|
||||
pub product_slug: Option<String>,
|
||||
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
|
||||
/// Requires `product_slug` to be set if specified.
|
||||
#[serde(default)]
|
||||
pub policy_slug: Option<String>,
|
||||
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
|
||||
#[serde(default)]
|
||||
pub referrer_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateDiscountCodeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Resolve product/policy slugs to ids if supplied.
|
||||
let product_id = if let Some(slug) = req.product_slug.as_deref() {
|
||||
let p = repo::get_product_by_slug(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
|
||||
Some(p.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
|
||||
let pid = product_id.as_deref().ok_or_else(|| {
|
||||
AppError::BadRequest("policy_slug requires product_slug".into())
|
||||
})?;
|
||||
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"policy '{slug}' for product '{}'",
|
||||
req.product_slug.as_deref().unwrap_or("")
|
||||
))
|
||||
})?;
|
||||
Some(policy.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let code = repo::create_discount_code(
|
||||
&state.db,
|
||||
&req.code,
|
||||
&req.kind,
|
||||
req.amount,
|
||||
req.max_uses,
|
||||
req.expires_at.as_deref(),
|
||||
product_id.as_deref(),
|
||||
policy_id.as_deref(),
|
||||
req.referrer_label.as_deref(),
|
||||
&req.description,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"discount_code.create",
|
||||
Some("discount_code"),
|
||||
Some(&code.id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({
|
||||
"code": code.code,
|
||||
"kind": code.kind,
|
||||
"amount": code.amount,
|
||||
"max_uses": code.max_uses,
|
||||
"expires_at": code.expires_at,
|
||||
"product_id": product_id,
|
||||
"policy_id": policy_id,
|
||||
"referrer_label": code.referrer_label,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!(code)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub include_inactive: bool,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?;
|
||||
Ok(Json(json!({ "codes": codes })))
|
||||
}
|
||||
|
||||
pub async fn get_one(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let code = repo::get_discount_code_by_id(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
|
||||
let redemptions = repo::list_redemptions_by_code(&state.db, &code.id).await?;
|
||||
Ok(Json(json!({
|
||||
"code": code,
|
||||
"redemptions": redemptions,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetActiveReq {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub async fn set_active(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_discount_code_active(&state.db, &id, req.active).await?;
|
||||
let action = if req.active {
|
||||
"discount_code.enable"
|
||||
} else {
|
||||
"discount_code.disable"
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
action,
|
||||
Some("discount_code"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "active": req.active }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Hard-delete a discount code. Refuses if any redemptions reference
|
||||
/// the code — those rows are part of the audit trail and shouldn't be
|
||||
/// orphaned. For codes that have been used, the operator should
|
||||
/// disable instead.
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Look up the code so we can audit-log meaningful detail.
|
||||
let code = repo::get_discount_code_by_id(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("discount code '{id}'")))?;
|
||||
|
||||
// Refuse if any redemptions exist (referential integrity + audit
|
||||
// trail preservation). Operator should use Disable in that case.
|
||||
let redemption_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM discount_redemptions WHERE code_id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if redemption_count > 0 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"cannot delete code '{}' — it has {} redemption(s) on the audit trail. \
|
||||
Disable it instead (it stops accepting new uses, but the history is kept).",
|
||||
code.code, redemption_count
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM discount_codes WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"discount_code.delete",
|
||||
Some("discount_code"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "code": code.code, "kind": code.kind }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true, "deleted": code.code })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PreviewQuery {
|
||||
pub code: String,
|
||||
pub product: String,
|
||||
}
|
||||
|
||||
/// PUBLIC endpoint — buyers hit this from the buy page when they click
|
||||
/// Apply on a discount code. Validates the code (existence, active
|
||||
/// state, expiry, product applicability) and returns the kind +
|
||||
/// computed discounted price WITHOUT consuming a slot. The actual
|
||||
/// purchase / redemption still goes through `/v1/purchase` or
|
||||
/// `/v1/redeem` and is the real transaction; this is just for showing
|
||||
/// the buyer what they'll be charged before they commit.
|
||||
pub async fn preview(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PreviewQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let code_str = q.code.trim();
|
||||
if code_str.is_empty() {
|
||||
return Err(AppError::BadRequest("code is required".into()));
|
||||
}
|
||||
let product = repo::get_product_by_slug(&state.db, &q.product)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product)))?;
|
||||
|
||||
let code = match repo::get_discount_code_by_code(&state.db, code_str).await? {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok(Json(json!({
|
||||
"valid": false,
|
||||
"reason": "unknown_code",
|
||||
"message": "Code not found.",
|
||||
"base_price_sats": product.price_sats,
|
||||
})));
|
||||
}
|
||||
};
|
||||
if !code.active {
|
||||
return Ok(Json(json!({
|
||||
"valid": false,
|
||||
"reason": "disabled",
|
||||
"message": "This code has been disabled.",
|
||||
"base_price_sats": product.price_sats,
|
||||
})));
|
||||
}
|
||||
if let Some(exp) = &code.expires_at {
|
||||
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
|
||||
return Ok(Json(json!({
|
||||
"valid": false,
|
||||
"reason": "expired",
|
||||
"message": "This code has expired.",
|
||||
"base_price_sats": product.price_sats,
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pid) = &code.applies_to_product_id {
|
||||
if pid != &product.id {
|
||||
return Ok(Json(json!({
|
||||
"valid": false,
|
||||
"reason": "wrong_product",
|
||||
"message": "This code does not apply to this product.",
|
||||
"base_price_sats": product.price_sats,
|
||||
})));
|
||||
}
|
||||
}
|
||||
if let Some(max) = code.max_uses {
|
||||
if code.used_count >= max {
|
||||
return Ok(Json(json!({
|
||||
"valid": false,
|
||||
"reason": "exhausted",
|
||||
"message": "This code has reached its use limit.",
|
||||
"base_price_sats": product.price_sats,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the discounted price (mirroring purchase.rs's logic).
|
||||
let base = product.price_sats;
|
||||
let (final_price, discount_applied) = match code.kind.as_str() {
|
||||
"free_license" => (0i64, base),
|
||||
"percent" => {
|
||||
let bps = (code.amount).clamp(0, 10_000) as i128;
|
||||
let b = base as i128;
|
||||
let discount = ((b * bps) / 10_000).max(0).min(b) as i64;
|
||||
((base - discount).max(1), discount)
|
||||
}
|
||||
"fixed_sats" => {
|
||||
let discount = code.amount.max(0).min(base);
|
||||
((base - discount).max(1), discount)
|
||||
}
|
||||
_ => (base, 0),
|
||||
};
|
||||
|
||||
let amount_pct = if code.kind == "percent" {
|
||||
Some(code.amount as f64 / 100.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"valid": true,
|
||||
"code": code.code,
|
||||
"kind": code.kind,
|
||||
"is_free": code.kind == "free_license",
|
||||
"base_price_sats": base,
|
||||
"discount_applied_sats": discount_applied,
|
||||
"final_price_sats": if code.kind == "free_license" { 0 } else { final_price },
|
||||
"amount_pct": amount_pct,
|
||||
"message": match code.kind.as_str() {
|
||||
"free_license" => "Free license — no payment required.".to_string(),
|
||||
"percent" => format!("{}% off applied.", code.amount as f64 / 100.0),
|
||||
"fixed_sats" => format!("{} sats off applied.", code.amount),
|
||||
_ => "Code applied.".to_string(),
|
||||
},
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Admin-only issuer-key import endpoint.
|
||||
//!
|
||||
//! Used exactly once, by exactly one operator: when bootstrapping a
|
||||
//! "master Keysat" instance (the one that issues licenses for the Keysat
|
||||
//! package itself). The master operator pre-generated an Ed25519 keypair
|
||||
//! offline; this endpoint takes the PEM-encoded private half and stores
|
||||
//! it as the daemon's signing keypair, replacing the auto-generated one
|
||||
//! that gets created on first boot.
|
||||
//!
|
||||
//! ## Why this isn't a StartOS Action
|
||||
//!
|
||||
//! 95% of Keysat operators install Keysat to sell their own software.
|
||||
//! Their auto-generated issuer key is exactly what they want; they never
|
||||
//! need this endpoint. Surfacing an "import issuer key" button in every
|
||||
//! operator's StartOS Actions tab would create cognitive load (am I
|
||||
//! supposed to do this?) for zero benefit. So this lives as an admin
|
||||
//! API endpoint only — invisible by default, callable via curl during
|
||||
//! the master-bootstrap procedure documented in
|
||||
//! `MASTER_KEYPAIR_PROCEDURE.md`.
|
||||
//!
|
||||
//! ## Safety guards
|
||||
//!
|
||||
//! Replacing the issuer key after licenses have been issued would
|
||||
//! invalidate every previously-signed customer license. To prevent that
|
||||
//! footgun, the endpoint refuses if any license rows exist in the
|
||||
//! database. The master Keysat instance hasn't issued anything when it
|
||||
//! gets bootstrapped, so this guard never trips during legitimate use
|
||||
//! and prevents the worst-case mistake.
|
||||
//!
|
||||
//! ## After successful import
|
||||
//!
|
||||
//! The new keypair lands in the `server_keys` table immediately, but the
|
||||
//! daemon's in-memory `AppState.keypair` still holds the old one until
|
||||
//! restart. The endpoint returns a `restart_required: true` so the
|
||||
//! operator (or their orchestration) knows to bounce the service before
|
||||
//! the new key takes effect.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{body::Bytes, extract::State, http::HeaderMap, Json};
|
||||
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn import(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let pem = std::str::from_utf8(&body)
|
||||
.map_err(|_| AppError::BadRequest("body is not valid UTF-8".into()))?
|
||||
.trim();
|
||||
if pem.is_empty() {
|
||||
return Err(AppError::BadRequest("body is empty".into()));
|
||||
}
|
||||
if !pem.contains("-----BEGIN") || !pem.contains("PRIVATE KEY-----") {
|
||||
return Err(AppError::BadRequest(
|
||||
"expected a PEM-encoded private key (must contain BEGIN/END PRIVATE KEY)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Parse + validate the supplied PEM.
|
||||
let signing = SigningKey::from_pkcs8_pem(pem).map_err(|e| {
|
||||
AppError::BadRequest(format!("could not parse Ed25519 private key: {e}"))
|
||||
})?;
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
// Re-encode through pkcs8 so we always store a normalized form. This
|
||||
// also catches any encoding oddity on the input side that would have
|
||||
// tripped a future load.
|
||||
use pkcs8::LineEnding;
|
||||
let priv_pem = signing
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("re-encode private key: {e}")))?
|
||||
.to_string();
|
||||
let pub_pem = verifying
|
||||
.to_public_key_pem(LineEnding::LF)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("encode public key: {e}")))?;
|
||||
|
||||
// Safety guard: refuse if any licenses have already been issued by
|
||||
// this Keysat. Replacing the issuer key would invalidate them.
|
||||
let licenses_exist: bool =
|
||||
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM licenses LIMIT 1)")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if licenses_exist {
|
||||
return Err(AppError::Conflict(
|
||||
"this Keysat has already issued at least one license; importing a new \
|
||||
issuer key would invalidate every previously-signed license. Refusing. \
|
||||
Use this endpoint only on a fresh master-Keysat install before any \
|
||||
licenses have been issued."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Upsert the keypair into server_keys row id=1. SQLite's INSERT ON
|
||||
// CONFLICT is the idiomatic way to do this in one statement.
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
|
||||
VALUES (1, 'ed25519', ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
algorithm = excluded.algorithm,
|
||||
public_key_pem = excluded.public_key_pem,
|
||||
private_key_pem = excluded.private_key_pem,
|
||||
created_at = excluded.created_at",
|
||||
)
|
||||
.bind(&pub_pem)
|
||||
.bind(&priv_pem)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Audit-log this prominently. There is no scenario where a regular
|
||||
// operator should be running this; if it shows up in the audit log
|
||||
// unexpectedly, that's a red flag worth investigating.
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"issuer_key.import",
|
||||
Some("server_key"),
|
||||
None,
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({
|
||||
"public_key_pem": pub_pem,
|
||||
"note": "master-bootstrap import",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::warn!(
|
||||
public_key = %pub_pem.lines().nth(1).unwrap_or(""),
|
||||
"issuer key imported via admin endpoint — restart the service for the new key to take effect"
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"public_key_pem": pub_pem,
|
||||
"restart_required": true,
|
||||
"message": "Issuer key imported. Restart the Keysat service for the new \
|
||||
key to take effect — until then, in-memory state still holds \
|
||||
the previous keypair."
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Machines — individual install records bound to a license.
|
||||
//!
|
||||
//! In the single-seat case (`licenses.max_machines = 1`), the first
|
||||
//! successful `/v1/validate` call locks the fingerprint onto the license
|
||||
//! and creates a `machines` row. Later validations keep heartbeating that
|
||||
//! row.
|
||||
//!
|
||||
//! In the multi-seat case (`max_machines > 1` or `0` for unlimited),
|
||||
//! validate auto-activates up to the cap. Beyond the cap, the client gets a
|
||||
//! `too_many_machines` reject and is expected to call
|
||||
//! `POST /v1/machines/deactivate` with the fingerprint of an old install to
|
||||
//! free up a slot, then retry.
|
||||
//!
|
||||
//! Explicit activation endpoints (`POST /v1/machines/activate`) are offered
|
||||
//! for apps that want to prompt the user about seat usage before starting up
|
||||
//! for the first time. They behave identically to `/v1/validate`'s implicit
|
||||
//! activation, just without requiring the full key check.
|
||||
//!
|
||||
//! Admin endpoints let operators look at who's using what and force-kick a
|
||||
//! machine off a license.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::crypto;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ---------- Public endpoints (client-facing) ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ActivateReq {
|
||||
pub key: String,
|
||||
pub fingerprint: String,
|
||||
pub hostname: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActivateResp {
|
||||
pub ok: bool,
|
||||
pub machine_id: Option<String>,
|
||||
pub active_count: i64,
|
||||
pub max_machines: i64,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn activate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ActivateReq>,
|
||||
) -> AppResult<Json<ActivateResp>> {
|
||||
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
|
||||
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
|
||||
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
||||
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
|
||||
let license_id = payload.license_id.to_string();
|
||||
let license = repo::get_license_by_id(&state.db, &license_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("license {license_id}")))?;
|
||||
|
||||
if license.status != "active" {
|
||||
return Ok(Json(ActivateResp {
|
||||
ok: false,
|
||||
machine_id: None,
|
||||
active_count: 0,
|
||||
max_machines: license.max_machines,
|
||||
reason: Some(license.status),
|
||||
}));
|
||||
}
|
||||
|
||||
let client_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
|
||||
|
||||
let fp_hash = crate::hex_sha256(&req.fingerprint);
|
||||
|
||||
if let Some(m) =
|
||||
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
|
||||
{
|
||||
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
|
||||
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
||||
return Ok(Json(ActivateResp {
|
||||
ok: true,
|
||||
machine_id: Some(m.id),
|
||||
active_count: active.len() as i64,
|
||||
max_machines: license.max_machines,
|
||||
reason: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
||||
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
|
||||
return Ok(Json(ActivateResp {
|
||||
ok: false,
|
||||
machine_id: None,
|
||||
active_count: active.len() as i64,
|
||||
max_machines: license.max_machines,
|
||||
reason: Some("too_many_machines".into()),
|
||||
}));
|
||||
}
|
||||
|
||||
let m = repo::activate_machine(
|
||||
&state.db,
|
||||
&license_id,
|
||||
&req.fingerprint,
|
||||
&fp_hash,
|
||||
req.hostname.as_deref(),
|
||||
req.platform.as_deref(),
|
||||
client_ip.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"machine.activated",
|
||||
&json!({
|
||||
"license_id": license_id,
|
||||
"machine_id": m.id,
|
||||
"fingerprint_hash": fp_hash,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
||||
Ok(Json(ActivateResp {
|
||||
ok: true,
|
||||
machine_id: Some(m.id),
|
||||
active_count: active.len() as i64,
|
||||
max_machines: license.max_machines,
|
||||
reason: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HeartbeatReq {
|
||||
pub key: String,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
pub async fn heartbeat(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<HeartbeatReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
|
||||
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
|
||||
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
||||
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
|
||||
let license_id = payload.license_id.to_string();
|
||||
|
||||
// Rate-limit heartbeats per-license to 60/hr.
|
||||
if !crate::rate_limit::consume(
|
||||
&state.db,
|
||||
"heartbeat_license",
|
||||
&license_id,
|
||||
/* capacity */ 60.0,
|
||||
/* refill_per_second */ 60.0 / 3600.0,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Json(json!({ "ok": false, "reason": "rate_limited" })));
|
||||
}
|
||||
|
||||
let fp_hash = crate::hex_sha256(&req.fingerprint);
|
||||
let client_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
|
||||
|
||||
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
|
||||
Some(m) => {
|
||||
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
|
||||
Ok(Json(json!({ "ok": true, "machine_id": m.id })))
|
||||
}
|
||||
None => Ok(Json(json!({ "ok": false, "reason": "not_activated" }))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeactivateReq {
|
||||
pub key: String,
|
||||
pub fingerprint: String,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn deactivate(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<DeactivateReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (payload, signature, signed_bytes) = crypto::parse_key(&req.key)
|
||||
.map_err(|e| AppError::BadRequest(format!("bad key: {e}")))?;
|
||||
crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
||||
.map_err(|_| AppError::BadRequest("signature verification failed".into()))?;
|
||||
let license_id = payload.license_id.to_string();
|
||||
let fp_hash = crate::hex_sha256(&req.fingerprint);
|
||||
|
||||
let m = repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?;
|
||||
let Some(m) = m else {
|
||||
return Ok(Json(json!({ "ok": false, "reason": "not_found" })));
|
||||
};
|
||||
let reason = req
|
||||
.reason
|
||||
.unwrap_or_else(|| "client_requested".to_string());
|
||||
repo::deactivate_machine(&state.db, &m.id, &reason).await?;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"machine.deactivated",
|
||||
&json!({
|
||||
"license_id": license_id,
|
||||
"machine_id": m.id,
|
||||
"reason": reason,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Single-seat legacy: also clear licenses.fingerprint so the next client
|
||||
// can re-bind cleanly.
|
||||
let license = repo::get_license_by_id(&state.db, &license_id).await?;
|
||||
if let Some(lic) = license {
|
||||
if lic.max_machines == 1 {
|
||||
let _ = sqlx::query("UPDATE licenses SET fingerprint = NULL WHERE id = ?")
|
||||
.bind(&license_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------- Admin endpoints ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminListQuery {
|
||||
pub license_id: String,
|
||||
#[serde(default)]
|
||||
pub include_inactive: bool,
|
||||
}
|
||||
|
||||
pub async fn admin_list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<AdminListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let machines = if q.include_inactive {
|
||||
repo::list_all_machines(&state.db, &q.license_id).await?
|
||||
} else {
|
||||
repo::list_active_machines(&state.db, &q.license_id).await?
|
||||
};
|
||||
Ok(Json(json!({ "machines": machines })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminDeactivateReq {
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub async fn admin_deactivate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<AdminDeactivateReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = if req.reason.is_empty() {
|
||||
"admin deactivate".to_string()
|
||||
} else {
|
||||
req.reason
|
||||
};
|
||||
let m = repo::get_machine_by_id(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("machine {id}")))?;
|
||||
repo::deactivate_machine(&state.db, &id, &reason).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"machine.deactivate",
|
||||
Some("machine"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "license_id": m.license_id, "reason": reason }),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"machine.deactivated",
|
||||
&json!({
|
||||
"license_id": m.license_id,
|
||||
"machine_id": id,
|
||||
"reason": reason,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
//! HTTP API surface.
|
||||
//!
|
||||
//! Route layout (v1):
|
||||
//!
|
||||
//! | Method | Path | Purpose |
|
||||
//! |--------|----------------------------------------|---------------------------------------------|
|
||||
//! | GET | `/` | service info + public key |
|
||||
//! | GET | `/healthz` | health check |
|
||||
//! | GET | `/thank-you` | post-payment landing (BTCPay redirect tgt) |
|
||||
//! | GET | `/admin/` | embedded admin web UI (SPA, client-gated) |
|
||||
//! | GET | `/admin/<path>` | static assets for the embedded admin UI |
|
||||
//! | GET | `/v1/pubkey` | PEM-encoded Ed25519 public key |
|
||||
//! | GET | `/v1/products` | list active products |
|
||||
//! | GET | `/v1/products/:slug` | single product |
|
||||
//! | POST | `/v1/purchase` | start purchase, returns BTCPay URL |
|
||||
//! | GET | `/v1/purchase/:invoice_id` | poll purchase status + license if ready |
|
||||
//! | POST | `/v1/redeem` | redeem a 'free_license' code, no BTCPay |
|
||||
//! | POST | `/v1/validate` | validate a license key |
|
||||
//! | POST | `/v1/machines/activate` | explicit seat activation |
|
||||
//! | POST | `/v1/machines/heartbeat` | seat heartbeat |
|
||||
//! | POST | `/v1/machines/deactivate` | free a seat (client-initiated) |
|
||||
//! | POST | `/v1/btcpay/webhook` | BTCPay webhook landing |
|
||||
//! | Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY` |
|
||||
//! | POST | `/v1/admin/products` | create product |
|
||||
//! | PATCH | `/v1/admin/products/:id/active` | activate / deactivate |
|
||||
//! | POST | `/v1/admin/licenses` | manually issue license (comp/dev) |
|
||||
//! | GET | `/v1/admin/licenses` | list licenses by product |
|
||||
//! | GET | `/v1/admin/licenses/search` | search by email / npub / invoice |
|
||||
//! | POST | `/v1/admin/licenses/:id/revoke` | revoke a license |
|
||||
//! | POST | `/v1/admin/licenses/:id/suspend` | suspend (reversible) |
|
||||
//! | POST | `/v1/admin/licenses/:id/unsuspend` | unsuspend |
|
||||
//! | POST | `/v1/admin/policies` | create policy (license template) |
|
||||
//! | GET | `/v1/admin/policies` | list policies for product |
|
||||
//! | PATCH | `/v1/admin/policies/:id/active` | activate / deactivate policy |
|
||||
//! | GET | `/v1/admin/machines` | list machines for a license |
|
||||
//! | POST | `/v1/admin/machines/:id/deactivate` | force-kick a machine |
|
||||
//! | POST | `/v1/admin/webhook-endpoints` | register webhook subscriber |
|
||||
//! | GET | `/v1/admin/webhook-endpoints` | list webhook subscribers |
|
||||
//! | PATCH | `/v1/admin/webhook-endpoints/:id/active` | enable/disable |
|
||||
//! | DELETE | `/v1/admin/webhook-endpoints/:id` | delete webhook subscriber |
|
||||
//! | POST | `/v1/admin/discount-codes` | create discount / referral code |
|
||||
//! | GET | `/v1/admin/discount-codes` | list discount codes |
|
||||
//! | GET | `/v1/admin/discount-codes/:id` | one code with redemption history |
|
||||
//! | PATCH | `/v1/admin/discount-codes/:id/active` | enable / disable code |
|
||||
//! | DELETE | `/v1/admin/discount-codes/:id` | hard-delete (refused if redeemed) |
|
||||
//! | GET | `/v1/discount-codes/preview` | PUBLIC: preview discount on a product |
|
||||
//! | GET | `/v1/admin/audit` | list audit log entries |
|
||||
|
||||
pub mod admin;
|
||||
pub mod admin_ui;
|
||||
pub mod btcpay_authorize;
|
||||
pub mod discount_codes;
|
||||
pub mod machines;
|
||||
pub mod policies;
|
||||
pub mod products;
|
||||
pub mod purchase;
|
||||
pub mod buy_page;
|
||||
pub mod issuer_key;
|
||||
pub mod redeem;
|
||||
pub mod self_license;
|
||||
pub mod validate;
|
||||
pub mod webhook;
|
||||
pub mod webhook_endpoints;
|
||||
|
||||
use crate::btcpay::client::BtcpayClient;
|
||||
use crate::config::Config;
|
||||
use crate::crypto::keys::ServerKeypair;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::FromRef,
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub keypair: Arc<ServerKeypair>,
|
||||
/// Active payment provider (BTCPay today, Zaprite eventually).
|
||||
/// `None` until the operator completes a connect flow. Stored as
|
||||
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
|
||||
/// write lock when the operator runs Connect / Disconnect.
|
||||
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
|
||||
pub config: Arc<Config>,
|
||||
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
|
||||
/// operator activates a fresh license via the admin endpoint.
|
||||
pub self_tier: Arc<RwLock<crate::license_self::Tier>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Provider-agnostic accessor. New code should use this; legacy
|
||||
/// `btcpay_client()` / `btcpay_webhook_secret()` accessors remain
|
||||
/// for v0.2 compat and will retire as call sites migrate in v0.3.
|
||||
pub async fn payment_provider(
|
||||
&self,
|
||||
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
|
||||
let guard = self.payment.read().await;
|
||||
guard
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or(AppError::BtcpayNotConfigured)
|
||||
}
|
||||
|
||||
/// Compat: returns the BTCPay-specific HTTP client, by clone, when
|
||||
/// the active provider is BTCPay. Falls back to
|
||||
/// `BtcpayNotConfigured` either when no provider is connected OR
|
||||
/// when the active provider isn't BTCPay (so Zaprite-only operators
|
||||
/// in v0.3 will get a clean error from BTCPay-specific code paths
|
||||
/// that haven't been migrated yet).
|
||||
pub async fn btcpay_client(&self) -> AppResult<BtcpayClient> {
|
||||
let guard = self.payment.read().await;
|
||||
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
|
||||
provider
|
||||
.as_any()
|
||||
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
|
||||
.map(|p| p.client().clone())
|
||||
.ok_or(AppError::BtcpayNotConfigured)
|
||||
}
|
||||
|
||||
/// Compat: returns the BTCPay HMAC webhook secret. See
|
||||
/// `btcpay_client()` for compat-error semantics.
|
||||
pub async fn btcpay_webhook_secret(&self) -> AppResult<String> {
|
||||
let guard = self.payment.read().await;
|
||||
let provider = guard.as_ref().ok_or(AppError::BtcpayNotConfigured)?;
|
||||
provider
|
||||
.as_any()
|
||||
.downcast_ref::<crate::payment::btcpay::BtcpayProvider>()
|
||||
.map(|p| p.webhook_secret().to_string())
|
||||
.ok_or(AppError::BtcpayNotConfigured)
|
||||
}
|
||||
|
||||
/// Swap the active payment provider. Called by `btcpay_authorize`
|
||||
/// (and, later, `zaprite_authorize`).
|
||||
pub async fn set_payment_provider(
|
||||
&self,
|
||||
provider: Arc<dyn crate::payment::PaymentProvider>,
|
||||
) {
|
||||
let mut guard = self.payment.write().await;
|
||||
*guard = Some(provider);
|
||||
}
|
||||
|
||||
/// Clear the active payment provider (Disconnect flow).
|
||||
pub async fn clear_payment_provider(&self) {
|
||||
let mut guard = self.payment.write().await;
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for SqlitePool {
|
||||
fn from_ref(app: &AppState) -> Self {
|
||||
app.db.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/thank-you", get(thank_you))
|
||||
// Public buyer-facing purchase page. Server-renders an HTML
|
||||
// page for a given product slug; the inlined form POSTs to
|
||||
// /v1/purchase and redirects to BTCPay checkout.
|
||||
.route("/buy/:slug", get(buy_page::render))
|
||||
// Admin web UI — embedded into the binary at compile time via
|
||||
// rust-embed (see api/admin_ui.rs). The HTML page itself is
|
||||
// public; the SPA gates access client-side using the admin API
|
||||
// key, which is enforced server-side on every /v1/admin/*
|
||||
// call.
|
||||
.route("/admin", get(admin_ui::admin_root_redirect))
|
||||
.route("/admin/", get(admin_ui::admin_index))
|
||||
.route("/admin/*path", get(admin_ui::admin_asset))
|
||||
.route("/v1/pubkey", get(pubkey))
|
||||
.route("/v1/products", get(products::list))
|
||||
.route("/v1/products/:slug", get(products::get))
|
||||
.route("/v1/purchase", post(purchase::start))
|
||||
.route("/v1/purchase/:invoice_id", get(purchase::status))
|
||||
.route("/v1/redeem", post(redeem::redeem))
|
||||
.route("/v1/validate", post(validate::validate))
|
||||
// Client-facing machine endpoints.
|
||||
.route("/v1/machines/activate", post(machines::activate))
|
||||
.route("/v1/machines/heartbeat", post(machines::heartbeat))
|
||||
.route("/v1/machines/deactivate", post(machines::deactivate))
|
||||
.route("/v1/btcpay/webhook", post(webhook::handle))
|
||||
.route(
|
||||
"/v1/admin/btcpay/connect",
|
||||
post(btcpay_authorize::start_connect),
|
||||
)
|
||||
.route(
|
||||
"/v1/btcpay/authorize/callback",
|
||||
post(btcpay_authorize::callback).get(btcpay_authorize::callback_get),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/btcpay/status",
|
||||
get(btcpay_authorize::status),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/btcpay/disconnect",
|
||||
post(btcpay_authorize::disconnect),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/btcpay/payment-methods",
|
||||
get(btcpay_authorize::payment_methods),
|
||||
)
|
||||
.route("/v1/admin/products", post(admin::create_product))
|
||||
.route(
|
||||
"/v1/admin/products/:id/active",
|
||||
patch(admin::set_product_active),
|
||||
)
|
||||
// Both GET (list) and POST (issue) on the same path — must be chained
|
||||
// onto a single MethodRouter, because axum's Router::route replaces.
|
||||
.route(
|
||||
"/v1/admin/licenses",
|
||||
get(admin::list_licenses).post(admin::issue_license),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/licenses/search",
|
||||
get(admin::search_licenses),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/licenses/:id/revoke",
|
||||
post(admin::revoke_license),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/licenses/:id/suspend",
|
||||
post(admin::suspend_license),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/licenses/:id/unsuspend",
|
||||
post(admin::unsuspend_license),
|
||||
)
|
||||
// Policies (license templates).
|
||||
.route(
|
||||
"/v1/admin/policies",
|
||||
get(policies::list).post(policies::create),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/policies/:id/active",
|
||||
patch(policies::set_active),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/policies/:id/tip",
|
||||
patch(policies::set_tip),
|
||||
)
|
||||
.route("/v1/admin/tips", get(policies::list_tips))
|
||||
// Machines (admin views).
|
||||
.route("/v1/admin/machines", get(machines::admin_list))
|
||||
.route(
|
||||
"/v1/admin/machines/:id/deactivate",
|
||||
post(machines::admin_deactivate),
|
||||
)
|
||||
// Webhook subscribers.
|
||||
.route(
|
||||
"/v1/admin/webhook-endpoints",
|
||||
get(webhook_endpoints::list).post(webhook_endpoints::create),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/webhook-endpoints/:id/active",
|
||||
patch(webhook_endpoints::set_active),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/webhook-endpoints/:id",
|
||||
axum::routing::delete(webhook_endpoints::delete),
|
||||
)
|
||||
// Discount / referral codes.
|
||||
.route(
|
||||
"/v1/admin/discount-codes",
|
||||
get(discount_codes::list).post(discount_codes::create),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/discount-codes/:id",
|
||||
get(discount_codes::get_one).delete(discount_codes::delete),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/discount-codes/:id/active",
|
||||
patch(discount_codes::set_active),
|
||||
)
|
||||
// Public preview — buyer hits this from the buy page when they
|
||||
// click Apply on a discount code. Returns kind + computed
|
||||
// discounted price, doesn't consume a redemption slot.
|
||||
.route(
|
||||
"/v1/discount-codes/preview",
|
||||
get(discount_codes::preview),
|
||||
)
|
||||
// Audit log.
|
||||
.route("/v1/admin/audit", get(admin::list_audit))
|
||||
// Live-mutable settings.
|
||||
.route(
|
||||
"/v1/admin/settings/operator-name",
|
||||
get(admin::get_operator_name).post(admin::set_operator_name),
|
||||
)
|
||||
// Keysat self-license (Keysat-licenses-Keysat).
|
||||
.route(
|
||||
"/v1/admin/self-license",
|
||||
get(self_license::status).post(self_license::activate),
|
||||
)
|
||||
// Issuer-key import — admin-only, master-bootstrap path. No
|
||||
// StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md.
|
||||
.route("/v1/admin/import-issuer-key", post(issuer_key::import))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn root(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Live-read the operator name from the settings table so admin
|
||||
// updates take effect without a daemon restart. Falls back to the
|
||||
// env-var-loaded config if the DB row hasn't been set yet (fresh
|
||||
// installs, or installs that pre-date this feature).
|
||||
let operator = match crate::db::repo::settings_get(
|
||||
&state.db,
|
||||
crate::api::admin::SETTING_OPERATOR_NAME,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(v)) => Some(v),
|
||||
_ => state.config.operator_name.clone(),
|
||||
};
|
||||
Json(json!({
|
||||
"service": "keysat",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"operator": operator,
|
||||
"public_key_pem": state.keypair.public_key_pem,
|
||||
"key_algorithm": "ed25519",
|
||||
"key_format_version": crate::crypto::KEY_VERSION,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<serde_json::Value> {
|
||||
Json(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// HTML "thank you" landing page that BTCPay redirects buyers to after a
|
||||
/// settled invoice. Reads `?invoice_id=<id>` from the query string,
|
||||
/// renders a Keysat-branded polling page that calls
|
||||
/// /v1/purchase/<invoice_id> every few seconds until the response
|
||||
/// includes a `license_key`, then renders the license inline in a
|
||||
/// certificate-style card with a Copy button. Same visual language
|
||||
/// as the buy page's free-license success state.
|
||||
async fn thank_you(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> axum::response::Html<String> {
|
||||
let invoice_id = params.get("invoice_id").cloned().unwrap_or_default();
|
||||
let invoice_id_safe = html_escape(&invoice_id);
|
||||
let invoice_id_json = serde_json::to_string(&invoice_id).unwrap_or_else(|_| "\"\"".into());
|
||||
// Live-read operator_name from the settings table; fall back to the
|
||||
// env-var config; final fallback to a neutral brand name.
|
||||
let live = crate::db::repo::settings_get(
|
||||
&state.db,
|
||||
crate::api::admin::SETTING_OPERATOR_NAME,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let operator_str = live
|
||||
.as_deref()
|
||||
.or(state.config.operator_name.as_deref())
|
||||
.unwrap_or("Keysat");
|
||||
let operator = html_escape(operator_str);
|
||||
let body = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Payment received — {operator}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {{
|
||||
--navy-950:#0E1F33; --navy-900:#142A47; --navy-800:#1E3A5F;
|
||||
--cream-50:#FBF9F2; --cream-100:#F5F1E8; --cream-200:#EDE7D7;
|
||||
--gold-700:#8A6F3D; --gold-500:#BFA068; --gold-400:#D4B985;
|
||||
--ink-900:#0E1F33; --ink-700:#2C3E54; --ink-500:#5A6B7F;
|
||||
--success:#2D7A5F; --success-bg:#E3F0EA;
|
||||
--danger:#B23A3A; --danger-bg:#F4E0E0;
|
||||
--border-1:rgba(14,31,51,0.12);
|
||||
--border-2:rgba(14,31,51,0.20);
|
||||
--font-display:'Manrope','Helvetica Neue',Arial,sans-serif;
|
||||
--font-body:'Inter','Helvetica Neue',Arial,sans-serif;
|
||||
--font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;
|
||||
--shadow-md:0 2px 4px rgba(14,31,51,0.06),0 4px 12px rgba(14,31,51,0.06);
|
||||
}}
|
||||
*{{box-sizing:border-box}} html,body{{margin:0;padding:0}}
|
||||
body {{
|
||||
font-family:var(--font-body); color:var(--ink-900);
|
||||
background:var(--cream-100);
|
||||
background-image:
|
||||
radial-gradient(rgba(14,31,51,0.025) 1px, transparent 1px),
|
||||
radial-gradient(rgba(138,111,61,0.022) 1px, transparent 1px);
|
||||
background-size:3px 3px, 7px 7px;
|
||||
-webkit-font-smoothing:antialiased; min-height:100vh;
|
||||
}}
|
||||
.topbar {{
|
||||
background:rgba(245,241,232,0.85); backdrop-filter:blur(10px);
|
||||
border-bottom:1px solid var(--border-1); padding:14px 24px;
|
||||
}}
|
||||
.topbar .inner {{
|
||||
max-width:680px; margin:0 auto;
|
||||
display:flex; align-items:center; gap:12px;
|
||||
font-family:var(--font-display); font-weight:500; font-size:14px;
|
||||
letter-spacing:0.28em; text-transform:uppercase; color:var(--navy-900);
|
||||
}}
|
||||
.topbar .operator {{
|
||||
font-family:var(--font-body); font-size:12px;
|
||||
letter-spacing:0.04em; text-transform:none;
|
||||
color:var(--ink-500); margin-left:auto;
|
||||
}}
|
||||
.wrap {{ max-width:560px; margin:48px auto; padding:0 24px; }}
|
||||
.eyebrow {{
|
||||
font-size:11.5px; font-weight:700; letter-spacing:0.18em;
|
||||
text-transform:uppercase; color:var(--gold-700); margin-bottom:14px;
|
||||
display:inline-flex; align-items:center; gap:10px;
|
||||
}}
|
||||
.eyebrow::before {{ content:''; display:inline-block; width:28px; height:1px; background:var(--gold-500); }}
|
||||
h1 {{
|
||||
font-family:var(--font-display); font-weight:500; font-size:38px;
|
||||
line-height:1.05; letter-spacing:-0.022em; color:var(--navy-950); margin:0 0 14px;
|
||||
}}
|
||||
.lede {{ font-size:16px; line-height:1.55; color:var(--ink-700); margin:0 0 28px; }}
|
||||
.pending-card, .license-success, .error-card {{
|
||||
background:var(--cream-50); border:1px solid var(--border-1);
|
||||
border-radius:14px; box-shadow:var(--shadow-md);
|
||||
padding:32px 32px 28px; position:relative;
|
||||
}}
|
||||
.license-success, .pending-card {{
|
||||
box-shadow:0 0 0 1px var(--gold-500) inset, var(--shadow-md);
|
||||
}}
|
||||
.license-success::before, .license-success::after,
|
||||
.pending-card::before, .pending-card::after {{
|
||||
content:''; position:absolute; left:14px; right:14px;
|
||||
height:1px; background:var(--gold-500); opacity:0.5;
|
||||
}}
|
||||
.license-success::before, .pending-card::before {{ top:14px; }}
|
||||
.license-success::after, .pending-card::after {{ bottom:14px; }}
|
||||
.stamp {{
|
||||
font-size:10px; font-weight:700; letter-spacing:0.22em;
|
||||
text-transform:uppercase; color:var(--gold-700);
|
||||
text-align:center; margin-bottom:16px;
|
||||
}}
|
||||
.pending-card h2 {{
|
||||
font-family:var(--font-display); font-weight:500; font-size:22px;
|
||||
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
|
||||
}}
|
||||
.pending-card .sub, .license-success .sub {{
|
||||
font-size:14px; color:var(--ink-500); text-align:center; margin:0 0 22px;
|
||||
}}
|
||||
.spinner {{
|
||||
width:32px; height:32px; border-radius:50%;
|
||||
border:3px solid var(--border-1); border-top-color:var(--gold-500);
|
||||
animation:spin 1s linear infinite;
|
||||
margin:18px auto 22px;
|
||||
}}
|
||||
@keyframes spin {{ to {{ transform:rotate(360deg); }} }}
|
||||
.status-detail {{
|
||||
font-family:var(--font-mono); font-size:12.5px;
|
||||
background:var(--cream-100); border:1px solid var(--border-1);
|
||||
border-radius:7px; padding:8px 12px;
|
||||
color:var(--ink-700); text-align:center;
|
||||
}}
|
||||
.license-success h2 {{
|
||||
font-family:var(--font-display); font-weight:500; font-size:22px;
|
||||
color:var(--navy-950); margin:0 0 6px; letter-spacing:-0.015em; text-align:center;
|
||||
}}
|
||||
.field-label {{
|
||||
font-size:11px; font-weight:600; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--ink-500); margin-bottom:6px;
|
||||
}}
|
||||
.key-box {{
|
||||
background:var(--navy-950); color:var(--cream-50);
|
||||
padding:14px 16px; border-radius:8px;
|
||||
font-family:var(--font-mono); font-size:12.5px;
|
||||
word-break:break-all; line-height:1.5;
|
||||
display:flex; align-items:flex-start; gap:12px;
|
||||
}}
|
||||
.key-box .key-text {{ flex:1; }}
|
||||
.key-box button {{
|
||||
background:rgba(245,241,232,0.10); color:var(--cream-50);
|
||||
border:0; padding:6px 10px; border-radius:6px;
|
||||
font-family:var(--font-body); font-size:11.5px; cursor:pointer;
|
||||
flex-shrink:0;
|
||||
}}
|
||||
.key-box button:hover {{ background:rgba(245,241,232,0.20); }}
|
||||
.save-note {{
|
||||
margin-top:14px; font-size:13px; color:var(--ink-700);
|
||||
background:var(--cream-100); border:1px solid var(--border-1);
|
||||
border-radius:8px; padding:10px 14px;
|
||||
}}
|
||||
.save-note strong {{ color:var(--navy-950); }}
|
||||
.error-card {{
|
||||
border-color:rgba(178,58,58,0.3); background:var(--danger-bg);
|
||||
color:#8a2828; font-size:14px;
|
||||
}}
|
||||
.hide {{ display:none !important; }}
|
||||
footer.kfooter {{
|
||||
text-align:center; font-size:12px; color:var(--ink-500);
|
||||
margin-top:48px; padding:18px;
|
||||
}}
|
||||
footer.kfooter a {{ color:var(--ink-500); text-decoration:none; }}
|
||||
footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="inner">
|
||||
<span>Keysat</span>
|
||||
<span class="operator">Sold by {operator}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">Payment received</div>
|
||||
<h1 id="page-title">Issuing your license…</h1>
|
||||
<p class="lede" id="page-lede">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.</p>
|
||||
|
||||
<!-- pending state (default): polling for the license -->
|
||||
<div class="pending-card" id="pending-card">
|
||||
<div class="stamp">— Awaiting confirmation —</div>
|
||||
<h2>Hang tight.</h2>
|
||||
<p class="sub">This page will refresh automatically when your license is ready.</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<div class="status-detail" id="status-detail">checking status…</div>
|
||||
</div>
|
||||
|
||||
<!-- success state: license card -->
|
||||
<div class="license-success hide" id="license-success" role="region" aria-label="License issued">
|
||||
<div class="stamp">— License issued —</div>
|
||||
<h2>You’re licensed.</h2>
|
||||
<p class="sub">Your signed license is below. We’ll also email a copy.</p>
|
||||
<div class="field-label">License key</div>
|
||||
<div class="key-box">
|
||||
<span class="key-text" id="license-key-text">…</span>
|
||||
<button id="license-key-copy">Copy</button>
|
||||
</div>
|
||||
<div class="save-note">
|
||||
<strong>Save this somewhere safe.</strong> The key is signed at issue time and verifies offline against the seller’s public key. You don’t need to come back here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- error state: invoice not found, or unrecoverable -->
|
||||
<div class="error-card hide" id="error-card" role="alert">
|
||||
<div id="error-msg">Something went wrong looking up this purchase.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="kfooter">
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-paid software licensing</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const INVOICE_ID = {invoice_id_json};
|
||||
if (!INVOICE_ID) {{
|
||||
document.getElementById('pending-card').classList.add('hide');
|
||||
document.getElementById('error-card').classList.remove('hide');
|
||||
document.getElementById('error-msg').textContent = 'No invoice id supplied. Looking for your license? Check your email or contact the seller.';
|
||||
return;
|
||||
}}
|
||||
|
||||
const pendingCard = document.getElementById('pending-card');
|
||||
const successCard = document.getElementById('license-success');
|
||||
const errorCard = document.getElementById('error-card');
|
||||
const statusDetail = document.getElementById('status-detail');
|
||||
const keyText = document.getElementById('license-key-text');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
const pageLede = document.getElementById('page-lede');
|
||||
|
||||
// Copy button.
|
||||
document.getElementById('license-key-copy').addEventListener('click', async function() {{
|
||||
try {{
|
||||
await navigator.clipboard.writeText(keyText.textContent);
|
||||
this.textContent = 'Copied';
|
||||
setTimeout(() => {{ this.textContent = 'Copy'; }}, 1400);
|
||||
}} catch (e) {{}}
|
||||
}});
|
||||
|
||||
function showSuccess(licenseKey) {{
|
||||
pendingCard.classList.add('hide');
|
||||
errorCard.classList.add('hide');
|
||||
keyText.textContent = licenseKey;
|
||||
successCard.classList.remove('hide');
|
||||
pageTitle.textContent = 'Your license is ready.';
|
||||
pageLede.textContent = 'Save the key below — it verifies offline against the seller’s public key. You can close this tab when you’re done.';
|
||||
}}
|
||||
function showError(msg) {{
|
||||
pendingCard.classList.add('hide');
|
||||
successCard.classList.add('hide');
|
||||
errorMsg.textContent = msg;
|
||||
errorCard.classList.remove('hide');
|
||||
pageTitle.textContent = 'Something went wrong.';
|
||||
pageLede.textContent = 'See the message below for details.';
|
||||
}}
|
||||
|
||||
let attempt = 0;
|
||||
const MAX_ATTEMPTS = 240; // 240 * 3s = 12 min total. Most settle inside 1.
|
||||
|
||||
async function poll() {{
|
||||
attempt++;
|
||||
try {{
|
||||
const r = await fetch('/v1/purchase/' + encodeURIComponent(INVOICE_ID));
|
||||
if (r.status === 404) {{
|
||||
return showError('Invoice not found. The link may have been mistyped.');
|
||||
}}
|
||||
if (!r.ok) {{
|
||||
statusDetail.textContent = 'server returned HTTP ' + r.status + ' (will retry)';
|
||||
return scheduleNext();
|
||||
}}
|
||||
const j = await r.json();
|
||||
if (j.license_key) {{
|
||||
return showSuccess(j.license_key);
|
||||
}}
|
||||
const status = j.status || 'pending';
|
||||
statusDetail.textContent = 'invoice status: ' + status + (attempt > 1 ? ' (still polling)' : '');
|
||||
if (status === 'expired' || status === 'invalid') {{
|
||||
return showError('Payment was not completed (status: ' + status + '). If you sent funds, contact the seller.');
|
||||
}}
|
||||
scheduleNext();
|
||||
}} catch (err) {{
|
||||
statusDetail.textContent = 'network error (retrying): ' + (err.message || err);
|
||||
scheduleNext();
|
||||
}}
|
||||
}}
|
||||
function scheduleNext() {{
|
||||
if (attempt >= MAX_ATTEMPTS) {{
|
||||
statusDetail.textContent = 'still waiting — refresh the page or come back later.';
|
||||
return;
|
||||
}}
|
||||
setTimeout(poll, 3000);
|
||||
}}
|
||||
poll();
|
||||
}})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>"#
|
||||
);
|
||||
axum::response::Html(body)
|
||||
}
|
||||
|
||||
/// Minimal HTML escape for the operator name. Keeps this module dependency-free.
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'&' => "&".to_string(),
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
'\'' => "'".to_string(),
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn pubkey(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"algorithm": "ed25519",
|
||||
"public_key_pem": state.keypair.public_key_pem,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Policies — reusable license templates.
|
||||
//!
|
||||
//! A policy captures "when I issue a license under this shape, what are the
|
||||
//! defaults?" (duration, grace period, entitlements, machine cap, trial flag,
|
||||
//! optional price override). Callers to `/v1/admin/licenses` can reference a
|
||||
//! policy by slug instead of specifying every field.
|
||||
//!
|
||||
//! Policies are per-product. The system looks up a "default" policy for a
|
||||
//! product when a customer buys it through the normal purchase flow — so most
|
||||
//! products should have at least one policy slugged `default`.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePolicyReq {
|
||||
pub product_slug: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
/// 0 = perpetual.
|
||||
#[serde(default)]
|
||||
pub duration_seconds: i64,
|
||||
#[serde(default)]
|
||||
pub grace_seconds: i64,
|
||||
/// 0 = unlimited, 1 = single-seat, n>1 = n-seat.
|
||||
#[serde(default = "default_max_machines")]
|
||||
pub max_machines: i64,
|
||||
#[serde(default)]
|
||||
pub is_trial: bool,
|
||||
#[serde(default)]
|
||||
pub price_sats_override: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub entitlements: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Value,
|
||||
/// Optional Lightning recipient (e.g. "tip@keysat.xyz") to tip a percentage
|
||||
/// of each successful issuance to. None = no tipping.
|
||||
#[serde(default)]
|
||||
pub tip_recipient: Option<String>,
|
||||
/// Tip percentage in basis points. 100 = 1%. Capped at 10000 (=100%).
|
||||
#[serde(default)]
|
||||
pub tip_pct_bps: i64,
|
||||
/// Free-form label for the tip recipient (audit/UI).
|
||||
#[serde(default)]
|
||||
pub tip_label: Option<String>,
|
||||
}
|
||||
|
||||
fn default_max_machines() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreatePolicyReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product_slug)))?;
|
||||
|
||||
if req.duration_seconds < 0 {
|
||||
return Err(AppError::BadRequest("duration_seconds must be >= 0".into()));
|
||||
}
|
||||
if req.grace_seconds < 0 {
|
||||
return Err(AppError::BadRequest("grace_seconds must be >= 0".into()));
|
||||
}
|
||||
if req.max_machines < 0 {
|
||||
return Err(AppError::BadRequest("max_machines must be >= 0".into()));
|
||||
}
|
||||
|
||||
let metadata = if req.metadata.is_null() {
|
||||
json!({})
|
||||
} else {
|
||||
req.metadata
|
||||
};
|
||||
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
|
||||
return Err(AppError::BadRequest(
|
||||
"tip_pct_bps must be between 0 and 10000 (100%)".into(),
|
||||
));
|
||||
}
|
||||
let tip_recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
|
||||
if tip_recipient.is_some() && req.tip_pct_bps == 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
|
||||
));
|
||||
}
|
||||
if tip_recipient.is_none() && req.tip_pct_bps > 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"tip_recipient must be set when tip_pct_bps > 0".into(),
|
||||
));
|
||||
}
|
||||
let tip_label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let policy = repo::create_policy(
|
||||
&state.db,
|
||||
&product.id,
|
||||
&req.name,
|
||||
&req.slug,
|
||||
req.duration_seconds,
|
||||
req.grace_seconds,
|
||||
req.max_machines,
|
||||
req.is_trial,
|
||||
req.price_sats_override,
|
||||
&req.entitlements,
|
||||
&metadata,
|
||||
tip_recipient,
|
||||
req.tip_pct_bps,
|
||||
tip_label,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"policy.create",
|
||||
Some("policy"),
|
||||
Some(&policy.id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "product_id": product.id, "slug": policy.slug }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!(policy)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListPoliciesQuery {
|
||||
pub product_slug: String,
|
||||
#[serde(default)]
|
||||
pub include_inactive: bool,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListPoliciesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
|
||||
let rows = repo::list_policies_by_product(&state.db, &product.id, !q.include_inactive).await?;
|
||||
Ok(Json(json!({ "policies": rows })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetActiveReq {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub async fn set_active(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_policy_active(&state.db, &id, req.active).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"policy.set_active",
|
||||
Some("policy"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "active": req.active }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetTipReq {
|
||||
/// Lightning Address (`user@domain`). Pass `null` to disable tipping.
|
||||
pub tip_recipient: Option<String>,
|
||||
/// Basis points: 0–10000. 0 = disabled.
|
||||
pub tip_pct_bps: i64,
|
||||
/// Optional free-form label (audit / UI).
|
||||
#[serde(default)]
|
||||
pub tip_label: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn set_tip(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetTipReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
|
||||
return Err(AppError::BadRequest(
|
||||
"tip_pct_bps must be between 0 and 10000".into(),
|
||||
));
|
||||
}
|
||||
let recipient = req.tip_recipient.as_deref().filter(|s| !s.trim().is_empty());
|
||||
if recipient.is_some() && req.tip_pct_bps == 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"tip_pct_bps must be > 0 when tip_recipient is set".into(),
|
||||
));
|
||||
}
|
||||
if recipient.is_none() && req.tip_pct_bps > 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"tip_recipient must be set when tip_pct_bps > 0".into(),
|
||||
));
|
||||
}
|
||||
let label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let updated =
|
||||
repo::set_policy_tip_config(&state.db, &id, recipient, req.tip_pct_bps, label).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"policy.set_tip",
|
||||
Some("policy"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({
|
||||
"tip_recipient": updated.tip_recipient,
|
||||
"tip_pct_bps": updated.tip_pct_bps,
|
||||
"tip_label": updated.tip_label,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListTipsQuery {
|
||||
#[serde(default)]
|
||||
pub license_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub recipient: Option<String>,
|
||||
#[serde(default = "default_tip_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
fn default_tip_limit() -> i64 {
|
||||
100
|
||||
}
|
||||
|
||||
pub async fn list_tips(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListTipsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let entries = repo::list_tip_attempts(
|
||||
&state.db,
|
||||
q.license_id.as_deref(),
|
||||
q.recipient.as_deref(),
|
||||
q.limit,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "tips": entries })))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Public product endpoints.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn list(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let products = repo::list_products(&state.db, true).await?;
|
||||
Ok(Json(json!({ "products": products })))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let product = repo::get_product_by_slug(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{slug}'")))?;
|
||||
Ok(Json(json!(product)))
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Purchase flow:
|
||||
//! 1. Client POSTs `/v1/purchase` with a product slug.
|
||||
//! 2. We create a BTCPay invoice, stash a row, return the checkout URL.
|
||||
//! 3. Client opens the URL, pays. BTCPay hits our webhook (see
|
||||
//! [`crate::api::webhook`]) which marks the invoice 'settled' and
|
||||
//! issues a license.
|
||||
//! 4. Client polls `/v1/purchase/:invoice_id` until `license_key` is
|
||||
//! present, then stores it locally.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::btcpay::client::BtcpayClient;
|
||||
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StartPurchaseReq {
|
||||
/// Product slug to buy.
|
||||
pub product: String,
|
||||
/// Optional email for receipt / future contact.
|
||||
pub buyer_email: Option<String>,
|
||||
/// Optional free-text note from the buyer.
|
||||
pub buyer_note: Option<String>,
|
||||
/// Optional URL the buyer should be returned to after payment.
|
||||
pub redirect_url: Option<String>,
|
||||
/// Optional discount / referral code (case-insensitive).
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StartPurchaseResp {
|
||||
pub invoice_id: String, // our internal id
|
||||
pub btcpay_invoice_id: String, // BTCPay's id (for debugging)
|
||||
pub checkout_url: String, // URL the user opens to pay
|
||||
pub amount_sats: i64, // what BTCPay was charged (post-discount)
|
||||
pub base_price_sats: i64, // product list price (pre-discount)
|
||||
pub discount_applied_sats: i64, // base - amount_sats; 0 if no code
|
||||
pub poll_url: String, // where to check status
|
||||
}
|
||||
|
||||
/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
|
||||
/// 100%-off codes still produce a real BTCPay invoice (and the buyer
|
||||
/// experiences the purchase flow). 0-sat invoices aren't always supported
|
||||
/// by BTCPay anyway.
|
||||
const MIN_INVOICE_SATS: i64 = 1;
|
||||
|
||||
pub async fn start(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<StartPurchaseReq>,
|
||||
) -> AppResult<Json<StartPurchaseResp>> {
|
||||
let product = repo::get_product_by_slug(&state.db, &req.product)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
|
||||
if !product.active {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"product '{}' is not available for purchase",
|
||||
req.product
|
||||
)));
|
||||
}
|
||||
|
||||
let base_price = product.price_sats;
|
||||
|
||||
// Resolve and validate the discount code if one was supplied. The
|
||||
// ordering here matters: we must atomically reserve a counter slot
|
||||
// BEFORE we create the BTCPay invoice, so that a code-cap race can't
|
||||
// result in a buyer holding a discounted live invoice for an
|
||||
// already-exhausted code.
|
||||
//
|
||||
// step A: lookup + eligibility checks (active, expired, applies-to)
|
||||
// step B: atomically increment used_count (try_reserve_code_slot)
|
||||
// step C: compute discount, create BTCPay invoice
|
||||
// step D: persist local invoice
|
||||
// step E: insert the pending redemption row (record_pending_redemption)
|
||||
//
|
||||
// If C, D, or E fail after B succeeded, we call release_code_slot to
|
||||
// give the slot back.
|
||||
let (final_price, reservation, discount_applied) = if let Some(raw_code) =
|
||||
req.code.as_deref().filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
let code = repo::get_discount_code_by_code(&state.db, raw_code)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("unknown discount code".into()))?;
|
||||
if !code.active {
|
||||
return Err(AppError::BadRequest("discount code is disabled".into()));
|
||||
}
|
||||
if let Some(exp) = &code.expires_at {
|
||||
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
|
||||
return Err(AppError::BadRequest("discount code has expired".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pid) = &code.applies_to_product_id {
|
||||
if pid != &product.id {
|
||||
return Err(AppError::BadRequest(
|
||||
"discount code does not apply to this product".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Note: applies_to_policy_id is informational in v0.1 — the
|
||||
// policy used at license-issuance time is the product's default.
|
||||
|
||||
// Step B: atomic reserve.
|
||||
repo::try_reserve_code_slot(&state.db, &code.id).await?;
|
||||
|
||||
let discount = compute_discount(&code.kind, code.amount, base_price);
|
||||
let final_price = (base_price - discount).max(MIN_INVOICE_SATS);
|
||||
(final_price, Some(code), discount)
|
||||
} else {
|
||||
(base_price, None, 0)
|
||||
};
|
||||
|
||||
// Pre-allocate an internal invoice id so we can pass it to BTCPay as
|
||||
// metadata, letting us correlate webhook events back to our row even
|
||||
// before we've persisted the BTCPay invoice id.
|
||||
let internal_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// If the caller didn't supply a redirect_url, default to our own
|
||||
// /thank-you page with the invoice id baked in. After payment
|
||||
// BTCPay sends the buyer's browser there; the page polls
|
||||
// /v1/purchase/<invoice_id> until the license is issued, then
|
||||
// renders it. Internal ID (UUID) goes in the URL so the buyer can
|
||||
// bookmark it / refresh later if they close the tab.
|
||||
let default_redirect = format!(
|
||||
"{}/thank-you?invoice_id={}",
|
||||
state.config.public_base_url, internal_id
|
||||
);
|
||||
let redirect_url = req
|
||||
.redirect_url
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&default_redirect);
|
||||
|
||||
let metadata = BtcpayClient::invoice_metadata(&product.id, &internal_id);
|
||||
let btcpay = match state.btcpay_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// Release the reserved slot if we have one — BTCPay isn't ready.
|
||||
if let Some(code) = &reservation {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Step C: BTCPay invoice. On failure, release the slot and bail.
|
||||
let created = match btcpay
|
||||
.create_invoice(final_price, metadata, Some(redirect_url))
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if let Some(code) = &reservation {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
}
|
||||
return Err(AppError::Upstream(format!(
|
||||
"BTCPay invoice create failed: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// BTCPay returns a checkout URL using whatever URL we called its
|
||||
// API at — for us, the internal Docker hostname (fast). Rewrite
|
||||
// the host to the configured public URL so the buyer actually
|
||||
// gets a link they can open. Falls through unchanged if no public
|
||||
// URL is configured (test/dev only).
|
||||
let checkout_url = match &state.config.btcpay_public_url {
|
||||
Some(public_base) => {
|
||||
let rewritten =
|
||||
crate::payment::btcpay::rewrite_to_public(&created.checkout_link, public_base);
|
||||
tracing::info!(
|
||||
original = %created.checkout_link,
|
||||
rewritten = %rewritten,
|
||||
public_base = %public_base,
|
||||
"purchase: checkout URL rewritten for buyer"
|
||||
);
|
||||
rewritten
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
original = %created.checkout_link,
|
||||
"purchase: checkout URL NOT rewritten — btcpay_public_url is None"
|
||||
);
|
||||
created.checkout_link.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Step D: persist local invoice. On failure, release the slot.
|
||||
// Use internal_id we pre-generated (and baked into the BTCPay
|
||||
// redirect_url) as the local row id so /v1/purchase/<id> and
|
||||
// /thank-you?invoice_id=<id> all resolve to the same row.
|
||||
let invoice = match repo::create_invoice(
|
||||
&state.db,
|
||||
&internal_id,
|
||||
&created.id,
|
||||
&product.id,
|
||||
final_price,
|
||||
&checkout_url,
|
||||
req.buyer_email.as_deref(),
|
||||
req.buyer_note.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(inv) => inv,
|
||||
Err(e) => {
|
||||
if let Some(code) = &reservation {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Step E: persist the redemption row tying the slot to the invoice.
|
||||
if let Some(code) = &reservation {
|
||||
if let Err(e) = repo::record_pending_redemption(
|
||||
&state.db,
|
||||
&code.id,
|
||||
&invoice.id,
|
||||
discount_applied,
|
||||
base_price,
|
||||
final_price,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Slot was reserved but we couldn't record the redemption.
|
||||
// Release the slot and mark the BTCPay invoice as invalid
|
||||
// locally so we don't accidentally honour it on settle.
|
||||
tracing::error!(
|
||||
code = %code.code,
|
||||
invoice_id = %invoice.id,
|
||||
error = %e,
|
||||
"failed to persist pending redemption; releasing slot \
|
||||
and invalidating local invoice"
|
||||
);
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
let _ = repo::update_invoice_status(&state.db, &created.id, "invalid").await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
let poll_url = format!("{}/v1/purchase/{}", state.config.public_base_url, invoice.id);
|
||||
|
||||
Ok(Json(StartPurchaseResp {
|
||||
invoice_id: invoice.id,
|
||||
btcpay_invoice_id: created.id,
|
||||
checkout_url,
|
||||
amount_sats: final_price,
|
||||
base_price_sats: base_price,
|
||||
discount_applied_sats: discount_applied,
|
||||
poll_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Apply the discount math. Returns the sats to subtract from `base`.
|
||||
/// Caller is responsible for clamping the result (and for floor enforcement).
|
||||
fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
|
||||
match kind {
|
||||
"percent" => {
|
||||
// amount is basis points (0..=10000). 5000 == 50%.
|
||||
// Multiply in i128 to avoid overflow on large sat amounts.
|
||||
let bps = amount.clamp(0, 10_000) as i128;
|
||||
let base = base_price_sats as i128;
|
||||
((base * bps) / 10_000).max(0).min(base) as i64
|
||||
}
|
||||
"fixed_sats" => amount.max(0).min(base_price_sats),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Polling endpoint — returns status; if settled and a license has been
|
||||
/// issued, returns the signed key string.
|
||||
pub async fn status(
|
||||
State(state): State<AppState>,
|
||||
Path(invoice_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let invoice = repo::get_invoice_by_id(&state.db, &invoice_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("invoice '{invoice_id}'")))?;
|
||||
|
||||
let license = repo::get_license_by_invoice(&state.db, &invoice.id).await?;
|
||||
|
||||
let license_key = match &license {
|
||||
Some(lic) if lic.status == "active" => {
|
||||
// Re-issue the encoded key deterministically from the stored
|
||||
// license row. `issued_at` is parseable as RFC3339; we reduce to
|
||||
// unix seconds. Fingerprint binding isn't done here because the
|
||||
// key is still unbound at first delivery — it'll be bound the
|
||||
// first time the app calls /v1/validate or /v1/machines/activate.
|
||||
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
|
||||
let expires_at = lic
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
let payload = LicensePayload {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
product_id: uuid::Uuid::parse_str(&lic.product_id).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}"))
|
||||
})?,
|
||||
license_id: uuid::Uuid::parse_str(&lic.id).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}"))
|
||||
})?,
|
||||
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0),
|
||||
expires_at,
|
||||
fingerprint_hash: [0u8; 32],
|
||||
entitlements: lic.entitlements.clone(),
|
||||
};
|
||||
let sig = sign_payload(&state.keypair.signing, &payload);
|
||||
Some(encode_key(&payload, &sig))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"invoice_id": invoice.id,
|
||||
"status": invoice.status,
|
||||
"product_id": invoice.product_id,
|
||||
"amount_sats": invoice.amount_sats,
|
||||
"license_key": license_key,
|
||||
"license_id": license.as_ref().map(|l| l.id.clone()),
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Free-license code redemption — the no-BTCPay path.
|
||||
//!
|
||||
//! Flow for `kind = 'free_license'` codes:
|
||||
//! 1. Buyer hits POST /v1/redeem with `{product, code, buyer_email?, buyer_note?}`.
|
||||
//! 2. Server validates the code (active, not expired, applies-to, kind == free_license).
|
||||
//! 3. Server atomically reserves a slot (try_reserve_code_slot).
|
||||
//! 4. Server synthesizes a settled invoice with amount_sats = 0
|
||||
//! (so the rest of the data model — license → invoice — stays uniform).
|
||||
//! 5. Server records the pending redemption row.
|
||||
//! 6. Server calls the existing `issue_license_for_invoice` path which:
|
||||
//! - issues the license,
|
||||
//! - fires `license.issued`,
|
||||
//! - finalizes the redemption (pending → redeemed),
|
||||
//! - fires `code.redeemed`.
|
||||
//! 7. Response includes the signed license_key so the buyer can paste it
|
||||
//! directly into your app — no polling, no BTCPay.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RedeemReq {
|
||||
/// Product slug.
|
||||
pub product: String,
|
||||
/// Redeemable code (case-insensitive).
|
||||
pub code: String,
|
||||
/// Optional email — recorded on the synthetic invoice and license for
|
||||
/// admin search and webhook payloads.
|
||||
pub buyer_email: Option<String>,
|
||||
/// Optional free-text note (recorded on invoice).
|
||||
pub buyer_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RedeemResp {
|
||||
pub license_id: String,
|
||||
pub license_key: String,
|
||||
pub invoice_id: String,
|
||||
pub redemption_id: String,
|
||||
}
|
||||
|
||||
pub async fn redeem(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RedeemReq>,
|
||||
) -> AppResult<Json<RedeemResp>> {
|
||||
let product = repo::get_product_by_slug(&state.db, &req.product)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
|
||||
if !product.active {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"product '{}' is not available for redemption",
|
||||
req.product
|
||||
)));
|
||||
}
|
||||
|
||||
if req.code.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("code is required".into()));
|
||||
}
|
||||
let code = repo::get_discount_code_by_code(&state.db, &req.code)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("unknown code".into()))?;
|
||||
if !code.active {
|
||||
return Err(AppError::BadRequest("code is disabled".into()));
|
||||
}
|
||||
if code.kind != "free_license" {
|
||||
return Err(AppError::BadRequest(
|
||||
"this code requires payment — use the standard purchase flow with the code applied".into(),
|
||||
));
|
||||
}
|
||||
if let Some(exp) = &code.expires_at {
|
||||
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
|
||||
return Err(AppError::BadRequest("code has expired".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pid) = &code.applies_to_product_id {
|
||||
if pid != &product.id {
|
||||
return Err(AppError::BadRequest(
|
||||
"code does not apply to this product".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic reserve. If reserved succeeds and any subsequent step fails,
|
||||
// we release the slot so a freed slot becomes available again.
|
||||
repo::try_reserve_code_slot(&state.db, &code.id).await?;
|
||||
|
||||
// Synthesize a settled, zero-amount invoice. Errors release the slot.
|
||||
let invoice = match repo::create_free_invoice(
|
||||
&state.db,
|
||||
&product.id,
|
||||
req.buyer_email.as_deref(),
|
||||
req.buyer_note.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(inv) => inv,
|
||||
Err(e) => {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Record the pending redemption row tying the slot to this invoice.
|
||||
if let Err(e) = repo::record_pending_redemption(
|
||||
&state.db,
|
||||
&code.id,
|
||||
&invoice.id,
|
||||
0, // discount_applied (whole price is "free")
|
||||
0, // base_price_sats (free)
|
||||
0, // final_price_sats
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Issue the license. This also finalizes the redemption (pending →
|
||||
// redeemed) and fires both `license.issued` and `code.redeemed`
|
||||
// outbound webhooks.
|
||||
let license_id = match crate::api::webhook::issue_license_for_invoice(&state, &invoice).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// The invoice + redemption are persisted but the license
|
||||
// failed. Cancel the redemption so the slot is released and
|
||||
// log loudly.
|
||||
tracing::error!(
|
||||
code = %code.code,
|
||||
invoice_id = %invoice.id,
|
||||
error = %e,
|
||||
"free redemption: license issuance failed after invoice + redemption \
|
||||
were persisted"
|
||||
);
|
||||
if let Ok(Some(red)) =
|
||||
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await
|
||||
{
|
||||
let _ = repo::cancel_redemption(&state.db, &red.id).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-derive the signed license key so we can return it to the buyer
|
||||
// directly. Mirrors the math in `purchase::status`.
|
||||
let license = repo::get_license_by_invoice(&state.db, &invoice.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("license vanished after issue")))?;
|
||||
let flags = if license.is_trial { FLAG_TRIAL } else { 0 };
|
||||
let expires_at_unix = license
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|t| t.with_timezone(&chrono::Utc).timestamp())
|
||||
.unwrap_or(0);
|
||||
let payload = LicensePayload {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
product_id: uuid::Uuid::parse_str(&license.product_id)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}")))?,
|
||||
license_id: uuid::Uuid::parse_str(&license.id)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}")))?,
|
||||
issued_at: chrono::DateTime::parse_from_rfc3339(&license.issued_at)
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0),
|
||||
expires_at: expires_at_unix,
|
||||
fingerprint_hash: [0u8; 32],
|
||||
entitlements: license.entitlements.clone(),
|
||||
};
|
||||
let sig = sign_payload(&state.keypair.signing, &payload);
|
||||
let license_key = encode_key(&payload, &sig);
|
||||
|
||||
// The redemption row was finalized inside issue_license_for_invoice;
|
||||
// re-fetch to surface its id in the response.
|
||||
let redemption_id = repo::list_redemptions_by_code(&state.db, &code.id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|rows| rows.into_iter().find(|r| r.invoice_id == invoice.id).map(|r| r.id))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(RedeemResp {
|
||||
license_id,
|
||||
license_key,
|
||||
invoice_id: invoice.id,
|
||||
redemption_id,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Admin endpoints for managing the daemon's own self-license
|
||||
//! (Keysat-licenses-Keysat).
|
||||
//!
|
||||
//! - `GET /v1/admin/self-license` — current tier (licensed / unlicensed)
|
||||
//! - `POST /v1/admin/self-license` — activate a new license. Validates
|
||||
//! against the embedded master pubkey, writes the file to
|
||||
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
|
||||
//!
|
||||
//! These run *only* when authenticated by the admin API key — same gate
|
||||
//! as every other `/v1/admin/*` route.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::error::AppResult;
|
||||
use crate::license_self::{self, Tier};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "tier", rename_all = "snake_case")]
|
||||
pub enum TierStatus {
|
||||
Unlicensed {
|
||||
reason: String,
|
||||
mode: &'static str,
|
||||
},
|
||||
Licensed {
|
||||
license_id: String,
|
||||
product_id: String,
|
||||
/// Unix seconds; 0 means perpetual.
|
||||
expires_at: i64,
|
||||
entitlements: Vec<String>,
|
||||
mode: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
fn tier_to_status(tier: &Tier) -> TierStatus {
|
||||
let mode = match license_self::mode() {
|
||||
license_self::Mode::Permissive => "permissive",
|
||||
license_self::Mode::Enforce => "enforce",
|
||||
};
|
||||
match tier {
|
||||
Tier::Unlicensed { reason } => TierStatus::Unlicensed {
|
||||
reason: reason.clone(),
|
||||
mode,
|
||||
},
|
||||
Tier::Licensed {
|
||||
license_id,
|
||||
product_id,
|
||||
expires_at,
|
||||
entitlements,
|
||||
} => TierStatus::Licensed {
|
||||
license_id: license_id.to_string(),
|
||||
product_id: product_id.to_string(),
|
||||
expires_at: *expires_at,
|
||||
entitlements: entitlements.clone(),
|
||||
mode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> Json<TierStatus> {
|
||||
let tier = state.self_tier.read().await.clone();
|
||||
Json(tier_to_status(&tier))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActivateBody {
|
||||
pub license_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActivateResponse {
|
||||
pub ok: bool,
|
||||
pub tier: TierStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn activate(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ActivateBody>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let key = body.license_key.trim().to_string();
|
||||
if key.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "license_key is required"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Verify against the embedded master pubkey before persisting.
|
||||
let new_tier = match license_self::verify_license(&key) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!("self-license activation rejected: {e:#}");
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "license_invalid",
|
||||
"detail": format!("{e:#}"),
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
// Persist to /data/keysat-license.txt.
|
||||
if let Err(e) = license_self::write_license_file(&key) {
|
||||
tracing::error!("self-license file write failed: {e:#}");
|
||||
return Ok((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "write_failed",
|
||||
"detail": format!("{e:#}"),
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Swap the runtime tier.
|
||||
{
|
||||
let mut guard = state.self_tier.write().await;
|
||||
*guard = new_tier.clone();
|
||||
}
|
||||
|
||||
let status_resp = tier_to_status(&new_tier);
|
||||
let summary = match &status_resp {
|
||||
TierStatus::Licensed {
|
||||
license_id,
|
||||
expires_at,
|
||||
entitlements,
|
||||
..
|
||||
} => {
|
||||
let exp = if *expires_at == 0 {
|
||||
"perpetual".to_string()
|
||||
} else {
|
||||
format!("expires unix={}", expires_at)
|
||||
};
|
||||
let ents = if entitlements.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
entitlements.join(",")
|
||||
};
|
||||
format!(
|
||||
"License {} verified — {}, entitlements={}.",
|
||||
license_id, exp, ents
|
||||
)
|
||||
}
|
||||
TierStatus::Unlicensed { .. } => {
|
||||
// Should be unreachable; verify_license never returns Unlicensed.
|
||||
"License processed.".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("self-license activated: {summary}");
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ActivateResponse {
|
||||
ok: true,
|
||||
tier: status_resp,
|
||||
message: summary,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! The single most-hit endpoint: validate a license key.
|
||||
//!
|
||||
//! Clients — typically another piece of software starting up — call this
|
||||
//! with their key and (optionally) the `product_slug` they expect the key
|
||||
//! to cover and a `fingerprint` identifying the machine/installation.
|
||||
//!
|
||||
//! Response shape (HTTP always 200; `ok` + `reason` machine-readable):
|
||||
//!
|
||||
//! ```json
|
||||
//! { "ok": true, "license_id": "...", "product_id": "...", "entitlements": ["pro"], "status": "active" }
|
||||
//! { "ok": false, "reason": "expired", "grace_until": "..." }
|
||||
//! ```
|
||||
//!
|
||||
//! Machine cap handling:
|
||||
//!
|
||||
//! When a license allows more than one concurrent machine (`max_machines != 1`),
|
||||
//! validate will auto-activate up to the cap. Beyond the cap, the call is
|
||||
//! rejected with `too_many_machines` — the client is expected to either
|
||||
//! prompt the user to deactivate another machine or to call
|
||||
//! `POST /v1/machines/deactivate` first. `max_machines == 0` means unlimited.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::crypto::{self, hash_fingerprint};
|
||||
use crate::db::repo;
|
||||
use crate::error::AppResult;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, HeaderMap},
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ValidateReq {
|
||||
pub key: String,
|
||||
/// Optional: the product slug the caller expects this key to cover.
|
||||
/// Rejects keys issued for a different product even if valid.
|
||||
pub product_slug: Option<String>,
|
||||
/// Optional: raw machine fingerprint. First successful validation binds
|
||||
/// this to the license row (if not already set); later validations
|
||||
/// succeed only if it matches.
|
||||
pub fingerprint: Option<String>,
|
||||
/// Optional client-supplied hostname for machine records.
|
||||
pub hostname: Option<String>,
|
||||
/// Optional client-supplied platform descriptor.
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct ValidateResp {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub license_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub product_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub product_slug: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub issued_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grace_until: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub in_grace_period: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_trial: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(default)]
|
||||
pub entitlements: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub machine_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_machines: Option<i64>,
|
||||
}
|
||||
|
||||
fn reject(reason: &str) -> ValidateResp {
|
||||
ValidateResp {
|
||||
ok: false,
|
||||
reason: Some(reason.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn validate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ValidateReq>,
|
||||
) -> AppResult<Json<ValidateResp>> {
|
||||
let client_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("").trim().to_string());
|
||||
let user_agent = headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Rate limit by client IP if available, else by license key prefix as a
|
||||
// last-ditch bucket key. Cap at 60 req / minute / bucket.
|
||||
let bucket_key = client_ip.clone().unwrap_or_else(|| {
|
||||
req.key
|
||||
.chars()
|
||||
.take(24)
|
||||
.collect::<String>()
|
||||
});
|
||||
if !crate::rate_limit::consume(
|
||||
&state.db,
|
||||
"validate_ip",
|
||||
&bucket_key,
|
||||
/* capacity */ 60.0,
|
||||
/* refill_per_second */ 1.0,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Json(reject("rate_limited")));
|
||||
}
|
||||
|
||||
// Step 1: parse & verify signature offline-style, using the server's own
|
||||
// verifying key (same key the SDK will ship).
|
||||
let (payload, signature, signed_bytes) = match crypto::parse_key(&req.key) {
|
||||
Ok(ok) => ok,
|
||||
Err(e) => {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
None,
|
||||
None,
|
||||
req.fingerprint.as_deref(),
|
||||
"bad_format",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
Some(&e.to_string()),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
tracing::debug!(error = %e, "rejected malformed key");
|
||||
return Ok(Json(reject("bad_format")));
|
||||
}
|
||||
};
|
||||
|
||||
if crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature).is_err() {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&payload.license_id.to_string()),
|
||||
Some(&payload.product_id.to_string()),
|
||||
req.fingerprint.as_deref(),
|
||||
"bad_signature",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(reject("bad_signature")));
|
||||
}
|
||||
|
||||
let license_id = payload.license_id.to_string();
|
||||
let product_id = payload.product_id.to_string();
|
||||
|
||||
// Step 2: look up the license row.
|
||||
let license = match repo::get_license_by_id(&state.db, &license_id).await? {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
req.fingerprint.as_deref(),
|
||||
"not_found",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(reject("not_found")));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: status checks — authoritative server-side.
|
||||
match license.status.as_str() {
|
||||
"active" => {}
|
||||
"revoked" => {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
req.fingerprint.as_deref(),
|
||||
"revoked",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
license.revocation_reason.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(reject("revoked")));
|
||||
}
|
||||
"suspended" => {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
req.fingerprint.as_deref(),
|
||||
"suspended",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
license.suspension_reason.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(reject("suspended")));
|
||||
}
|
||||
other => {
|
||||
tracing::warn!(status = other, license_id, "unknown license status");
|
||||
return Ok(Json(reject("invalid_state")));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: product match (optional).
|
||||
let product = repo::get_product_by_id(&state.db, &license.product_id).await?;
|
||||
if let (Some(expected_slug), Some(p)) = (&req.product_slug, &product) {
|
||||
if &p.slug != expected_slug {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
req.fingerprint.as_deref(),
|
||||
"product_mismatch",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(reject("product_mismatch")));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: expiry + grace.
|
||||
let now = Utc::now();
|
||||
let mut in_grace_period = false;
|
||||
let mut grace_until: Option<String> = None;
|
||||
if let Some(exp_str) = &license.expires_at {
|
||||
if let Ok(exp_dt) = DateTime::parse_from_rfc3339(exp_str) {
|
||||
let exp_utc = exp_dt.with_timezone(&Utc);
|
||||
let grace_cutoff = exp_utc + chrono::Duration::seconds(license.grace_seconds);
|
||||
if now >= grace_cutoff {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
req.fingerprint.as_deref(),
|
||||
"expired",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
Some(&format!("expired at {exp_str}")),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(ValidateResp {
|
||||
ok: false,
|
||||
reason: Some("expired".into()),
|
||||
license_id: Some(license_id),
|
||||
product_id: Some(product_id),
|
||||
expires_at: Some(exp_str.clone()),
|
||||
..Default::default()
|
||||
}));
|
||||
} else if now >= exp_utc {
|
||||
in_grace_period = true;
|
||||
grace_until = Some(grace_cutoff.to_rfc3339());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: fingerprint + machine binding.
|
||||
// - Single-seat (max_machines == 1): preserve legacy column-based TOFU
|
||||
// on `licenses.fingerprint` for backwards compatibility, AND also
|
||||
// write/update a `machines` row so admins see a consistent view.
|
||||
// - Multi-seat: look up / auto-activate in the machines table, enforce
|
||||
// the cap.
|
||||
let mut machine_id: Option<String> = None;
|
||||
if let Some(fp) = req.fingerprint.as_deref() {
|
||||
let fp_hash = crate::hex_sha256(fp);
|
||||
|
||||
if license.max_machines == 1 {
|
||||
match &license.fingerprint {
|
||||
Some(stored) if stored != fp => {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
Some(fp),
|
||||
"fingerprint_mismatch",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(reject("fingerprint_mismatch")));
|
||||
}
|
||||
Some(_) => {
|
||||
// Already bound and matches — touch heartbeat on any machine row.
|
||||
if let Some(m) =
|
||||
repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await?
|
||||
{
|
||||
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
|
||||
machine_id = Some(m.id);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
repo::bind_fingerprint_if_unset(&state.db, &license_id, fp).await?;
|
||||
let m = repo::activate_machine(
|
||||
&state.db,
|
||||
&license_id,
|
||||
fp,
|
||||
&fp_hash,
|
||||
req.hostname.as_deref(),
|
||||
req.platform.as_deref(),
|
||||
client_ip.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"machine.activated",
|
||||
&serde_json::json!({
|
||||
"license_id": license_id,
|
||||
"machine_id": m.id,
|
||||
"fingerprint_hash": fp_hash,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
machine_id = Some(m.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-seat: consult machines table.
|
||||
match repo::get_active_machine_by_fp(&state.db, &license_id, &fp_hash).await? {
|
||||
Some(m) => {
|
||||
repo::heartbeat_machine(&state.db, &m.id, client_ip.as_deref()).await?;
|
||||
machine_id = Some(m.id);
|
||||
}
|
||||
None => {
|
||||
// Count existing active machines. max_machines = 0 means unlimited.
|
||||
let active = repo::list_active_machines(&state.db, &license_id).await?;
|
||||
if license.max_machines > 0 && active.len() as i64 >= license.max_machines {
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
Some(fp),
|
||||
"too_many_machines",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
None,
|
||||
Some(&format!(
|
||||
"cap {} already reached",
|
||||
license.max_machines
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(Json(ValidateResp {
|
||||
ok: false,
|
||||
reason: Some("too_many_machines".into()),
|
||||
license_id: Some(license_id),
|
||||
product_id: Some(product_id),
|
||||
max_machines: Some(license.max_machines),
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
let m = repo::activate_machine(
|
||||
&state.db,
|
||||
&license_id,
|
||||
fp,
|
||||
&fp_hash,
|
||||
req.hostname.as_deref(),
|
||||
req.platform.as_deref(),
|
||||
client_ip.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::webhooks::dispatch(
|
||||
&state,
|
||||
"machine.activated",
|
||||
&serde_json::json!({
|
||||
"license_id": license_id,
|
||||
"machine_id": m.id,
|
||||
"fingerprint_hash": fp_hash,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
machine_id = Some(m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the signed payload is itself fingerprint-bound, enforce hash
|
||||
// match against the signed blob (an extra belt-and-braces check).
|
||||
if payload.is_fingerprint_bound() && payload.fingerprint_hash != hash_fingerprint(fp) {
|
||||
return Ok(Json(reject("fingerprint_mismatch")));
|
||||
}
|
||||
}
|
||||
|
||||
repo::log_validation(
|
||||
&state.db,
|
||||
Some(&license_id),
|
||||
Some(&product_id),
|
||||
req.fingerprint.as_deref(),
|
||||
"ok",
|
||||
client_ip.as_deref(),
|
||||
user_agent.as_deref(),
|
||||
machine_id.as_deref(),
|
||||
if in_grace_period {
|
||||
Some("in_grace_period")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(Json(ValidateResp {
|
||||
ok: true,
|
||||
reason: None,
|
||||
license_id: Some(license_id),
|
||||
product_id: Some(product_id),
|
||||
product_slug: product.map(|p| p.slug),
|
||||
issued_at: Some(license.issued_at),
|
||||
expires_at: license.expires_at,
|
||||
grace_until,
|
||||
in_grace_period: if in_grace_period { Some(true) } else { None },
|
||||
is_trial: if license.is_trial { Some(true) } else { None },
|
||||
entitlements: license.entitlements,
|
||||
status: Some(license.status),
|
||||
machine_id,
|
||||
max_machines: Some(license.max_machines),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Payment-provider webhook landing endpoint.
|
||||
//!
|
||||
//! Generic over the active `PaymentProvider` (BTCPay today; Zaprite in
|
||||
//! v0.3). The flow:
|
||||
//!
|
||||
//! 1. The provider POSTs an invoice status event here. We hand the raw
|
||||
//! bytes + headers to the active provider's `validate_webhook` so it
|
||||
//! can apply its own signature scheme before we trust the body.
|
||||
//! 2. On `InvoiceSettled`, we mark the invoice settled AND issue a
|
||||
//! license row (if one doesn't already exist for this invoice —
|
||||
//! webhooks can be retried). Idempotency is critical.
|
||||
//! 3. On other events (expired / invalid / refunded), we update status
|
||||
//! and (for refunds in v0.3) revoke the license.
|
||||
//!
|
||||
//! We do **not** sign and return the license key here — the key is
|
||||
//! lazily re-derived from the stored license row when the buyer polls
|
||||
//! `/v1/purchase/:invoice_id`. This keeps webhook handling fast and
|
||||
//! means a dropped webhook response doesn't lose a key.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::ProviderWebhookEvent;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
pub async fn handle(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<StatusCode> {
|
||||
// Active provider validates its own webhooks (each provider has a
|
||||
// different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
|
||||
// Zaprite's TBD). On any verification failure we 401.
|
||||
let provider = state.payment_provider().await?;
|
||||
let event = provider
|
||||
.validate_webhook(&headers, &body)
|
||||
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
|
||||
|
||||
let provider_invoice_id = match event.provider_invoice_id() {
|
||||
Some(id) => id.to_string(),
|
||||
None => {
|
||||
tracing::info!("webhook event without an invoice id; acking");
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
};
|
||||
|
||||
let new_status = match &event {
|
||||
ProviderWebhookEvent::InvoiceSettled { .. } => Some("settled"),
|
||||
ProviderWebhookEvent::InvoiceExpired { .. } => Some("expired"),
|
||||
ProviderWebhookEvent::InvoiceInvalid { .. } => Some("invalid"),
|
||||
// Refunds are a v0.3 surface; for now we treat them as a noop
|
||||
// and just ack so the provider stops retrying. Once the
|
||||
// license-revoke-on-refund flow ships, this branch flips to
|
||||
// doing the revoke + audit-entry work.
|
||||
ProviderWebhookEvent::InvoiceRefunded { .. } => {
|
||||
tracing::info!(
|
||||
provider_invoice_id = %provider_invoice_id,
|
||||
"refund webhook received; revoke-on-refund flow lands in v0.3"
|
||||
);
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
ProviderWebhookEvent::Other { kind, .. } => {
|
||||
tracing::info!(
|
||||
event_type = %kind,
|
||||
provider_invoice_id = %provider_invoice_id,
|
||||
"ignoring non-actionable webhook event"
|
||||
);
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
};
|
||||
|
||||
let new_status = match new_status {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::OK),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
provider = provider.kind().as_str(),
|
||||
new_status,
|
||||
provider_invoice_id = %provider_invoice_id,
|
||||
"webhook event applied"
|
||||
);
|
||||
|
||||
// Persist status.
|
||||
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
|
||||
|
||||
// If the invoice is going to a non-success terminal state, free any
|
||||
// discount-code slot that was reserved for it. We need the internal
|
||||
// invoice id (not the provider one) to look up the redemption.
|
||||
if matches!(new_status, "expired" | "invalid") {
|
||||
if let Ok(Some(inv)) =
|
||||
repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await
|
||||
{
|
||||
if let Ok(Some(redemption)) =
|
||||
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
|
||||
{
|
||||
if let Err(e) = repo::cancel_redemption(&state.db, &redemption.id).await {
|
||||
tracing::warn!(
|
||||
redemption_id = %redemption.id,
|
||||
error = %e,
|
||||
"failed to cancel redemption on terminal invoice; counter slot may leak"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if new_status != "settled" {
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
// Find the invoice and issue a license if not already issued.
|
||||
let invoice = repo::get_invoice_by_btcpay_id(&state.db, &provider_invoice_id).await?;
|
||||
let Some(invoice) = invoice else {
|
||||
tracing::warn!(
|
||||
provider_invoice_id = %provider_invoice_id,
|
||||
"settled invoice not found in local DB; ignoring"
|
||||
);
|
||||
return Ok(StatusCode::OK);
|
||||
};
|
||||
|
||||
// Idempotency: if a license already exists for this invoice, do nothing.
|
||||
if repo::get_license_by_invoice(&state.db, &invoice.id)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
let _license_id = issue_license_for_invoice(&state, &invoice).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Shared issuance path — used by both the webhook handler and the reconcile
|
||||
/// loop. Pulls the invoice's associated policy (if the product has a default
|
||||
/// one) and materializes a license row with the right expiry / entitlements.
|
||||
pub async fn issue_license_for_invoice(
|
||||
state: &AppState,
|
||||
invoice: &crate::models::Invoice,
|
||||
) -> AppResult<String> {
|
||||
// Pick the "default" policy for the product: the first active policy
|
||||
// whose slug is "default" if present, else the first active policy, else
|
||||
// none (perpetual, no entitlements, max_machines=1).
|
||||
let policies = repo::list_policies_by_product(&state.db, &invoice.product_id, true).await?;
|
||||
let policy = policies
|
||||
.iter()
|
||||
.find(|p| p.slug == "default")
|
||||
.or_else(|| policies.first())
|
||||
.cloned();
|
||||
|
||||
let now = Utc::now();
|
||||
let issued_at = now.to_rfc3339();
|
||||
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0);
|
||||
let expires_at = if duration_seconds == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((now + chrono::Duration::seconds(duration_seconds)).to_rfc3339())
|
||||
};
|
||||
let grace_seconds = policy.as_ref().map(|p| p.grace_seconds).unwrap_or(0);
|
||||
let max_machines = policy.as_ref().map(|p| p.max_machines).unwrap_or(1);
|
||||
let is_trial = policy.as_ref().map(|p| p.is_trial).unwrap_or(false);
|
||||
let entitlements = policy
|
||||
.as_ref()
|
||||
.map(|p| p.entitlements.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let license_id = uuid::Uuid::new_v4().to_string();
|
||||
repo::create_license(
|
||||
&state.db,
|
||||
&license_id,
|
||||
&invoice.product_id,
|
||||
Some(&invoice.id),
|
||||
&issued_at,
|
||||
&serde_json::json!({
|
||||
"source": "purchase",
|
||||
"btcpay_invoice_id": invoice.btcpay_invoice_id,
|
||||
}),
|
||||
policy.as_ref().map(|p| p.id.as_str()),
|
||||
expires_at.as_deref(),
|
||||
grace_seconds,
|
||||
max_machines,
|
||||
&entitlements,
|
||||
is_trial,
|
||||
invoice.buyer_email.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
license_id = %license_id,
|
||||
invoice_id = %invoice.id,
|
||||
policy_id = ?policy.as_ref().map(|p| &p.id),
|
||||
"license issued for settled invoice"
|
||||
);
|
||||
|
||||
// Fire-and-forget Lightning tip to the policy's configured recipient,
|
||||
// if any. This never blocks issuance: errors are logged + audited inside
|
||||
// the spawned task. Skipped silently when the policy has no tip config.
|
||||
if let Some(p) = policy.as_ref() {
|
||||
if p.tip_recipient.is_some() && p.tip_pct_bps > 0 {
|
||||
crate::tipping::spawn_tip(
|
||||
state.clone(),
|
||||
license_id.clone(),
|
||||
p.clone(),
|
||||
invoice.amount_sats,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
crate::webhooks::dispatch(
|
||||
state,
|
||||
"license.issued",
|
||||
&serde_json::json!({
|
||||
"license_id": license_id,
|
||||
"product_id": invoice.product_id,
|
||||
"invoice_id": invoice.id,
|
||||
"policy_id": policy.as_ref().map(|p| &p.id),
|
||||
"is_trial": is_trial,
|
||||
"expires_at": expires_at,
|
||||
"entitlements": entitlements,
|
||||
"source": "purchase",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// If this invoice used a discount code, finalize the redemption row
|
||||
// (transition pending → redeemed, attach license_id) and fire a
|
||||
// `code.redeemed` webhook. Done here (rather than in the webhook
|
||||
// handler) so both the webhook path and the reconciler-recovered
|
||||
// path produce identical effects.
|
||||
if let Some(redemption) =
|
||||
repo::get_pending_redemption_by_invoice(&state.db, &invoice.id).await?
|
||||
{
|
||||
if let Err(e) =
|
||||
repo::mark_redemption_redeemed(&state.db, &redemption.id, &license_id).await
|
||||
{
|
||||
tracing::warn!(
|
||||
redemption_id = %redemption.id,
|
||||
license_id = %license_id,
|
||||
error = %e,
|
||||
"failed to mark redemption as redeemed; continuing"
|
||||
);
|
||||
}
|
||||
|
||||
let code_payload = match repo::get_discount_code_by_id(&state.db, &redemption.code_id).await
|
||||
{
|
||||
Ok(Some(code)) => Some(code),
|
||||
_ => None,
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"system",
|
||||
None,
|
||||
"code.redeemed",
|
||||
Some("discount_code"),
|
||||
Some(&redemption.code_id),
|
||||
None,
|
||||
None,
|
||||
&serde_json::json!({
|
||||
"redemption_id": redemption.id,
|
||||
"invoice_id": invoice.id,
|
||||
"license_id": license_id,
|
||||
"discount_applied_sats": redemption.discount_applied_sats,
|
||||
"base_price_sats": redemption.base_price_sats,
|
||||
"final_price_sats": redemption.final_price_sats,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
state,
|
||||
"code.redeemed",
|
||||
&serde_json::json!({
|
||||
"redemption_id": redemption.id,
|
||||
"code_id": redemption.code_id,
|
||||
"code": code_payload.as_ref().map(|c| c.code.clone()),
|
||||
"license_id": license_id,
|
||||
"product_id": invoice.product_id,
|
||||
"invoice_id": invoice.id,
|
||||
"discount_applied_sats": redemption.discount_applied_sats,
|
||||
"base_price_sats": redemption.base_price_sats,
|
||||
"final_price_sats": redemption.final_price_sats,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(license_id)
|
||||
}
|
||||
|
||||
// Small helper to attach a log line to an error conversion.
|
||||
trait TapLog {
|
||||
fn tap_log(self, msg: String) -> Self;
|
||||
}
|
||||
impl TapLog for AppError {
|
||||
fn tap_log(self, msg: String) -> Self {
|
||||
tracing::warn!("{msg}");
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Admin CRUD for webhook endpoints.
|
||||
//!
|
||||
//! Operators register one or more URLs that will receive signed JSON
|
||||
//! notifications of interesting events (`license.issued`, `license.revoked`,
|
||||
//! `machine.activated`, etc.). Each endpoint has its own HMAC-SHA256 secret;
|
||||
//! the delivery worker in [`crate::webhooks`] signs bodies with it.
|
||||
//!
|
||||
//! The secret is only returned to the operator in plaintext on create — once
|
||||
//! they've stored it somewhere safe, later reads return the secret masked.
|
||||
//! (If they lose it, they can rotate by deleting + recreating the endpoint.)
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::AppResult;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEndpointReq {
|
||||
pub url: String,
|
||||
/// Event types this endpoint is interested in. Use `["*"]` to receive all
|
||||
/// events. Examples: `license.issued`, `license.revoked`,
|
||||
/// `license.suspended`, `machine.activated`, `machine.deactivated`,
|
||||
/// `invoice.settled`.
|
||||
#[serde(default = "default_event_types")]
|
||||
pub event_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Optional explicit secret (hex, 32+ bytes). If omitted, the server
|
||||
/// generates a fresh 32-byte secret and returns it in the response.
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
fn default_event_types() -> Vec<String> {
|
||||
vec!["*".to_string()]
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateEndpointReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let secret = req.secret.unwrap_or_else(generate_secret);
|
||||
let ep = repo::create_webhook_endpoint(
|
||||
&state.db,
|
||||
&req.url,
|
||||
&secret,
|
||||
&req.event_types,
|
||||
&req.description,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"webhook_endpoint.create",
|
||||
Some("webhook_endpoint"),
|
||||
Some(&ep.id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({
|
||||
"url": ep.url,
|
||||
"event_types": ep.event_types,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Return the full endpoint (including the plaintext secret) on create —
|
||||
// this is the only chance the operator gets to see it.
|
||||
Ok(Json(json!(ep)))
|
||||
}
|
||||
|
||||
fn generate_secret() -> String {
|
||||
let mut raw = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut raw);
|
||||
hex::encode(raw)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListEndpointsQuery {
|
||||
#[serde(default)]
|
||||
pub include_secret: bool,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListEndpointsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?;
|
||||
Ok(Json(json!({ "endpoints": rows })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetActiveReq {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub async fn set_active(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_webhook_active(&state.db, &id, req.active).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"webhook_endpoint.set_active",
|
||||
Some("webhook_endpoint"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "active": req.active }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::delete_webhook_endpoint(&state.db, &id).await?;
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"webhook_endpoint.delete",
|
||||
Some("webhook_endpoint"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({}),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Minimal BTCPay Greenfield API client — only the endpoints this service
|
||||
//! actually calls. Add more as needs grow.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BtcpayClient {
|
||||
http: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
store_id: String,
|
||||
}
|
||||
|
||||
/// Response subset from `POST /api/v1/stores/{storeId}/invoices`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatedInvoice {
|
||||
pub id: String,
|
||||
#[serde(rename = "checkoutLink")]
|
||||
pub checkout_link: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Fields we include when creating an invoice. BTCPay accepts many more; we
|
||||
/// only send what we need.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateInvoiceRequest<'a> {
|
||||
amount: String,
|
||||
currency: &'a str,
|
||||
metadata: serde_json::Value,
|
||||
checkout: CheckoutOptions<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CheckoutOptions<'a> {
|
||||
#[serde(rename = "redirectURL")]
|
||||
redirect_url: Option<&'a str>,
|
||||
#[serde(rename = "redirectAutomatically")]
|
||||
redirect_automatically: bool,
|
||||
}
|
||||
|
||||
impl BtcpayClient {
|
||||
pub fn new(base_url: &str, api_key: &str, store_id: &str) -> Self {
|
||||
Self {
|
||||
http: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("reqwest client"),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
store_id: store_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invoice priced in satoshis. BTCPay accepts "BTC" currency
|
||||
/// with decimal amounts; we convert sats → BTC here.
|
||||
pub async fn create_invoice(
|
||||
&self,
|
||||
amount_sats: i64,
|
||||
metadata: serde_json::Value,
|
||||
redirect_url: Option<&str>,
|
||||
) -> Result<CreatedInvoice> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{}/invoices",
|
||||
self.base_url, self.store_id
|
||||
);
|
||||
let amount_btc = format!("{:.8}", amount_sats as f64 / 100_000_000.0);
|
||||
|
||||
let body = CreateInvoiceRequest {
|
||||
amount: amount_btc,
|
||||
currency: "BTC",
|
||||
metadata,
|
||||
checkout: CheckoutOptions {
|
||||
redirect_url,
|
||||
redirect_automatically: true,
|
||||
},
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay create-invoice")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay create-invoice returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
|
||||
let invoice: CreatedInvoice = resp
|
||||
.json()
|
||||
.await
|
||||
.context("parsing BTCPay create-invoice response")?;
|
||||
Ok(invoice)
|
||||
}
|
||||
|
||||
/// Pay a BOLT11 Lightning invoice from the operator's BTCPay node.
|
||||
/// Used by the tip-recipient flow. Returns the BTCPay payment record so
|
||||
/// the caller can extract the payment hash and surface it in the audit
|
||||
/// log. Errors if the store has no internal LN node or the node refuses
|
||||
/// the payment (insufficient liquidity, invoice already paid, etc.).
|
||||
///
|
||||
/// BTCPay endpoint:
|
||||
/// POST /api/v1/stores/{storeId}/lightning/BTC/invoices/pay
|
||||
/// { "BOLT11": "<bolt11>" }
|
||||
///
|
||||
/// The BTC path-component is the cryptoCode; on BTCPay-Server it's
|
||||
/// always "BTC" for the Bitcoin Lightning network.
|
||||
pub async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<serde_json::Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{}/lightning/BTC/invoices/pay",
|
||||
self.base_url, self.store_id
|
||||
);
|
||||
let body = json!({ "BOLT11": bolt11 });
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay pay-lightning-invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay pay-lightning-invoice returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
|
||||
let payment: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("parsing BTCPay pay-lightning-invoice response")?;
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
/// Fetch invoice state for reconciliation on startup / admin queries.
|
||||
/// Not used in the hot path; webhooks are the source of truth.
|
||||
pub async fn get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{}/invoices/{}",
|
||||
self.base_url, self.store_id, invoice_id
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {}", self.api_key))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay get-invoice returned {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn store_id(&self) -> &str {
|
||||
&self.store_id
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn api_key(&self) -> &str {
|
||||
&self.api_key
|
||||
}
|
||||
|
||||
// Helper to quickly construct sample metadata for invoice correlation.
|
||||
pub fn invoice_metadata(product_id: &str, internal_invoice_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"orderId": internal_invoice_id,
|
||||
"productId": product_id,
|
||||
"source": "keysat",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone helpers for the authorize / bootstrap flow. These operate
|
||||
/// *before* a full `BtcpayClient` exists, since we don't yet know which
|
||||
/// store the API key is scoped to.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StoreSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// List the stores the given API key has access to.
|
||||
pub async fn list_stores(base_url: &str, api_key: &str) -> Result<Vec<StoreSummary>> {
|
||||
let url = format!("{}/api/v1/stores", base_url.trim_end_matches('/'));
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay list-stores")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay list-stores returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
Ok(resp.json::<Vec<StoreSummary>>().await?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatedWebhook {
|
||||
pub id: String,
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Register a webhook on the given store pointing at `callback_url` and
|
||||
/// subscribing to the three invoice lifecycle events we care about.
|
||||
pub async fn create_webhook(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
callback_url: &str,
|
||||
secret: &str,
|
||||
) -> Result<CreatedWebhook> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/webhooks",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
let body = json!({
|
||||
"url": callback_url,
|
||||
"enabled": true,
|
||||
"automaticRedelivery": true,
|
||||
"secret": secret,
|
||||
"authorizedEvents": {
|
||||
"everything": false,
|
||||
"specificEvents": [
|
||||
"InvoiceSettled",
|
||||
"InvoiceExpired",
|
||||
"InvoiceInvalid",
|
||||
],
|
||||
},
|
||||
});
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay create-webhook")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay create-webhook returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
Ok(resp.json::<CreatedWebhook>().await?)
|
||||
}
|
||||
|
||||
/// Delete a webhook on the given store. Used by the Disconnect flow so
|
||||
/// that re-authorizing later doesn't leave behind a duplicate webhook
|
||||
/// pointing at this Keysat install.
|
||||
pub async fn delete_webhook(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
webhook_id: &str,
|
||||
) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/webhooks/{webhook_id}",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay delete-webhook")?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay delete-webhook returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
// 404 is treated as success — the webhook is already gone.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a BTCPay API key. Best-effort — failures are logged by the
|
||||
/// caller but don't block the local Disconnect from completing.
|
||||
pub async fn revoke_api_key(base_url: &str, api_key: &str) -> Result<()> {
|
||||
let url = format!("{}/api/v1/api-keys/current", base_url.trim_end_matches('/'));
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay revoke-api-key")?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay revoke-api-key returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List the payment methods configured on a store. Used by the
|
||||
/// post-connect "missing wallet" detection. Returns the raw JSON array
|
||||
/// because the per-method shape varies (onchain vs LN, BTC vs altcoins).
|
||||
/// Empty array → no payment methods configured.
|
||||
pub async fn list_payment_methods(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/payment-methods",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay list-payment-methods")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay list-payment-methods returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
let raw: serde_json::Value = resp.json().await?;
|
||||
Ok(raw
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Persistent BTCPay connection state.
|
||||
//!
|
||||
//! Runtime credentials (API key, store, webhook secret) live in the DB so that
|
||||
//! the operator can reconfigure BTCPay from the StartOS dashboard without
|
||||
//! editing env vars or restarting the container.
|
||||
//!
|
||||
//! Written on first connect (via the authorize flow) and on explicit
|
||||
//! reconnects. Read at startup to construct the `BtcpayClient`.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BtcpayConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub store_id: String,
|
||||
pub webhook_id: Option<String>,
|
||||
pub webhook_secret: String,
|
||||
}
|
||||
|
||||
/// Load the current BTCPay config. Returns `None` if the operator has not
|
||||
/// completed the authorize flow yet.
|
||||
pub async fn load(pool: &SqlitePool) -> Result<Option<BtcpayConfig>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT base_url, api_key, store_id, webhook_id, webhook_secret \
|
||||
FROM btcpay_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("loading btcpay_config")?;
|
||||
|
||||
Ok(row.map(|r| BtcpayConfig {
|
||||
base_url: r.get("base_url"),
|
||||
api_key: r.get("api_key"),
|
||||
store_id: r.get("store_id"),
|
||||
webhook_id: r.get("webhook_id"),
|
||||
webhook_secret: r.get("webhook_secret"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete the entire BTCPay config row. Used by the Disconnect flow.
|
||||
/// Subsequent calls to `load` return `None` until the operator
|
||||
/// re-authorizes.
|
||||
pub async fn clear(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query("DELETE FROM btcpay_config WHERE id = 1")
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("clearing btcpay_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert the full config. Called by the authorize-callback path after the
|
||||
/// service has fetched/created everything it needs from BTCPay.
|
||||
pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_config \
|
||||
(id, base_url, api_key, store_id, webhook_id, webhook_secret, connected_at) \
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
base_url = excluded.base_url, \
|
||||
api_key = excluded.api_key, \
|
||||
store_id = excluded.store_id, \
|
||||
webhook_id = excluded.webhook_id, \
|
||||
webhook_secret = excluded.webhook_secret, \
|
||||
connected_at = excluded.connected_at",
|
||||
)
|
||||
.bind(&cfg.base_url)
|
||||
.bind(&cfg.api_key)
|
||||
.bind(&cfg.store_id)
|
||||
.bind(cfg.webhook_id.as_deref())
|
||||
.bind(&cfg.webhook_secret)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("saving btcpay_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a new in-flight authorize state token. The caller has already
|
||||
/// generated a cryptographically-random token.
|
||||
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("recording btcpay authorize state")?;
|
||||
// Best-effort prune of rows older than 30 minutes.
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let _ = sqlx::query("DELETE FROM btcpay_authorize_state WHERE created_at < ?")
|
||||
.bind(&cutoff)
|
||||
.execute(pool)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that `token` was issued recently and has not been consumed.
|
||||
/// Consumes (deletes) the token on success so a replay fails.
|
||||
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let row = sqlx::query(
|
||||
"SELECT state_token FROM btcpay_authorize_state \
|
||||
WHERE state_token = ? AND created_at >= ?",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(&cutoff)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if row.is_none() {
|
||||
return Err(anyhow!("unknown or expired authorize state token"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! BTCPay Server integration.
|
||||
//!
|
||||
//! - [`client`] creates invoices via the BTCPay Greenfield API.
|
||||
//! - [`webhook`] verifies and parses incoming webhook calls from BTCPay.
|
||||
//!
|
||||
//! BTCPay's Greenfield API is documented at
|
||||
//! <https://docs.btcpayserver.org/API/Greenfield/v1/>.
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod webhook;
|
||||
@@ -0,0 +1,93 @@
|
||||
//! BTCPay webhook handling.
|
||||
//!
|
||||
//! BTCPay signs each webhook body with HMAC-SHA256 using the shared secret
|
||||
//! we configured, and sends the hex digest in the `BTCPay-Sig` header as
|
||||
//! `sha256=<hex>`. We verify in constant time before trusting anything.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Verify the `BTCPay-Sig` header matches the raw request body.
|
||||
///
|
||||
/// Returns `Ok(())` on success, `Err` on any mismatch. Callers must pass the
|
||||
/// raw, unmodified body — any reserialization will break the HMAC.
|
||||
pub fn verify_signature(secret: &str, header_value: &str, raw_body: &[u8]) -> Result<()> {
|
||||
let expected_hex = header_value
|
||||
.strip_prefix("sha256=")
|
||||
.ok_or_else(|| anyhow!("BTCPay-Sig header missing 'sha256=' prefix"))?;
|
||||
let expected =
|
||||
hex::decode(expected_hex).map_err(|_| anyhow!("BTCPay-Sig header is not hex"))?;
|
||||
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC takes any key size");
|
||||
mac.update(raw_body);
|
||||
let computed = mac.finalize().into_bytes();
|
||||
|
||||
if bool::from(computed.as_slice().ct_eq(&expected)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("BTCPay webhook signature mismatch"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of webhook payload fields we care about. BTCPay sends many
|
||||
/// event types; we key off `invoiceId` and `type` / `status`.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct WebhookEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(rename = "invoiceId")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
impl WebhookEvent {
|
||||
/// BTCPay fires event types like `InvoiceSettled`, `InvoiceExpired`,
|
||||
/// `InvoiceInvalid`, `InvoiceProcessing`. We normalize to our internal
|
||||
/// status vocabulary.
|
||||
pub fn to_status(&self) -> Option<&'static str> {
|
||||
match self.event_type.as_str() {
|
||||
"InvoiceSettled" | "InvoicePaymentSettled" => Some("settled"),
|
||||
"InvoiceExpired" => Some("expired"),
|
||||
"InvoiceInvalid" => Some("invalid"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verifies_correct_signature() {
|
||||
let secret = "super-secret";
|
||||
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let header = format!("sha256={sig}");
|
||||
|
||||
assert!(verify_signature(secret, &header, body).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_body() {
|
||||
let secret = "super-secret";
|
||||
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
|
||||
let tampered = br#"{"type":"InvoiceSettled","invoiceId":"evil"}"#;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let header = format!("sha256={sig}");
|
||||
|
||||
assert!(verify_signature(secret, &header, tampered).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Runtime configuration.
|
||||
//!
|
||||
//! Loaded once at startup from environment variables. A `.env` file is read
|
||||
//! if present (via `dotenvy`) so local development is frictionless. In
|
||||
//! production on StartOS, the same variables are set by the service manifest.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Where the HTTP server binds.
|
||||
pub bind: SocketAddr,
|
||||
|
||||
/// Path to the SQLite database file (e.g. `/data/keysat.db` inside a
|
||||
/// Start9 container; `./data/keysat.db` in dev).
|
||||
pub db_path: PathBuf,
|
||||
|
||||
/// Shared secret required on admin endpoints via `Authorization: Bearer ...`.
|
||||
/// Generated once by the operator and kept secret.
|
||||
pub admin_api_key: String,
|
||||
|
||||
/// BTCPay Server base URL used for daemon → BTCPay API calls. On
|
||||
/// StartOS this is the internal-network hostname like
|
||||
/// `http://btcpayserver.startos:23000`, which is only resolvable from
|
||||
/// inside other StartOS containers.
|
||||
pub btcpay_url: String,
|
||||
|
||||
/// BTCPay Server base URL used for the OPERATOR'S BROWSER. The
|
||||
/// authorize flow redirects the operator's browser to BTCPay's
|
||||
/// consent page; that target must be reachable from the LAN /
|
||||
/// clearnet, not the internal-network hostname. The wrapper sets
|
||||
/// this to BTCPay's preferred operator-facing URL — typically
|
||||
/// mDNS (`https://immense-voyage.local:49347`) since the operator
|
||||
/// is on the same LAN as the Start9.
|
||||
pub btcpay_browser_url: Option<String>,
|
||||
|
||||
/// BTCPay Server PUBLIC URL used for BUYER-facing redirects.
|
||||
/// The daemon rewrites checkout URLs returned by BTCPay's API so
|
||||
/// they point at this URL — random internet buyers can't reach
|
||||
/// mDNS or LAN URLs, so this needs to be a real clearnet domain
|
||||
/// like `https://btcpay.your-domain.com`. Falls back to
|
||||
/// `btcpay_browser_url` if unset (useful for local testing only).
|
||||
pub btcpay_public_url: Option<String>,
|
||||
|
||||
/// Seed BTCPay API key, used only on first boot before the operator has
|
||||
/// completed the authorize flow. Leave empty in the normal case.
|
||||
pub btcpay_api_key: Option<String>,
|
||||
|
||||
/// Seed BTCPay store id. Same rules as `btcpay_api_key` — empty in the
|
||||
/// normal case.
|
||||
pub btcpay_store_id: Option<String>,
|
||||
|
||||
/// Seed webhook secret. Only used when bootstrapping from env vars.
|
||||
pub btcpay_webhook_secret: Option<String>,
|
||||
|
||||
/// Public base URL of *this* Keysat instance, used when constructing
|
||||
/// invoice redirect / webhook URLs (e.g. `https://license.example.com`).
|
||||
pub public_base_url: String,
|
||||
|
||||
/// Optional human-readable operator name shown in `/` index responses.
|
||||
pub operator_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
// Best-effort load of .env in dev. Missing file is not an error.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// All runtime knobs live under `KEYSAT_*`. For older installs and
|
||||
// dev shells that predate the rename we still honour the original
|
||||
// `LICENSING_*` names as a silent fallback.
|
||||
let bind_str = env_with_fallback("KEYSAT_BIND", "LICENSING_BIND")
|
||||
.unwrap_or_else(|| "0.0.0.0:8080".to_string());
|
||||
let bind: SocketAddr = bind_str
|
||||
.parse()
|
||||
.with_context(|| format!("KEYSAT_BIND is not a valid socket address: {bind_str}"))?;
|
||||
|
||||
let db_path = PathBuf::from(
|
||||
env_with_fallback("KEYSAT_DB_PATH", "LICENSING_DB_PATH")
|
||||
.unwrap_or_else(|| "./data/keysat.db".into()),
|
||||
);
|
||||
|
||||
let admin_api_key = required_with_fallback("KEYSAT_ADMIN_API_KEY", "LICENSING_ADMIN_API_KEY")?;
|
||||
if admin_api_key.len() < 32 {
|
||||
return Err(anyhow!(
|
||||
"KEYSAT_ADMIN_API_KEY must be at least 32 characters (use `openssl rand -hex 32`)"
|
||||
));
|
||||
}
|
||||
|
||||
let btcpay_url = required("BTCPAY_URL")?;
|
||||
let btcpay_browser_url = optional_nonempty("BTCPAY_BROWSER_URL")
|
||||
.map(|s| s.trim_end_matches('/').to_string());
|
||||
let btcpay_public_url = optional_nonempty("BTCPAY_PUBLIC_URL")
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
// Fallback: if no public URL is plumbed, use browser URL.
|
||||
// Won't work for real customers but is fine for local testing.
|
||||
.or_else(|| btcpay_browser_url.clone());
|
||||
let btcpay_api_key = optional_nonempty("BTCPAY_API_KEY");
|
||||
let btcpay_store_id = optional_nonempty("BTCPAY_STORE_ID");
|
||||
let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET");
|
||||
let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?;
|
||||
let operator_name = env_with_fallback("KEYSAT_OPERATOR_NAME", "LICENSING_OPERATOR_NAME");
|
||||
|
||||
Ok(Self {
|
||||
bind,
|
||||
db_path,
|
||||
admin_api_key,
|
||||
btcpay_url: btcpay_url.trim_end_matches('/').to_string(),
|
||||
btcpay_browser_url,
|
||||
btcpay_public_url,
|
||||
btcpay_api_key,
|
||||
btcpay_store_id,
|
||||
btcpay_webhook_secret,
|
||||
public_base_url: public_base_url.trim_end_matches('/').to_string(),
|
||||
operator_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_nonempty(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String> {
|
||||
std::env::var(name).map_err(|_| anyhow!("missing required env var: {name}"))
|
||||
}
|
||||
|
||||
/// Look up a var under its current (KEYSAT_*) name, falling back to the
|
||||
/// pre-rename (LICENSING_*) name if unset.
|
||||
fn env_with_fallback(primary: &str, fallback: &str) -> Option<String> {
|
||||
optional_nonempty(primary).or_else(|| optional_nonempty(fallback))
|
||||
}
|
||||
|
||||
fn required_with_fallback(primary: &str, fallback: &str) -> Result<String> {
|
||||
env_with_fallback(primary, fallback)
|
||||
.ok_or_else(|| anyhow!("missing required env var: {primary} (or {fallback})"))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Server key lifecycle: generate on first boot, load on subsequent boots.
|
||||
//!
|
||||
//! Keys are stored in SQLite (rather than on the filesystem) so the same
|
||||
//! backup mechanism that protects licenses also protects the signing key.
|
||||
//! On StartOS, the database file lives under the service's encrypted data
|
||||
//! volume, so at-rest encryption is handled by the OS.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use rand::rngs::OsRng;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Both halves of the server keypair.
|
||||
#[derive(Clone)]
|
||||
pub struct ServerKeypair {
|
||||
pub signing: SigningKey,
|
||||
pub verifying: VerifyingKey,
|
||||
/// PEM-encoded public key, for display / SDK bundling.
|
||||
pub public_key_pem: String,
|
||||
}
|
||||
|
||||
/// Load the keypair from the DB, generating and persisting a new one if no
|
||||
/// row exists. This function is idempotent and safe to call on every boot.
|
||||
pub async fn load_or_generate(pool: &SqlitePool) -> Result<ServerKeypair> {
|
||||
// Try to load.
|
||||
let existing = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT public_key_pem, private_key_pem FROM server_keys WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((pub_pem, priv_pem)) = existing {
|
||||
let signing = SigningKey::from_pkcs8_pem(&priv_pem)
|
||||
.context("failed to parse stored private key")?;
|
||||
let verifying = VerifyingKey::from_public_key_pem(&pub_pem)
|
||||
.context("failed to parse stored public key")?;
|
||||
return Ok(ServerKeypair {
|
||||
signing,
|
||||
verifying,
|
||||
public_key_pem: pub_pem,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a new keypair.
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
use pkcs8::LineEnding;
|
||||
let priv_pem = signing
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.context("failed to encode private key to PEM")?
|
||||
.to_string();
|
||||
let pub_pem = verifying
|
||||
.to_public_key_pem(LineEnding::LF)
|
||||
.context("failed to encode public key to PEM")?;
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO server_keys (id, algorithm, public_key_pem, private_key_pem, created_at)
|
||||
VALUES (1, 'ed25519', ?, ?, ?)",
|
||||
)
|
||||
.bind(&pub_pem)
|
||||
.bind(&priv_pem)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("generated new Ed25519 server signing key");
|
||||
|
||||
Ok(ServerKeypair {
|
||||
signing,
|
||||
verifying,
|
||||
public_key_pem: pub_pem,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
//! License key cryptography.
|
||||
//!
|
||||
//! # Key format
|
||||
//!
|
||||
//! A license key presented to users looks like:
|
||||
//!
|
||||
//! ```text
|
||||
//! LIC1-<base32 payload>-<base32 signature>
|
||||
//! ```
|
||||
//!
|
||||
//! The base32 alphabet is `BASE32_NOPAD` (RFC 4648, no padding, case-insensitive
|
||||
//! decode). Signatures are always 64 bytes of Ed25519.
|
||||
//!
|
||||
//! ## Payload — version 1 (legacy, still accepted)
|
||||
//!
|
||||
//! A fixed 74-byte blob:
|
||||
//!
|
||||
//! | offset | size | field |
|
||||
//! |--------|------|----------------------------------------------|
|
||||
//! | 0 | 1 | version = 1 |
|
||||
//! | 1 | 1 | flags (bit 0: fingerprint-bound) |
|
||||
//! | 2 | 16 | product_id (UUID, big-endian bytes) |
|
||||
//! | 18 | 16 | license_id (UUID, big-endian bytes) |
|
||||
//! | 34 | 8 | issued_at (u64 unix seconds, BE) |
|
||||
//! | 42 | 32 | fingerprint_hash (SHA-256, zero if unbound) |
|
||||
//!
|
||||
//! ## Payload — version 2 (current default)
|
||||
//!
|
||||
//! Variable-length. The fixed head is 83 bytes, followed by the entitlements
|
||||
//! table. Every byte here is signed.
|
||||
//!
|
||||
//! | offset | size | field |
|
||||
//! |--------|------|---------------------------------------------------------|
|
||||
//! | 0 | 1 | version = 2 |
|
||||
//! | 1 | 1 | flags |
|
||||
//! | 2 | 16 | product_id |
|
||||
//! | 18 | 16 | license_id |
|
||||
//! | 34 | 8 | issued_at (u64 BE, unix seconds) |
|
||||
//! | 42 | 8 | expires_at (u64 BE, unix seconds; 0 = perpetual) |
|
||||
//! | 50 | 32 | fingerprint_hash (SHA-256; zero iff flag bit unset) |
|
||||
//! | 82 | 1 | entitlements_count (N, 0..=255) |
|
||||
//! | 83.. | ... | entitlements: N × `<len: u8><ascii bytes>` |
|
||||
//!
|
||||
//! Each entitlement is a short ASCII string ≤ 255 bytes; the canonical examples
|
||||
//! are feature slugs (`"pro"`, `"cloud-sync"`, `"multi-seat"`). The list is
|
||||
//! signed so offline verifiers can gate features without contacting the server.
|
||||
//!
|
||||
//! ## Flag bits (shared across versions)
|
||||
//!
|
||||
//! | bit | meaning |
|
||||
//! |-----|------------------------------------------------------------|
|
||||
//! | 0 | fingerprint-bound |
|
||||
//! | 1 | trial license (v2 only; best-effort — clients may warn) |
|
||||
//!
|
||||
//! # Why versioned
|
||||
//!
|
||||
//! v2 adds expiry and entitlements, both of which need to be inside the signed
|
||||
//! blob if we want offline enforcement (a stripped entitlement or pushed-back
|
||||
//! expiry would have to match a valid signature, which the attacker can't
|
||||
//! produce). Keeping the v1 parser in place means any keys already issued with
|
||||
//! v1 continue to verify forever — the whole point of cryptographic licensing.
|
||||
//!
|
||||
//! # Offline verification
|
||||
//!
|
||||
//! Third-party clients ship the server's **public key** (not the private
|
||||
//! key) bundled in their SDK. They can verify signatures, enforce expiry, and
|
||||
//! gate features on entitlements entirely offline. Revocation, machine binding,
|
||||
//! and suspension are authoritative server-side — clients that want true
|
||||
//! strictness should call `/v1/validate` periodically.
|
||||
|
||||
pub mod keys;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Key format version currently issued by the server.
|
||||
pub const KEY_VERSION: u8 = 2;
|
||||
|
||||
/// v1 format — legacy, still accepted on parse.
|
||||
pub const KEY_VERSION_V1: u8 = 1;
|
||||
/// v2 format — current default.
|
||||
pub const KEY_VERSION_V2: u8 = 2;
|
||||
|
||||
/// Fixed-size of the v1 payload (for tests / legacy parsing).
|
||||
pub const PAYLOAD_V1_LEN: usize = 1 + 1 + 16 + 16 + 8 + 32; // = 74
|
||||
|
||||
/// Minimum size of a v2 payload (head only, no entitlements).
|
||||
pub const PAYLOAD_V2_HEAD_LEN: usize = 1 + 1 + 16 + 16 + 8 + 8 + 32 + 1; // = 83
|
||||
|
||||
/// Flag bit indicating the license is bound to a fingerprint hash.
|
||||
pub const FLAG_FINGERPRINT_BOUND: u8 = 0b0000_0001;
|
||||
|
||||
/// Flag bit indicating the license was issued as a trial (comp/paid trial).
|
||||
/// Clients that care may render a "Trial" badge; enforcement is via expiry.
|
||||
pub const FLAG_TRIAL: u8 = 0b0000_0010;
|
||||
|
||||
/// Prefix that tags our key strings and future-proofs the envelope.
|
||||
pub const KEY_PREFIX: &str = "LIC1";
|
||||
|
||||
/// Parsed, not-yet-verified key payload. This is a unified v1+v2 shape; on a
|
||||
/// v1 parse we zero-fill the v2-only fields, so downstream code can be
|
||||
/// version-agnostic as long as it reads `version` before trusting `expires_at`
|
||||
/// or `entitlements`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LicensePayload {
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub product_id: Uuid,
|
||||
pub license_id: Uuid,
|
||||
pub issued_at: i64,
|
||||
/// Unix seconds; `0` means perpetual. Always 0 for v1.
|
||||
pub expires_at: i64,
|
||||
/// SHA-256 of the fingerprint, or zeros if `FLAG_FINGERPRINT_BOUND` is unset.
|
||||
pub fingerprint_hash: [u8; 32],
|
||||
/// Feature slugs ASCII; empty for v1 or v2 licenses with no entitlements.
|
||||
pub entitlements: Vec<String>,
|
||||
}
|
||||
|
||||
impl LicensePayload {
|
||||
pub fn is_fingerprint_bound(&self) -> bool {
|
||||
self.flags & FLAG_FINGERPRINT_BOUND != 0
|
||||
}
|
||||
|
||||
pub fn is_trial(&self) -> bool {
|
||||
self.flags & FLAG_TRIAL != 0
|
||||
}
|
||||
|
||||
/// Has this license expired at the given instant? `expires_at == 0` means
|
||||
/// perpetual and returns `false`.
|
||||
pub fn is_expired_at(&self, now_unix: i64) -> bool {
|
||||
self.expires_at != 0 && now_unix >= self.expires_at
|
||||
}
|
||||
|
||||
/// Does this license grant the given entitlement? Comparison is
|
||||
/// case-sensitive and exact — pick a canonical casing and stick with it.
|
||||
pub fn has_entitlement(&self, slug: &str) -> bool {
|
||||
self.entitlements.iter().any(|e| e == slug)
|
||||
}
|
||||
|
||||
/// Serialize to the v2 wire format. Always emits v2 — v1 is parse-only.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(PAYLOAD_V2_HEAD_LEN + self.entitlements.len() * 16);
|
||||
buf.push(KEY_VERSION_V2);
|
||||
buf.push(self.flags);
|
||||
buf.extend_from_slice(self.product_id.as_bytes());
|
||||
buf.extend_from_slice(self.license_id.as_bytes());
|
||||
buf.extend_from_slice(&(self.issued_at as u64).to_be_bytes());
|
||||
buf.extend_from_slice(&(self.expires_at as u64).to_be_bytes());
|
||||
buf.extend_from_slice(&self.fingerprint_hash);
|
||||
// entitlement count — capped at 255 by u8
|
||||
let n: u8 = self
|
||||
.entitlements
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("too many entitlements (max 255)");
|
||||
buf.push(n);
|
||||
for e in &self.entitlements {
|
||||
let bytes = e.as_bytes();
|
||||
let len: u8 = bytes
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("entitlement slug too long (max 255 bytes)");
|
||||
buf.push(len);
|
||||
buf.extend_from_slice(bytes);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse a payload blob. Dispatches on the first byte (version).
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.is_empty() {
|
||||
return Err(anyhow!("empty payload"));
|
||||
}
|
||||
match bytes[0] {
|
||||
KEY_VERSION_V1 => Self::from_bytes_v1(bytes),
|
||||
KEY_VERSION_V2 => Self::from_bytes_v2(bytes),
|
||||
other => Err(anyhow!("unsupported key version: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_bytes_v1(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.len() != PAYLOAD_V1_LEN {
|
||||
return Err(anyhow!(
|
||||
"v1 payload length {} != expected {}",
|
||||
bytes.len(),
|
||||
PAYLOAD_V1_LEN
|
||||
));
|
||||
}
|
||||
let flags = bytes[1];
|
||||
let product_id = Uuid::from_slice(&bytes[2..18])?;
|
||||
let license_id = Uuid::from_slice(&bytes[18..34])?;
|
||||
let issued_at = u64::from_be_bytes(bytes[34..42].try_into().unwrap()) as i64;
|
||||
let mut fingerprint_hash = [0u8; 32];
|
||||
fingerprint_hash.copy_from_slice(&bytes[42..74]);
|
||||
Ok(Self {
|
||||
version: KEY_VERSION_V1,
|
||||
flags,
|
||||
product_id,
|
||||
license_id,
|
||||
issued_at,
|
||||
expires_at: 0,
|
||||
fingerprint_hash,
|
||||
entitlements: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_bytes_v2(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.len() < PAYLOAD_V2_HEAD_LEN {
|
||||
return Err(anyhow!(
|
||||
"v2 payload length {} < head length {}",
|
||||
bytes.len(),
|
||||
PAYLOAD_V2_HEAD_LEN
|
||||
));
|
||||
}
|
||||
let flags = bytes[1];
|
||||
let product_id = Uuid::from_slice(&bytes[2..18])?;
|
||||
let license_id = Uuid::from_slice(&bytes[18..34])?;
|
||||
let issued_at = u64::from_be_bytes(bytes[34..42].try_into().unwrap()) as i64;
|
||||
let expires_at = u64::from_be_bytes(bytes[42..50].try_into().unwrap()) as i64;
|
||||
let mut fingerprint_hash = [0u8; 32];
|
||||
fingerprint_hash.copy_from_slice(&bytes[50..82]);
|
||||
let n = bytes[82] as usize;
|
||||
|
||||
let mut entitlements = Vec::with_capacity(n);
|
||||
let mut cursor = PAYLOAD_V2_HEAD_LEN;
|
||||
for i in 0..n {
|
||||
if cursor >= bytes.len() {
|
||||
return Err(anyhow!(
|
||||
"truncated entitlement list at index {i} (cursor {cursor}, len {})",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let len = bytes[cursor] as usize;
|
||||
cursor += 1;
|
||||
if cursor + len > bytes.len() {
|
||||
return Err(anyhow!(
|
||||
"entitlement {i} length {len} runs past end of payload"
|
||||
));
|
||||
}
|
||||
let slug = std::str::from_utf8(&bytes[cursor..cursor + len])
|
||||
.with_context(|| format!("entitlement {i} is not UTF-8"))?;
|
||||
entitlements.push(slug.to_string());
|
||||
cursor += len;
|
||||
}
|
||||
if cursor != bytes.len() {
|
||||
return Err(anyhow!(
|
||||
"trailing bytes after entitlement list ({} unread)",
|
||||
bytes.len() - cursor
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
version: KEY_VERSION_V2,
|
||||
flags,
|
||||
product_id,
|
||||
license_id,
|
||||
issued_at,
|
||||
expires_at,
|
||||
fingerprint_hash,
|
||||
entitlements,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a raw fingerprint string. We hash so that the full fingerprint never
|
||||
/// travels inside the key (only its hash), making keys shorter and hiding
|
||||
/// information like MAC addresses from anyone who intercepts a key string.
|
||||
pub fn hash_fingerprint(fp: &str) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(fp.as_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Encode a payload + signature into a user-facing key string.
|
||||
pub fn encode_key(payload: &LicensePayload, signature: &Signature) -> String {
|
||||
let payload_b32 = BASE32_NOPAD.encode(&payload.to_bytes());
|
||||
let sig_b32 = BASE32_NOPAD.encode(&signature.to_bytes());
|
||||
format!("{KEY_PREFIX}-{payload_b32}-{sig_b32}")
|
||||
}
|
||||
|
||||
/// Parse a user-provided key string into its payload + signature components
|
||||
/// (plus the raw signed bytes, which the caller needs to verify against).
|
||||
/// Does *not* verify the signature — call `verify_key` for that.
|
||||
pub fn parse_key(s: &str) -> Result<(LicensePayload, Signature, Vec<u8>)> {
|
||||
let s = s.trim();
|
||||
let mut parts = s.splitn(3, '-');
|
||||
let prefix = parts.next().context("key is empty")?;
|
||||
if prefix != KEY_PREFIX {
|
||||
return Err(anyhow!("unrecognized key prefix: {prefix}"));
|
||||
}
|
||||
let payload_b32 = parts.next().context("missing payload section")?;
|
||||
let sig_b32 = parts.next().context("missing signature section")?;
|
||||
|
||||
let payload_bytes = BASE32_NOPAD
|
||||
.decode(payload_b32.to_ascii_uppercase().as_bytes())
|
||||
.context("invalid base32 in payload")?;
|
||||
let sig_bytes = BASE32_NOPAD
|
||||
.decode(sig_b32.to_ascii_uppercase().as_bytes())
|
||||
.context("invalid base32 in signature")?;
|
||||
|
||||
let payload = LicensePayload::from_bytes(&payload_bytes)?;
|
||||
let sig_array: [u8; 64] = sig_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("signature length != 64"))?;
|
||||
let signature = Signature::from_bytes(&sig_array);
|
||||
Ok((payload, signature, payload_bytes))
|
||||
}
|
||||
|
||||
/// Sign a payload with the server's private key.
|
||||
pub fn sign_payload(signing_key: &SigningKey, payload: &LicensePayload) -> Signature {
|
||||
signing_key.sign(&payload.to_bytes())
|
||||
}
|
||||
|
||||
/// Verify a parsed payload's signature against a public key.
|
||||
///
|
||||
/// For v2 keys, `signed_bytes` is the raw payload blob that was parsed from
|
||||
/// the wire. For v1 keys it's the 74-byte v1 blob. Always pass the blob you
|
||||
/// got out of `parse_key` directly — never re-serialize a `LicensePayload`,
|
||||
/// because we always serialize as v2 and that will break v1 signatures.
|
||||
pub fn verify_payload(
|
||||
verifying_key: &VerifyingKey,
|
||||
signed_bytes: &[u8],
|
||||
signature: &Signature,
|
||||
) -> Result<()> {
|
||||
verifying_key
|
||||
.verify(signed_bytes, signature)
|
||||
.context("signature verification failed")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
fn test_payload() -> LicensePayload {
|
||||
LicensePayload {
|
||||
version: KEY_VERSION_V2,
|
||||
flags: 0,
|
||||
product_id: Uuid::new_v4(),
|
||||
license_id: Uuid::new_v4(),
|
||||
issued_at: 1_700_000_000,
|
||||
expires_at: 0,
|
||||
fingerprint_hash: [0u8; 32],
|
||||
entitlements: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_unbound_perpetual_v2() {
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
let payload = test_payload();
|
||||
let sig = sign_payload(&signing, &payload);
|
||||
let encoded = encode_key(&payload, &sig);
|
||||
|
||||
let (parsed, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
|
||||
assert_eq!(parsed, payload);
|
||||
verify_payload(&verifying, &signed_bytes, &parsed_sig).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_entitlements_and_expiry() {
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
let payload = LicensePayload {
|
||||
expires_at: 1_900_000_000,
|
||||
entitlements: vec![
|
||||
"pro".to_string(),
|
||||
"cloud-sync".to_string(),
|
||||
"multi-seat".to_string(),
|
||||
],
|
||||
..test_payload()
|
||||
};
|
||||
let sig = sign_payload(&signing, &payload);
|
||||
let encoded = encode_key(&payload, &sig);
|
||||
|
||||
let (parsed, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
|
||||
assert_eq!(parsed, payload);
|
||||
assert!(parsed.has_entitlement("pro"));
|
||||
assert!(parsed.has_entitlement("cloud-sync"));
|
||||
assert!(!parsed.has_entitlement("enterprise"));
|
||||
assert!(!parsed.is_expired_at(1_800_000_000));
|
||||
assert!(parsed.is_expired_at(1_900_000_000));
|
||||
verify_payload(&verifying, &signed_bytes, &parsed_sig).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_payload_fails_verification() {
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
let payload = LicensePayload {
|
||||
entitlements: vec!["free".to_string()],
|
||||
..test_payload()
|
||||
};
|
||||
let sig = sign_payload(&signing, &payload);
|
||||
let encoded = encode_key(&payload, &sig);
|
||||
let (_, parsed_sig, signed_bytes) = parse_key(&encoded).unwrap();
|
||||
|
||||
// Flip a bit in the signed blob.
|
||||
let mut tampered = signed_bytes.clone();
|
||||
let last = tampered.len() - 1;
|
||||
tampered[last] ^= 0x01;
|
||||
|
||||
assert!(verify_payload(&verifying, &tampered, &parsed_sig).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_bound_roundtrip() {
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let fp = "machine-abc-123";
|
||||
let payload = LicensePayload {
|
||||
flags: FLAG_FINGERPRINT_BOUND,
|
||||
fingerprint_hash: hash_fingerprint(fp),
|
||||
..test_payload()
|
||||
};
|
||||
let sig = sign_payload(&signing, &payload);
|
||||
let encoded = encode_key(&payload, &sig);
|
||||
let (parsed, _, _) = parse_key(&encoded).unwrap();
|
||||
assert!(parsed.is_fingerprint_bound());
|
||||
assert_eq!(parsed.fingerprint_hash, hash_fingerprint(fp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trial_flag_roundtrip() {
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let payload = LicensePayload {
|
||||
flags: FLAG_TRIAL,
|
||||
expires_at: 1_710_000_000,
|
||||
..test_payload()
|
||||
};
|
||||
let sig = sign_payload(&signing, &payload);
|
||||
let encoded = encode_key(&payload, &sig);
|
||||
let (parsed, _, _) = parse_key(&encoded).unwrap();
|
||||
assert!(parsed.is_trial());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_parse_still_works() {
|
||||
// Hand-craft a v1-shaped payload (the wire format that old service
|
||||
// versions emitted) and confirm we still parse it, zero-filling the
|
||||
// v2-only fields.
|
||||
let product_id = Uuid::new_v4();
|
||||
let license_id = Uuid::new_v4();
|
||||
let mut v1 = Vec::with_capacity(PAYLOAD_V1_LEN);
|
||||
v1.push(KEY_VERSION_V1);
|
||||
v1.push(FLAG_FINGERPRINT_BOUND);
|
||||
v1.extend_from_slice(product_id.as_bytes());
|
||||
v1.extend_from_slice(license_id.as_bytes());
|
||||
v1.extend_from_slice(&1_700_000_000u64.to_be_bytes());
|
||||
v1.extend_from_slice(&hash_fingerprint("rig-1"));
|
||||
assert_eq!(v1.len(), PAYLOAD_V1_LEN);
|
||||
|
||||
let parsed = LicensePayload::from_bytes(&v1).unwrap();
|
||||
assert_eq!(parsed.version, KEY_VERSION_V1);
|
||||
assert!(parsed.is_fingerprint_bound());
|
||||
assert_eq!(parsed.expires_at, 0);
|
||||
assert!(parsed.entitlements.is_empty());
|
||||
assert_eq!(parsed.product_id, product_id);
|
||||
assert_eq!(parsed.license_id, license_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_entitlement_list_is_rejected() {
|
||||
// v2 payload head claiming 2 entitlements but only 1 supplied.
|
||||
let mut buf = Vec::new();
|
||||
buf.push(KEY_VERSION_V2);
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&[0u8; 16]);
|
||||
buf.extend_from_slice(&[0u8; 16]);
|
||||
buf.extend_from_slice(&0u64.to_be_bytes()); // issued_at
|
||||
buf.extend_from_slice(&0u64.to_be_bytes()); // expires_at
|
||||
buf.extend_from_slice(&[0u8; 32]); // fingerprint
|
||||
buf.push(2); // count = 2
|
||||
buf.push(3); // len = 3
|
||||
buf.extend_from_slice(b"pro");
|
||||
// missing the second entitlement entirely
|
||||
assert!(LicensePayload::from_bytes(&buf).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Database layer. Runs migrations on startup and provides typed repository
|
||||
//! helpers for each table. Using `sqlx::query` (not `query!`) keeps the
|
||||
//! project buildable without a live DB at compile time.
|
||||
|
||||
pub mod repo;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||
use sqlx::SqlitePool;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Opens (or creates) the SQLite database at `path`, applies migrations, and
|
||||
/// returns a connection pool ready for use.
|
||||
pub async fn init(path: &Path) -> Result<SqlitePool> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating parent dir for db at {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!("sqlite://{}", path.display());
|
||||
let opts = SqliteConnectOptions::from_str(&url)?
|
||||
.create_if_missing(true)
|
||||
// WAL mode is the right default for a read-heavy validation workload.
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(8)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.with_context(|| format!("opening sqlite at {}", path.display()))?;
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.context("running migrations")?;
|
||||
|
||||
tracing::info!(path = %path.display(), "database ready");
|
||||
Ok(pool)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
//! Unified error type for the service. Converts into appropriate HTTP
|
||||
//! responses so handlers can just `?`-propagate.
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("license invalid: {0}")]
|
||||
LicenseInvalid(String),
|
||||
|
||||
#[error("upstream error: {0}")]
|
||||
Upstream(String),
|
||||
|
||||
#[error("BTCPay not configured: connect via the StartOS dashboard first")]
|
||||
BtcpayNotConfigured,
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = match &self {
|
||||
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
|
||||
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
|
||||
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
|
||||
AppError::Database(_) | AppError::Internal(_) => {
|
||||
tracing::error!(error = %self, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"ok": false,
|
||||
"error": code,
|
||||
"message": self.to_string(),
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Keysat-licenses-Keysat: dogfooded self-licensing layer.
|
||||
//!
|
||||
//! The Keysat package ships with the master public key embedded in
|
||||
//! `TRUST_ROOT_PUBKEY_PEM` below. On every boot we look for a license
|
||||
//! at `SELF_LICENSE_PATH` (or the `KEYSAT_LICENSE` env var), parse it
|
||||
//! using the same wire-format machinery the daemon uses to issue
|
||||
//! customer licenses, and verify its signature against the master
|
||||
//! public key.
|
||||
//!
|
||||
//! Two modes:
|
||||
//! - `Permissive` (default for dev builds): missing or invalid
|
||||
//! licenses log a warning and the daemon starts in
|
||||
//! `Tier::Unlicensed`. No features are gated yet — that's a
|
||||
//! future v0.2.x flip.
|
||||
//! - `Enforce`: missing or invalid licenses cause the daemon to
|
||||
//! refuse to start. Set at compile time via the
|
||||
//! `KEYSAT_LICENSE_ENFORCE=1` env var. Marketplace builds set
|
||||
//! this; local dev builds don't.
|
||||
//!
|
||||
//! The master pubkey is the *public* half of an Ed25519 keypair held
|
||||
//! offline by the keysat.xyz team. It is not secret — embedding it in
|
||||
//! source on GitHub is fine. Anyone with the *private* half can mint
|
||||
//! Keysat self-licenses; the private half lives on paper backup +
|
||||
//! hardware-token storage and never touches a connected machine
|
||||
//! except briefly when a master Keysat instance is being initialized.
|
||||
|
||||
use crate::crypto::{parse_key, verify_payload};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ed25519_dalek::pkcs8::DecodePublicKey;
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Master public key for Keysat self-licensing. PEM-encoded Ed25519,
|
||||
/// SubjectPublicKeyInfo wrapped (the format `openssl pkey -pubout`
|
||||
/// emits). To rotate this in a future release: replace the const,
|
||||
/// ship a new build, distribute fresh licenses to existing customers.
|
||||
/// Existing customers' licenses won't verify against the new key —
|
||||
/// that's the breaking event. Plan rotations carefully.
|
||||
pub const TRUST_ROOT_PUBKEY_PEM: &str = "-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
|
||||
-----END PUBLIC KEY-----";
|
||||
|
||||
/// Where the daemon expects a self-license file. Single line, the raw
|
||||
/// license-key string in `LIC1-…-…` format. Mounted from the
|
||||
/// persistent data volume so it survives package upgrades.
|
||||
pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt";
|
||||
|
||||
/// Build-time enforcement toggle. `KEYSAT_LICENSE_ENFORCE=1` at
|
||||
/// `cargo build` time enables enforce mode.
|
||||
const ENFORCE_FLAG: Option<&str> = option_env!("KEYSAT_LICENSE_ENFORCE");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Missing/invalid license logs a warning and continues. Default.
|
||||
Permissive,
|
||||
/// Missing/invalid license refuses to start the daemon.
|
||||
Enforce,
|
||||
}
|
||||
|
||||
pub fn mode() -> Mode {
|
||||
match ENFORCE_FLAG {
|
||||
Some("1") | Some("true") | Some("yes") => Mode::Enforce,
|
||||
_ => Mode::Permissive,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Tier {
|
||||
/// No license configured, or license verify failed in permissive mode.
|
||||
Unlicensed { reason: String },
|
||||
/// Valid license verified against the trust-root.
|
||||
Licensed {
|
||||
license_id: uuid::Uuid,
|
||||
product_id: uuid::Uuid,
|
||||
/// Unix seconds; 0 means perpetual.
|
||||
expires_at: i64,
|
||||
entitlements: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Tier::Unlicensed { .. } => "unlicensed",
|
||||
Tier::Licensed { .. } => "licensed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot-time check. In permissive mode this always returns `Ok`; in
|
||||
/// enforce mode it returns `Err` on missing / invalid / expired
|
||||
/// licenses, which causes `main` to bail out before we open any
|
||||
/// network sockets.
|
||||
pub fn check_at_boot() -> Result<Tier> {
|
||||
let mode = mode();
|
||||
tracing::info!(
|
||||
mode = mode.as_str(),
|
||||
"Keysat self-license check (mode={})",
|
||||
mode.as_str()
|
||||
);
|
||||
|
||||
let license_str = match read_license_string() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let reason = format!(
|
||||
"no license at {} or KEYSAT_LICENSE env var",
|
||||
SELF_LICENSE_PATH
|
||||
);
|
||||
return handle_missing_or_invalid(mode, reason, None);
|
||||
}
|
||||
};
|
||||
|
||||
match verify_license(&license_str) {
|
||||
Ok(tier) => {
|
||||
log_licensed(&tier);
|
||||
Ok(tier)
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = format!("verification failed: {e:#}");
|
||||
handle_missing_or_invalid(mode, reason, Some(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_missing_or_invalid(
|
||||
mode: Mode,
|
||||
reason: String,
|
||||
err: Option<anyhow::Error>,
|
||||
) -> Result<Tier> {
|
||||
match mode {
|
||||
Mode::Permissive => {
|
||||
tracing::warn!(
|
||||
tier = "unlicensed",
|
||||
"Keysat self-license: {} — running unlicensed (permissive build)",
|
||||
reason
|
||||
);
|
||||
Ok(Tier::Unlicensed { reason })
|
||||
}
|
||||
Mode::Enforce => {
|
||||
tracing::error!(
|
||||
"Keysat self-license: {} — refusing to start. \
|
||||
Activate via StartOS → Keysat → Actions → Activate Keysat license.",
|
||||
reason
|
||||
);
|
||||
match err {
|
||||
Some(e) => Err(e.context("self-license invalid (enforce mode)")),
|
||||
None => bail!("self-license missing (enforce mode): {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_license_string() -> Option<String> {
|
||||
if let Ok(s) = std::env::var("KEYSAT_LICENSE") {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
let path = std::path::Path::new(SELF_LICENSE_PATH);
|
||||
if let Ok(s) = std::fs::read_to_string(path) {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Verify a license-key string against the embedded trust-root.
|
||||
/// Returns the parsed `Tier::Licensed` on success.
|
||||
pub fn verify_license(license_key: &str) -> Result<Tier> {
|
||||
let trust_key = parse_trust_root_pubkey()?;
|
||||
let (payload, signature, signed_bytes) =
|
||||
parse_key(license_key).context("license key parse failed")?;
|
||||
verify_payload(&trust_key, &signed_bytes, &signature)
|
||||
.context("license signature does not verify against master pubkey")?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if payload.is_expired_at(now) {
|
||||
bail!(
|
||||
"license expired at unix={} (now unix={})",
|
||||
payload.expires_at,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Tier::Licensed {
|
||||
license_id: payload.license_id,
|
||||
product_id: payload.product_id,
|
||||
expires_at: payload.expires_at,
|
||||
entitlements: payload.entitlements,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist a verified license string to `SELF_LICENSE_PATH`. Caller
|
||||
/// is expected to have run `verify_license` first.
|
||||
pub fn write_license_file(license_key: &str) -> Result<()> {
|
||||
let path = std::path::Path::new(SELF_LICENSE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating parent directory {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, format!("{}\n", license_key.trim()))
|
||||
.with_context(|| format!("writing license to {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_trust_root_pubkey() -> Result<VerifyingKey> {
|
||||
let pem = TRUST_ROOT_PUBKEY_PEM.trim();
|
||||
if pem.is_empty() {
|
||||
bail!("trust-root pubkey not embedded in this build");
|
||||
}
|
||||
let vk = VerifyingKey::from_public_key_pem(pem)
|
||||
.context("trust-root pubkey PEM parse failed")?;
|
||||
Ok(vk)
|
||||
}
|
||||
|
||||
fn log_licensed(tier: &Tier) {
|
||||
if let Tier::Licensed {
|
||||
license_id,
|
||||
product_id,
|
||||
expires_at,
|
||||
entitlements,
|
||||
} = tier
|
||||
{
|
||||
let exp = if *expires_at == 0 {
|
||||
"perpetual".to_string()
|
||||
} else {
|
||||
format!("expires_at_unix={expires_at}")
|
||||
};
|
||||
let ents = if entitlements.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
entitlements.join(",")
|
||||
};
|
||||
tracing::info!(
|
||||
tier = "licensed",
|
||||
license = %license_id,
|
||||
product = %product_id,
|
||||
"Keysat self-license: VERIFIED — {exp}, entitlements={ents}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Permissive => "permissive",
|
||||
Mode::Enforce => "enforce",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Entry point. Wires config → logging → DB → keypair → HTTP server.
|
||||
|
||||
mod api;
|
||||
mod btcpay;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod db;
|
||||
mod error;
|
||||
mod license_self;
|
||||
mod models;
|
||||
mod payment;
|
||||
mod rate_limit;
|
||||
mod reconcile;
|
||||
mod tipping;
|
||||
mod webhooks;
|
||||
|
||||
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
|
||||
/// id from a raw value (machine fingerprints, admin key hashes).
|
||||
pub fn hex_sha256(s: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
use anyhow::Context;
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// --- logging ---
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn")),
|
||||
)
|
||||
.with(fmt::layer().with_target(false))
|
||||
.init();
|
||||
|
||||
// --- config ---
|
||||
let cfg = config::Config::from_env().context("loading configuration")?;
|
||||
tracing::info!(
|
||||
bind = %cfg.bind,
|
||||
db = %cfg.db_path.display(),
|
||||
btcpay_url = %cfg.btcpay_url,
|
||||
btcpay_browser_url = ?cfg.btcpay_browser_url,
|
||||
btcpay_public_url = ?cfg.btcpay_public_url,
|
||||
"starting keysat v{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
// --- self-license tier (Keysat-licenses-Keysat) ---
|
||||
// Verifies any /data/keysat-license.txt against the embedded master
|
||||
// pubkey. In permissive builds (default) a missing/invalid license
|
||||
// logs a warning and we continue. In enforce builds (compiled with
|
||||
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
|
||||
// start. Result is held in app state so the admin UI can surface it.
|
||||
let self_tier = Arc::new(tokio::sync::RwLock::new(
|
||||
license_self::check_at_boot()
|
||||
.context("Keysat self-license check failed (enforce mode)")?,
|
||||
));
|
||||
|
||||
// --- database ---
|
||||
let pool = db::init(&cfg.db_path).await?;
|
||||
|
||||
// --- signing key ---
|
||||
let keypair = crypto::keys::load_or_generate(&pool).await?;
|
||||
tracing::info!(
|
||||
"signing key ready; public key:\n{}",
|
||||
keypair.public_key_pem.trim()
|
||||
);
|
||||
|
||||
// --- payment provider (may be None until operator connects) ---
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> =
|
||||
load_btcpay_provider(&pool, &cfg).await.map(|p| {
|
||||
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
|
||||
arc
|
||||
});
|
||||
match &provider {
|
||||
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
|
||||
None => tracing::warn!(
|
||||
"no payment provider yet configured — purchases will return 503 until the \
|
||||
operator completes the 'Connect BTCPay' flow"
|
||||
),
|
||||
}
|
||||
|
||||
let state = api::AppState {
|
||||
db: pool,
|
||||
keypair: Arc::new(keypair),
|
||||
payment: Arc::new(tokio::sync::RwLock::new(provider)),
|
||||
config: Arc::new(cfg.clone()),
|
||||
self_tier,
|
||||
};
|
||||
|
||||
// Spawn background loops before handing state to the router.
|
||||
reconcile::spawn(state.clone());
|
||||
webhooks::spawn_delivery_worker(state.clone());
|
||||
|
||||
let app = api::router(state).layer(TraceLayer::new_for_http());
|
||||
|
||||
// --- serve ---
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind)
|
||||
.await
|
||||
.with_context(|| format!("binding to {}", cfg.bind))?;
|
||||
tracing::info!("listening on http://{}", cfg.bind);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
tracing::info!("shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
|
||||
/// Load a BtcpayProvider from (in order): DB, then env var seed, then None.
|
||||
/// Never fails — an unconfigured service simply returns 503 on purchase paths
|
||||
/// until the operator completes the connect flow. Returns the concrete
|
||||
/// `BtcpayProvider` so the caller can decide how to wrap it (we wrap as
|
||||
/// `Arc<dyn PaymentProvider>` in `main`).
|
||||
async fn load_btcpay_provider(
|
||||
pool: &sqlx::SqlitePool,
|
||||
cfg: &config::Config,
|
||||
) -> Option<payment::btcpay::BtcpayProvider> {
|
||||
// DB first.
|
||||
if let Ok(Some(saved)) = btcpay::config::load(pool).await {
|
||||
let client = btcpay::client::BtcpayClient::new(
|
||||
&saved.base_url,
|
||||
&saved.api_key,
|
||||
&saved.store_id,
|
||||
);
|
||||
return Some(
|
||||
payment::btcpay::BtcpayProvider::new(client, saved.webhook_secret)
|
||||
.with_public_base(cfg.btcpay_public_url.clone()),
|
||||
);
|
||||
}
|
||||
// Fall back to env seed (useful for dev / legacy installs).
|
||||
if let (Some(api_key), Some(store_id), Some(secret)) = (
|
||||
cfg.btcpay_api_key.as_deref(),
|
||||
cfg.btcpay_store_id.as_deref(),
|
||||
cfg.btcpay_webhook_secret.as_deref(),
|
||||
) {
|
||||
let client =
|
||||
btcpay::client::BtcpayClient::new(&cfg.btcpay_url, api_key, store_id);
|
||||
// Persist the seed into DB so it survives env changes.
|
||||
let _ = btcpay::config::save(
|
||||
pool,
|
||||
&btcpay::config::BtcpayConfig {
|
||||
base_url: cfg.btcpay_url.clone(),
|
||||
api_key: api_key.to_string(),
|
||||
store_id: store_id.to_string(),
|
||||
webhook_id: None,
|
||||
webhook_secret: secret.to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Some(
|
||||
payment::btcpay::BtcpayProvider::new(client, secret.to_string())
|
||||
.with_public_base(cfg.btcpay_public_url.clone()),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Domain models — shared types used by DB, API, and BTCPay layers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Product {
|
||||
pub id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub price_sats: i64,
|
||||
pub active: bool,
|
||||
/// Arbitrary JSON metadata the developer can attach.
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InvoiceStatus {
|
||||
Pending,
|
||||
Settled,
|
||||
Expired,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl InvoiceStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
InvoiceStatus::Pending => "pending",
|
||||
InvoiceStatus::Settled => "settled",
|
||||
InvoiceStatus::Expired => "expired",
|
||||
InvoiceStatus::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"settled" => InvoiceStatus::Settled,
|
||||
"expired" => InvoiceStatus::Expired,
|
||||
"invalid" => InvoiceStatus::Invalid,
|
||||
_ => InvoiceStatus::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub btcpay_invoice_id: String,
|
||||
pub product_id: String,
|
||||
pub status: String,
|
||||
pub buyer_email: Option<String>,
|
||||
pub buyer_note: Option<String>,
|
||||
pub amount_sats: i64,
|
||||
pub checkout_url: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LicenseStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
/// Temporarily disabled but recoverable — distinct from revocation, which
|
||||
/// is terminal. Suspended licenses fail `/v1/validate` with reason
|
||||
/// `suspended` until an admin un-suspends them.
|
||||
Suspended,
|
||||
}
|
||||
|
||||
impl LicenseStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LicenseStatus::Active => "active",
|
||||
LicenseStatus::Revoked => "revoked",
|
||||
LicenseStatus::Suspended => "suspended",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full license row. Older fields are unchanged; v2 columns live behind
|
||||
/// `Option`s since they were introduced in migration 0003.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct License {
|
||||
pub id: String,
|
||||
pub product_id: String,
|
||||
pub invoice_id: Option<String>,
|
||||
pub status: String,
|
||||
pub fingerprint: Option<String>,
|
||||
pub bound_identity: Option<String>,
|
||||
pub issued_at: String,
|
||||
pub revoked_at: Option<String>,
|
||||
pub revocation_reason: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
|
||||
// v2 / migration 0003 fields
|
||||
pub policy_id: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub grace_seconds: i64,
|
||||
pub max_machines: i64,
|
||||
pub suspended_at: Option<String>,
|
||||
pub suspension_reason: Option<String>,
|
||||
pub entitlements: Vec<String>,
|
||||
pub is_trial: bool,
|
||||
pub nostr_npub: Option<String>,
|
||||
pub buyer_email: Option<String>,
|
||||
}
|
||||
|
||||
/// Reusable license template. A policy says "when we issue a license under
|
||||
/// this slug, set these defaults" (duration, grace, entitlements, machine
|
||||
/// cap, trial flag, price override, optional tip recipient).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Policy {
|
||||
pub id: String,
|
||||
pub product_id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub duration_seconds: i64,
|
||||
pub grace_seconds: i64,
|
||||
pub max_machines: i64,
|
||||
pub is_trial: bool,
|
||||
pub price_sats_override: Option<i64>,
|
||||
pub entitlements: Vec<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub active: bool,
|
||||
/// Lightning Address (user@domain) the daemon tips a percentage of
|
||||
/// each successful issuance to. None = no tipping. The amount is
|
||||
/// `license_price_sats * tip_pct_bps / 10000`. Tip failures never
|
||||
/// block license issuance.
|
||||
pub tip_recipient: Option<String>,
|
||||
/// Percentage in basis points (1bps = 0.01%; 100bps = 1%; 10000bps = 100%).
|
||||
/// 0 = no tipping. Capped at 10000 server-side.
|
||||
pub tip_pct_bps: i64,
|
||||
/// Free-form label for the tip recipient — surfaced in the audit log.
|
||||
pub tip_label: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// A machine activated under a license. One row per active install.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Machine {
|
||||
pub id: String,
|
||||
pub license_id: String,
|
||||
pub fingerprint: String,
|
||||
pub fingerprint_hash: String,
|
||||
pub hostname: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
pub ip_last_seen: Option<String>,
|
||||
pub activated_at: String,
|
||||
pub last_heartbeat_at: Option<String>,
|
||||
pub deactivated_at: Option<String>,
|
||||
pub deactivation_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Machine {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.deactivated_at.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Outbound webhook subscription.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookEndpoint {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
/// HMAC-SHA256 secret — never returned on list endpoints after creation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secret: Option<String>,
|
||||
pub event_types: Vec<String>,
|
||||
pub active: bool,
|
||||
pub description: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookDelivery {
|
||||
pub id: String,
|
||||
pub endpoint_id: String,
|
||||
pub event_type: String,
|
||||
pub payload_json: String,
|
||||
pub attempt_count: i64,
|
||||
pub next_attempt_at: Option<String>,
|
||||
pub last_status_code: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
pub delivered_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEntry {
|
||||
pub id: i64,
|
||||
pub actor_kind: String,
|
||||
pub actor_hash: Option<String>,
|
||||
pub action: String,
|
||||
pub target_kind: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub request_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub details: serde_json::Value,
|
||||
pub occurred_at: String,
|
||||
}
|
||||
|
||||
/// Discount / referral code. See `migrations/0004_discount_codes.sql`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscountCode {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
/// 'percent' | 'fixed_sats'.
|
||||
pub kind: String,
|
||||
/// Basis points if `kind == 'percent'` (0..=10000); sats if `kind == 'fixed_sats'`.
|
||||
pub amount: i64,
|
||||
pub max_uses: Option<i64>,
|
||||
pub used_count: i64,
|
||||
pub expires_at: Option<String>,
|
||||
pub applies_to_product_id: Option<String>,
|
||||
pub applies_to_policy_id: Option<String>,
|
||||
pub referrer_label: Option<String>,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// One row per (code, invoice) pair. Status transitions:
|
||||
/// pending → redeemed (invoice settled, license issued)
|
||||
/// pending → cancelled (invoice expired or invalidated)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscountRedemption {
|
||||
pub id: String,
|
||||
pub code_id: String,
|
||||
pub invoice_id: String,
|
||||
pub license_id: Option<String>,
|
||||
/// 'pending' | 'redeemed' | 'cancelled'.
|
||||
pub status: String,
|
||||
pub discount_applied_sats: i64,
|
||||
pub base_price_sats: i64,
|
||||
pub final_price_sats: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! BTCPay implementation of the [`PaymentProvider`] trait.
|
||||
//!
|
||||
//! Wraps the existing `BtcpayClient` (in `crate::btcpay::client`) and
|
||||
//! the existing webhook signature verifier
|
||||
//! (`crate::btcpay::webhook::verify_signature`). All BTCPay-specific
|
||||
//! types and HTTP shape stay in `crate::btcpay::*`; this file is just
|
||||
//! the trait-shaped facade.
|
||||
|
||||
use super::{
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
|
||||
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
};
|
||||
use crate::btcpay::client::BtcpayClient;
|
||||
use crate::btcpay::webhook::{verify_signature, WebhookEvent as BtcpayWebhookEvent};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::any::Any;
|
||||
|
||||
const BTCPAY_SIG_HEADER: &str = "BTCPay-Sig";
|
||||
|
||||
/// Active BTCPay provider. Wraps the lower-level HTTP client and the
|
||||
/// HMAC secret that BTCPay signs webhooks with. Constructed by
|
||||
/// `api::btcpay_authorize` after the operator completes the OAuth flow.
|
||||
///
|
||||
/// `public_base` is BTCPay's PUBLIC URL (the StartTunnel / clearnet
|
||||
/// one). Optional because it may not be known yet during very-first-
|
||||
/// boot. When set, every checkout URL returned by `create_invoice`
|
||||
/// gets its host rewritten from the internal `.startos` hostname to
|
||||
/// this public host, so buyers actually receive a URL they can open
|
||||
/// in their browser.
|
||||
pub struct BtcpayProvider {
|
||||
pub(crate) client: BtcpayClient,
|
||||
pub(crate) webhook_secret: String,
|
||||
pub(crate) public_base: Option<String>,
|
||||
}
|
||||
|
||||
impl BtcpayProvider {
|
||||
pub fn new(client: BtcpayClient, webhook_secret: String) -> Self {
|
||||
Self {
|
||||
client,
|
||||
webhook_secret,
|
||||
public_base: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_public_base(mut self, public_base: Option<String>) -> Self {
|
||||
self.public_base = public_base.filter(|s| !s.trim().is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
/// Compat accessor for code paths that haven't yet migrated to the
|
||||
/// `PaymentProvider` trait. Returns the underlying BTCPay-specific
|
||||
/// client by clone (the client is `Clone` and stores only an HTTP
|
||||
/// client + a few strings; cloning is cheap).
|
||||
pub fn client(&self) -> &BtcpayClient {
|
||||
&self.client
|
||||
}
|
||||
|
||||
pub fn webhook_secret(&self) -> &str {
|
||||
&self.webhook_secret
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite the host (scheme + host + port) of `url_in` to that of
|
||||
/// `public_base`, preserving the path, query, and fragment. Used to
|
||||
/// turn `http://btcpayserver.startos:23000/i/abc?x=y` into
|
||||
/// `https://btcpay.keysat.xyz/i/abc?x=y` before handing the URL to a
|
||||
/// buyer's browser. Returns the input unchanged if either URL fails
|
||||
/// to parse — bad-URL handling stays in the caller.
|
||||
///
|
||||
/// `pub(crate)` so other modules (like `api::purchase`) can apply the
|
||||
/// same rewrite when they go through the compat-shim BtcpayClient
|
||||
/// path instead of the PaymentProvider trait.
|
||||
pub(crate) fn rewrite_to_public(url_in: &str, public_base: &str) -> String {
|
||||
let parsed_in = match url::Url::parse(url_in) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return url_in.to_string(),
|
||||
};
|
||||
let parsed_pub = match url::Url::parse(public_base) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return url_in.to_string(),
|
||||
};
|
||||
let mut out = parsed_pub.clone();
|
||||
out.set_path(parsed_in.path());
|
||||
out.set_query(parsed_in.query());
|
||||
out.set_fragment(parsed_in.fragment());
|
||||
out.to_string()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PaymentProvider for BtcpayProvider {
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Btcpay
|
||||
}
|
||||
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: CreateInvoiceParams<'_>,
|
||||
) -> Result<CreatedInvoiceHandle> {
|
||||
// BTCPay invoices in our flow are sat-denominated. If a future
|
||||
// caller hands us non-sat money for BTCPay, fail loudly — that's
|
||||
// a programming error, not a runtime condition.
|
||||
if params.amount.currency != "SAT" {
|
||||
anyhow::bail!(
|
||||
"BTCPayProvider.create_invoice expected SAT-denominated amount, got {}",
|
||||
params.amount.currency
|
||||
);
|
||||
}
|
||||
// The existing BtcpayClient::create_invoice already takes
|
||||
// (amount_sats, metadata, redirect_url). We pass through.
|
||||
let metadata = enrich_metadata(params.metadata, params.external_order_id);
|
||||
let created = self
|
||||
.client
|
||||
.create_invoice(params.amount.amount, metadata, Some(params.redirect_url))
|
||||
.await
|
||||
.context("BTCPay create-invoice")?;
|
||||
|
||||
// Rewrite the checkout URL's host to the public BTCPay URL so
|
||||
// buyers actually get a link they can open. BTCPay derives the
|
||||
// checkout URL from whatever URL we used to call its API
|
||||
// (internal Docker hostname `btcpayserver.startos:23000`) —
|
||||
// useless to a buyer's browser. If `public_base` is set we
|
||||
// swap the host; if not, log loudly because that's a misconfig.
|
||||
let checkout_url = match &self.public_base {
|
||||
Some(pb) => {
|
||||
let rewritten = rewrite_to_public(&created.checkout_link, pb);
|
||||
tracing::info!(
|
||||
original = %created.checkout_link,
|
||||
rewritten = %rewritten,
|
||||
public_base = %pb,
|
||||
"checkout URL rewritten for buyer-reachability"
|
||||
);
|
||||
rewritten
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
original = %created.checkout_link,
|
||||
"checkout URL NOT rewritten — public_base is None. \
|
||||
Set BTCPAY_PUBLIC_URL via the wrapper, or ensure \
|
||||
BTCPay's interface list includes a clearnet domain. \
|
||||
Buyer will see the internal Docker hostname which \
|
||||
is unreachable from outside."
|
||||
);
|
||||
created.checkout_link
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CreatedInvoiceHandle {
|
||||
provider_invoice_id: created.id,
|
||||
checkout_url,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_invoice_status(
|
||||
&self,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<ProviderInvoiceStatus> {
|
||||
let raw = self
|
||||
.client
|
||||
.get_invoice(provider_invoice_id)
|
||||
.await
|
||||
.context("BTCPay get-invoice")?;
|
||||
let status = raw
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Pending");
|
||||
Ok(match status {
|
||||
"Settled" | "Complete" => ProviderInvoiceStatus::Settled,
|
||||
"Expired" => ProviderInvoiceStatus::Expired,
|
||||
"Invalid" => ProviderInvoiceStatus::Invalid,
|
||||
// Refunded isn't a top-level BTCPay status; if BTCPay ever
|
||||
// reports it via metadata we'd handle here. For now it falls
|
||||
// through to Pending.
|
||||
_ => ProviderInvoiceStatus::Pending,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_webhook(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<ProviderWebhookEvent> {
|
||||
let sig = headers
|
||||
.get(BTCPAY_SIG_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| anyhow!("missing {BTCPAY_SIG_HEADER} header"))?;
|
||||
verify_signature(&self.webhook_secret, sig, body)
|
||||
.context("BTCPay webhook signature")?;
|
||||
|
||||
let parsed: BtcpayWebhookEvent = serde_json::from_slice(body)
|
||||
.context("malformed BTCPay webhook body")?;
|
||||
|
||||
Ok(match parsed.event_type.as_str() {
|
||||
"InvoiceSettled" | "InvoicePaymentSettled" => ProviderWebhookEvent::InvoiceSettled {
|
||||
provider_invoice_id: parsed.invoice_id,
|
||||
},
|
||||
"InvoiceExpired" => ProviderWebhookEvent::InvoiceExpired {
|
||||
provider_invoice_id: parsed.invoice_id,
|
||||
},
|
||||
"InvoiceInvalid" => ProviderWebhookEvent::InvoiceInvalid {
|
||||
provider_invoice_id: parsed.invoice_id,
|
||||
},
|
||||
other => ProviderWebhookEvent::Other {
|
||||
kind: other.to_string(),
|
||||
provider_invoice_id: Some(parsed.invoice_id),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<PaymentReceipt> {
|
||||
let raw = self
|
||||
.client
|
||||
.pay_lightning_invoice(bolt11)
|
||||
.await
|
||||
.context("BTCPay pay-lightning-invoice")?;
|
||||
let payment_hash = raw
|
||||
.get("paymentHash")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(PaymentReceipt { payment_hash, raw })
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: ensure the provider-side metadata always includes our
|
||||
/// internal invoice id so webhook events are correlatable. BTCPay
|
||||
/// preserves arbitrary metadata fields and returns them on
|
||||
/// `get_invoice` and on webhook deliveries.
|
||||
fn enrich_metadata(mut metadata: Value, external_order_id: &str) -> Value {
|
||||
if !metadata.is_object() {
|
||||
metadata = serde_json::json!({});
|
||||
}
|
||||
if let Some(obj) = metadata.as_object_mut() {
|
||||
// BTCPay's checkout displays `orderId` if present.
|
||||
obj.entry("orderId")
|
||||
.or_insert_with(|| Value::String(external_order_id.to_string()));
|
||||
obj.entry("source")
|
||||
.or_insert_with(|| Value::String("keysat".to_string()));
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
/// Money helper for callers translating from `i64` sat amounts.
|
||||
pub fn sats(amount: i64) -> Money {
|
||||
Money::sats(amount)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Payment-provider abstraction.
|
||||
//!
|
||||
//! Today there's exactly one provider, BTCPay. v0.3 adds Zaprite. The
|
||||
//! daemon stores the active provider as a trait object so adding new
|
||||
//! providers is a single-impl drop-in.
|
||||
//!
|
||||
//! ## Why a trait
|
||||
//!
|
||||
//! Pre-v0.2 the daemon hard-coded BTCPay assumptions in `webhook.rs`,
|
||||
//! `purchase.rs`, `reconcile.rs`, and `tipping.rs`. Adding Zaprite would
|
||||
//! have meant either parallel code paths (gross) or post-hoc retrofitting
|
||||
//! (worse). The `PaymentProvider` trait is a one-time refactor that lets
|
||||
//! every later provider slot in cleanly.
|
||||
//!
|
||||
//! ## Trait surface
|
||||
//!
|
||||
//! Just the operations the rest of the daemon actually needs:
|
||||
//!
|
||||
//! - `kind()` — provider identity, for logs / audit / admin UI
|
||||
//! - `create_invoice` — make a hosted-checkout session, return a URL
|
||||
//! - `get_invoice_status` — for the reconcile loop (webhook misses)
|
||||
//! - `validate_webhook` — provider-specific signature scheme + parse
|
||||
//! - `pay_lightning_invoice` — for the tip-recipient flow; default impl
|
||||
//! returns a "not supported" error so providers without a Lightning
|
||||
//! payout capability can stay silent.
|
||||
//!
|
||||
//! ## What stays out of the trait
|
||||
//!
|
||||
//! Provider-specific setup (OAuth-style consent flows, webhook
|
||||
//! registration, store enumeration) lives in provider-specific modules
|
||||
//! like `api::btcpay_authorize`. Those modules are responsible for
|
||||
//! constructing a provider impl and handing it to
|
||||
//! `AppState::set_payment_provider`.
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::any::Any;
|
||||
|
||||
pub mod btcpay;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProviderKind {
|
||||
Btcpay,
|
||||
Zaprite,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::Btcpay => "btcpay",
|
||||
ProviderKind::Zaprite => "zaprite",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A monetary amount + the unit it's denominated in.
|
||||
///
|
||||
/// We carry currency through the system because v0.3 adds USD/EUR for
|
||||
/// card payments via Zaprite. v0.2 still emits everything as `SAT`
|
||||
/// since BTCPay invoices are sat-denominated for our flow.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Money {
|
||||
/// The currency code. ISO 4217 for fiat; `SAT` and `BTC` for Bitcoin.
|
||||
pub currency: String,
|
||||
/// The amount in the currency's smallest indivisible unit (sats for
|
||||
/// BTC, cents for USD, etc.). Using i64 because integer math is
|
||||
/// cheaper than decimals and we never need fractional sats.
|
||||
pub amount: i64,
|
||||
}
|
||||
|
||||
impl Money {
|
||||
pub fn sats(amount: i64) -> Self {
|
||||
Money {
|
||||
currency: "SAT".to_string(),
|
||||
amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs for `create_invoice`. Bundled into a struct so the trait
|
||||
/// signature stays stable as we add fields.
|
||||
pub struct CreateInvoiceParams<'a> {
|
||||
pub amount: Money,
|
||||
/// Where the buyer is sent after a successful payment. The provider
|
||||
/// appends its own status fragments / query params as needed.
|
||||
pub redirect_url: &'a str,
|
||||
/// Arbitrary metadata pinned to the invoice on the provider's side.
|
||||
/// Used by Keysat to round-trip its internal invoice id back through
|
||||
/// webhook events (`metadata.orderId` for BTCPay; `externalOrderId`
|
||||
/// for Zaprite).
|
||||
pub metadata: serde_json::Value,
|
||||
/// Keysat's internal invoice id (UUID). Passed back in webhook
|
||||
/// events to correlate with the local row.
|
||||
pub external_order_id: &'a str,
|
||||
/// Buyer email if known. Some providers use this for receipts.
|
||||
pub buyer_email: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Result of `create_invoice`. Whatever the provider returned, narrowed
|
||||
/// to the two things the rest of Keysat actually needs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreatedInvoiceHandle {
|
||||
/// Provider-side invoice id. BTCPay invoice id today; Zaprite order
|
||||
/// id later. Stored on the invoice row so we can reconcile.
|
||||
pub provider_invoice_id: String,
|
||||
/// Public URL the buyer is redirected to to pay.
|
||||
pub checkout_url: String,
|
||||
}
|
||||
|
||||
/// Provider-agnostic invoice status used by the reconcile loop. Maps to
|
||||
/// the daemon's existing `InvoiceStatus` model but stays decoupled so
|
||||
/// the trait doesn't pull in domain types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderInvoiceStatus {
|
||||
Pending,
|
||||
Settled,
|
||||
Expired,
|
||||
Refunded,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Parsed webhook event. Only the kinds Keysat actually acts on are
|
||||
/// modeled; everything else falls into `Other` and is ignored.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProviderWebhookEvent {
|
||||
InvoiceSettled {
|
||||
provider_invoice_id: String,
|
||||
},
|
||||
InvoiceExpired {
|
||||
provider_invoice_id: String,
|
||||
},
|
||||
InvoiceInvalid {
|
||||
provider_invoice_id: String,
|
||||
},
|
||||
InvoiceRefunded {
|
||||
provider_invoice_id: String,
|
||||
refunded_amount: Option<Money>,
|
||||
},
|
||||
/// Anything else the provider sent. We log + 200 it so the provider
|
||||
/// stops retrying.
|
||||
Other {
|
||||
kind: String,
|
||||
provider_invoice_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProviderWebhookEvent {
|
||||
pub fn provider_invoice_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
ProviderWebhookEvent::InvoiceSettled { provider_invoice_id }
|
||||
| ProviderWebhookEvent::InvoiceExpired { provider_invoice_id }
|
||||
| ProviderWebhookEvent::InvoiceInvalid { provider_invoice_id }
|
||||
| ProviderWebhookEvent::InvoiceRefunded {
|
||||
provider_invoice_id, ..
|
||||
} => Some(provider_invoice_id),
|
||||
ProviderWebhookEvent::Other {
|
||||
provider_invoice_id,
|
||||
..
|
||||
} => provider_invoice_id.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of paying a Lightning invoice via the provider's LN node.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaymentReceipt {
|
||||
pub payment_hash: Option<String>,
|
||||
/// Raw provider response, for the audit log.
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The trait every payment provider implements.
|
||||
///
|
||||
/// Object-safe (uses `&dyn`/`Box<dyn>`) thanks to `#[async_trait]`. The
|
||||
/// `Any` supertrait lets call sites that still need provider-specific
|
||||
/// types (e.g., the BTCPay-specific authorize flow) downcast.
|
||||
#[async_trait::async_trait]
|
||||
pub trait PaymentProvider: Send + Sync + Any {
|
||||
fn kind(&self) -> ProviderKind;
|
||||
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: CreateInvoiceParams<'_>,
|
||||
) -> Result<CreatedInvoiceHandle>;
|
||||
|
||||
async fn get_invoice_status(
|
||||
&self,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<ProviderInvoiceStatus>;
|
||||
|
||||
/// Verify and parse a webhook delivery. Implementations are
|
||||
/// responsible for reading whatever signature header their provider
|
||||
/// uses, computing the expected HMAC, and constant-time comparing.
|
||||
fn validate_webhook(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<ProviderWebhookEvent>;
|
||||
|
||||
/// Pay a BOLT11 Lightning invoice via the provider's LN node.
|
||||
/// Default impl returns a "not supported" error so providers
|
||||
/// without LN payout capability don't have to override.
|
||||
async fn pay_lightning_invoice(&self, _bolt11: &str) -> Result<PaymentReceipt> {
|
||||
anyhow::bail!(
|
||||
"pay_lightning_invoice not supported by this payment provider"
|
||||
)
|
||||
}
|
||||
|
||||
/// Hatch for compat-era downcasting. Lets `AppState`'s legacy
|
||||
/// `btcpay_client()` accessor reach the inner BTCPay-specific
|
||||
/// client. v0.3 will retire the compat accessors and remove this.
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Token-bucket rate limiting backed by SQLite.
|
||||
//!
|
||||
//! The state for each bucket lives in `rate_buckets` (bucket_kind, bucket_key).
|
||||
//! Each incoming request refills the bucket based on wall-clock elapsed time
|
||||
//! since last refill, then tries to spend one token. Returns `true` if the
|
||||
//! request is allowed, `false` if it's rate-limited.
|
||||
//!
|
||||
//! Why store in SQLite instead of in-memory? Because the service is
|
||||
//! single-tenant and small, and persisting lets us survive restarts without
|
||||
//! giving attackers a "just bounce the process" bypass. The overhead of one
|
||||
//! extra SQLite write per hit is negligible at our expected traffic.
|
||||
|
||||
use crate::error::AppResult;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Try to spend one token from the given bucket. Returns `Ok(true)` if the
|
||||
/// request is allowed, `Ok(false)` if rate-limited, or `Err` on a DB error.
|
||||
///
|
||||
/// - `capacity`: maximum tokens the bucket can hold (and what it starts at)
|
||||
/// - `refill_per_second`: how many tokens to add per wall-clock second
|
||||
pub async fn consume(
|
||||
pool: &SqlitePool,
|
||||
bucket_kind: &str,
|
||||
bucket_key: &str,
|
||||
capacity: f64,
|
||||
refill_per_second: f64,
|
||||
) -> AppResult<bool> {
|
||||
let now = Utc::now();
|
||||
// Pull existing bucket, if any.
|
||||
let row = sqlx::query_as::<_, (f64, f64, f64, String)>(
|
||||
"SELECT tokens_remaining, capacity, refill_per_second, last_refill_at
|
||||
FROM rate_buckets WHERE bucket_kind = ? AND bucket_key = ?",
|
||||
)
|
||||
.bind(bucket_kind)
|
||||
.bind(bucket_key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let (new_tokens, allowed) = match row {
|
||||
Some((prev_tokens, _cap, _refill, last_refill_at)) => {
|
||||
let last = DateTime::parse_from_rfc3339(&last_refill_at)
|
||||
.map(|t| t.with_timezone(&Utc))
|
||||
.unwrap_or(now);
|
||||
let elapsed_s = (now - last).num_milliseconds() as f64 / 1000.0;
|
||||
let mut tokens = (prev_tokens + elapsed_s * refill_per_second).min(capacity);
|
||||
if tokens >= 1.0 {
|
||||
tokens -= 1.0;
|
||||
(tokens, true)
|
||||
} else {
|
||||
(tokens, false)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Start with a full bucket minus the current request.
|
||||
(capacity - 1.0, true)
|
||||
}
|
||||
};
|
||||
|
||||
let now_str = now.to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO rate_buckets
|
||||
(bucket_kind, bucket_key, tokens_remaining, capacity, refill_per_second, last_refill_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(bucket_kind, bucket_key) DO UPDATE SET
|
||||
tokens_remaining = excluded.tokens_remaining,
|
||||
capacity = excluded.capacity,
|
||||
refill_per_second = excluded.refill_per_second,
|
||||
last_refill_at = excluded.last_refill_at",
|
||||
)
|
||||
.bind(bucket_kind)
|
||||
.bind(bucket_key)
|
||||
.bind(new_tokens)
|
||||
.bind(capacity)
|
||||
.bind(refill_per_second)
|
||||
.bind(&now_str)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(allowed)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Invoice reconciliation background task.
|
||||
//!
|
||||
//! Webhooks are the primary signal from BTCPay to us — fast, push-based, and
|
||||
//! authenticated with HMAC. But webhooks can be dropped (network blips, our
|
||||
//! service restarting during a burst, BTCPay retry-budget exhaustion on a
|
||||
//! long outage). If we only ever reacted to webhooks, a dropped settle
|
||||
//! notification would mean a buyer paid and never got their license.
|
||||
//!
|
||||
//! Reconciliation closes that gap. Every N seconds we scan our own table
|
||||
//! for invoices still in `pending` status that were created recently, ask
|
||||
//! BTCPay directly what their real state is, and reconcile:
|
||||
//!
|
||||
//! - BTCPay says `Settled` → mark settled AND issue a license if one
|
||||
//! doesn't exist yet (idempotency enforced by the UNIQUE index on
|
||||
//! `licenses.invoice_id`).
|
||||
//! - BTCPay says `Expired` / `Invalid` → mark accordingly, don't issue.
|
||||
//! - BTCPay still says `New` / `Processing` → leave it alone.
|
||||
//!
|
||||
//! The task is cheap — one DB query and at most N HTTP calls per tick —
|
||||
//! and bounded (we only look at invoices younger than MAX_AGE_HOURS).
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const TICK: Duration = Duration::from_secs(60);
|
||||
const MAX_AGE_HOURS: i64 = 72;
|
||||
|
||||
pub fn spawn(state: AppState) {
|
||||
tokio::spawn(async move {
|
||||
// Small initial delay so we don't race startup logs.
|
||||
sleep(Duration::from_secs(15)).await;
|
||||
loop {
|
||||
if let Err(e) = tick(&state).await {
|
||||
tracing::warn!(error = %e, "reconciliation tick failed");
|
||||
}
|
||||
sleep(TICK).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
let btcpay = match state.btcpay_client().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // not configured yet — skip silently
|
||||
};
|
||||
|
||||
let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?;
|
||||
if pending.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::debug!(count = pending.len(), "reconciling pending invoices");
|
||||
|
||||
for inv in pending {
|
||||
match btcpay.get_invoice(&inv.btcpay_invoice_id).await {
|
||||
Ok(raw) => {
|
||||
let status_str = raw
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let normalized = match status_str.as_str() {
|
||||
"Settled" | "Complete" => Some("settled"),
|
||||
"Expired" => Some("expired"),
|
||||
"Invalid" => Some("invalid"),
|
||||
// still in flight
|
||||
_ => None,
|
||||
};
|
||||
let Some(new_status) = normalized else { continue };
|
||||
|
||||
if new_status == inv.status.as_str() {
|
||||
continue; // no-op
|
||||
}
|
||||
|
||||
if let Err(e) = repo::update_invoice_status(
|
||||
&state.db,
|
||||
&inv.btcpay_invoice_id,
|
||||
new_status,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
btcpay_invoice_id = %inv.btcpay_invoice_id,
|
||||
"reconciler failed to update invoice status"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Free any reserved discount-code slot if the invoice
|
||||
// entered a terminal failure state.
|
||||
if matches!(new_status, "expired" | "invalid") {
|
||||
if let Ok(Some(redemption)) =
|
||||
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
|
||||
{
|
||||
let _ = repo::cancel_redemption(&state.db, &redemption.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
if new_status == "settled" {
|
||||
if let Err(e) = ensure_license(state, &inv).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
btcpay_invoice_id = %inv.btcpay_invoice_id,
|
||||
"reconciler failed to issue license after recovered settle"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
btcpay_invoice_id = %inv.btcpay_invoice_id,
|
||||
"reconciler issued license for recovered settled invoice"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
error = %e,
|
||||
btcpay_invoice_id = %inv.btcpay_invoice_id,
|
||||
"reconciler failed to fetch invoice from BTCPay"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_license(
|
||||
state: &AppState,
|
||||
invoice: &crate::models::Invoice,
|
||||
) -> anyhow::Result<()> {
|
||||
if repo::get_license_by_invoice(&state.db, &invoice.id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
crate::api::webhook::issue_license_for_invoice(state, invoice)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Tip-recipient-on-policy: fire a Lightning tip after every successful
|
||||
//! license issuance under a tip-enabled policy.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. License is issued (existing path; this module is called from the
|
||||
//! reconcile/webhook layer once that completes).
|
||||
//! 2. Look up the policy. If `tip_recipient` is set and `tip_pct_bps > 0`,
|
||||
//! compute `amount_sats = paid_sats * tip_pct_bps / 10000`.
|
||||
//! 3. Resolve the Lightning Address. We support exactly the Lightning
|
||||
//! Address scheme `user@domain`, which maps to
|
||||
//! `https://domain/.well-known/lnurlp/user`. Plain LNURL-pay bech32
|
||||
//! strings are not supported in v0.1; can add later.
|
||||
//! 4. Fetch the LNURL-pay metadata, verify the amount fits in
|
||||
//! `[minSendable, maxSendable]`, request a BOLT11 invoice for our
|
||||
//! amount via the `callback` URL.
|
||||
//! 5. Pay the BOLT11 via the operator's BTCPay Lightning node.
|
||||
//! 6. Record success/failure in the `tip_attempts` audit table.
|
||||
//!
|
||||
//! Failure semantics: this module **never** propagates errors back to the
|
||||
//! issuance path. A tip failing is a logged + audited concern, not a reason
|
||||
//! to fail a customer's purchase. Operators set up tipping voluntarily;
|
||||
//! they accept the trade-off that an occasional tip will fail and can be
|
||||
//! retried manually.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::models::Policy;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Maximum amount in millisats we'll send via a single tip. Defense in
|
||||
/// depth — a misconfigured `tip_pct_bps` shouldn't be able to drain the
|
||||
/// wallet on a single sale.
|
||||
const MAX_TIP_MSAT: u64 = 5_000_000_000; // 50,000,000 sats; 0.5 BTC
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LnurlPayMetadata {
|
||||
callback: String,
|
||||
#[serde(rename = "minSendable")]
|
||||
min_sendable: u64,
|
||||
#[serde(rename = "maxSendable")]
|
||||
max_sendable: u64,
|
||||
#[serde(default)]
|
||||
tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LnurlPayInvoice {
|
||||
pr: String, // BOLT11
|
||||
}
|
||||
|
||||
/// Spawn a tip in the background. Caller fires this after issuance and
|
||||
/// returns immediately — the customer's purchase response doesn't wait for
|
||||
/// the tip to complete.
|
||||
pub fn spawn_tip(
|
||||
state: AppState,
|
||||
license_id: String,
|
||||
policy: Policy,
|
||||
paid_sats: i64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_tip(&state, &license_id, &policy, paid_sats).await {
|
||||
tracing::warn!(
|
||||
license = %license_id,
|
||||
policy = %policy.id,
|
||||
"tip flow ended with error: {e:#}"
|
||||
);
|
||||
// run_tip records its own audit entries; this is just the catch-all log.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_tip(
|
||||
state: &AppState,
|
||||
license_id: &str,
|
||||
policy: &Policy,
|
||||
paid_sats: i64,
|
||||
) -> Result<()> {
|
||||
let recipient = match &policy.tip_recipient {
|
||||
Some(r) if !r.trim().is_empty() => r.trim().to_string(),
|
||||
_ => return Ok(()), // no tip configured; not an error
|
||||
};
|
||||
let pct = policy.tip_pct_bps;
|
||||
if pct <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let label = policy.tip_label.clone();
|
||||
|
||||
// Compute tip amount. Round down (floor); we never tip more than the
|
||||
// configured percentage of what the buyer paid.
|
||||
let tip_sats = paid_sats.saturating_mul(pct) / 10_000;
|
||||
if tip_sats <= 0 {
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
0,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"skipped",
|
||||
Some("tip_sats <= 0 after percentage applied"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tip_msat = (tip_sats as u64).saturating_mul(1000);
|
||||
if tip_msat > MAX_TIP_MSAT {
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"skipped",
|
||||
Some(&format!(
|
||||
"tip exceeds safety cap ({} msat > {} msat)",
|
||||
tip_msat, MAX_TIP_MSAT
|
||||
)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Resolve Lightning Address → LNURL-pay metadata.
|
||||
let metadata = match resolve_lightning_address(&recipient).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
let detail = format!("address resolution failed: {e:#}");
|
||||
tracing::warn!(license = %license_id, recipient = %recipient, "{detail}");
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"failed",
|
||||
Some(&detail),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if tip_msat < metadata.min_sendable || tip_msat > metadata.max_sendable {
|
||||
let detail = format!(
|
||||
"tip amount {tip_msat} msat outside recipient bounds [{}, {}]",
|
||||
metadata.min_sendable, metadata.max_sendable
|
||||
);
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"failed",
|
||||
Some(&detail),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Request a BOLT11 invoice from the recipient for our amount.
|
||||
let invoice = match request_lnurl_invoice(&metadata.callback, tip_msat).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let detail = format!("invoice request failed: {e:#}");
|
||||
tracing::warn!(license = %license_id, "{detail}");
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"failed",
|
||||
Some(&detail),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Pay it via the operator's BTCPay Lightning node.
|
||||
let btcpay = match state.btcpay_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let detail = format!("BTCPay client unavailable: {e:?}");
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"failed",
|
||||
Some(&detail),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match btcpay.pay_lightning_invoice(&invoice).await {
|
||||
Ok(payment) => {
|
||||
let payment_hash = payment
|
||||
.get("paymentHash")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
tracing::info!(
|
||||
license = %license_id,
|
||||
recipient = %recipient,
|
||||
amount_sats = tip_sats,
|
||||
payment_hash = ?payment_hash,
|
||||
"tip sent"
|
||||
);
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"sent",
|
||||
Some(&format!("paid via BTCPay LN node ({} sats)", tip_sats)),
|
||||
payment_hash.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
let detail = format!("BTCPay pay-LN-invoice failed: {e:#}");
|
||||
tracing::warn!(license = %license_id, "{detail}");
|
||||
repo::record_tip_attempt(
|
||||
&state.db,
|
||||
license_id,
|
||||
&policy.id,
|
||||
&recipient,
|
||||
tip_sats,
|
||||
pct,
|
||||
label.as_deref(),
|
||||
"failed",
|
||||
Some(&detail),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `user@domain` and fetch the LNURL-pay metadata document at
|
||||
/// `https://domain/.well-known/lnurlp/user`. Returns the parsed metadata.
|
||||
async fn resolve_lightning_address(addr: &str) -> Result<LnurlPayMetadata> {
|
||||
let (user, domain) = addr
|
||||
.split_once('@')
|
||||
.ok_or_else(|| anyhow!("not a Lightning Address (expected user@domain)"))?;
|
||||
if user.is_empty() || domain.is_empty() {
|
||||
bail!("Lightning Address has empty user or domain");
|
||||
}
|
||||
// Reasonable charset check — LN addresses are user-input-safe alphanum + dash + underscore + dot.
|
||||
let charset_ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
|
||||
if !user.chars().all(charset_ok) || !domain.chars().all(charset_ok) {
|
||||
bail!("Lightning Address contains disallowed characters");
|
||||
}
|
||||
|
||||
let url = format!("https://{domain}/.well-known/lnurlp/{user}");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
let resp = client.get(&url).send().await.context("LNURL-pay GET")?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("LNURL-pay endpoint returned {}", resp.status());
|
||||
}
|
||||
let metadata: LnurlPayMetadata = resp
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LNURL-pay metadata response")?;
|
||||
|
||||
if !metadata.tag.is_empty() && metadata.tag != "payRequest" {
|
||||
bail!(
|
||||
"expected LNURL-pay metadata tag='payRequest', got '{}'",
|
||||
metadata.tag
|
||||
);
|
||||
}
|
||||
if !metadata.callback.starts_with("https://") {
|
||||
bail!(
|
||||
"LNURL-pay callback must be HTTPS, got: {}",
|
||||
metadata.callback
|
||||
);
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Hit the recipient's `callback` URL with `?amount=<msat>` and return the
|
||||
/// resulting BOLT11 invoice string.
|
||||
async fn request_lnurl_invoice(callback: &str, amount_msat: u64) -> Result<String> {
|
||||
let sep = if callback.contains('?') { '&' } else { '?' };
|
||||
let url = format!("{callback}{sep}amount={amount_msat}");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
let resp = client.get(&url).send().await.context("LNURL-pay invoice GET")?;
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"LNURL-pay invoice endpoint returned {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
// The response can be either { pr, ... } on success or
|
||||
// { status: "ERROR", reason: "..." } on failure.
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("parsing LNURL-pay invoice response")?;
|
||||
if let Some("ERROR") = body.get("status").and_then(|s| s.as_str()) {
|
||||
let reason = body
|
||||
.get("reason")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("unknown");
|
||||
bail!("LNURL-pay invoice error: {reason}");
|
||||
}
|
||||
let parsed: LnurlPayInvoice = serde_json::from_value(body)
|
||||
.context("LNURL-pay response missing 'pr' field")?;
|
||||
Ok(parsed.pr)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Outbound webhooks.
|
||||
//!
|
||||
//! When interesting things happen (a license is issued, revoked, suspended,
|
||||
//! a machine activates, an invoice settles), the service can POST a signed
|
||||
//! JSON payload to one or more URLs configured by the operator.
|
||||
//!
|
||||
//! Design:
|
||||
//!
|
||||
//! - Each endpoint has its own HMAC-SHA256 secret (32 random bytes, hex).
|
||||
//! - Each delivery is a row in `webhook_deliveries`. Deliveries that fail are
|
||||
//! retried with exponential backoff up to 10 attempts.
|
||||
//! - Deliveries are dispatched by a single background task that polls the
|
||||
//! table every 5 seconds for rows whose `next_attempt_at` is due.
|
||||
//! - The signature scheme is the same shape as BTCPay's webhook signing
|
||||
//! (`sha256=<hex>`), so integrators who've already written BTCPay webhook
|
||||
//! receivers can adapt their code trivially.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::models::WebhookEndpoint;
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Signature header we attach to every outbound delivery. Receivers verify by
|
||||
/// recomputing `HMAC-SHA256(body, secret)` and comparing in constant time.
|
||||
pub const SIG_HEADER: &str = "X-Keysat-Signature";
|
||||
/// Event-type header, mirrors `event_type` in the payload for convenience.
|
||||
pub const EVENT_HEADER: &str = "X-Keysat-Event";
|
||||
/// Idempotency key header — the delivery id, stable across retries.
|
||||
pub const DELIVERY_HEADER: &str = "X-Keysat-Delivery";
|
||||
|
||||
/// Fire off a logical event. Persists one `webhook_deliveries` row per
|
||||
/// active subscribed endpoint; the delivery worker handles the HTTP.
|
||||
///
|
||||
/// Infallible from the caller's perspective: any DB error is logged and
|
||||
/// swallowed so event dispatch never blocks the main mutation.
|
||||
pub async fn dispatch(state: &AppState, event_type: &str, data: &Value) {
|
||||
let envelope = serde_json::json!({
|
||||
"event_type": event_type,
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
"data": data,
|
||||
});
|
||||
let envelope_json = match serde_json::to_string(&envelope) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "webhook dispatch: failed to serialize envelope");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let endpoints = match repo::list_active_webhook_endpoints(&state.db).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "webhook dispatch: failed to list endpoints");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for ep in endpoints {
|
||||
if !ep_wants(&ep, event_type) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = repo::enqueue_delivery(&state.db, &ep.id, event_type, &envelope_json).await
|
||||
{
|
||||
tracing::warn!(error = ?e, endpoint = %ep.id, "failed to enqueue delivery");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ep_wants(ep: &WebhookEndpoint, event_type: &str) -> bool {
|
||||
ep.event_types.iter().any(|t| t == "*" || t == event_type)
|
||||
}
|
||||
|
||||
/// Background task: every 5s, pick up to 25 deliveries whose `next_attempt_at`
|
||||
/// is due, POST them, update the row.
|
||||
pub fn spawn_delivery_worker(state: AppState) {
|
||||
tokio::spawn(async move {
|
||||
// Stagger startup slightly to avoid racing the initial reconcile loop.
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
loop {
|
||||
if let Err(e) = tick(&state).await {
|
||||
tracing::warn!(error = %e, "webhook delivery tick failed");
|
||||
}
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
let due = repo::list_ready_deliveries(&state.db, 25)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||
if due.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
for d in due {
|
||||
// Look up endpoint + secret.
|
||||
let ep = match repo::get_webhook_endpoint_by_id(&state.db, &d.endpoint_id, true).await {
|
||||
Ok(Some(ep)) if ep.active => ep,
|
||||
_ => {
|
||||
// Endpoint gone or disabled — mark delivery permanently failed.
|
||||
repo::mark_delivery_failure(
|
||||
&state.db,
|
||||
&d.id,
|
||||
None,
|
||||
"endpoint deleted or disabled",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let secret = ep.secret.as_deref().unwrap_or("");
|
||||
|
||||
// Compute HMAC signature of the raw body.
|
||||
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
repo::mark_delivery_failure(
|
||||
&state.db,
|
||||
&d.id,
|
||||
None,
|
||||
&format!("bad HMAC key: {e}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
mac.update(d.payload_json.as_bytes());
|
||||
let sig_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let sig_header_val = format!("sha256={sig_hex}");
|
||||
|
||||
let req = http
|
||||
.post(&ep.url)
|
||||
.header("content-type", "application/json")
|
||||
.header(SIG_HEADER, &sig_header_val)
|
||||
.header(EVENT_HEADER, &d.event_type)
|
||||
.header(DELIVERY_HEADER, &d.id)
|
||||
.body(d.payload_json.clone());
|
||||
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16() as i64;
|
||||
if resp.status().is_success() {
|
||||
repo::mark_delivery_success(&state.db, &d.id, status).await.ok();
|
||||
} else {
|
||||
let backoff = backoff_for(d.attempt_count + 1);
|
||||
let next = backoff.map(|bs| (Utc::now() + bs).to_rfc3339());
|
||||
let body_preview = resp.text().await.unwrap_or_default();
|
||||
let trimmed: String = body_preview.chars().take(200).collect();
|
||||
repo::mark_delivery_failure(
|
||||
&state.db,
|
||||
&d.id,
|
||||
Some(status),
|
||||
&format!("non-2xx response: {trimmed}"),
|
||||
next.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let backoff = backoff_for(d.attempt_count + 1);
|
||||
let next = backoff.map(|bs| (Utc::now() + bs).to_rfc3339());
|
||||
repo::mark_delivery_failure(
|
||||
&state.db,
|
||||
&d.id,
|
||||
None,
|
||||
&format!("request error: {e}"),
|
||||
next.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exponential backoff for delivery retries, capped at 10 attempts. Returns
|
||||
/// `None` when the max is reached (meaning: do not reschedule).
|
||||
fn backoff_for(attempts_after: i64) -> Option<ChronoDuration> {
|
||||
const MAX_ATTEMPTS: i64 = 10;
|
||||
if attempts_after >= MAX_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
// 5s, 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 6h
|
||||
let minutes = match attempts_after {
|
||||
1 => 0,
|
||||
2 => 0,
|
||||
3 => 0,
|
||||
4 => 1,
|
||||
5 => 5,
|
||||
6 => 15,
|
||||
7 => 30,
|
||||
8 => 60,
|
||||
9 => 120,
|
||||
_ => 360,
|
||||
};
|
||||
let seconds = match attempts_after {
|
||||
1 => 5,
|
||||
2 => 10,
|
||||
3 => 30,
|
||||
_ => 0,
|
||||
};
|
||||
Some(ChronoDuration::seconds(seconds) + ChronoDuration::minutes(minutes))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+50
-21
@@ -9,10 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"license": "SEE LICENSE IN ../licensing-service/LICENSE",
|
||||
"dependencies": {
|
||||
"@start9labs/start-sdk": "^0.4.0"
|
||||
"@start9labs/start-sdk": "^1.3.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vercel/ncc": "^0.38.4",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
@@ -50,9 +53,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz",
|
||||
"integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -62,21 +65,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@start9labs/start-sdk": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0.tgz",
|
||||
"integrity": "sha512-PFfO7tV9nzQFZL3KXaZyf16C5VZtM+dCDlRhLHpmwssTKtcjyCEhBrB9locuS2yFqu69rj+5kLFzCWDHeRRibg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.3.2.tgz",
|
||||
"integrity": "sha512-Iw26tSjN+2yKYul8VabrV9VtpbHnEes4FCCxhGrmdFzjP/wc+K4DLqN+cLgqwtVebVB0mJVhhh63nbRil1pqWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
"@noble/curves": "^1.8.2",
|
||||
"@noble/hashes": "^1.7.2",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@types/ini": "^4.1.1",
|
||||
"deep-equality-data-structures": "^2.0.0",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"fast-xml-parser": "~5.6.0",
|
||||
"ini": "^5.0.0",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"mime": "^4.0.7",
|
||||
"yaml": "^2.7.1",
|
||||
"mime": "^4.1.0",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zod-deep-partial": "^1.2.0"
|
||||
}
|
||||
@@ -88,15 +91,25 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/ncc": {
|
||||
"version": "0.38.4",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
|
||||
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ncc": "dist/ncc/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equality-data-structures": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
|
||||
@@ -122,9 +135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
|
||||
"integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz",
|
||||
"integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -133,8 +146,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.1.5",
|
||||
"@nodable/entities": "^1.1.0",
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.2.3"
|
||||
},
|
||||
@@ -220,6 +233,22 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
|
||||
+9
-5
@@ -5,14 +5,18 @@
|
||||
"description": "StartOS 0.4.0.x package wrapping the Keysat daemon",
|
||||
"license": "SEE LICENSE IN ../licensing-service/LICENSE",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check": "tsc --noEmit"
|
||||
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||
"check": "tsc --noEmit",
|
||||
"prettier": "prettier --write startos"
|
||||
},
|
||||
"dependencies": {
|
||||
"@start9labs/start-sdk": "^0.4.0"
|
||||
"@start9labs/start-sdk": "^1.3.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"@types/node": "^20.0.0"
|
||||
"@types/node": "^22.0.0",
|
||||
"@vercel/ncc": "^0.38.4",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Action: activate the Keysat package's own self-license.
|
||||
//
|
||||
// The daemon embeds the keysat.xyz master public key at compile time
|
||||
// (see licensing-service/src/license_self.rs). The operator pastes a
|
||||
// LIC1-… key here; the daemon verifies it against the master pubkey,
|
||||
// writes it to /data/keysat-license.txt, and swaps its runtime tier
|
||||
// to Licensed without a restart.
|
||||
//
|
||||
// In permissive builds (the default for local `make x86`) the daemon
|
||||
// will start regardless and this action just records the tier. In
|
||||
// enforce builds (compiled with KEYSAT_LICENSE_ENFORCE=1, used for
|
||||
// the marketplace .s9pk) the daemon refuses to start without a valid
|
||||
// license, and this action is the bootstrap path: install Keysat,
|
||||
// run this action with your activation key, then start the service.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_key: Value.text({
|
||||
name: 'License key',
|
||||
description:
|
||||
'Paste the LIC1-… license key issued for your Keysat install. ' +
|
||||
'Buy or redeem one at registry.keysat.xyz.',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: 'LIC1-XXXXXXXXXXXX-XXXXXXXXXXXX',
|
||||
}),
|
||||
})
|
||||
|
||||
export const activateLicense = sdk.Action.withInput(
|
||||
'activate-license',
|
||||
async () => ({
|
||||
name: 'Activate Keysat license',
|
||||
description:
|
||||
'Activate this Keysat install. Required for marketplace builds; ' +
|
||||
'optional but recommended for source-built dev installs (signals support, ' +
|
||||
'and lets the admin UI show your tier).',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'License',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const key = formInput.license_key.trim()
|
||||
if (!key) {
|
||||
throw new Error('License key is required.')
|
||||
}
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/self-license',
|
||||
{ method: 'POST', body: JSON.stringify({ license_key: key }) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text()
|
||||
let detail = body
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
if (parsed.detail) detail = parsed.detail
|
||||
else if (parsed.error) detail = parsed.error
|
||||
} catch (_) {}
|
||||
throw new Error(`Activation rejected (HTTP ${resp.status}): ${detail}`)
|
||||
}
|
||||
|
||||
const result = (await resp.json()) as {
|
||||
ok: boolean
|
||||
tier: {
|
||||
tier: 'licensed' | 'unlicensed'
|
||||
license_id?: string
|
||||
product_id?: string
|
||||
expires_at?: number
|
||||
entitlements?: string[]
|
||||
mode: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Keysat license activated',
|
||||
message:
|
||||
result.message +
|
||||
' The license is stored at /data/keysat-license.txt and survives upgrades and reinstalls (it is part of your StartOS backup set).',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Companion read-only action: surface the current self-license tier.
|
||||
// Useful both as a sanity check after activation and as a way for the
|
||||
// operator to see "am I running licensed or unlicensed?" without
|
||||
// digging into logs.
|
||||
export const showLicenseStatus = sdk.Action.withoutInput(
|
||||
'show-license-status',
|
||||
async () => ({
|
||||
name: 'Show Keysat license status',
|
||||
description:
|
||||
'Reports whether this Keysat install is running licensed or unlicensed, ' +
|
||||
'and which entitlements are active.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'License',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/self-license',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Could not read license status: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const j = (await resp.json()) as {
|
||||
tier: 'licensed' | 'unlicensed'
|
||||
license_id?: string
|
||||
product_id?: string
|
||||
expires_at?: number
|
||||
entitlements?: string[]
|
||||
reason?: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
if (j.tier === 'licensed') {
|
||||
const exp = !j.expires_at
|
||||
? 'perpetual'
|
||||
: new Date(j.expires_at * 1000).toISOString().slice(0, 10)
|
||||
const ents = (j.entitlements || []).length === 0 ? '(none)' : (j.entitlements || []).join(', ')
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Licensed',
|
||||
message:
|
||||
`License id: ${j.license_id}\n` +
|
||||
`Expires: ${exp}\n` +
|
||||
`Entitlements: ${ents}\n` +
|
||||
`Build mode: ${j.mode}`,
|
||||
result: null,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Unlicensed',
|
||||
message:
|
||||
`Reason: ${j.reason || 'no license configured'}\n` +
|
||||
`Build mode: ${j.mode}\n\n` +
|
||||
(j.mode === 'enforce'
|
||||
? 'This is a marketplace build that requires a valid license to run. Use the "Activate Keysat license" action to bootstrap.'
|
||||
: 'This is a permissive (dev) build. The daemon will keep running. Activate a license to see your tier reflected here.'),
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -14,11 +14,12 @@
|
||||
// The operator never sees or types an API key, store id, or webhook secret.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
export const configureBtcpay = sdk.Action.withoutInput(
|
||||
'configureBtcpay',
|
||||
async ({ effects }) => ({
|
||||
'configure-btcpay',
|
||||
async () => ({
|
||||
name: 'Connect BTCPay',
|
||||
description:
|
||||
"One-click connect to your BTCPay Server. Opens a consent page in " +
|
||||
@@ -29,11 +30,12 @@ export const configureBtcpay = sdk.Action.withoutInput(
|
||||
group: 'BTCPay',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/connect',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
@@ -43,22 +45,102 @@ export const configureBtcpay = sdk.Action.withoutInput(
|
||||
const body = (await resp.json()) as { authorize_url: string }
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Approve on BTCPay to finish connecting',
|
||||
message:
|
||||
'Open the URL below in your browser. You will be taken to your ' +
|
||||
'BTCPay Server, where you click "Authorize". After that BTCPay ' +
|
||||
'sends the API key back to Keysat automatically — you do not ' +
|
||||
'need to copy anything.\n\n' +
|
||||
body.authorize_url +
|
||||
'\n\nYou can confirm the connection succeeded with the "Check BTCPay ' +
|
||||
'connection" action once approval is complete.',
|
||||
'need to copy anything.\n\nYou can confirm the connection succeeded ' +
|
||||
'with the "Check BTCPay connection" action once approval is complete.',
|
||||
result: {
|
||||
type: 'single',
|
||||
value: body.authorize_url,
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Replay id used by init/index.ts when surfacing the BTCPay setup task. */
|
||||
const BTCPAY_SETUP_TASK_ID = 'btcpay-initial-setup'
|
||||
|
||||
/** Disconnect BTCPay — clean revocation path for re-authorize cases. */
|
||||
export const disconnectBtcpay = sdk.Action.withoutInput(
|
||||
'disconnect-btcpay',
|
||||
async () => ({
|
||||
name: 'Disconnect BTCPay',
|
||||
description:
|
||||
'Disconnect Keysat from your BTCPay Server: revoke the API key, ' +
|
||||
'delete the registered webhook, and clear local connection state. ' +
|
||||
"Run this before 'Connect BTCPay' if you want to re-authorize " +
|
||||
'(e.g., to switch stores or rotate the API key). Existing license ' +
|
||||
'keys, products, and policies are unaffected.',
|
||||
warning:
|
||||
'Until you re-run "Connect BTCPay" after this, new purchases will ' +
|
||||
'return 503 (BTCPay not configured). Already-issued license keys ' +
|
||||
'continue to validate normally.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'BTCPay',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/disconnect',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Disconnect failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as
|
||||
| { ok: true; noop: true; message: string }
|
||||
| {
|
||||
ok: true
|
||||
noop: false
|
||||
store_id: string | null
|
||||
webhook_id: string | null
|
||||
warnings: string[]
|
||||
}
|
||||
if ('noop' in body && body.noop) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Already disconnected',
|
||||
message: body.message,
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const b = body as {
|
||||
ok: true
|
||||
noop: false
|
||||
store_id: string | null
|
||||
webhook_id: string | null
|
||||
warnings: string[]
|
||||
}
|
||||
const warningsBlock = b.warnings.length > 0
|
||||
? `\n\nWarnings:\n${b.warnings.map((w) => `• ${w}`).join('\n')}`
|
||||
: ''
|
||||
return {
|
||||
version: '1',
|
||||
title: 'BTCPay disconnected',
|
||||
message:
|
||||
`Local BTCPay connection cleared. ` +
|
||||
`Store id was ${b.store_id ?? '(unknown)'}, webhook id was ${b.webhook_id ?? '(none)'}. ` +
|
||||
`You can now run "Connect BTCPay" again to re-authorize.${warningsBlock}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Optional companion action: show current BTCPay connection state. */
|
||||
export const btcpayStatus = sdk.Action.withoutInput(
|
||||
'btcpayStatus',
|
||||
async ({ effects }) => ({
|
||||
'btcpay-status',
|
||||
async () => ({
|
||||
name: 'Check BTCPay connection',
|
||||
description: 'Shows whether BTCPay is currently connected, and the store id.',
|
||||
warning: null,
|
||||
@@ -67,10 +149,11 @@ export const btcpayStatus = sdk.Action.withoutInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/status',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -83,15 +166,62 @@ export const btcpayStatus = sdk.Action.withoutInput(
|
||||
|
||||
if (!body.connected) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Not connected',
|
||||
message: 'BTCPay is not connected yet. Run the "Connect BTCPay" action to authorize.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
|
||||
// BTCPay is connected — clear the install-time setup task so it
|
||||
// disappears from the dashboard. clearTask is idempotent and
|
||||
// tolerates being called when no such task exists, so this is safe
|
||||
// every time btcpayStatus is run.
|
||||
try {
|
||||
await sdk.action.clearTask(effects, BTCPAY_SETUP_TASK_ID)
|
||||
} catch (_) {
|
||||
// Non-fatal — we still report status.
|
||||
}
|
||||
|
||||
// Also check whether BTCPay's store has any payment methods (wallet
|
||||
// / Lightning) configured. A connected store with zero payment
|
||||
// methods can't actually issue invoices — that's the trap that
|
||||
// surfaces as "BTC-CHAIN: Payment method unavailable" when buyers
|
||||
// try to purchase. Surface the situation here so the operator
|
||||
// discovers it BEFORE a customer hits a broken purchase flow.
|
||||
let walletNote = ''
|
||||
try {
|
||||
const pmResp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/payment-methods',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (pmResp.ok) {
|
||||
const pmBody = (await pmResp.json()) as { count: number }
|
||||
if (pmBody.count === 0) {
|
||||
walletNote =
|
||||
`\n\n⚠ NO WALLET CONFIGURED on this BTCPay store. Buyers won't ` +
|
||||
`be able to pay until you set one up.\n` +
|
||||
`Open your BTCPay store settings (${body.base_url.replace(/^http:\/\//, 'http://').replace(/^https:\/\//, 'https://')}/stores/${body.store_id}) ` +
|
||||
`→ Wallets / Lightning, then come back and re-run "Check BTCPay connection".`
|
||||
} else {
|
||||
walletNote = `\n\n✓ ${pmBody.count} payment method(s) configured.`
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal: payment-method check is informational.
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'BTCPay is connected',
|
||||
message:
|
||||
`BTCPay is connected.\n` +
|
||||
`Store id: ${body.store_id}\n` +
|
||||
`Webhook id: ${body.webhook_id ?? '(not registered — check BTCPay manually)'}\n` +
|
||||
`Base URL: ${body.base_url}`,
|
||||
`Base URL: ${body.base_url}` +
|
||||
walletNote,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Action: create a discount / referral code.
|
||||
//
|
||||
// A code can be percentage-off or fixed-sats-off. It can target a specific
|
||||
// product (or apply universally), have an optional expiry, an optional
|
||||
// usage cap, and a free-form referrer label for tracking ("twitter-launch",
|
||||
// "alice@example.com"). Codes are case-insensitive and normalized to
|
||||
// uppercase on create.
|
||||
//
|
||||
// Buyers redeem codes by passing `?code=FOUNDERS50` to the public purchase
|
||||
// flow. The discount is reserved atomically at purchase time and finalized
|
||||
// when the BTCPay invoice settles.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
code: Value.text({
|
||||
name: 'Code',
|
||||
description:
|
||||
'The redeemable string. Case-insensitive (will be uppercased). ' +
|
||||
'ASCII letters, digits, "-", and "_" only. E.g., "FOUNDERS50".',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^[A-Za-z0-9_-]{2,40}$',
|
||||
description:
|
||||
'letters, digits, dashes, underscores; 2 to 40 characters',
|
||||
},
|
||||
],
|
||||
}),
|
||||
kind: Value.select({
|
||||
name: 'Discount kind',
|
||||
description:
|
||||
'"Percent off" reduces the price by N%. ' +
|
||||
'"Fixed sats off" subtracts a fixed number of sats. ' +
|
||||
'"Free license" issues a license outright with no payment, ' +
|
||||
'redeemed via the public /v1/redeem endpoint.',
|
||||
default: 'percent',
|
||||
values: {
|
||||
percent: 'Percent off',
|
||||
fixed_sats: 'Fixed sats off',
|
||||
free_license: 'Free license (no payment)',
|
||||
},
|
||||
}),
|
||||
amount: Value.number({
|
||||
name: 'Amount',
|
||||
description:
|
||||
'For percent: 1..=100 (integer percentage). E.g., 50 = 50% off. ' +
|
||||
'For fixed sats off: any positive integer (sats). ' +
|
||||
'For free license: ignored (set to 0). ' +
|
||||
'Note: percent is converted to basis points server-side.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
}),
|
||||
max_uses: Value.number({
|
||||
name: 'Max uses',
|
||||
description: '0 = unlimited. Otherwise, max number of redemptions.',
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
}),
|
||||
expires_at: Value.text({
|
||||
name: 'Expires at (ISO 8601)',
|
||||
description:
|
||||
'Optional cutoff date. RFC3339 / ISO 8601 UTC, e.g. ' +
|
||||
'"2026-12-31T23:59:59Z". Leave blank for no expiry.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug (optional)',
|
||||
description:
|
||||
'Restrict the code to one specific product. Leave blank to apply ' +
|
||||
'the discount to any product.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
policy_slug: Value.text({
|
||||
name: 'Policy slug (optional)',
|
||||
description:
|
||||
'Further restrict to a single policy of the chosen product. ' +
|
||||
'Requires "Product slug" to be set if used.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
referrer_label: Value.text({
|
||||
name: 'Referrer / campaign label (optional)',
|
||||
description:
|
||||
'Free-form tracking string. E.g., "twitter-launch", ' +
|
||||
'"partner-alice@example.com", "podcast-XYZ-spring-2026". Shown in ' +
|
||||
'usage reports; never visible to buyers.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
description: Value.textarea({
|
||||
name: 'Description (optional)',
|
||||
description: 'Internal note. E.g., "Founders rate, expires May 31."',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
})
|
||||
|
||||
export const createDiscountCode = sdk.Action.withInput(
|
||||
'create-discount-code',
|
||||
async () => ({
|
||||
name: 'Create discount code',
|
||||
description:
|
||||
'Add a redeemable discount / referral code. Buyers append ' +
|
||||
'?code=YOUR_CODE to the purchase URL to apply it.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
if (formInput.policy_slug && !formInput.product_slug) {
|
||||
throw new Error('Policy slug requires Product slug to also be set.')
|
||||
}
|
||||
|
||||
// Convert UI percent (1..=100) to basis points (100..=10000).
|
||||
let amount = formInput.amount
|
||||
if (formInput.kind === 'percent') {
|
||||
if (amount < 1 || amount > 100) {
|
||||
throw new Error('Percent amount must be between 1 and 100.')
|
||||
}
|
||||
amount = amount * 100
|
||||
} else if (formInput.kind === 'fixed_sats') {
|
||||
if (amount < 1) {
|
||||
throw new Error('Fixed sats amount must be at least 1.')
|
||||
}
|
||||
} else if (formInput.kind === 'free_license') {
|
||||
// Amount is unused for free licenses; force to 0 so the server
|
||||
// accepts it.
|
||||
amount = 0
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
code: formInput.code,
|
||||
kind: formInput.kind,
|
||||
amount,
|
||||
description: formInput.description ?? '',
|
||||
}
|
||||
if (formInput.max_uses > 0) body.max_uses = formInput.max_uses
|
||||
if (formInput.expires_at) body.expires_at = formInput.expires_at
|
||||
if (formInput.product_slug) body.product_slug = formInput.product_slug
|
||||
if (formInput.policy_slug) body.policy_slug = formInput.policy_slug
|
||||
if (formInput.referrer_label) body.referrer_label = formInput.referrer_label
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/discount-codes',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create discount code failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const code = (await resp.json()) as { id: string; code: string; kind: string; amount: number }
|
||||
const humanAmount =
|
||||
code.kind === 'percent'
|
||||
? `${code.amount / 100}% off`
|
||||
: code.kind === 'fixed_sats'
|
||||
? `${code.amount} sats off`
|
||||
: 'free license (no payment)'
|
||||
const redemptionHint =
|
||||
code.kind === 'free_license'
|
||||
? `Buyers redeem this code via the public /v1/redeem endpoint or via ` +
|
||||
`the "Redeem free license" buyer-side action — they receive the key ` +
|
||||
`directly, no BTCPay invoice.`
|
||||
: `Buyers can redeem this code by appending ?code=${code.code} to the ` +
|
||||
`public purchase URL.`
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Discount code created',
|
||||
message:
|
||||
`Created code "${code.code}" — ${humanAmount}.\n` +
|
||||
redemptionHint +
|
||||
`\n\nInternal id: ${code.id}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -6,35 +6,36 @@
|
||||
// normal purchase flow.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
product_slug: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug',
|
||||
description: 'The product this policy applies to.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
slug: {
|
||||
type: 'text',
|
||||
}),
|
||||
slug: Value.text({
|
||||
name: 'Policy slug',
|
||||
description:
|
||||
'URL-safe name, e.g., "default", "annual", "trial". ' +
|
||||
'Use "default" for the one consumed by the public purchase flow.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }],
|
||||
},
|
||||
name: {
|
||||
type: 'text',
|
||||
patterns: [
|
||||
{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' },
|
||||
],
|
||||
}),
|
||||
name: Value.text({
|
||||
name: 'Display name',
|
||||
description: 'Shown in admin listings. E.g., "Annual subscription".',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
duration_seconds: {
|
||||
type: 'number',
|
||||
}),
|
||||
duration_seconds: Value.number({
|
||||
name: 'Duration (seconds)',
|
||||
description: '0 = perpetual. 31536000 = one year. 7776000 = 90 days.',
|
||||
required: true,
|
||||
@@ -42,9 +43,8 @@ const input = sdk.InputSpec.of({
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
grace_seconds: {
|
||||
type: 'number',
|
||||
}),
|
||||
grace_seconds: Value.number({
|
||||
name: 'Grace period (seconds)',
|
||||
description:
|
||||
'After expiry, how long a cached validation remains honoured ' +
|
||||
@@ -54,9 +54,8 @@ const input = sdk.InputSpec.of({
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
max_machines: {
|
||||
type: 'number',
|
||||
}),
|
||||
max_machines: Value.number({
|
||||
name: 'Max machines',
|
||||
description: '0 = unlimited, 1 = single-seat, n>1 = multi-seat cap.',
|
||||
required: true,
|
||||
@@ -64,24 +63,21 @@ const input = sdk.InputSpec.of({
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
is_trial: {
|
||||
type: 'toggle',
|
||||
}),
|
||||
is_trial: Value.toggle({
|
||||
name: 'Trial policy',
|
||||
description: 'Mark issued keys as trial (sets the TRIAL flag in the payload).',
|
||||
default: false,
|
||||
},
|
||||
entitlements: {
|
||||
type: 'text',
|
||||
}),
|
||||
entitlements: Value.text({
|
||||
name: 'Entitlements',
|
||||
description:
|
||||
'Comma-separated list of feature slugs embedded in the license key. ' +
|
||||
'E.g., "pro,multi-device". Leave blank for none.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
price_sats_override: {
|
||||
type: 'number',
|
||||
}),
|
||||
price_sats_override: Value.number({
|
||||
name: 'Price override (sats, optional)',
|
||||
description:
|
||||
"Override the product's default price for licenses issued under this " +
|
||||
@@ -91,12 +87,12 @@ const input = sdk.InputSpec.of({
|
||||
min: -1,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const createPolicy = sdk.Action.withInput(
|
||||
'createPolicy',
|
||||
async ({ effects }) => ({
|
||||
'create-policy',
|
||||
async () => ({
|
||||
name: 'Create policy',
|
||||
description:
|
||||
'Add a reusable license template to a product. The public purchase ' +
|
||||
@@ -108,8 +104,11 @@ export const createPolicy = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const entitlements = (formInput.entitlements ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
@@ -129,7 +128,7 @@ export const createPolicy = sdk.Action.withInput(
|
||||
body.price_sats_override = formInput.price_sats_override
|
||||
}
|
||||
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/policies', {
|
||||
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/policies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -138,11 +137,14 @@ export const createPolicy = sdk.Action.withInput(
|
||||
}
|
||||
const policy = (await resp.json()) as { id: string; slug: string; name: string }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Policy created',
|
||||
message:
|
||||
`Created policy '${policy.slug}' (id ${policy.id}).\n` +
|
||||
(formInput.slug === 'default'
|
||||
? 'Because the slug is "default", this policy will be used by the public purchase flow.'
|
||||
: 'Use this slug when calling "Issue license manually".'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,33 +4,34 @@
|
||||
// key. No need for the operator to touch curl or handle tokens.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
slug: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
slug: Value.text({
|
||||
name: 'Slug',
|
||||
description: 'URL-safe short name, e.g., "my-app". Used in product links.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }],
|
||||
},
|
||||
name: {
|
||||
type: 'text',
|
||||
patterns: [
|
||||
{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' },
|
||||
],
|
||||
}),
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'Display name shown to buyers.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: 'textarea',
|
||||
}),
|
||||
description: Value.textarea({
|
||||
name: 'Description',
|
||||
description: 'Public description of what the buyer is getting.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
price_sats: {
|
||||
type: 'number',
|
||||
}),
|
||||
price_sats: Value.number({
|
||||
name: 'Price (sats)',
|
||||
description: 'Price per license in satoshis. 100,000,000 sats = 1 BTC.',
|
||||
required: true,
|
||||
@@ -38,12 +39,12 @@ const input = sdk.InputSpec.of({
|
||||
min: 1,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const createProduct = sdk.Action.withInput(
|
||||
'createProduct',
|
||||
async ({ effects }) => ({
|
||||
'create-product',
|
||||
async () => ({
|
||||
name: 'Create product',
|
||||
description: 'Add a new product that can be purchased through this service.',
|
||||
warning: null,
|
||||
@@ -52,9 +53,11 @@ export const createProduct = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/products', {
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
slug: formInput.slug,
|
||||
@@ -67,13 +70,16 @@ export const createProduct = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create product failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = await resp.json()
|
||||
const body = (await resp.json()) as { id: string; slug: string; price_sats: number }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Product created',
|
||||
message:
|
||||
`Created product '${body.slug}' (id ${body.id}).\n` +
|
||||
`Priced at ${body.price_sats} sats.\n\n` +
|
||||
`Buyers can purchase by POSTing to your Keysat URL:\n` +
|
||||
`<your Keysat URL>/v1/purchase with body: {"product":"${body.slug}"}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,28 +4,29 @@
|
||||
// with `not_activated`, freeing up a seat for another install.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
machine_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
machine_id: Value.text({
|
||||
name: 'Machine ID',
|
||||
description: 'UUID of the machine to deactivate. Find via list-machines.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: 'text',
|
||||
}),
|
||||
reason: Value.text({
|
||||
name: 'Reason',
|
||||
description: 'Stored for audit. E.g., "laptop stolen", "support request".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const deactivateMachine = sdk.Action.withInput(
|
||||
'deactivateMachine',
|
||||
async ({ effects }) => ({
|
||||
'deactivate-machine',
|
||||
async () => ({
|
||||
name: 'Deactivate machine',
|
||||
description:
|
||||
'Force an install off a license. Frees up a seat and causes that ' +
|
||||
@@ -38,11 +39,13 @@ export const deactivateMachine = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/machines/${encodeURIComponent(formInput.machine_id)}/deactivate`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -52,6 +55,11 @@ export const deactivateMachine = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Deactivate failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Deactivated machine ${formInput.machine_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Machine deactivated',
|
||||
message: `Deactivated machine ${formInput.machine_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Action: disable (or re-enable) a discount code.
|
||||
//
|
||||
// Disabling is reversible — the code's redemption history is preserved
|
||||
// either way. Disabled codes simply won't redeem on new purchases.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
code: Value.text({
|
||||
name: 'Code',
|
||||
description:
|
||||
'The redeemable string (e.g. "FOUNDERS50"). Case-insensitive — ' +
|
||||
'will be uppercased before lookup.',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
active: Value.toggle({
|
||||
name: 'Active',
|
||||
description:
|
||||
'Toggle off to disable this code. Toggle on to re-enable a ' +
|
||||
'previously disabled code.',
|
||||
default: false,
|
||||
}),
|
||||
})
|
||||
|
||||
export const disableDiscountCode = sdk.Action.withInput(
|
||||
'disable-discount-code',
|
||||
async () => ({
|
||||
name: 'Disable / enable discount code',
|
||||
description:
|
||||
'Disable a code so it stops accepting new redemptions. Existing ' +
|
||||
'redemptions and the underlying license are unaffected. Re-enable ' +
|
||||
'by running again with "Active" toggled on.',
|
||||
warning:
|
||||
'Disabling does not refund or revoke previously-issued licenses. ' +
|
||||
'Use "Revoke license" for that.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
// Look the code up by string to discover its id.
|
||||
const lookup = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/discount-codes',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!lookup.ok) {
|
||||
throw new Error(`Lookup failed: HTTP ${lookup.status} — ${await lookup.text()}`)
|
||||
}
|
||||
const body = (await lookup.json()) as { codes: Array<{ id: string; code: string }> }
|
||||
const target = body.codes.find(
|
||||
(c) => c.code.toUpperCase() === formInput.code.trim().toUpperCase(),
|
||||
)
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`No discount code found matching "${formInput.code}". ` +
|
||||
'Use "List discount codes" with "Include disabled codes" toggled on to see all codes.',
|
||||
)
|
||||
}
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/discount-codes/${target.id}/active`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ active: formInput.active }) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Update failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: formInput.active ? 'Code re-enabled' : 'Code disabled',
|
||||
message: formInput.active
|
||||
? `Code "${target.code}" is now active and will accept new redemptions.`
|
||||
: `Code "${target.code}" is now disabled. New purchases that try ` +
|
||||
'to redeem it will be rejected. Existing redemptions and licenses are unaffected.',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
+26
-32
@@ -1,44 +1,38 @@
|
||||
// Register every action with StartOS.
|
||||
// Register actions with StartOS.
|
||||
//
|
||||
// As of v0.1.0:11 the StartOS Actions tab is intentionally minimal —
|
||||
// only setup-time operations live here:
|
||||
//
|
||||
// - General → Set operator name
|
||||
// - BTCPay → Connect / Check / Disconnect
|
||||
// - License → Activate Keysat license / Show license status
|
||||
// - Credentials → Show admin API key
|
||||
//
|
||||
// Everything else (products, policies, discount codes, licenses,
|
||||
// machines, webhooks, audit log) lives in the embedded admin web UI
|
||||
// at /admin/. The action source files remain in this directory for
|
||||
// reference — and the underlying admin HTTP API is unchanged — but
|
||||
// they're no longer registered as StartOS UI buttons. This keeps the
|
||||
// dashboard from feeling like an undifferentiated wall of buttons.
|
||||
//
|
||||
// The web UI uses the same /v1/admin/* endpoints those actions used to
|
||||
// call, so functionality is identical; only the UI surface changed.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { btcpayStatus, configureBtcpay } from './configureBtcpay'
|
||||
import { createPolicy } from './createPolicy'
|
||||
import { createProduct } from './createProduct'
|
||||
import { deactivateMachine } from './deactivateMachine'
|
||||
import { issueLicense } from './issueLicense'
|
||||
import { listMachines } from './listMachines'
|
||||
import { listWebhooks } from './listWebhooks'
|
||||
import { registerWebhook } from './registerWebhook'
|
||||
import { revokeLicense } from './revokeLicense'
|
||||
import { searchLicenses } from './searchLicenses'
|
||||
import { activateLicense, showLicenseStatus } from './activateLicense'
|
||||
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
|
||||
import { setOperatorName } from './setOperatorName'
|
||||
import { showCredentials } from './showCredentials'
|
||||
import { suspendLicense } from './suspendLicense'
|
||||
import { unsuspendLicense } from './unsuspendLicense'
|
||||
import { viewAuditLog } from './viewAuditLog'
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
// General
|
||||
.addAction(setOperatorName)
|
||||
// BTCPay
|
||||
// BTCPay setup
|
||||
.addAction(configureBtcpay)
|
||||
.addAction(btcpayStatus)
|
||||
.addAction(disconnectBtcpay)
|
||||
// Keysat self-license (Keysat-licenses-Keysat)
|
||||
.addAction(activateLicense)
|
||||
.addAction(showLicenseStatus)
|
||||
// Credentials
|
||||
.addAction(showCredentials)
|
||||
// Products + Policies
|
||||
.addAction(createProduct)
|
||||
.addAction(createPolicy)
|
||||
// Licenses
|
||||
.addAction(issueLicense)
|
||||
.addAction(searchLicenses)
|
||||
.addAction(suspendLicense)
|
||||
.addAction(unsuspendLicense)
|
||||
.addAction(revokeLicense)
|
||||
// Machines
|
||||
.addAction(listMachines)
|
||||
.addAction(deactivateMachine)
|
||||
// Webhooks
|
||||
.addAction(registerWebhook)
|
||||
.addAction(listWebhooks)
|
||||
// Diagnostics
|
||||
.addAction(viewAuditLog)
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
// Action: manually issue a license for a product (comp, press, dev).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
product_slug: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug',
|
||||
description: 'Which product to issue a license for.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
note: {
|
||||
type: 'text',
|
||||
}),
|
||||
note: Value.text({
|
||||
name: 'Note (optional)',
|
||||
description: 'Audit trail — e.g., "comp for @alice", "press key".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const issueLicense = sdk.Action.withInput(
|
||||
'issueLicense',
|
||||
async ({ effects }) => ({
|
||||
'issue-license',
|
||||
async () => ({
|
||||
name: 'Issue license manually',
|
||||
description: 'Generate a license key outside the purchase flow. Useful for comps and press.',
|
||||
warning: null,
|
||||
@@ -31,9 +32,11 @@ export const issueLicense = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/licenses', {
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/licenses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_slug: formInput.product_slug,
|
||||
@@ -43,11 +46,18 @@ export const issueLicense = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Issue failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = await resp.json()
|
||||
const body = (await resp.json()) as { license_id: string; license_key: string }
|
||||
return {
|
||||
message:
|
||||
`License issued.\nID: ${body.license_id}\n\n` +
|
||||
`Key (give this to the recipient):\n${body.license_key}`,
|
||||
version: '1',
|
||||
title: 'License issued',
|
||||
message: `License ID: ${body.license_id}\n\nGive this key to the recipient.`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: body.license_key,
|
||||
copyable: true,
|
||||
qr: true,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Action: list discount / referral codes with usage stats.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
include_inactive: Value.toggle({
|
||||
name: 'Include disabled codes',
|
||||
description: 'Show codes that have been disabled.',
|
||||
default: false,
|
||||
}),
|
||||
})
|
||||
|
||||
interface DiscountCode {
|
||||
id: string
|
||||
code: string
|
||||
kind: string
|
||||
amount: number
|
||||
max_uses: number | null
|
||||
used_count: number
|
||||
expires_at: string | null
|
||||
applies_to_product_id: string | null
|
||||
applies_to_policy_id: string | null
|
||||
referrer_label: string | null
|
||||
description: string
|
||||
active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const listDiscountCodes = sdk.Action.withInput(
|
||||
'list-discount-codes',
|
||||
async () => ({
|
||||
name: 'List discount codes',
|
||||
description: 'View every discount / referral code with usage stats.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (formInput.include_inactive) params.set('include_inactive', 'true')
|
||||
const path =
|
||||
'/v1/admin/discount-codes' +
|
||||
(params.toString() ? `?${params.toString()}` : '')
|
||||
|
||||
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, path, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`List failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as { codes: DiscountCode[] }
|
||||
if (body.codes.length === 0) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No discount codes',
|
||||
message:
|
||||
formInput.include_inactive
|
||||
? 'No discount codes exist yet.'
|
||||
: 'No active discount codes. Toggle "Include disabled codes" to also see disabled ones.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.codes.map((c) => {
|
||||
const off =
|
||||
c.kind === 'percent'
|
||||
? `${(c.amount / 100).toFixed(c.amount % 100 === 0 ? 0 : 2)}% off`
|
||||
: c.kind === 'fixed_sats'
|
||||
? `${c.amount} sats off`
|
||||
: 'free license'
|
||||
const usage = c.max_uses
|
||||
? `${c.used_count}/${c.max_uses}`
|
||||
: `${c.used_count}/∞`
|
||||
const status = c.active ? 'active' : 'DISABLED'
|
||||
const exp = c.expires_at ? `expires ${c.expires_at}` : 'no expiry'
|
||||
const target = c.applies_to_product_id
|
||||
? c.applies_to_policy_id
|
||||
? '(product+policy scoped)'
|
||||
: '(product scoped)'
|
||||
: '(any product)'
|
||||
const ref = c.referrer_label ? ` — ref:${c.referrer_label}` : ''
|
||||
return `• ${c.code} [${status}] ${off} uses ${usage} ${exp} ${target}${ref}`
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.codes.length} discount code(s)`,
|
||||
message: lines.join('\n'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -4,27 +4,28 @@
|
||||
// troubleshooting a multi-seat cap ("can't activate, too many machines").
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to inspect.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
include_inactive: {
|
||||
type: 'toggle',
|
||||
}),
|
||||
include_inactive: Value.toggle({
|
||||
name: 'Include deactivated machines',
|
||||
description: 'Show rows for machines that were previously deactivated.',
|
||||
default: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const listMachines = sdk.Action.withInput(
|
||||
'listMachines',
|
||||
async ({ effects }) => ({
|
||||
'list-machines',
|
||||
async () => ({
|
||||
name: 'List machines',
|
||||
description: 'Show installs currently bound to a license.',
|
||||
warning: null,
|
||||
@@ -33,15 +34,17 @@ export const listMachines = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const params = new URLSearchParams()
|
||||
params.set('license_id', formInput.license_id)
|
||||
if (formInput.include_inactive) params.set('include_inactive', 'true')
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/machines?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -60,7 +63,12 @@ export const listMachines = sdk.Action.withInput(
|
||||
}>
|
||||
}
|
||||
if (body.machines.length === 0) {
|
||||
return { message: 'No machines bound to this license.' }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No machines',
|
||||
message: 'No machines bound to this license.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.machines.map((m) => {
|
||||
const activeStr =
|
||||
@@ -76,10 +84,13 @@ export const listMachines = sdk.Action.withInput(
|
||||
return '• ' + bits.filter(Boolean).join(' ')
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.machines.length} machine(s)`,
|
||||
message:
|
||||
`${body.machines.length} machine(s) on license ${formInput.license_id}:\n\n` +
|
||||
lines.join('\n') +
|
||||
'\n\nTo free a seat, use the "Deactivate machine" action with the machine id.',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// masked — rotate by deleting and recreating an endpoint.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
export const listWebhooks = sdk.Action.withoutInput(
|
||||
'listWebhooks',
|
||||
async ({ effects }) => ({
|
||||
'list-webhooks',
|
||||
async () => ({
|
||||
name: 'List webhook endpoints',
|
||||
description: 'Show all currently-registered outbound webhook subscribers.',
|
||||
warning: null,
|
||||
@@ -16,11 +17,12 @@ export const listWebhooks = sdk.Action.withoutInput(
|
||||
group: 'Webhooks',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/webhook-endpoints',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -38,9 +40,12 @@ export const listWebhooks = sdk.Action.withoutInput(
|
||||
}
|
||||
if (body.endpoints.length === 0) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No webhooks',
|
||||
message:
|
||||
'No webhook endpoints registered. Use "Register webhook endpoint" ' +
|
||||
'to add one.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.endpoints.map((ep) => {
|
||||
@@ -49,8 +54,11 @@ export const listWebhooks = sdk.Action.withoutInput(
|
||||
(ep.description ? ` ("${ep.description}")` : '')
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.endpoints.length} endpoint(s)`,
|
||||
message:
|
||||
`${body.endpoints.length} endpoint(s):\n\n` + lines.join('\n'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,19 +7,20 @@
|
||||
// `sha256=<hex>` — same shape as BTCPay's outbound hooks.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
url: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
url: Value.text({
|
||||
name: 'Webhook URL',
|
||||
description: 'HTTPS endpoint that will receive POSTed event bodies.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [{ regex: '^https?://', description: 'must be an HTTP(S) URL' }],
|
||||
},
|
||||
event_types: {
|
||||
type: 'text',
|
||||
}),
|
||||
event_types: Value.text({
|
||||
name: 'Event types',
|
||||
description:
|
||||
'Comma-separated list of events to subscribe to, or "*" for all. ' +
|
||||
@@ -28,19 +29,18 @@ const input = sdk.InputSpec.of({
|
||||
'machine.activated, machine.deactivated, invoice.settled.',
|
||||
required: true,
|
||||
default: '*',
|
||||
},
|
||||
description: {
|
||||
type: 'text',
|
||||
}),
|
||||
description: Value.text({
|
||||
name: 'Description',
|
||||
description: 'Free-form label, shown in the admin list.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const registerWebhook = sdk.Action.withInput(
|
||||
'registerWebhook',
|
||||
async ({ effects }) => ({
|
||||
'register-webhook',
|
||||
async () => ({
|
||||
name: 'Register webhook endpoint',
|
||||
description:
|
||||
'Tell Keysat to POST signed event notifications to an HTTPS URL you ' +
|
||||
@@ -53,8 +53,10 @@ export const registerWebhook = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const eventTypes = formInput.event_types
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
@@ -64,7 +66,7 @@ export const registerWebhook = sdk.Action.withInput(
|
||||
}
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/webhook-endpoints',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -85,13 +87,22 @@ export const registerWebhook = sdk.Action.withInput(
|
||||
event_types: string[]
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Webhook registered',
|
||||
message:
|
||||
`Registered webhook endpoint (id ${ep.id}).\n` +
|
||||
`URL: ${ep.url}\n` +
|
||||
`Events: ${ep.event_types.join(', ')}\n\n` +
|
||||
`HMAC secret (save this now — will not be shown again):\n${ep.secret}\n\n` +
|
||||
`Save the HMAC secret shown below — it will not be displayed again.\n\n` +
|
||||
`Verify incoming requests with header X-Keysat-Signature: sha256=<hex> ` +
|
||||
`(HMAC-SHA256 of the raw request body using this secret).`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: ep.secret,
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,44 +1,49 @@
|
||||
// Action: revoke an existing license.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to revoke. Find via list-licenses action.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: 'text',
|
||||
}),
|
||||
reason: Value.text({
|
||||
name: 'Reason',
|
||||
description: 'Stored for audit. E.g., "chargeback", "key leaked".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const revokeLicense = sdk.Action.withInput(
|
||||
'revokeLicense',
|
||||
async ({ effects }) => ({
|
||||
'revoke-license',
|
||||
async () => ({
|
||||
name: 'Revoke license',
|
||||
description:
|
||||
'Mark a license as revoked (one-way; use "Suspend license" for a ' +
|
||||
'reversible lockout). The next time downstream software checks ' +
|
||||
'revocation, it will be denied.',
|
||||
warning: 'Revocation takes effect on the next online validation. Clients with cached results may continue running until their cache expires.',
|
||||
warning:
|
||||
'Revocation takes effect on the next online validation. Clients with ' +
|
||||
'cached results may continue running until their cache expires.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Licenses',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -48,6 +53,11 @@ export const revokeLicense = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Revoke failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Revoked license ${formInput.license_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'License revoked',
|
||||
message: `Revoked license ${formInput.license_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,35 +5,35 @@
|
||||
// matching licenses with IDs, product slugs, and current status.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
buyer_email: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
buyer_email: Value.text({
|
||||
name: 'Buyer email',
|
||||
description: 'Exact-match email address (leave blank if searching by another field).',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
nostr_npub: {
|
||||
type: 'text',
|
||||
}),
|
||||
nostr_npub: Value.text({
|
||||
name: 'Nostr npub',
|
||||
description: 'Nostr public key (npub…). Optional.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
invoice_id: {
|
||||
type: 'text',
|
||||
}),
|
||||
invoice_id: Value.text({
|
||||
name: 'BTCPay invoice ID',
|
||||
description: 'The BTCPay invoice ID associated with a purchase. Optional.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const searchLicenses = sdk.Action.withInput(
|
||||
'searchLicenses',
|
||||
async ({ effects }) => ({
|
||||
'search-licenses',
|
||||
async () => ({
|
||||
name: 'Search licenses',
|
||||
description:
|
||||
"Look up a buyer's licenses by email, Nostr npub, or BTCPay " +
|
||||
@@ -44,8 +44,10 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (formInput.buyer_email) params.set('buyer_email', formInput.buyer_email)
|
||||
@@ -57,7 +59,7 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/search?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -75,7 +77,12 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
}>
|
||||
}
|
||||
if (body.licenses.length === 0) {
|
||||
return { message: 'No licenses matched.' }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No matches',
|
||||
message: 'No licenses matched.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.licenses.map(
|
||||
(l) =>
|
||||
@@ -85,11 +92,14 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
(l.expires_at ? ` expires=${l.expires_at}` : ''),
|
||||
)
|
||||
return {
|
||||
version: '1',
|
||||
title: `Found ${body.licenses.length} license(s)`,
|
||||
message:
|
||||
`Found ${body.licenses.length} license(s):\n\n` +
|
||||
lines.join('\n') +
|
||||
'\n\nTo reissue the key to the buyer, look up the license details ' +
|
||||
'via /v1/admin/licenses with the admin API key.',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,36 +1,75 @@
|
||||
// Action: set the operator display name shown on the service homepage.
|
||||
//
|
||||
// As of v0.1.0:7+ this writes to the daemon's runtime settings table via
|
||||
// the admin API, so changes take effect immediately without a daemon
|
||||
// restart. We also mirror the value to the wrapper's package store so
|
||||
// the StartOS prefill / future env-var handoff remains consistent.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
operator_name: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
operator_name: Value.text({
|
||||
name: 'Operator name',
|
||||
description:
|
||||
'Displayed on the service homepage so buyers know whose Keysat ' +
|
||||
'instance they are interacting with. E.g., your name or business name.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const setOperatorName = sdk.Action.withInput(
|
||||
'setOperatorName',
|
||||
async ({ effects }) => ({
|
||||
'set-operator-name',
|
||||
async () => ({
|
||||
name: 'Set operator name',
|
||||
description: 'Edit the operator name shown publicly.',
|
||||
description: 'Edit the operator name shown publicly. Takes effect immediately — no restart required.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'General',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
// Pre-fill the form with the current value.
|
||||
async ({ effects: _effects }) => {
|
||||
const current = await store.read().once()
|
||||
return current?.operator_name ? { operator_name: current.operator_name } : null
|
||||
},
|
||||
async ({ effects, input: formInput }) => {
|
||||
const current = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
await sdk.store.setOwn(effects, sdk.StorePath, {
|
||||
...current,
|
||||
operator_name: formInput.operator_name,
|
||||
})
|
||||
return { message: `Operator name set to ${formInput.operator_name}. Restart the service to apply.` }
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const trimmed = formInput.operator_name.trim()
|
||||
|
||||
// Live-update the daemon via admin endpoint. This stores the value
|
||||
// in the daemon's settings table and the very next request to / or
|
||||
// /thank-you uses it. No restart needed.
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/settings/operator-name',
|
||||
{ method: 'POST', body: JSON.stringify({ name: trimmed }) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(
|
||||
`Operator-name update failed: HTTP ${resp.status} — ${await resp.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror to the wrapper store. This isn't strictly required (the
|
||||
// daemon owns the live value), but it keeps the prefill working
|
||||
// and gives us a fallback path during package upgrades.
|
||||
await store.merge(effects, { operator_name: trimmed })
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Operator name updated',
|
||||
message:
|
||||
`Operator name set to "${trimmed}". The change is live immediately — ` +
|
||||
`no restart needed. Anyone visiting your service homepage from now on will see the new name.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
// The BTCPay webhook secret used to live in the StartOS store; it now lives
|
||||
// inside the daemon's own SQLite database, generated automatically during
|
||||
// the "Connect BTCPay" authorize flow. Operators don't need to know it.
|
||||
//
|
||||
// SDK 0.4.0 shape: `Action.withoutInput(id, metadata, run)` — the run fn is
|
||||
// the third positional arg, not a chained `.withoutRunner(...)` method.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
|
||||
export const showCredentials = sdk.Action.withoutInput(
|
||||
'showCredentials',
|
||||
async ({ effects }) => ({
|
||||
'show-credentials',
|
||||
async () => ({
|
||||
name: 'Show admin API key',
|
||||
description:
|
||||
'Display the auto-generated admin API key. Treat it like a password — ' +
|
||||
@@ -24,13 +28,23 @@ export const showCredentials = sdk.Action.withoutInput(
|
||||
group: 'Credentials',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
).withoutRunner(async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Admin API key',
|
||||
message:
|
||||
`Admin API key:\n${store.admin_api_key}\n\n` +
|
||||
`Used as 'Authorization: Bearer <key>' against /v1/admin/*. All ` +
|
||||
`StartOS actions already supply this for you — only export it if ` +
|
||||
`you intend to script against the admin API from outside the box.`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: storeData.admin_api_key,
|
||||
copyable: true,
|
||||
qr: true,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,28 +5,29 @@
|
||||
// disputes where the outcome isn't yet known.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to suspend. Find via search-licenses action.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: 'text',
|
||||
}),
|
||||
reason: Value.text({
|
||||
name: 'Reason',
|
||||
description: 'Stored for audit. E.g., "payment dispute pending".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const suspendLicense = sdk.Action.withInput(
|
||||
'suspendLicense',
|
||||
async ({ effects }) => ({
|
||||
'suspend-license',
|
||||
async () => ({
|
||||
name: 'Suspend license',
|
||||
description:
|
||||
'Temporarily disable a license. Validation calls will fail with a ' +
|
||||
@@ -40,11 +41,13 @@ export const suspendLicense = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/suspend`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -54,6 +57,11 @@ export const suspendLicense = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Suspend failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Suspended license ${formInput.license_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'License suspended',
|
||||
message: `Suspended license ${formInput.license_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
// Action: clear a previously-applied suspension.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the suspended license to re-enable.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const unsuspendLicense = sdk.Action.withInput(
|
||||
'unsuspendLicense',
|
||||
async ({ effects }) => ({
|
||||
'unsuspend-license',
|
||||
async () => ({
|
||||
name: 'Unsuspend license',
|
||||
description:
|
||||
'Lift a previous suspension. Validation will succeed again on the ' +
|
||||
@@ -27,17 +29,24 @@ export const unsuspendLicense = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/unsuspend`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Unsuspend failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Unsuspended license ${formInput.license_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'License unsuspended',
|
||||
message: `Unsuspended license ${formInput.license_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
// operator can skim without curl.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
limit: {
|
||||
type: 'number',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
limit: Value.number({
|
||||
name: 'Limit',
|
||||
description: 'Number of most recent entries to return (1–1000).',
|
||||
required: true,
|
||||
@@ -18,21 +20,20 @@ const input = sdk.InputSpec.of({
|
||||
min: 1,
|
||||
max: 1000,
|
||||
integer: true,
|
||||
},
|
||||
action: {
|
||||
type: 'text',
|
||||
}),
|
||||
action: Value.text({
|
||||
name: 'Filter action',
|
||||
description:
|
||||
'Optional action slug to filter on. E.g., "license.revoke", ' +
|
||||
'"license.suspend", "policy.create", "webhook_endpoint.create".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const viewAuditLog = sdk.Action.withInput(
|
||||
'viewAuditLog',
|
||||
async ({ effects }) => ({
|
||||
'view-audit-log',
|
||||
async () => ({
|
||||
name: 'View audit log',
|
||||
description:
|
||||
'Show the most recent admin mutations recorded by the service — ' +
|
||||
@@ -44,15 +45,17 @@ export const viewAuditLog = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(formInput.limit))
|
||||
if (formInput.action) params.set('action', formInput.action)
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/audit?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -72,7 +75,12 @@ export const viewAuditLog = sdk.Action.withInput(
|
||||
}>
|
||||
}
|
||||
if (body.entries.length === 0) {
|
||||
return { message: 'No audit entries match the filter.' }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No entries',
|
||||
message: 'No audit entries match the filter.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.entries.map((e) => {
|
||||
const target = e.target_type && e.target_id ? `${e.target_type}:${e.target_id}` : '(no target)'
|
||||
@@ -81,8 +89,11 @@ export const viewAuditLog = sdk.Action.withInput(
|
||||
return `• ${e.created_at} ${e.action} ${target} ${actor} ${ip}`
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.entries.length} entry(ies)`,
|
||||
message:
|
||||
`${body.entries.length} entry(ies):\n\n` + lines.join('\n'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+11
-3
@@ -3,9 +3,17 @@
|
||||
// Everything important lives in the `main` volume (SQLite DB, which in turn
|
||||
// contains the signing key). StartOS's default backup mechanism captures
|
||||
// the whole volume, so we don't need custom backup logic — we just opt in.
|
||||
//
|
||||
// `setupBackups` returns `{ createBackup, restoreInit }`. `createBackup` is
|
||||
// the package-level backup export; `restoreInit` is an InitScript we chain
|
||||
// into `sdk.setupInit(...)` so that a restore triggers the right init
|
||||
// sequence after the volume is repopulated.
|
||||
//
|
||||
// NOTE: The JSDoc example in 0.4.0 shows `sdk.Backups.volumes('main')`, but
|
||||
// the actual runtime/type name is `ofVolumes`. The example is stale.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const { createBackup, restoreBackup } = sdk.setupBackups(async ({ effects }) => [
|
||||
sdk.Backups.volumes('main'),
|
||||
])
|
||||
export const { createBackup, restoreInit } = sdk.setupBackups(
|
||||
async () => sdk.Backups.ofVolumes('main'),
|
||||
)
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
// - prevent starting if BTCPay isn't installed,
|
||||
// - gate our service's health status on BTCPay's,
|
||||
// - provide the `btcpayserver.startos` hostname inside our container.
|
||||
//
|
||||
// versionRange uses ExVer (StartOS's Extended Versioning). The ':0' suffix
|
||||
// is the downstream revision; ':0' is the conventional value meaning "any
|
||||
// downstream revision of upstream version 1.11.0 or later".
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
|
||||
export const setDependencies = sdk.setupDependencies(async ({ effects: _effects }) => {
|
||||
return {
|
||||
btcpayserver: {
|
||||
kind: 'running',
|
||||
versionRange: '>=1.11.0',
|
||||
versionRange: '>=1.11.0:0',
|
||||
healthChecks: [],
|
||||
},
|
||||
}
|
||||
|
||||
+20
-16
@@ -3,27 +3,31 @@
|
||||
// between service starts (e.g., the generated admin API key so we don't
|
||||
// regenerate it on every restart).
|
||||
//
|
||||
// StartOS persists this JSON through upgrades and backs it up automatically.
|
||||
// StartOS persists this JSON through upgrades and backs it up automatically
|
||||
// (the file lives alongside the package data dir).
|
||||
//
|
||||
// In 0.4.0.x we model this with `FileHelper.json` + a Zod schema. Consumers
|
||||
// read via `store.read().once()` (fire-and-forget) or `store.read().const(effects)`
|
||||
// (re-runs the calling context if the file changes), and write with
|
||||
// `store.write(effects, data)` or `store.merge(effects, partial)`.
|
||||
|
||||
import { matches } from '@start9labs/start-sdk'
|
||||
import { FileHelper } from '@start9labs/start-sdk'
|
||||
import { z } from 'zod'
|
||||
|
||||
const { arr, num, obj, oneOf, literal, string } = matches
|
||||
|
||||
export const storeShape = obj({
|
||||
export const storeShape = z.object({
|
||||
// Admin API key for /v1/admin/* endpoints. Auto-generated on first init.
|
||||
admin_api_key: string,
|
||||
// Shared webhook secret configured on both sides (BTCPay + our service).
|
||||
btcpay_webhook_secret: string,
|
||||
admin_api_key: z.string(),
|
||||
// Shared webhook secret historically configured on both sides (BTCPay +
|
||||
// our service). Kept in the shape for backwards compatibility with
|
||||
// installs made before the one-click "Connect BTCPay" authorize flow; the
|
||||
// daemon now generates and persists its own webhook secret.
|
||||
btcpay_webhook_secret: z.string(),
|
||||
// Operator display name shown on the service homepage.
|
||||
operator_name: string,
|
||||
operator_name: z.string(),
|
||||
// Tracks which version's init has already been applied.
|
||||
schema_version: num,
|
||||
schema_version: z.number(),
|
||||
})
|
||||
|
||||
export type Store = typeof storeShape._TYPE
|
||||
export type Store = z.infer<typeof storeShape>
|
||||
|
||||
export const store = {
|
||||
shape: storeShape,
|
||||
// Defaults. Populated for real during init.
|
||||
path: 'store.json' as const,
|
||||
}
|
||||
export const store = FileHelper.json('store.json', storeShape)
|
||||
|
||||
+43
-16
@@ -1,29 +1,56 @@
|
||||
// StartOS entry point. Glues every module together so `start-cli` can pack
|
||||
// the package.
|
||||
// StartOS entry point. Composes every module together so `start-cli` can
|
||||
// pack the package and so StartOS can find the expected exports.
|
||||
//
|
||||
// The ABI StartOS expects (see ExpectedExports in the SDK):
|
||||
// - manifest
|
||||
// - main
|
||||
// - init
|
||||
// - uninit
|
||||
// - createBackup
|
||||
// - actions
|
||||
//
|
||||
// In SDK 0.4.0 `setupInit(...inits)` / `setupUninit(...uninits)` are variadic
|
||||
// — each argument is either an InitScript/UninitScript or an
|
||||
// InitFn/UninitFn. They run in the order provided.
|
||||
//
|
||||
// Ordering of init scripts matters:
|
||||
// 1. restoreInit — repopulates the main volume from backup if applicable
|
||||
// 2. versions — runs any pending migrations from the version graph
|
||||
// 3. initFn — our own first-boot key generation
|
||||
// 4. setDependencies — publishes our declared dependency on BTCPay
|
||||
// 5. setInterfaces — publishes the public-facing API + webhook URL
|
||||
// 6. actions — registers the admin actions with StartOS
|
||||
|
||||
import { buildManifest } from '@start9labs/start-sdk'
|
||||
import { sdk } from './sdk'
|
||||
|
||||
import { actions } from './actions'
|
||||
import { createBackup, restoreBackup } from './backups'
|
||||
import { createBackup, restoreInit } from './backups'
|
||||
import { setDependencies } from './dependencies'
|
||||
import { initFn, uninitFn } from './init'
|
||||
import { setInterfaces } from './interfaces'
|
||||
import { main } from './main'
|
||||
import { manifest } from './manifest'
|
||||
import { manifest as sdkManifest } from './manifest'
|
||||
import { versions } from './versions'
|
||||
|
||||
export const { packageInit, packageUninit, containerInit } = sdk.setupPackageInit({
|
||||
init: initFn,
|
||||
uninit: uninitFn,
|
||||
})
|
||||
// `setupManifest(...)` in `./manifest` produces the raw SDKManifest.
|
||||
// `buildManifest(versions, sdkManifest)` injects `version`, `sdkVersion`,
|
||||
// `releaseNotes`, `canMigrateTo/From`, normalized `alerts`, `images`
|
||||
// defaults, etc — producing the final T.Manifest that `start-cli s9pk pack`
|
||||
// serializes. Exporting the raw SDKManifest here (without buildManifest)
|
||||
// causes start-cli to fail with: `Deserialization Error: missing field
|
||||
// `version``.
|
||||
export const manifest = buildManifest(versions, sdkManifest)
|
||||
|
||||
export {
|
||||
manifest,
|
||||
main,
|
||||
actions,
|
||||
export const init = sdk.setupInit(
|
||||
restoreInit,
|
||||
versions,
|
||||
initFn,
|
||||
setDependencies,
|
||||
setInterfaces,
|
||||
createBackup,
|
||||
restoreBackup,
|
||||
versions,
|
||||
}
|
||||
actions,
|
||||
)
|
||||
|
||||
export const uninit = sdk.setupUninit(versions, uninitFn)
|
||||
|
||||
export { main, actions, createBackup }
|
||||
|
||||
+50
-7
@@ -1,24 +1,38 @@
|
||||
// First-boot initialization.
|
||||
//
|
||||
// On fresh install:
|
||||
// - Generate an admin API key (stored in the StartOS store; user can
|
||||
// retrieve it via an action if they need to script against the API).
|
||||
// - Generate an admin API key (stored in the StartOS package-local store;
|
||||
// user can retrieve it via the `showCredentials` action if they need to
|
||||
// script against the API).
|
||||
// - Surface "Connect BTCPay" as a critical task so the operator sees a
|
||||
// clear "do this next" prompt in the StartOS dashboard. Cleared by the
|
||||
// btcpayStatus action once BTCPay reports connected (see
|
||||
// ../actions/configureBtcpay.ts).
|
||||
//
|
||||
// The BTCPay webhook secret is no longer stored here — the daemon generates
|
||||
// and persists it in its own DB during the one-click "Connect BTCPay" flow.
|
||||
// The field is kept in the store shape for backward compatibility with
|
||||
// installs made before v0.1.0; it is not used.
|
||||
// installs made before the authorize flow; it is not authoritative.
|
||||
//
|
||||
// On subsequent boots this is a no-op (keys already exist).
|
||||
//
|
||||
// SDK 0.4.0 note: InitFn signature is `(effects, kind)` positional — NOT the
|
||||
// 0.3.x `({effects})` object destructure. `setupOnInit` wraps the function
|
||||
// into an InitScript so it can be composed with `setupInit(...)`.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { generateSecret } from '../utils'
|
||||
import { configureBtcpay } from '../actions/configureBtcpay'
|
||||
|
||||
export const initFn = sdk.setupOnInit(async ({ effects }) => {
|
||||
const current = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
/** Replay id used to dedupe + later-clear the BTCPay setup task. */
|
||||
export const BTCPAY_SETUP_TASK_ID = 'btcpay-initial-setup'
|
||||
|
||||
export const initFn = sdk.setupOnInit(async (effects, kind) => {
|
||||
const current = await store.read().once()
|
||||
|
||||
if (!current || current.schema_version === 0 || current.schema_version === undefined) {
|
||||
await sdk.store.setOwn(effects, sdk.StorePath, {
|
||||
await store.write(effects, {
|
||||
admin_api_key: current?.admin_api_key || generateSecret(32),
|
||||
// Kept in the shape for backcompat; no longer authoritative.
|
||||
btcpay_webhook_secret: current?.btcpay_webhook_secret || '',
|
||||
@@ -26,9 +40,38 @@ export const initFn = sdk.setupOnInit(async ({ effects }) => {
|
||||
schema_version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// Surface BTCPay setup as a prominent task on first install and on
|
||||
// restore (a backup older than the BTCPay-authorize flow may not have a
|
||||
// valid BTCPay config). On regular updates / container rebuilds we
|
||||
// skip — BTCPay should already be connected by then. createOwnTask is
|
||||
// idempotent on the same replayId, so a re-run won't duplicate.
|
||||
//
|
||||
// Severity is 'important', not 'critical', because 'critical' blocks
|
||||
// the service from STARTING until the task is completed — but the
|
||||
// configureBtcpay action requires the service to BE running (it makes
|
||||
// an HTTP call to the local daemon to kick off the authorize flow).
|
||||
// 'critical' would deadlock: task blocks start, action needs running.
|
||||
// 'important' shows the task prominently without blocking startup.
|
||||
if (kind === 'install' || kind === 'restore') {
|
||||
try {
|
||||
await sdk.action.createOwnTask(effects, configureBtcpay, 'important', {
|
||||
replayId: BTCPAY_SETUP_TASK_ID,
|
||||
reason:
|
||||
'Connect Keysat to your BTCPay Server to start selling licenses. ' +
|
||||
'Your BTCPay instance on this Start9 is already a declared ' +
|
||||
'dependency — Keysat just needs to authorize against it.',
|
||||
})
|
||||
} catch (e) {
|
||||
// Don't block init on a task-create failure. Operators can still
|
||||
// run "Connect BTCPay" manually.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('createOwnTask(configureBtcpay) failed:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const uninitFn = sdk.setupOnUninit(async ({ effects }) => {
|
||||
export const uninitFn = sdk.setupOnUninit(async (_effects, _target) => {
|
||||
// Nothing to tear down at the StartOS level — the DB volume is handled by
|
||||
// StartOS directly when the package is uninstalled.
|
||||
})
|
||||
|
||||
+53
-13
@@ -1,22 +1,30 @@
|
||||
// Network interfaces exposed by the service.
|
||||
//
|
||||
// Two logical interfaces:
|
||||
// Three logical interfaces, all sharing the same internal port (8080).
|
||||
// The Rust daemon routes by path, and StartOS uses the interface
|
||||
// concept for *access surfaces* and *display grouping*.
|
||||
//
|
||||
// - `api` — the REST API that buyers (purchase flow) and licensed
|
||||
// software (validate flow) hit. Must be reachable from
|
||||
// outside the StartOS host if you're selling to the public,
|
||||
// so we expose it on LAN + Tor + optional clearnet.
|
||||
// - `webhook` — the BTCPay webhook landing endpoint. Only BTCPay needs to
|
||||
// reach it; same-host LAN is sufficient.
|
||||
//
|
||||
// In practice both live on the same HTTP port (8080) because the service
|
||||
// routes by path. StartOS's interface concept is about *access surfaces*
|
||||
// and *display grouping*, not separate ports.
|
||||
// outside the host if you're selling to the public.
|
||||
// - `webhook` — the BTCPay webhook landing endpoint. Only BTCPay needs
|
||||
// to reach it; same-host LAN is sufficient.
|
||||
// - `admin-ui` — the embedded admin web UI (rust-embed at /admin/).
|
||||
// type: 'ui' so StartOS surfaces a "Launch UI" button.
|
||||
// Operator should restrict this interface's exposure
|
||||
// to LAN-only or Tor-only — the public clearnet
|
||||
// doesn't need to see it. (For v0.2 follow-up: split
|
||||
// onto a separate port so it can be fully isolated
|
||||
// from the public api.)
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
const apiMulti = sdk.MultiHost.of(effects, 'api-multi')
|
||||
await apiMulti.bindPort(8080, { protocol: 'http', preferredExternalPort: 443 })
|
||||
const multi = sdk.MultiHost.of(effects, 'api-multi')
|
||||
const origin = await multi.bindPort(8080, {
|
||||
protocol: 'http',
|
||||
preferredExternalPort: 443,
|
||||
})
|
||||
|
||||
const api = sdk.createInterface(effects, {
|
||||
name: 'Licensing API',
|
||||
@@ -26,7 +34,6 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
'the URL you share with customers and bake into your own software ' +
|
||||
'builds as the licensing endpoint.',
|
||||
type: 'api',
|
||||
hasPrimary: true,
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
@@ -34,5 +41,38 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
query: {},
|
||||
})
|
||||
|
||||
return [await api.export([apiMulti])]
|
||||
const webhook = sdk.createInterface(effects, {
|
||||
name: 'BTCPay webhook endpoint',
|
||||
id: 'webhook',
|
||||
description:
|
||||
'The landing URL for BTCPay webhook callbacks. Not intended for ' +
|
||||
'human use — Keysat registers this URL with BTCPay automatically ' +
|
||||
'during the one-click "Connect BTCPay" flow.',
|
||||
type: 'api',
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
path: '/btcpay',
|
||||
query: {},
|
||||
})
|
||||
|
||||
const adminUi = sdk.createInterface(effects, {
|
||||
name: 'Admin Web UI',
|
||||
id: 'admin-ui',
|
||||
description:
|
||||
'Embedded admin dashboard — manage products, policies, discount ' +
|
||||
'codes, licenses, machines, webhooks, and audit log without ' +
|
||||
'leaving the browser. Login is gated by your Keysat admin API key. ' +
|
||||
'Recommended: restrict this interface to LAN or Tor only; the ' +
|
||||
'public clearnet does not need to reach the admin UI.',
|
||||
type: 'ui',
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
path: '/admin',
|
||||
query: {},
|
||||
})
|
||||
|
||||
const receipt = await origin.export([api, webhook, adminUi])
|
||||
return [receipt]
|
||||
})
|
||||
|
||||
+202
-15
@@ -1,41 +1,228 @@
|
||||
// Daemon definition — the thing that actually runs when the service is
|
||||
// started. Passes configuration into the Rust binary via environment
|
||||
// variables, same interface as `.env.example` in the upstream project.
|
||||
// variables (same interface as `.env.example` in the upstream project).
|
||||
//
|
||||
// SDK 0.4.0 shape:
|
||||
// - `setupMain(async ({ effects }) => Daemons)` — no `started` any more.
|
||||
// - Mounts are built via `sdk.Mounts.of().mountVolume(...)` (immutable
|
||||
// builder) and passed as a single object to `sdk.SubContainer.of`.
|
||||
// - Daemons are created via `sdk.Daemons.of(effects)` (effects directly).
|
||||
// - Store reads use the FileHelper reactive API: `.read().const(effects)`
|
||||
// so the daemon re-runs if the store changes at runtime.
|
||||
// - The public URL is read from our own `api` service interface via
|
||||
// `sdk.serviceInterface.getOwn(...).const()` + `.addressInfo.nonLocal.format()`.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
import { store } from './fileModels/store'
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects, started }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
/**
|
||||
* Pick a URL from a service interface's address list that's actually
|
||||
* reachable from the operator's normal-LAN browser.
|
||||
*
|
||||
* StartOS hands us a list of URLs the service is reachable on, but they
|
||||
* vary in who-can-reach-them:
|
||||
* - mDNS `.local` hostname → reachable on the operator's LAN
|
||||
* - LAN RFC1918 IP (192.168, etc) → reachable on the operator's LAN
|
||||
* - public clearnet URL → reachable from anywhere
|
||||
* - StartTunnel local IP (10.59) → only reachable inside StartOS
|
||||
* - .startos bridge hostname → only reachable inside containers
|
||||
* - localhost / 127.x → only reachable inside the container
|
||||
*
|
||||
* Naively picking `addressInfo.nonLocal.format()[0]` can land on the
|
||||
* StartTunnel-local IP, which breaks any flow where the operator's
|
||||
* browser actually has to follow the URL. This helper ranks URLs by
|
||||
* realistic browser reachability instead.
|
||||
*/
|
||||
// Shared URL filters used by both pickers below.
|
||||
function isLocalhost(u: string): boolean {
|
||||
return (
|
||||
u.startsWith('http://localhost') ||
|
||||
u.startsWith('https://localhost') ||
|
||||
u.startsWith('http://127.') ||
|
||||
u.startsWith('https://127.')
|
||||
)
|
||||
}
|
||||
function isBridge(u: string): boolean {
|
||||
return u.includes('.startos:')
|
||||
}
|
||||
function isMdns(u: string): boolean {
|
||||
return /\/\/[^/:]+\.local(:|\/)/.test(u)
|
||||
}
|
||||
function isRfc1918(u: string): boolean {
|
||||
return (
|
||||
/\/\/192\.168\.\d+\.\d+(:|\/)/.test(u) ||
|
||||
/\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:|\/)/.test(u) ||
|
||||
// Real RFC1918 10.0.0.0/8 — but exclude StartTunnel's 10.59.x.x range
|
||||
// which is StartOS-internal and not reachable from a normal browser.
|
||||
(/\/\/10\.\d+\.\d+\.\d+(:|\/)/.test(u) && !/\/\/10\.59\./.test(u))
|
||||
)
|
||||
}
|
||||
function isStarttunnelLocal(u: string): boolean {
|
||||
return /\/\/10\.59\./.test(u)
|
||||
}
|
||||
function isIpv4(u: string): boolean {
|
||||
return /\/\/\d+\.\d+\.\d+\.\d+(:|\/)/.test(u)
|
||||
}
|
||||
function isIpv6Bracketed(u: string): boolean {
|
||||
return /\/\/\[/.test(u)
|
||||
}
|
||||
|
||||
// Public URL the service advertises to buyers / referenced in webhooks.
|
||||
// We read our own primary interface address from StartOS at runtime so
|
||||
// this works whether the operator exposes on Tor, LAN, or clearnet.
|
||||
const publicUrl = await sdk.serviceInterface
|
||||
.getOwn(effects, 'api')
|
||||
/// Pick a URL the OPERATOR's browser can reach during one-time setup
|
||||
/// flows (OAuth authorize, etc.). Operator is typically on the same
|
||||
/// LAN as the Start9, so mDNS / RFC1918 LAN URLs are preferred —
|
||||
/// they're faster and don't depend on Cloudflare being up.
|
||||
function pickBrowserUrl(
|
||||
allUrls: string[],
|
||||
addrInfo?: { nonLocal: { format(): string[] } } | null | undefined,
|
||||
): string | undefined {
|
||||
const browserUsable = (u: string) =>
|
||||
!isLocalhost(u) && !isBridge(u) && !isStarttunnelLocal(u)
|
||||
|
||||
const mdnsUrls = allUrls.filter((u) => isMdns(u) && browserUsable(u))
|
||||
if (mdnsUrls.length > 0) return mdnsUrls[0]
|
||||
const lanUrls = allUrls.filter((u) => isRfc1918(u) && browserUsable(u))
|
||||
if (lanUrls.length > 0) return lanUrls[0]
|
||||
const nonLocalUrls = (addrInfo?.nonLocal.format() ?? []).filter(browserUsable)
|
||||
if (nonLocalUrls.length > 0) return nonLocalUrls[0]
|
||||
const anyUsable = allUrls.filter(browserUsable)
|
||||
if (anyUsable.length > 0) return anyUsable[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
/// Pick a URL that BUYERS on the public internet can reach. Used to
|
||||
/// rewrite checkout URLs so they're browser-reachable from anywhere.
|
||||
/// Prefers domain-named URLs (clearnet via StartTunnel) over IP/mDNS
|
||||
/// addresses. Falls back to LAN/mDNS only if no public domain is set
|
||||
/// up — useful for local testing but won't work for real customers.
|
||||
function pickPublicUrl(allUrls: string[]): string | undefined {
|
||||
const usable = allUrls.filter(
|
||||
(u) => !isLocalhost(u) && !isBridge(u) && !isStarttunnelLocal(u),
|
||||
)
|
||||
// Prefer URLs with a real domain name (no IP, no .local).
|
||||
const clearnet = usable.filter(
|
||||
(u) => !isIpv4(u) && !isIpv6Bracketed(u) && !isMdns(u),
|
||||
)
|
||||
if (clearnet.length > 0) return clearnet[0]
|
||||
// Fall back to LAN (still browser-reachable for testing on the same network).
|
||||
const mdns = usable.filter(isMdns)
|
||||
if (mdns.length > 0) return mdns[0]
|
||||
const lan = usable.filter(isRfc1918)
|
||||
if (lan.length > 0) return lan[0]
|
||||
return usable[0]
|
||||
}
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects }) => {
|
||||
const storeData = await store.read().const(effects)
|
||||
if (!storeData) {
|
||||
// Init should always run before main, so this is a real error.
|
||||
throw new Error(
|
||||
'Keysat store.json is missing — init did not run. Try restarting the service.',
|
||||
)
|
||||
}
|
||||
|
||||
// Public URL advertised to buyers / baked into webhook payloads. We read
|
||||
// our own `api` interface from StartOS at runtime so this works whether the
|
||||
// operator exposes on Tor, LAN, or clearnet. `.nonLocal` filters out
|
||||
// localhost/link-local; we pick the first resulting URL, falling back to
|
||||
// localhost only if StartOS hasn't filled in the interface yet.
|
||||
// Pick a browser-reachable URL for ourselves. This is what we hand to
|
||||
// BTCPay as the OAuth redirect_uri (the operator's browser follows it
|
||||
// after clicking Authorize), and it's the URL buyers later use to
|
||||
// poll purchase status. Same ranking logic as for BTCPay's URL —
|
||||
// prefer mDNS .local and RFC1918 LAN IPs, deprioritize StartTunnel
|
||||
// local addresses (10.59.x.x), avoid localhost / bridge.
|
||||
const iface = await sdk.serviceInterface.getOwn(effects, 'api').const()
|
||||
const ownAllUrls = iface?.addressInfo?.format() ?? []
|
||||
// Use the PUBLIC-preferred picker for our own URL — buyers redirected
|
||||
// back from BTCPay after payment hit this URL with their browser; it
|
||||
// needs to be clearnet-resolvable. Falls back to the operator-facing
|
||||
// mDNS/LAN URL if no clearnet domain is set up.
|
||||
const publicUrl =
|
||||
pickPublicUrl(ownAllUrls) ??
|
||||
pickBrowserUrl(ownAllUrls, iface?.addressInfo) ??
|
||||
'http://localhost:8080'
|
||||
|
||||
// BTCPay's PUBLIC web UI URL — distinct from the internal-network
|
||||
// hostname we use for daemon-to-daemon API calls. The operator's
|
||||
// browser is redirected here to authorize Keysat against BTCPay; that
|
||||
// means the URL must be resolvable from a normal browser.
|
||||
//
|
||||
// We can't hardcode BTCPay's interface ID because it's package-
|
||||
// specific (and the previous version of this code guessed wrong by
|
||||
// assuming `'ui'`). Instead, fetch ALL interfaces BTCPay exposes,
|
||||
// pick the one whose TYPE is `'ui'`, and read its address list.
|
||||
// Within that, prefer non-local URLs but accept LAN URLs as a
|
||||
// fallback (they're perfectly browser-reachable for the operator).
|
||||
const btcpayIfaces = await sdk.serviceInterface
|
||||
.getAll(effects, { packageId: 'btcpayserver' })
|
||||
.const()
|
||||
.then((i) => i?.addressInfo?.urls?.[0] ?? 'http://localhost:8080')
|
||||
const ifaceList = btcpayIfaces ?? []
|
||||
const uiIface = ifaceList.find((i) => i.type === 'ui') ?? null
|
||||
const btcpayAllUrls = uiIface?.addressInfo?.format() ?? []
|
||||
const btcpayBrowserUrl = pickBrowserUrl(btcpayAllUrls, uiIface?.addressInfo) ?? ''
|
||||
// PUBLIC URL preference is different — for buyer-facing checkout
|
||||
// URLs we want a clearnet domain that random internet customers
|
||||
// can resolve. Falls back to the operator-facing browser URL (mDNS/
|
||||
// LAN) if no clearnet domain is set up; that's only useful for
|
||||
// local testing but won't break production.
|
||||
const btcpayPublicUrl = pickPublicUrl(btcpayAllUrls) ?? btcpayBrowserUrl
|
||||
console.info(
|
||||
`Keysat BTCPay lookup: ${ifaceList.length} interface(s) declared by btcpayserver. ` +
|
||||
`Types found: [${ifaceList.map((i) => `${i.id}:${i.type}`).join(', ')}]. ` +
|
||||
`Selected ui interface id="${uiIface?.id ?? '(none)'}". ` +
|
||||
`Picked browser URL "${btcpayBrowserUrl || '(none)'}". ` +
|
||||
`Picked public URL "${btcpayPublicUrl || '(none — falling back to internal URL)'}".`,
|
||||
)
|
||||
|
||||
const mounts = sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
mountpoint: '/data',
|
||||
subpath: null,
|
||||
readonly: false,
|
||||
})
|
||||
|
||||
const sub = await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
[{ mountpoint: '/data', volumeId: 'main', subpath: null, readonly: false }],
|
||||
mounts,
|
||||
'keysat',
|
||||
)
|
||||
|
||||
return sdk.Daemons.of({ effects, started, healthReceipts: [] }).addDaemon('primary', {
|
||||
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||
subcontainer: sub,
|
||||
exec: {
|
||||
// Use the Dockerfile's ENTRYPOINT / CMD instead of hardcoding a command
|
||||
// here; the image is the source of truth for how to launch the binary.
|
||||
command: sdk.useEntrypoint(),
|
||||
env: {
|
||||
KEYSAT_BIND: '0.0.0.0:8080',
|
||||
KEYSAT_DB_PATH: '/data/keysat.db',
|
||||
KEYSAT_PUBLIC_URL: publicUrl,
|
||||
KEYSAT_ADMIN_API_KEY: store.admin_api_key,
|
||||
KEYSAT_OPERATOR_NAME: store.operator_name,
|
||||
// Reachable because of our dependency on btcpayserver.
|
||||
KEYSAT_ADMIN_API_KEY: storeData.admin_api_key,
|
||||
KEYSAT_OPERATOR_NAME: storeData.operator_name,
|
||||
// Reachable because of our dependency on btcpayserver. This is
|
||||
// the INTERNAL hostname used for daemon-to-daemon API calls.
|
||||
// Keysat's container can't reliably reach the public StartTunnel
|
||||
// URL from outside (egress is restricted), so all BTCPay API
|
||||
// traffic stays on the local Docker network — fast + always
|
||||
// reachable. The downside (BTCPay returns checkout URLs with
|
||||
// this internal hostname) is mitigated in the daemon: we
|
||||
// rewrite the host of every checkout URL to the public
|
||||
// BTCPAY_BROWSER_URL before handing it back to a buyer.
|
||||
BTCPAY_URL: 'http://btcpayserver.startos:23000',
|
||||
// BTCPay's web UI URL for OPERATOR-facing browser redirects
|
||||
// (the OAuth-style authorize flow). Operator is on the same
|
||||
// LAN as the Start9 typically, so this prefers mDNS / LAN.
|
||||
BTCPAY_BROWSER_URL: btcpayBrowserUrl,
|
||||
// BTCPay's PUBLIC URL for BUYER-facing redirects. Used by the
|
||||
// daemon to rewrite checkout URLs returned by BTCPay so they
|
||||
// resolve from random internet browsers. Prefers clearnet
|
||||
// domain names (e.g. `https://btcpay.your-domain.com`); falls
|
||||
// back to LAN/mDNS only if no public domain is set up. If
|
||||
// empty, daemon won't rewrite (only useful for local testing).
|
||||
BTCPAY_PUBLIC_URL: btcpayPublicUrl,
|
||||
// The three credentials below are left empty in the normal case —
|
||||
// the daemon now persists them in its own DB after the one-click
|
||||
// the daemon persists them in its own DB after the one-click
|
||||
// "Connect BTCPay" action completes. Only seed them here if you are
|
||||
// migrating from a pre-authorize-flow install.
|
||||
BTCPAY_API_KEY: '',
|
||||
|
||||
+29
-22
@@ -1,9 +1,12 @@
|
||||
// StartOS package manifest. Run through `setupManifest()` from the SDK.
|
||||
//
|
||||
// NOTE: This service's source code is source-available but not open source
|
||||
// (see ../../../licensing-service/LICENSE). The `license` field here is
|
||||
// set to 'Proprietary' accordingly — StartOS displays this on the install
|
||||
// page so users know what they're installing.
|
||||
// NOTE: This service's source code is source-available but not open source.
|
||||
// The `license` field takes an SPDX identifier, and the actual license text
|
||||
// must live in a file named `LICENSE` at the package root (start-cli bundles
|
||||
// it as an ingredient). Since this project ships under a custom license, we
|
||||
// use the SPDX `LicenseRef-` prefix per the SPDX spec for non-standard
|
||||
// licenses. The `LICENSE` file at the package root is a copy of
|
||||
// `../licensing-service/LICENSE`.
|
||||
|
||||
import { setupManifest } from '@start9labs/start-sdk'
|
||||
import { short, long } from './i18n'
|
||||
@@ -11,14 +14,14 @@ import { short, long } from './i18n'
|
||||
export const manifest = setupManifest({
|
||||
id: 'keysat',
|
||||
title: 'Keysat',
|
||||
license: 'Proprietary',
|
||||
packageRepo: 'https://github.com/ten31/keysat-startos',
|
||||
upstreamRepo: 'https://github.com/ten31/keysat',
|
||||
marketingUrl: 'https://ten31.xyz/keysat',
|
||||
license: 'LicenseRef-Proprietary',
|
||||
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
|
||||
upstreamRepo: 'https://github.com/keysat-xyz/keysat',
|
||||
marketingUrl: 'https://keysat.xyz',
|
||||
donationUrl: null,
|
||||
docsUrls: [
|
||||
'https://github.com/ten31/keysat/blob/main/README.md',
|
||||
'https://github.com/ten31/keysat/blob/main/docs/INTEGRATION.md',
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/README.md',
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/docs/INTEGRATION.md',
|
||||
],
|
||||
description: { short, long },
|
||||
// A single data volume holds the SQLite database (which in turn holds the
|
||||
@@ -26,15 +29,14 @@ export const manifest = setupManifest({
|
||||
volumes: ['main'],
|
||||
images: {
|
||||
main: {
|
||||
// Built from the project's Dockerfile. The build context is the parent
|
||||
// `Licensing/` directory so the Dockerfile can COPY from the sibling
|
||||
// `licensing-service/` Rust source; a top-level .dockerignore keeps the
|
||||
// uploaded context small.
|
||||
// Built from the project's Dockerfile. Build context is this package
|
||||
// directory itself (the start-cli default). The Rust source is
|
||||
// exposed inside the package dir as `licensing-service/`, which is
|
||||
// a symlink to the sibling `../licensing-service/` repo so the
|
||||
// upstream sources stay in their natural location while the build
|
||||
// context stays self-contained.
|
||||
source: {
|
||||
dockerBuild: {
|
||||
workdir: '..',
|
||||
dockerfile: 'licensing-service-startos/Dockerfile',
|
||||
},
|
||||
dockerBuild: {},
|
||||
},
|
||||
arch: ['x86_64', 'aarch64'],
|
||||
},
|
||||
@@ -53,12 +55,17 @@ export const manifest = setupManifest({
|
||||
stop: null,
|
||||
},
|
||||
dependencies: {
|
||||
// DepInfo = { description, optional } & ({ metadata: {title, icon} } | { s9pk })
|
||||
// We use the s9pk form with `null` since we don't want to bundle a copy of
|
||||
// BTCPay's s9pk into our package just to extract its metadata at build time
|
||||
// — StartOS will pull the metadata from the installed instance at runtime.
|
||||
btcpayserver: {
|
||||
description: 'Required to receive Bitcoin payments and confirm settlement via webhook.',
|
||||
optional: false,
|
||||
metadata: {
|
||||
title: 'BTCPay Server',
|
||||
description: {
|
||||
en_US:
|
||||
'Required to receive Bitcoin payments and confirm settlement via webhook.',
|
||||
},
|
||||
optional: false,
|
||||
s9pk: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+8
-5
@@ -1,9 +1,12 @@
|
||||
// Re-export of the SDK pre-bound to our manifest and file models. Import
|
||||
// `sdk` from here everywhere else in `startos/` so every call benefits from
|
||||
// the typed narrowing of our package-specific store shape.
|
||||
// Re-export of the SDK pre-bound to our manifest. Import `sdk` from here
|
||||
// everywhere else in `startos/` so every call benefits from the typed
|
||||
// narrowing of our package-specific manifest.
|
||||
//
|
||||
// NOTE: In 0.4.0.x the SDK builder does not take a store — package-local
|
||||
// persistent state is now expressed through `FileHelper` (see
|
||||
// `./fileModels/store.ts`). We just bind the manifest here.
|
||||
|
||||
import { StartSdk } from '@start9labs/start-sdk'
|
||||
import { manifest } from './manifest'
|
||||
import { store } from './fileModels/store'
|
||||
|
||||
export const sdk = StartSdk.of().withManifest(manifest).withStore(store).build(true)
|
||||
export const sdk = StartSdk.of().withManifest(manifest).build(true)
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
// Version graph. The current version must be listed first; older versions
|
||||
// we can migrate from go in `other: [...]`. Passed as an InitScript into
|
||||
// `sdk.setupInit(...)` and `sdk.setupUninit(...)` so StartOS can run the
|
||||
// correct migrations on install / update / downgrade / restore.
|
||||
|
||||
import { VersionGraph } from '@start9labs/start-sdk'
|
||||
import { v0_1_0 } from './v0.1.0'
|
||||
|
||||
export const versions = { 'v0.1.0:0': v0_1_0 }
|
||||
export const versions = VersionGraph.of({
|
||||
current: v0_1_0,
|
||||
other: [],
|
||||
})
|
||||
|
||||
+135
-13
@@ -1,18 +1,140 @@
|
||||
// First version of the package. Migrations get added here as versions
|
||||
// increment. For v0.1.0 there's nothing to migrate because nothing exists
|
||||
// yet.
|
||||
// Current version of the package. Migrations get added here as versions
|
||||
// increment.
|
||||
//
|
||||
// Version-string format is ExVer: `<upstream>:<downstream>`. Downstream
|
||||
// revision is bumped for wrapper-only or daemon-only changes that don't
|
||||
// alter on-disk data shape (we use SQLite migrations for schema changes
|
||||
// rather than ExVer-level migrations).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v0_1_0 = sdk.Version.of({
|
||||
version: '0.1.0:0',
|
||||
releaseNotes: `Initial release:\n` +
|
||||
`- Core licensing API: products, purchase, validate, revoke.\n` +
|
||||
`- BTCPay Server integration with HMAC-verified webhooks.\n` +
|
||||
`- Ed25519-signed license keys (offline-verifiable).\n` +
|
||||
`- Admin actions in StartOS UI.\n`,
|
||||
export const v0_1_0 = VersionInfo.of({
|
||||
version: '0.1.0:24',
|
||||
releaseNotes: [
|
||||
`Alpha-iteration revision 24 of v0.1.0 — Apply-discount button on the buy page + delete discount codes from the admin UI.`,
|
||||
``,
|
||||
`Buy page (/buy/<slug>) — buyers can now click an "Apply" button next to the discount code input to preview the discount before committing. The price card updates with strikethrough on the original price, the new price, and a green tag showing the percent or sats off. If the code is a free_license type, the primary CTA flips from "Pay with Bitcoin" to "Redeem license" and skips the BTCPay path entirely on submit. Validation happens against a new public endpoint GET /v1/discount-codes/preview which checks existence/active/expiry/product/exhaustion and computes the discounted price WITHOUT consuming a redemption slot. Editing the code after Apply resets the price card.`,
|
||||
``,
|
||||
`Admin UI — discount codes table now has a Delete button next to Disable/Enable. Hard-deletes the code with a confirmation prompt. Backed by a new endpoint DELETE /v1/admin/discount-codes/:id that refuses with 409 Conflict if any redemptions reference the code (preserves the audit trail). Operators should keep using Disable for redeemed codes; Delete is for cleaning up codes that were created but never used.`,
|
||||
``,
|
||||
`New public endpoint: GET /v1/discount-codes/preview?code=…&product=… — used by the buy page Apply button. Returns {valid, code, kind, is_free, base_price_sats, discount_applied_sats, final_price_sats, amount_pct, message}. Same pricing math as /v1/purchase, kept in sync.`,
|
||||
``,
|
||||
`New admin endpoint: DELETE /v1/admin/discount-codes/:id — audit-logged as discount_code.delete; returns 409 Conflict with a clear message if the code has been redeemed.`,
|
||||
``,
|
||||
`Net effect: the buy page is now a single-form purchase flow that handles paid + free + discount-coded purchases without surprises, and the admin can prune mistakenly-created codes.`,
|
||||
``,
|
||||
`No DB schema changes since :23.`,
|
||||
``,
|
||||
`Alpha-iteration revision 23 of v0.1.0 — buyers actually receive their license after paying.`,
|
||||
``,
|
||||
`Three coordinated fixes:`,
|
||||
`1. KEYSAT_PUBLIC_URL is now picked using pickPublicUrl (clearnet preferred) instead of pickBrowserUrl (mDNS preferred). The daemon's own public URL needs to resolve from random buyer browsers, not just the operator's LAN.`,
|
||||
`2. purchase.rs now defaults BTCPay's redirect_url to {public_base_url}/thank-you?invoice_id=<internal-id> so BTCPay sends the buyer back to a Keysat page after payment. Internal invoice id is also used as the local row id (was previously a fresh UUID), so /v1/purchase/<internal_id> and /thank-you?invoice_id=<id> both resolve to the same row.`,
|
||||
`3. /thank-you completely rewritten as a buyer-facing license-display page. Reads ?invoice_id from the URL, polls /v1/purchase/<id> every 3 seconds, renders the license in a certificate-style card with a Copy button when issued. Polls for up to 12 minutes before giving up. Falls back to a friendly error if the invoice id is missing/invalid.`,
|
||||
``,
|
||||
`Net effect: after paying via BTCPay, buyers land on a Keysat-branded thank-you page that auto-displays their license key as soon as the BTCPay webhook fires and the daemon issues the license. No StartOS dashboard required — this is a pure end-buyer flow.`,
|
||||
``,
|
||||
`Database change: repo::create_invoice now takes the invoice id as a parameter (was previously self-generated). Backwards-compatible at the schema level.`,
|
||||
``,
|
||||
`Alpha-iteration revision 22 of v0.1.0 — buy page auto-handles free-license discount codes.`,
|
||||
``,
|
||||
`Before: pasting a discount code of kind 'free_license' on /buy/<slug> still tried to create a BTCPay invoice for the post-discount sat amount, which BTCPay rejected with "amount below dust threshold" for tiny amounts. Buyers had to manually curl /v1/redeem to actually use free codes.`,
|
||||
``,
|
||||
`Now: when a code is provided, the buy page tries POST /v1/redeem first. If the code is free_license type, the daemon issues a license directly with no payment leg and the page renders the license key inline in a certificate-style success card with a Copy button. If the code is percent or fixed_sats type, /v1/redeem returns "this code requires payment" and the page falls through to the standard BTCPay purchase flow with the code applied. Real code errors (unknown, expired, wrong product) surface to the buyer cleanly.`,
|
||||
``,
|
||||
`Net effect: free-license codes now Just Work via the normal buyer UI. Useful for press, beta testers, partners, the early-100-users plan, etc.`,
|
||||
``,
|
||||
`Alpha-iteration revision 21 of v0.1.0 — actually fix the buyer-facing checkout URL.`,
|
||||
``,
|
||||
`Bug found via :20 diagnostic logs: BtcpayProvider::create_invoice (the trait method) had the rewrite logic, but purchase.rs uses the compat shim state.btcpay_client() which returns the raw BtcpayClient and bypasses the trait entirely. Result: the rewrite was never reached, and buyers always got the internal Docker hostname.`,
|
||||
``,
|
||||
`Fix: apply the same rewrite_to_public helper inline in purchase.rs after BtcpayClient::create_invoice returns. Same diagnostic log lines now fire from the purchase code path. Eventually purchase.rs (and reconcile.rs, tipping.rs) will migrate fully to the PaymentProvider trait — that's a v0.3 cleanup. For now the rewrite happens in both places so the urgent buyer-facing bug is fixed.`,
|
||||
``,
|
||||
`Operator action: install, then make a fresh purchase. The new log line "purchase: checkout URL rewritten for buyer" with original/rewritten URLs should appear, and the Pay-with-Bitcoin redirect should land on \`https://btcpay.<your-domain>/i/...\`.`,
|
||||
``,
|
||||
`Alpha-iteration revision 20 of v0.1.0 — diagnostic logging on the BTCPay checkout-URL rewrite path.`,
|
||||
``,
|
||||
`On startup the daemon now logs the resolved \`btcpay_url\`, \`btcpay_browser_url\`, and \`btcpay_public_url\` so it's clear what the wrapper handed in. On every checkout-URL rewrite, BtcpayProvider logs the original URL, the rewritten URL, and the public_base used. If public_base is None (no rewrite), it logs a loud warning explaining what to check.`,
|
||||
``,
|
||||
`Use these logs to diagnose any remaining "buyer gets the internal .startos URL" issue: tail Keysat logs, kick a purchase, look for "checkout URL rewritten" (good) or "checkout URL NOT rewritten" (misconfig — wrapper or env var problem).`,
|
||||
``,
|
||||
`No code-flow changes since :19; pure observability bump.`,
|
||||
``,
|
||||
`Alpha-iteration revision 19 of v0.1.0 — buyer-facing checkout URLs now use clearnet domain instead of mDNS.`,
|
||||
``,
|
||||
`:18 added a checkout-URL host rewrite, but used the same URL-picker as the operator OAuth redirect — which prefers mDNS/LAN URLs (good for the operator on the same LAN as the Start9, useless for buyers on the public internet). The rewrite produced URLs like \`https://immense-voyage.local:49347/i/...\` that random buyers couldn't resolve.`,
|
||||
``,
|
||||
`:19 splits the pickers. New \`pickPublicUrl\` prefers domain-named clearnet URLs (e.g. \`https://btcpay.your-domain.com\`) over IP/mDNS, used specifically for buyer-facing checkout URL rewrites. \`pickBrowserUrl\` (operator OAuth flow) keeps preferring LAN/mDNS — operator is local, faster path. New env var \`BTCPAY_PUBLIC_URL\` plumbs the public-preferred URL into the daemon, and the BtcpayProvider's host-rewrite uses it instead of \`BTCPAY_BROWSER_URL\`.`,
|
||||
``,
|
||||
`Operator action: install, then Disconnect → Connect BTCPay one more time to refresh the active provider with the new public URL. After that, /buy/<slug> should produce checkout URLs at your clearnet BTCPay domain (e.g. \`https://btcpay.keysat.xyz/i/...\`) which buyers can actually open.`,
|
||||
``,
|
||||
`Falls back to the old behaviour (BTCPAY_BROWSER_URL = mDNS) only if no clearnet URL is configured for BTCPay — useful for local-only testing but won't produce working URLs for real customers.`,
|
||||
``,
|
||||
`Alpha-iteration revision 18 of v0.1.0 — proper fix for BTCPay checkout URLs (revert :17, rewrite at the boundary instead).`,
|
||||
``,
|
||||
`:17 changed BTCPAY_URL to BTCPay's public StartTunnel URL for API calls. That broke the OAuth Connect flow because the Keysat container can't reliably reach the public URL from outside (StartOS egress routing). Reverted that.`,
|
||||
``,
|
||||
`Better fix: keep API calls on the internal \`btcpayserver.startos:23000\` hostname (fast, always reachable). Then in the BtcpayProvider's create_invoice path, rewrite the checkout URL's host (scheme + host + port) from the internal one to BTCPay's public URL before returning to the buyer. Path/query/fragment are preserved. Buyers now get a working public URL; daemon-to-daemon API calls stay internal.`,
|
||||
``,
|
||||
`Operator action: install this version, then run Disconnect BTCPay → Connect BTCPay once to refresh stored connection state. After that, /buy/<slug> purchases should produce a checkout URL like \`https://btcpay.<your-domain>/i/...\` instead of \`btcpayserver.startos:23000/i/...\`.`,
|
||||
``,
|
||||
`New cargo dep: \`url = "2"\` (already transitively present via reqwest; now declared directly for the host-rewrite helper).`,
|
||||
``,
|
||||
`Alpha-iteration revision 17 of v0.1.0 — fix BTCPay URL handed to daemon (checkout URLs were broken for buyers).`,
|
||||
``,
|
||||
`Bug: BTCPAY_URL was hard-coded to the internal Docker hostname \`btcpayserver.startos:23000\`. When Keysat created an invoice via BTCPay's API at that URL, BTCPay generated a checkout URL using the same internal hostname — and any buyer hitting that checkout URL got a "Server Not Found" error because \`.startos\` only resolves on the local Start9.`,
|
||||
``,
|
||||
`Fix: BTCPAY_URL now defaults to BTCPay's PUBLIC URL (the same URL used for browser redirects during the authorize flow). API calls cost a small out-and-back through StartTunnel per invoice — invoice creation is rare and the URL correctness wins. Falls back to the internal URL if the public URL hasn't been enumerated yet.`,
|
||||
``,
|
||||
`After installing this version, run Disconnect BTCPay → Connect BTCPay once to refresh the stored connection state, then test with a fresh /buy/<slug> purchase. The checkout URL should now be \`https://btcpay.<your-domain>/i/...\` instead of \`btcpayserver.startos:23000/i/...\`.`,
|
||||
``,
|
||||
`Alpha-iteration revision 16 of v0.1.0 — public buyer-facing purchase page.`,
|
||||
``,
|
||||
`New route: GET /buy/:slug. Server-renders a Keysat-branded HTML page for a given product slug — name, description, price-in-sats, optional email + discount code form, "Pay with Bitcoin" button. The button POSTs via JS to /v1/purchase, gets the BTCPay checkout URL, redirects the buyer there. After payment BTCPay returns them to /thank-you (existing handler).`,
|
||||
``,
|
||||
`Inlined navy/cream/gold styling matches the rest of the Keysat brand. Self-contained — no asset hosting required. 404 for inactive or missing slugs with a friendly explanation page.`,
|
||||
``,
|
||||
`Operator's "buy URL to share with customers" is now: https://<keysat-host>/buy/<product-slug>. Update marketing copy / install docs to point at this URL.`,
|
||||
``,
|
||||
`Alpha-iteration revision 15 of v0.1.0 — admin-only issuer-key import endpoint for master-Keysat bootstrap.`,
|
||||
``,
|
||||
`New endpoint: POST /v1/admin/import-issuer-key. Accepts a PEM-encoded Ed25519 private key in the request body, validates it, and upserts into the server_keys table replacing the auto-generated keypair. Refuses if any licenses have already been issued (safety guard against accidentally invalidating customer keys). Audit-logged. Restart the service after a successful import for the new keypair to take effect.`,
|
||||
``,
|
||||
`Why this isn't a StartOS Action: it'd clutter every operator's UI to serve a one-time setup for the single master operator. Documented in MASTER_KEYPAIR_PROCEDURE.md as the canonical bootstrap path. Curl during master-Keysat setup, never touched by the 95% of operators selling their own software.`,
|
||||
``,
|
||||
`No DB schema changes. No new dependencies.`,
|
||||
``,
|
||||
`Alpha-iteration revision 14 of v0.1.0 — Marketplace icon updated to the new Keysat brand mark (gold key on a navy-bordered certificate). Cosmetic only — no code or schema changes since :13.`,
|
||||
``,
|
||||
`Alpha-iteration revision 13 of v0.1.0 — PaymentProvider abstraction (Phase 1 of multi-provider work).`,
|
||||
``,
|
||||
`Refactor only — no user-visible behavior change. Sets up v0.3 to add Zaprite as a second payment provider alongside BTCPay without parallel code paths.`,
|
||||
``,
|
||||
`New module 'src/payment/' defines a PaymentProvider trait with create_invoice / get_invoice_status / validate_webhook / pay_lightning_invoice methods. BtcpayProvider is the first impl, wrapping the existing BtcpayClient and HMAC webhook secret. The webhook handler now dispatches through the trait — same BTCPay flow, but the abstraction is exercised end-to-end so we know the design holds before Zaprite arrives.`,
|
||||
``,
|
||||
`AppState replaces its 'btcpay' field with 'payment: Arc<RwLock<Option<Arc<dyn PaymentProvider>>>>'. Existing BTCPay-specific call sites (purchase, reconcile, tipping) unchanged; they go through compat accessors that downcast the trait object back to BtcpayProvider. Those compat accessors retire in v0.3 as the call sites migrate.`,
|
||||
``,
|
||||
`New cargo dep: async-trait (for object-safe async methods on the new trait).`,
|
||||
``,
|
||||
`No DB schema changes vs :12.`,
|
||||
``,
|
||||
`Earlier in the v0.1.0 line:`,
|
||||
`:12 — Tip-recipient on policy + Support development footer link.`,
|
||||
`:11 — Keysat-licenses-Keysat dogfooded; daemon embeds master pubkey, verifies /data/keysat-license.txt at boot; new "Activate Keysat license" + "Show license status" StartOS actions.`,
|
||||
`:10 — admin web UI restyled in Keysat brand (navy/cream/gold).`,
|
||||
`:9 — admin web UI made functional; Actions trimmed to setup-only.`,
|
||||
`:8 — embedded admin SPA scaffolding (placeholder).`,
|
||||
`:7 — operator-name live-reload; idempotent Connect BTCPay; Disconnect action; payment-method check.`,
|
||||
`:6 — CSRF state encoded inside redirect URL.`,
|
||||
`:5 — URL ranking applied to our own public URL.`,
|
||||
`:4 — URL ranking by browser-reachability for BTCPay's URL.`,
|
||||
`:3 — getAll() over BTCPay interfaces, filter by type='ui'.`,
|
||||
`:2 — broader BTCPay URL filter for LAN-only setups.`,
|
||||
`:1 — kebab-case action IDs; task severity 'important'; root in container; BTCPAY_BROWSER_URL plumbing.`,
|
||||
`:0 — initial release.`,
|
||||
].join('\n'),
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
up: async () => {},
|
||||
down: async () => {},
|
||||
},
|
||||
})
|
||||
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"outDir": "javascript",
|
||||
"rootDir": "startos"
|
||||
},
|
||||
"include": ["startos/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user