Compare commits
46 Commits
a3662de6d8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc08230f72 | |||
| 1faab61098 | |||
| fd71b19f86 | |||
| 97bf9cc843 | |||
| 9f08a72619 | |||
| b68bb4b882 | |||
| c739d5c515 | |||
| 46972be9db | |||
| 0a6d73aa29 | |||
| 241478af95 | |||
| 51a88f2a2f | |||
| 554f3b2da0 | |||
| 4755639bdc | |||
| eafdc6646e | |||
| 8c5cdb6468 | |||
| b6758cf30a | |||
| a507cfa978 | |||
| c673b10a94 | |||
| 8eb4a97c6f | |||
| be8688de80 | |||
| 7a1c70ab9b | |||
| 3afac078d4 | |||
| 069cf1eb40 | |||
| d5885d1d97 | |||
| 6b02992013 | |||
| d2846ac6ae | |||
| b088bfc062 | |||
| 5cf56007f0 | |||
| 5fc2c4516f | |||
| ca32309ad9 | |||
| 0508690d5a | |||
| 495fbbf351 | |||
| 783372c03b | |||
| 8c4baccf6b | |||
| 31f4670efa | |||
| b17565bdcb | |||
| 8bf3d646ab | |||
| 89f1b89705 | |||
| 9df1908328 | |||
| cf251fc63f | |||
| 7c4dfbacd2 | |||
| 04e0dcd591 | |||
| 4cde540b60 | |||
| fea6995192 | |||
| c71345f002 | |||
| 17d5df72d3 |
+30
-10
@@ -57,6 +57,7 @@ API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars>
|
||||
| `read-only` | List / get every resource. Mutate nothing. |
|
||||
| `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. |
|
||||
| `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. |
|
||||
| `merchant-onboard` | All `read-only` scopes + `products:write` + `policies:write` + `licenses:write` — the least-privilege credential for standing up a fresh catalog (create products, define policies/tiers, issue licenses against them) via the API. Deliberately excludes the support writes (subscriptions / machines) and every master-only gate. Tier caps still bound it. |
|
||||
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
|
||||
|
||||
Endpoints that touch settings (operator name, payment provider connections,
|
||||
@@ -138,7 +139,7 @@ upgrade CTA without parsing message strings.
|
||||
| `expired` | Past `expires_at` |
|
||||
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
|
||||
| `product_mismatch` | License is for a different product than the caller asserted |
|
||||
| `machine_cap_exceeded` | Activating this fingerprint would exceed `max_machines` |
|
||||
| `too_many_machines` | Activating this fingerprint would exceed `max_machines` |
|
||||
|
||||
---
|
||||
|
||||
@@ -170,10 +171,12 @@ Scope required: `licenses:write` (any role except `read-only`).
|
||||
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
|
||||
-H "Authorization: Bearer ks_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason":"refund issued"}'
|
||||
-d '{"reason":"customer request"}'
|
||||
```
|
||||
|
||||
Idempotent. The next online validate from the buyer's app returns `reason: revoked`.
|
||||
The next online validate from the buyer's app returns `reason: revoked`. Not
|
||||
idempotent — a second revoke of the same license returns `404 not_found` (treat
|
||||
as success-equivalent on retry; see Idempotency below).
|
||||
|
||||
Scope required: `licenses:write`.
|
||||
|
||||
@@ -277,10 +280,16 @@ A few patterns that work well in practice.
|
||||
|
||||
### Idempotency
|
||||
|
||||
The daemon's mutation endpoints are idempotent where they can be. Revoke,
|
||||
suspend, unsuspend, archive, unarchive, subscription cancel — all return
|
||||
success on the second call without changing state. Your agent can safely
|
||||
retry on network errors.
|
||||
The daemon's mutation endpoints are idempotent where they can be. Suspend,
|
||||
unsuspend, archive, unarchive, subscription cancel — all return success on the
|
||||
second call without changing state. Your agent can safely retry on network
|
||||
errors.
|
||||
|
||||
One exception: **revoke is not idempotent** — revoking an already-revoked
|
||||
license returns `404 not_found` (the row no longer matches the
|
||||
`status != 'revoked'` update guard). When retrying a revoke after an ambiguous
|
||||
network failure, treat a `404` as success-equivalent: the license is already
|
||||
revoked.
|
||||
|
||||
### Pagination
|
||||
|
||||
@@ -349,15 +358,26 @@ Some operations are deliberately operator-only and not accessible to any
|
||||
scoped key, including `full-admin`:
|
||||
|
||||
- Generating / revoking scoped API keys (`/v1/admin/api-keys`)
|
||||
- Connecting / disconnecting payment providers
|
||||
- Disconnecting a payment provider, and connecting *any* provider on a
|
||||
production daemon
|
||||
- Setting the operator name
|
||||
- Activating the self-license (`/v1/admin/self-license`)
|
||||
- Resetting the analytics install_uuid
|
||||
- Changing the web UI password (StartOS Action only)
|
||||
|
||||
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
|
||||
that can rotate its own credentials, connect arbitrary payment processors, or
|
||||
change the operator identity is no longer bounded by the role it was given.
|
||||
that can rotate its own credentials, redirect settled payments, or change the
|
||||
operator identity is no longer bounded by the role it was given.
|
||||
|
||||
**One narrow exception — agent-delegated payment connect.** A key granted the
|
||||
à-la-carte `payment_providers:write` scope (never granted by any role —
|
||||
operators add it explicitly per key) CAN initiate a BTCPay connect, but only
|
||||
fail-closed under two gates: the daemon must be in **sandbox mode** (an outer
|
||||
gate — scoped connect is refused outright on a production daemon, even for
|
||||
regtest), and the target store must be **non-mainnet** (an inner gate enforced
|
||||
after the OAuth round-trip). Disconnecting a provider, and any connect on a
|
||||
production / mainnet daemon, remain master-only. This lets an integrating agent
|
||||
wire up a throwaway sandbox without ever touching a live store's settlement.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,4 +15,9 @@
|
||||
#
|
||||
# Chain targets when needed: `make clean arm install`.
|
||||
|
||||
# Only x86_64 and aarch64 are supported and declared in the manifest. The shared
|
||||
# s9pk.mk defaults ARCHES to include riscv; override it here so a bare `make`
|
||||
# (which builds every arch in ARCHES) does not attempt an unverified riscv build.
|
||||
ARCHES := x86 arm
|
||||
|
||||
include s9pk.mk
|
||||
|
||||
@@ -80,10 +80,10 @@ comp keys, beta access, or "first N users free" launch promos.
|
||||
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`.
|
||||
is statically compiled against musl (target `*-unknown-linux-musl`), and the
|
||||
runtime stage is `debian:bookworm-slim` with `ca-certificates`, `tini` (init /
|
||||
signal handling), and `sqlite3` (an SQL shell for occasional admin tasks)
|
||||
installed. 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
|
||||
@@ -105,25 +105,24 @@ 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
|
||||
2. Resolve the auto-created **important task** "Connect BTCPay" — open
|
||||
the embedded admin web UI (**Settings → Payment providers**) and
|
||||
click **Connect BTCPay**. 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. The install task
|
||||
clears automatically once BTCPay reports connected.
|
||||
3. 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. Multi-tier ladders
|
||||
4. Create one or more **products** — each represents something you sell.
|
||||
5. Create at least one **policy** per product. Multi-tier ladders
|
||||
(Basic / Pro / Max) are first-class: when a product has two or more
|
||||
public policies, the buy page renders a tier picker and the buyer
|
||||
chooses before paying. Policies define duration, grace period, seat
|
||||
cap, entitlements, recurring cadence, trial flag, price overrides,
|
||||
marketing bullets, and per-entitlement hide-on-buy-page toggles.
|
||||
7. Optionally create **discount / referral / free-license codes** (see
|
||||
`Create discount code` action).
|
||||
8. Share the public service URL with buyers.
|
||||
6. Optionally create **discount / referral / free-license codes** in the
|
||||
admin web UI.
|
||||
7. Share the public service URL with buyers.
|
||||
|
||||
## Configuration Management
|
||||
|
||||
@@ -145,7 +144,7 @@ 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. |
|
||||
| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically when you connect BTCPay in the admin web UI; 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
|
||||
@@ -153,44 +152,23 @@ purchase page) hardcode `https://`.
|
||||
|
||||
## Actions (StartOS UI)
|
||||
|
||||
Grouped as displayed in the dashboard.
|
||||
The StartOS Actions tab is intentionally minimal — only the four operations
|
||||
that must happen outside the embedded admin web UI are registered as actions:
|
||||
|
||||
**General**
|
||||
- *Set operator name* — your public-facing brand.
|
||||
- *Set web UI password* — set / recover the admin SPA login password (you
|
||||
can't reset it from inside the UI if you're locked out).
|
||||
- *Show credentials* — reveal the admin API key on first install, before
|
||||
you've logged into the admin UI.
|
||||
- *Activate Keysat license* — first-install bootstrap for paid self-hosting
|
||||
tiers, and recovery if `/data/keysat-license.txt` is lost.
|
||||
- *Show license status* — sanity-check the self-license state without
|
||||
logging into the admin UI.
|
||||
|
||||
**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 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.
|
||||
Everything else — connecting BTCPay (and Zaprite), operator name, products,
|
||||
policies, discount / referral / free-license codes, licenses, machines,
|
||||
outbound webhooks, scoped API keys, and the audit log — lives in the embedded
|
||||
**admin web UI** (Settings tab + the workspace sidebar), not as StartOS
|
||||
actions.
|
||||
|
||||
## Backups and Restore
|
||||
|
||||
@@ -228,7 +206,7 @@ Known current limitations:
|
||||
- **No bulk / volume licensing UI.** "Buy 10 keys at once with discount" is not built into the buy page. Operators can issue N comp licenses via the admin API in a loop.
|
||||
- **Webhook delivery retries are bounded.** A subscriber down past the 10-attempt retry window lands in the dead-letter queue (visible in admin UI → Webhooks → Failed). 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. The fingerprint is bound on first activate and enforced thereafter.
|
||||
- **Card payments not shipped.** The Zaprite payment provider is in design for v0.3 — operators on Pro / Patron will get a card-payment option alongside BTCPay. Until then, payments are BTC / Lightning only.
|
||||
- **Card payments via Zaprite are gated.** Zaprite ships as an optional second payment provider (card / fiat alongside BTCPay) but is gated by the `zaprite_payments` entitlement — operators on the tiers that grant it can connect Zaprite in the admin web UI. BTCPay remains the required provider; without the entitlement, payments are BTC / Lightning only.
|
||||
|
||||
## What Is Unchanged from Upstream
|
||||
|
||||
@@ -257,7 +235,7 @@ service:
|
||||
marketingUrl: https://keysat.xyz
|
||||
image:
|
||||
source: dockerBuild
|
||||
baseImage: scratch (musl-static Rust binary)
|
||||
baseImage: debian:bookworm-slim (musl-compiled Rust binary; bundles ca-certificates, tini, sqlite3)
|
||||
arches: [x86_64, aarch64]
|
||||
volumes:
|
||||
- id: main
|
||||
@@ -292,10 +270,10 @@ backups:
|
||||
firstRun:
|
||||
tasks:
|
||||
- id: btcpay-initial-setup
|
||||
severity: critical
|
||||
severity: important
|
||||
runs: configureBtcpay
|
||||
features:
|
||||
paymentRail: btcpay-server # zaprite planned for v0.3 (card payments)
|
||||
paymentProviders: [btcpay-server, zaprite] # btcpay required; zaprite optional, gated by the zaprite_payments entitlement
|
||||
signing: ed25519
|
||||
offlineVerification: true
|
||||
multiSeat: true
|
||||
@@ -315,7 +293,7 @@ features:
|
||||
outboundWebhooks: true
|
||||
webhookDlq: true # failed deliveries retryable from admin UI
|
||||
auditLog: true
|
||||
scopedApiKeys: [read-only, license-issuer, support, full-admin]
|
||||
scopedApiKeys: [read-only, license-issuer, support, merchant-onboard, full-admin]
|
||||
openapiSpec: /v1/openapi.json
|
||||
selfLicensingTier: [Creator, Pro, Patron]
|
||||
sdks:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,87 @@
|
||||
# Keysat Licensing — Instructions
|
||||
|
||||
Keysat is a Bitcoin-native, self-hosted licensing service for software
|
||||
creators. You run your own instance, hold your own signing key, and issue
|
||||
Ed25519-signed license keys that your software verifies offline. There is no
|
||||
central authority and no shared database.
|
||||
|
||||
## Before you start
|
||||
|
||||
- **BTCPay Server is required.** Install and start BTCPay Server first — Keysat
|
||||
uses it to take Bitcoin/Lightning payments and confirm settlement. StartOS
|
||||
lists this dependency before it lets you install Keysat.
|
||||
- **A clearnet domain is recommended if you sell to the public**, so buyers
|
||||
anywhere can reach your checkout. LAN/Tor-only works for testing.
|
||||
- **Zaprite is optional** (adds card payments). You connect it later from inside
|
||||
the admin web UI; nothing to do up front.
|
||||
|
||||
## First-time setup
|
||||
|
||||
1. **Get your admin API key.** Open the **Actions** tab and run
|
||||
**Show admin API key**. Copy it — you sign into the admin web UI with it the
|
||||
first time.
|
||||
2. **Open the admin dashboard.** Click **Launch UI** on the **Admin Web UI**
|
||||
interface and paste the admin API key to sign in.
|
||||
3. **(Recommended) Set a real password.** Run the **Set web UI password** action
|
||||
(Actions tab, minimum 12 characters). After this the login page shows a
|
||||
password field; the admin API key keeps working for automation.
|
||||
4. **Connect your payment provider.** In the admin web UI's Settings, use the
|
||||
one-click **Connect BTCPay** flow to authorize Keysat against your BTCPay
|
||||
Server. (Optionally connect Zaprite here too.)
|
||||
5. **Set your operator name** in the admin web UI — it appears on buyer-facing
|
||||
checkout and receipts.
|
||||
6. **Create what you sell.** Use **Create product** for each item, and
|
||||
optionally **Create policy** to set per-product defaults (duration, grace
|
||||
period, entitlements, seat cap, trial flag). A policy slugged `default` is the
|
||||
one the public purchase flow uses.
|
||||
|
||||
Activation is optional. Keysat runs out of the box at the free **Creator** tier
|
||||
(up to 5 products, 5 policies per product, and 10 active discount codes).
|
||||
Activating a license lifts those caps and unlocks recurring billing and Zaprite
|
||||
(card) payments. To activate, get a key at
|
||||
[registry.keysat.xyz](https://registry.keysat.xyz), run the **Activate Keysat
|
||||
license** action, and confirm with **Show Keysat license status**.
|
||||
|
||||
## Selling licenses
|
||||
|
||||
Share your **Licensing API** URL with buyers and bake it into your software as
|
||||
the validation endpoint. Buyers call `POST /v1/purchase`, pay via BTCPay, and
|
||||
Keysat issues a signed license key. Your software validates keys against
|
||||
`POST /v1/validate` — including revocation checks, which return
|
||||
`ok: false` with `reason: "revoked"`.
|
||||
|
||||
The same admin web UI covers manual license issuance (comps, press, trials),
|
||||
suspension/unsuspension, revocation, machine management, discount codes,
|
||||
outbound webhooks, and the audit log.
|
||||
|
||||
## Interfaces and exposure
|
||||
|
||||
- **Licensing API** (`/`) — public-facing. This is the URL you share with
|
||||
customers and bake into your builds.
|
||||
- **Admin Web UI** (`/admin`) — your dashboard. Restrict this interface to LAN or
|
||||
Tor only; the public internet does not need to reach it.
|
||||
- **BTCPay webhook endpoint** (`/btcpay`) — registered with BTCPay automatically
|
||||
during the Connect BTCPay flow. Not for human use.
|
||||
|
||||
## Backups and uninstalling
|
||||
|
||||
Your data volume holds the SQLite database — which contains your server signing
|
||||
key and every license record — and StartOS backs it up automatically. Your
|
||||
self-license at `/data/keysat-license.txt` is included in the backup and
|
||||
survives upgrades and reinstalls.
|
||||
|
||||
**Uninstalling deletes your signing key and all license records.** Once it is
|
||||
gone, previously issued license keys no longer validate against this server. Back
|
||||
up first if you plan to reinstall.
|
||||
|
||||
## Recovery
|
||||
|
||||
- **Locked out of the admin UI?** Run **Set web UI password** to set a new one,
|
||||
or **Show admin API key** to sign in with the key.
|
||||
- **Lost your Keysat license?** Re-run **Activate Keysat license** with your key.
|
||||
|
||||
## More
|
||||
|
||||
Full developer and integration documentation lives in the upstream repository
|
||||
(`README.md` and `KEYSAT_INTEGRATION.md`) and at
|
||||
[keysat.xyz](https://keysat.xyz).
|
||||
+21
-38
@@ -56,44 +56,21 @@ 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
|
||||
```
|
||||
The daemon source lives under `src/`, organized by subsystem (browse it for the current layout — the tree below has grown well past the v0.1 snapshot):
|
||||
|
||||
- `main.rs`, `config.rs`, `error.rs`, `models.rs` — entry point, env-driven config, error → HTTP mapping, shared domain types.
|
||||
- `crypto/` — the LIC1 license-key byte layout and Ed25519 sign/verify (the contract the four SDKs implement).
|
||||
- `db/` — SQLite pool, migrations, and `repo.rs` (all SQL). `migrations/` holds the numbered, additive schema (0001 through the latest; the schema has grown substantially since 0001).
|
||||
- `payment/` (`btcpay/`, `zaprite/`) + `merchant_profiles.rs` — the payment-provider abstraction and multi-profile routing.
|
||||
- `reconcile.rs`, `subscriptions.rs`, `upgrades.rs` — the background worker (invoice reconciliation, recurring renewals, tier upgrades).
|
||||
- `api/` — the ~30 route modules: public (`products`, `purchase`, `validate`, `redeem`) and admin (`admin*`, scoped API keys, webhooks, etc.), plus the router and `AppState` in `api/mod.rs`.
|
||||
- `web/index.html` — the embedded admin SPA.
|
||||
|
||||
Deeper docs: [`docs/API.md`](docs/API.md), [`docs/INTEGRATION.md`](docs/INTEGRATION.md), [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
## Running locally
|
||||
|
||||
Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted).
|
||||
Prerequisites: Rust 1.88+ (the build toolchain; the crate's Cargo.toml still declares MSRV 1.75, but the dependency tree now requires a newer compiler), a BTCPay Server instance you can point at (local or hosted).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -109,7 +86,7 @@ On first boot the server generates a fresh Ed25519 keypair and stores it in the
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/admin/products \
|
||||
-H "Authorization: Bearer $LICENSING_ADMIN_API_KEY" \
|
||||
-H "Authorization: Bearer $KEYSAT_ADMIN_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"slug": "my-app",
|
||||
@@ -143,7 +120,7 @@ curl -X POST http://localhost:8080/v1/validate \
|
||||
|
||||
## 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:
|
||||
The StartOS wrapper lives in **this same repository** under `../startos/` (this `licensing-service/` directory is the daemon source it bundles). Build the `.s9pk` for the 0.4.0.x platform from the parent directory — see the build/release guide and `../Makefile`. 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.
|
||||
@@ -170,4 +147,10 @@ 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.2.0 — shipped and in production. The current feature set:
|
||||
|
||||
- **Four published SDKs** — TypeScript (npm), Rust (crates.io), Python (PyPI), and Go — all wire-compatible against the cross-check fixtures in `tests/crosscheck/`.
|
||||
- **StartOS wrapper included in this repo** under `../startos/`; build the `.s9pk` from the parent directory (no separate wrapper repository).
|
||||
- **Embedded admin SPA** (`web/index.html`) for all day-to-day operations.
|
||||
- **Subscriptions** (recurring auto-renew with trials + grace), **policies / tiers** with per-policy entitlements, **discount / referral / free-license codes**, **outbound webhooks** with a dead-letter queue, and a background **invoice reconciliation** job that recovers dropped payment webhooks.
|
||||
- **Payment providers**: BTCPay Server is required; Zaprite (card / fiat) is optional and gated by the `zaprite_payments` entitlement.
|
||||
|
||||
@@ -6,7 +6,7 @@ All endpoints are JSON in / JSON out. Errors return a body of the form:
|
||||
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
|
||||
```
|
||||
|
||||
Admin endpoints require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
|
||||
Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +19,7 @@ Service metadata including the Ed25519 public key. Useful for SDKs to fetch the
|
||||
```json
|
||||
{
|
||||
"service": "keysat",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"operator": "Acme Software",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
|
||||
"key_algorithm": "ed25519",
|
||||
@@ -128,7 +128,7 @@ On failure:
|
||||
{ "ok": false, "reason": "revoked" }
|
||||
```
|
||||
|
||||
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `product_mismatch`, `fingerprint_mismatch`.
|
||||
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`, `expired`, `product_mismatch`, `fingerprint_mismatch`, `too_many_machines` (multi-seat cap reached).
|
||||
|
||||
### `POST /v1/btcpay/webhook`
|
||||
|
||||
@@ -138,7 +138,7 @@ Landing point for BTCPay Server webhook events. Only BTCPay should call this. We
|
||||
|
||||
## Admin endpoints
|
||||
|
||||
All of these require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
|
||||
All of these require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`.
|
||||
|
||||
### `POST /v1/admin/products`
|
||||
|
||||
|
||||
@@ -12,13 +12,18 @@
|
||||
|
||||
## Data model
|
||||
|
||||
See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables:
|
||||
The schema lives in [`migrations/`](../migrations/) as numbered, additive
|
||||
migrations (0001 through the latest — it has grown substantially past the
|
||||
original five-table v0.1 schema, adding discount codes, tiered pricing,
|
||||
multi-currency, subscriptions, tier upgrades, per-product entitlement catalogs,
|
||||
scoped API keys, merchant profiles, and more). The core tables established in
|
||||
[`0001_initial.sql`](../migrations/0001_initial.sql):
|
||||
|
||||
- `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.
|
||||
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns. Later migrations add `expires_at`, entitlements, trial flag, and tier 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).
|
||||
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot.
|
||||
|
||||
## License key format
|
||||
|
||||
@@ -28,7 +33,7 @@ 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)?
|
||||
Why base32 (RFC 4648, 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.
|
||||
@@ -58,14 +63,12 @@ Who might attack this?
|
||||
|
||||
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
|
||||
## Deliberately out of scope
|
||||
|
||||
- **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.
|
||||
- **Key rotation.** A single static signing key is fine for now. Rotation requires SDK multi-key support and a migration strategy; deferred.
|
||||
- **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`.
|
||||
|
||||
(Trial/time-limited policies, multi-currency pricing, the optional Zaprite card rail, and the embedded admin UI all shipped after v0.1 and are no longer on this list.)
|
||||
|
||||
## Notes on Start9 dependencies
|
||||
|
||||
|
||||
@@ -25,9 +25,18 @@ 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.
|
||||
|
||||
> **Official SDKs exist — use them first.** Four wire-compatible client SDKs
|
||||
> are published: TypeScript (`@keysat/licensing-client` on npm), Rust
|
||||
> (`keysat-licensing-client` on crates.io), Python (`keysat-licensing-client`
|
||||
> on PyPI), and Go (`github.com/keysat-xyz/keysat-client-go`). Install commands
|
||||
> are in the main README. The by-hand reference implementations below are a
|
||||
> fallback for languages without an SDK, or for understanding exactly what the
|
||||
> SDKs do under the hood.
|
||||
|
||||
## 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:
|
||||
This is what a Start9 package written in Rust might look like if you verify by
|
||||
hand instead of using the Rust SDK:
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Zaprite saved-payment-profile metadata for recurring subscriptions.
|
||||
--
|
||||
-- Wires up the auto-charge path that the v0.2.0:1+ subscriptions
|
||||
-- module comment promised but never delivered: when a buyer pays the
|
||||
-- FIRST cycle of a recurring subscription via Zaprite (Stripe card),
|
||||
-- Keysat asks Zaprite to save the payment profile and persists the
|
||||
-- profile id here. The renewal worker then calls
|
||||
-- `POST /v1/orders/charge` against the saved profile instead of
|
||||
-- waiting for the buyer to manually pay each renewal.
|
||||
--
|
||||
-- All four columns are nullable + nothing in the existing read path
|
||||
-- requires them, so this migration is a pure additive drop-in:
|
||||
-- - BTCPay subscriptions stay NULL on all four (BTCPay has no
|
||||
-- equivalent concept; renewals continue to require manual pay).
|
||||
-- - Pre-feature Zaprite subscriptions stay NULL — the renewal
|
||||
-- worker falls through to the existing "buyer pays manually"
|
||||
-- branch when `zaprite_payment_profile_id IS NULL`.
|
||||
-- - Zaprite subscriptions whose buyer either paid with Bitcoin/
|
||||
-- Lightning instead of card, OR declined the save-card prompt,
|
||||
-- also stay NULL. Same fallback.
|
||||
--
|
||||
-- Decisions encoded here:
|
||||
-- - `zaprite_contact_id`: needed because Zaprite's order endpoint
|
||||
-- doesn't surface the profile id directly. After settle we fetch
|
||||
-- the contact, find the profile whose `sourceOrder.externalUniqId`
|
||||
-- matches our invoice id, and persist both.
|
||||
-- - `zaprite_payment_profile_method` / `expires_at`: informational
|
||||
-- only — the admin UI uses them to render "card ending 4242,
|
||||
-- expires 03/27" on the subscription detail. The renewal worker
|
||||
-- doesn't gate on either today; if Zaprite returns expired-card
|
||||
-- errors on the auto-charge we fall through to manual pay and
|
||||
-- log the failure, same as any other decline.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN zaprite_contact_id TEXT;
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN zaprite_payment_profile_id TEXT;
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN zaprite_payment_profile_method TEXT;
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN zaprite_payment_profile_expires_at TEXT;
|
||||
|
||||
-- Helps the admin-UI "subs with auto-charge configured" filter and
|
||||
-- any future "subs whose saved card is about to expire" sweep.
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_zaprite_profile
|
||||
ON subscriptions(zaprite_payment_profile_id)
|
||||
WHERE zaprite_payment_profile_id IS NOT NULL;
|
||||
@@ -0,0 +1,242 @@
|
||||
-- Multi-merchant-profile + multi-provider model.
|
||||
--
|
||||
-- Replaces the singleton btcpay_config + zaprite_config + SETTING_ACTIVE_PROVIDER
|
||||
-- pattern with a generalized two-table model:
|
||||
--
|
||||
-- merchant_profiles — one row per business identity (brand, redirect,
|
||||
-- optional SMTP override). Creator tier: 1 profile.
|
||||
-- Pro/Patron: unlimited.
|
||||
-- payment_providers — one row per configured BTCPay/Zaprite account,
|
||||
-- attached to a merchant profile via FK. A profile
|
||||
-- can have multiple providers (BTCPay for Bitcoin
|
||||
-- AND Zaprite for card). Unique per (profile, kind).
|
||||
--
|
||||
-- Products and subscriptions both get a merchant_profile_id column;
|
||||
-- subscriptions additionally snapshot the payment_provider_id at creation
|
||||
-- so mid-cycle product edits don't redirect existing buyers to a different
|
||||
-- merchant or payment account.
|
||||
--
|
||||
-- One-way migration: drops btcpay_config + zaprite_config + the
|
||||
-- active_payment_provider setting after porting their data into the new
|
||||
-- tables. The master operator (the only person running Keysat today) needs
|
||||
-- one post-migration manual step: update the Zaprite webhook URL on the
|
||||
-- Zaprite dashboard to the new `/v1/zaprite/webhook/{provider_id}` form,
|
||||
-- or click "Reconnect Zaprite" in the new admin UI to have Keysat
|
||||
-- re-register the webhook with the correct URL automatically.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- merchant_profiles: business identity layer
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Each profile represents one "business" the operator is running on this
|
||||
-- Keysat instance. Owns its own brand block, support contact, post-purchase
|
||||
-- redirect URL, and optionally an SMTP override (paired with the
|
||||
-- keysat-smtp-emails plan — the columns are added now so the SMTP work
|
||||
-- layers on cleanly later without another schema migration).
|
||||
--
|
||||
-- Tier gating is enforced at the Rust layer (`merchant_profiles::create`
|
||||
-- checks the operator's tier and refuses with AppError::TierCap if a
|
||||
-- Creator already has one profile). No CHECK at the schema layer because
|
||||
-- tier resolution requires reading the operator's signed license, not just
|
||||
-- counting rows.
|
||||
CREATE TABLE IF NOT EXISTS merchant_profiles (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
name TEXT NOT NULL, -- "Recaps", "Keysat"
|
||||
legal_name TEXT, -- optional, for receipts/tax
|
||||
support_url TEXT,
|
||||
support_email TEXT,
|
||||
brand_color TEXT, -- hex, e.g. '#1E3A5F'
|
||||
post_purchase_redirect_url TEXT, -- NULL = Keysat's /thank-you
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Per-profile SMTP override. NULL = inherit StartOS-level SMTP config.
|
||||
-- See keysat-smtp-emails.md for the email-sending plan that consumes
|
||||
-- these. Added in this migration so the SMTP plan doesn't need its
|
||||
-- own migration to add per-profile branding fields.
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
smtp_username TEXT,
|
||||
smtp_password TEXT, -- TODO: encryption at rest
|
||||
smtp_from_address TEXT,
|
||||
smtp_from_name TEXT,
|
||||
smtp_use_starttls INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (is_default IN (0, 1)),
|
||||
CHECK (smtp_use_starttls IN (0, 1))
|
||||
);
|
||||
|
||||
-- Exactly one default profile. Partial unique index enforces this without
|
||||
-- needing a trigger; updates to is_default must clear the previous default
|
||||
-- in the same transaction (Rust layer handles this).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_profiles_one_default
|
||||
ON merchant_profiles(is_default) WHERE is_default = 1;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- payment_providers: replaces btcpay_config + zaprite_config singletons
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- One row per configured payment account. Multiple rows allowed per
|
||||
-- profile, but at most one of each `kind` (no two BTCPay stores on the
|
||||
-- same business — operators wanting that should split into two profiles).
|
||||
CREATE TABLE IF NOT EXISTS payment_providers (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id),
|
||||
kind TEXT NOT NULL, -- 'btcpay' | 'zaprite'
|
||||
label TEXT NOT NULL, -- operator-set, e.g. "Recaps BTCPay"
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
webhook_id TEXT, -- provider-side webhook id, for delete on disconnect
|
||||
webhook_secret TEXT, -- BTCPay HMAC secret; NULL for Zaprite
|
||||
store_id TEXT, -- BTCPay only
|
||||
connected_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (kind IN ('btcpay', 'zaprite'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_providers_profile
|
||||
ON payment_providers(merchant_profile_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_providers_profile_kind
|
||||
ON payment_providers(merchant_profile_id, kind);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- merchant_profile_rail_preferences: tie-breaker for multi-provider profiles
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- When a profile has 2 providers that BOTH serve the same payment rail
|
||||
-- (e.g., both BTCPay and Zaprite can settle Lightning), the operator picks
|
||||
-- which provider serves that rail for THIS profile here. Without an entry,
|
||||
-- the routing layer picks the provider with the earliest connected_at
|
||||
-- (deterministic but warns in the admin UI).
|
||||
--
|
||||
-- Rails-per-kind are inherent (BTCPay → Lightning + OnChain; Zaprite →
|
||||
-- Card + Lightning + OnChain) — declared via the trait method
|
||||
-- `served_rails()` in Rust, not stored per provider row. This table
|
||||
-- is purely the ambiguity resolver.
|
||||
CREATE TABLE IF NOT EXISTS merchant_profile_rail_preferences (
|
||||
merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id),
|
||||
rail TEXT NOT NULL, -- 'lightning' | 'onchain' | 'card'
|
||||
payment_provider_id TEXT NOT NULL REFERENCES payment_providers(id),
|
||||
PRIMARY KEY (merchant_profile_id, rail),
|
||||
CHECK (rail IN ('lightning', 'onchain', 'card'))
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- products: attach to a merchant profile
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Nullable during the data-port window (we set it in the UPDATE below).
|
||||
-- After backfill the Rust create_product path requires it (enforced at
|
||||
-- the application layer; can't add NOT NULL via ALTER on SQLite).
|
||||
ALTER TABLE products
|
||||
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_profile
|
||||
ON products(merchant_profile_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- subscriptions: snapshot profile + provider at creation
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The snapshot semantics matter: if an operator later edits a product to
|
||||
-- attach a different profile / point at a different provider, existing
|
||||
-- subscriptions keep renewing through their ORIGINAL profile + provider.
|
||||
-- Re-routing an existing sub to a new merchant is a deliberate admin
|
||||
-- action, never an automatic consequence of editing a product.
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_profile
|
||||
ON subscriptions(merchant_profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subs_provider
|
||||
ON subscriptions(payment_provider_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Data port: singletons → multi-row tables
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 1. Create the default merchant profile. Name = the operator_name setting
|
||||
-- if present; else 'Keysat'. UUID-style id via SQLite's randomblob hex.
|
||||
INSERT INTO merchant_profiles(
|
||||
id, name, support_url, support_email, brand_color,
|
||||
post_purchase_redirect_url, is_default, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
COALESCE((SELECT value FROM settings WHERE key = 'operator_name'), 'Keysat'),
|
||||
NULL, NULL, NULL, NULL,
|
||||
1,
|
||||
datetime('now'),
|
||||
datetime('now')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM merchant_profiles WHERE is_default = 1);
|
||||
|
||||
-- 2. Port btcpay_config (if a row exists) into payment_providers, attached
|
||||
-- to the default profile.
|
||||
INSERT INTO payment_providers(
|
||||
id, merchant_profile_id, kind, label,
|
||||
api_key, base_url, webhook_id, webhook_secret, store_id,
|
||||
connected_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
(SELECT id FROM merchant_profiles WHERE is_default = 1),
|
||||
'btcpay',
|
||||
'BTCPay (migrated)',
|
||||
api_key, base_url, webhook_id, webhook_secret, store_id,
|
||||
connected_at, connected_at
|
||||
FROM btcpay_config;
|
||||
|
||||
-- 3. Port zaprite_config (if a row exists). Zaprite has no webhook_secret
|
||||
-- or store_id; map both to NULL.
|
||||
INSERT INTO payment_providers(
|
||||
id, merchant_profile_id, kind, label,
|
||||
api_key, base_url, webhook_id, webhook_secret, store_id,
|
||||
connected_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
(SELECT id FROM merchant_profiles WHERE is_default = 1),
|
||||
'zaprite',
|
||||
'Zaprite (migrated)',
|
||||
api_key, base_url, webhook_id, NULL, NULL,
|
||||
connected_at, connected_at
|
||||
FROM zaprite_config;
|
||||
|
||||
-- 4. Backfill existing products to point at the default profile.
|
||||
UPDATE products
|
||||
SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
|
||||
WHERE merchant_profile_id IS NULL;
|
||||
|
||||
-- 5. Backfill existing subscriptions. Pick the provider whose kind matches
|
||||
-- SETTING_ACTIVE_PROVIDER if set; otherwise pick the earliest-connected
|
||||
-- provider on the default profile (deterministic). Subs sitting on a
|
||||
-- provider that no longer exists in payment_providers (extremely
|
||||
-- unlikely — would require corrupted singleton data) are left NULL
|
||||
-- and the operator's admin UI will flag them.
|
||||
UPDATE subscriptions
|
||||
SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1),
|
||||
payment_provider_id = (
|
||||
SELECT id FROM payment_providers
|
||||
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
|
||||
AND kind = COALESCE(
|
||||
(SELECT value FROM settings WHERE key = 'active_payment_provider'),
|
||||
(SELECT kind FROM payment_providers
|
||||
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
|
||||
ORDER BY connected_at ASC
|
||||
LIMIT 1)
|
||||
)
|
||||
)
|
||||
WHERE merchant_profile_id IS NULL OR payment_provider_id IS NULL;
|
||||
|
||||
-- 6. Drop the singleton tables + the active-provider setting. Now the only
|
||||
-- source of truth for payment configuration is payment_providers +
|
||||
-- merchant_profiles.
|
||||
DROP TABLE IF EXISTS btcpay_config;
|
||||
DROP TABLE IF EXISTS zaprite_config;
|
||||
DELETE FROM settings WHERE key = 'active_payment_provider';
|
||||
|
||||
-- Note: btcpay_authorize_state stays (it's the in-flight OAuth CSRF
|
||||
-- token table from migration 0002; nothing to migrate, just continues
|
||||
-- to scope per-attempt). Its `state_token` rows will now carry a
|
||||
-- `merchant_profile_id` in their associated payload — see the
|
||||
-- btcpay_authorize.rs changes that add this column in a future
|
||||
-- micro-migration if needed (today the state token is opaque to the
|
||||
-- DB and the profile id is round-tripped via the OAuth state param).
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Link invoices to the payment provider that created them.
|
||||
--
|
||||
-- Companion to migration 0020 (merchant profiles + multi-provider). With a
|
||||
-- single active provider, the reconciler could just iterate pending
|
||||
-- invoices and call `provider.get_invoice_status()` on every one — every
|
||||
-- invoice was implicitly from the only configured provider. With
|
||||
-- N providers per profile and M profiles per Keysat instance, that
|
||||
-- assumption breaks: each invoice needs to record WHICH provider it was
|
||||
-- created against so the reconciler can dispatch to the right
|
||||
-- `get_invoice_status()` and the webhook handler can validate against
|
||||
-- the right secret.
|
||||
--
|
||||
-- Additive: nullable column + index. Backfill points every pre-migration
|
||||
-- invoice at whatever provider was active when 0020 ran (same heuristic
|
||||
-- the subscriptions backfill uses — earliest-connected on the default
|
||||
-- profile). Post-migration, `repo::create_invoice_with_currency` always
|
||||
-- writes the provider id.
|
||||
--
|
||||
-- Why not part of 0020: 0020 has shipped to the master operator's git
|
||||
-- history (commit 04e0dcd) but not yet been *applied* to any DB (the
|
||||
-- master box is still on :51, which has neither migration). The append-
|
||||
-- only convention for migrations is the safer pattern even when we could
|
||||
-- technically still rewrite 0020 — keeps the sqlx migration hashes
|
||||
-- stable for anyone who ever runs an intermediate WIP build.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_provider
|
||||
ON invoices(payment_provider_id);
|
||||
|
||||
-- Backfill existing pending/settled invoices to point at the provider
|
||||
-- that was active when 0020 ran. Heuristic: pick the provider on the
|
||||
-- default merchant profile whose kind matches the (now-removed)
|
||||
-- active_payment_provider setting if it existed pre-0020; else the
|
||||
-- earliest-connected provider on the default profile. Mirrors the
|
||||
-- backfill logic in 0020's UPDATE subscriptions block — same merchant
|
||||
-- identity, same provider, deterministic across re-runs.
|
||||
UPDATE invoices
|
||||
SET payment_provider_id = (
|
||||
SELECT id FROM payment_providers
|
||||
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
|
||||
ORDER BY connected_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE payment_provider_id IS NULL;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Carry merchant_profile_id through the BTCPay OAuth round trip.
|
||||
--
|
||||
-- Operator hits POST /v1/admin/btcpay/connect with a merchant_profile_id,
|
||||
-- daemon generates a CSRF state token and stores it; operator opens
|
||||
-- BTCPay's authorize URL in their browser; BTCPay POSTs back to our
|
||||
-- callback with the apiKey + the state token; daemon consumes the state
|
||||
-- token and uses it to look up which merchant profile the new provider
|
||||
-- row should attach to.
|
||||
--
|
||||
-- Pre-multi-provider, `btcpay_authorize_state` was a singleton-ish
|
||||
-- pattern (one in-flight authorize at a time) and the resulting provider
|
||||
-- always attached to "the singleton btcpay_config row." With multi-
|
||||
-- profile, the operator might want to authorize a SECOND BTCPay store
|
||||
-- onto a different profile (Pro/Patron); the state token has to
|
||||
-- remember which profile they kicked off the flow from.
|
||||
--
|
||||
-- Additive: nullable column, NULL = "attach to the default profile"
|
||||
-- (back-compat for any pre-:52 state tokens that survived a daemon
|
||||
-- restart mid-flow, though the table is also pruned by timestamp).
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE btcpay_authorize_state
|
||||
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration 0023: add the 'merchant-onboard' scoped-API-key role.
|
||||
--
|
||||
-- 0016 created scoped_api_keys with a CHECK that pins `role` to the four
|
||||
-- roles known then (read-only | license-issuer | support | full-admin).
|
||||
-- SQLite can't ALTER or DROP a CHECK constraint in place, so adding a
|
||||
-- fifth role means rebuilding the table with a widened CHECK.
|
||||
--
|
||||
-- scoped_api_keys has no foreign keys (inbound or outbound), so this is
|
||||
-- the simple copy -> drop -> rename rebuild, without any of the FK
|
||||
-- juggling that 0009 needed. sqlx-migrate wraps each file in a
|
||||
-- transaction; we don't BEGIN here.
|
||||
--
|
||||
-- Idempotent: re-running produces the same end state. Existing rows (any
|
||||
-- role, active or revoked) are preserved verbatim. The leading DROP IF
|
||||
-- EXISTS clears a stray _new table from any partially-applied prior run
|
||||
-- before we rebuild.
|
||||
|
||||
DROP TABLE IF EXISTS scoped_api_keys_new;
|
||||
|
||||
CREATE TABLE scoped_api_keys_new (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT,
|
||||
CHECK (role IN ('read-only', 'license-issuer', 'support', 'merchant-onboard', 'full-admin'))
|
||||
);
|
||||
|
||||
INSERT INTO scoped_api_keys_new
|
||||
SELECT id, label, token_hash, role, created_at, last_used_at, revoked_at
|
||||
FROM scoped_api_keys;
|
||||
|
||||
DROP TABLE scoped_api_keys;
|
||||
ALTER TABLE scoped_api_keys_new RENAME TO scoped_api_keys;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_token ON scoped_api_keys(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_active ON scoped_api_keys(revoked_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration 0024: per-key à-la-carte scopes on scoped API keys.
|
||||
--
|
||||
-- Roles (read-only | license-issuer | support | merchant-onboard | full-admin)
|
||||
-- expand to a fixed scope set. Some capabilities are too sensitive to bake into
|
||||
-- any role but still need to be grantable to a SPECIFIC key. The first is
|
||||
-- `payment_providers:write` — agent-delegated payment-provider connect, itself
|
||||
-- gated further by the daemon sandbox flag + a non-mainnet network check (see
|
||||
-- plans/agent-payment-connect-scope.md).
|
||||
--
|
||||
-- `extra_scopes` holds a JSON array of additional scope strings granted to THIS
|
||||
-- key on top of its role. NULL / absent = role scopes only (every existing key),
|
||||
-- so this is a pure additive column — no table rebuild.
|
||||
ALTER TABLE scoped_api_keys ADD COLUMN extra_scopes TEXT;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Carry the connect *initiator* through the BTCPay OAuth round trip.
|
||||
--
|
||||
-- agent-payment-connect (plans/agent-payment-connect-scope.md): a scoped key
|
||||
-- bearing `payment_providers:write` may start a BTCPay connect, but only on a
|
||||
-- sandbox daemon (outer gate) AND only for a non-mainnet store (inner gate).
|
||||
-- The inner gate can only be evaluated at callback time — that's the first
|
||||
-- moment we know the store and can resolve its network. So the connect handler
|
||||
-- must remember, across the operator's browser round-trip to BTCPay, whether
|
||||
-- the initiator was the master key (may connect any network) or a scoped key
|
||||
-- (restricted to non-mainnet).
|
||||
--
|
||||
-- `scoped_initiator`: 0 = master (no network restriction), 1 = scoped key
|
||||
-- (callback enforces non-mainnet, fail-closed). Default 0 keeps any in-flight
|
||||
-- pre-upgrade state token behaving as a master connect (the only kind that
|
||||
-- existed before this migration).
|
||||
-- `initiator_actor_hash`: sha256 of the initiating credential, so the callback
|
||||
-- can write an audit row attributing the scoped connect without a header.
|
||||
--
|
||||
-- Additive, one-way (consistent with 0020-0022). The table is also pruned by
|
||||
-- timestamp, so any pre-migration rows expire within 30 minutes regardless.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE btcpay_authorize_state
|
||||
ADD COLUMN scoped_initiator INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE btcpay_authorize_state
|
||||
ADD COLUMN initiator_actor_hash TEXT;
|
||||
@@ -16,6 +16,13 @@ use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
// The scoped-API-key gate lives in `api_keys` (next to the Role/scope logic),
|
||||
// but endpoint modules import both auth gates from here so there's one obvious
|
||||
// place to reach for when wiring an admin route. `require_admin` = master key
|
||||
// only; `require_scope` = master key OR a scoped key whose role grants the
|
||||
// named scope.
|
||||
pub use crate::api::api_keys::require_scope;
|
||||
|
||||
/// 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
|
||||
@@ -100,6 +107,11 @@ pub struct CreateProductReq {
|
||||
/// policies can carry any entitlement string.
|
||||
#[serde(default)]
|
||||
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
|
||||
/// Merchant profile to attach the product to (migration 0020).
|
||||
/// Omit / null to resolve to the default profile. Only meaningful
|
||||
/// when the operator runs more than one profile.
|
||||
#[serde(default)]
|
||||
pub merchant_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Currencies the admin endpoints accept. Whitelist enforced here so
|
||||
@@ -169,7 +181,7 @@ pub async fn create_product(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateProductReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "products:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
// Tier-cap gate: Creator caps at 5 products. 402 if over.
|
||||
crate::api::tier::enforce_product_cap(&state).await?;
|
||||
@@ -205,6 +217,17 @@ pub async fn create_product(
|
||||
} else {
|
||||
product
|
||||
};
|
||||
// Attach to a merchant profile if the operator picked one (same
|
||||
// post-write pattern as the entitlements catalog). Omitted = NULL =
|
||||
// resolves to the default profile. A bad profile id 404s here AFTER
|
||||
// the row exists, leaving it with a NULL profile — benign (resolves
|
||||
// to default; reattach or delete). The admin UI only offers existing
|
||||
// profiles, so this is an API-direct edge only.
|
||||
let product = if let Some(profile_id) = req.merchant_profile_id.as_deref() {
|
||||
repo::set_product_merchant_profile(&state.db, &product.id, Some(profile_id)).await?
|
||||
} else {
|
||||
product
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
@@ -260,7 +283,7 @@ pub async fn delete_product(
|
||||
Path(id): Path<String>,
|
||||
Query(opts): Query<DeleteOpts>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "products:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let product = repo::get_product_by_id(&state.db, &id)
|
||||
@@ -430,6 +453,20 @@ pub struct UpdateProductReq {
|
||||
/// string until the catalog is set again.
|
||||
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
|
||||
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
|
||||
/// Reassign the product's merchant profile (migration 0020).
|
||||
/// `Some(Some(id))` attaches, `Some(None)` clears it back to
|
||||
/// default-resolution, omit / absent leaves it unchanged.
|
||||
#[serde(default, deserialize_with = "deser_double_option_profile", skip_serializing_if = "Option::is_none")]
|
||||
pub merchant_profile_id: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// Serde adapter for the nullable merchant-profile patch — same
|
||||
/// "omitted vs null vs value" three-way distinction as the catalog.
|
||||
fn deser_double_option_profile<'de, D>(de: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(de).map(Some)
|
||||
}
|
||||
|
||||
/// Serde adapter — distinguishes "field omitted" (None) from
|
||||
@@ -451,7 +488,7 @@ pub async fn update_product(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateProductReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "products:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Resolve the pricing patch into (currency, value, sats) tuple
|
||||
@@ -526,6 +563,16 @@ pub async fn update_product(
|
||||
}
|
||||
None => updated,
|
||||
};
|
||||
// Merchant-profile reassignment, same three-way patch as the
|
||||
// catalog: Some(Some) attaches, Some(None) clears to default, None
|
||||
// leaves it untouched.
|
||||
let updated = match &req.merchant_profile_id {
|
||||
Some(Some(profile_id)) => {
|
||||
repo::set_product_merchant_profile(&state.db, &id, Some(profile_id.as_str())).await?
|
||||
}
|
||||
Some(None) => repo::set_product_merchant_profile(&state.db, &id, None).await?,
|
||||
None => updated,
|
||||
};
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
@@ -551,7 +598,7 @@ pub async fn set_product_active(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "products:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_product_active(&state.db, &id, req.active).await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -581,7 +628,7 @@ pub async fn list_licenses(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListLicensesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "licenses:read").await?;
|
||||
let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?;
|
||||
Ok(Json(json!({ "licenses": licenses })))
|
||||
}
|
||||
@@ -605,7 +652,7 @@ pub async fn search_licenses(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<SearchLicensesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "licenses:read").await?;
|
||||
let licenses = repo::search_licenses(
|
||||
&state.db,
|
||||
q.buyer_email.as_deref(),
|
||||
@@ -685,7 +732,7 @@ pub async fn revenue_summary(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "licenses:read").await?;
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices WHERE status = 'settled'",
|
||||
)
|
||||
@@ -730,7 +777,7 @@ pub async fn license_counts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "licenses:read").await?;
|
||||
let by_product: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT product_id, COUNT(*) FROM licenses GROUP BY product_id",
|
||||
)
|
||||
@@ -762,7 +809,7 @@ pub async fn licenses_summary(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "licenses:read").await?;
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
@@ -845,7 +892,7 @@ pub async fn issue_license(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<IssueLicenseReq>,
|
||||
) -> AppResult<Json<IssueLicenseResp>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
|
||||
@@ -997,7 +1044,7 @@ pub async fn revoke_license(
|
||||
Path(license_id): Path<String>,
|
||||
Json(req): Json<RevokeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = if req.reason.is_empty() {
|
||||
"admin revoke".to_string()
|
||||
@@ -1040,7 +1087,7 @@ pub async fn suspend_license(
|
||||
Path(license_id): Path<String>,
|
||||
Json(req): Json<SuspendReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = if req.reason.is_empty() {
|
||||
"admin suspend".to_string()
|
||||
@@ -1074,7 +1121,7 @@ pub async fn unsuspend_license(
|
||||
headers: HeaderMap,
|
||||
Path(license_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::unsuspend_license(&state.db, &license_id).await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -1116,7 +1163,7 @@ pub async fn list_audit(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListAuditQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "audit:read").await?;
|
||||
let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?;
|
||||
Ok(Json(json!({ "entries": rows })))
|
||||
}
|
||||
@@ -1164,7 +1211,7 @@ pub async fn get_operator_name(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "settings:read").await?;
|
||||
let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?;
|
||||
let effective = stored
|
||||
.clone()
|
||||
|
||||
@@ -5,20 +5,29 @@
|
||||
//! script a credential that does only what it needs to. Operator-friendly
|
||||
//! flow:
|
||||
//!
|
||||
//! 1. Operator generates a new key in Settings → API keys, picks a role
|
||||
//! from a fixed list (Read-only / License issuer / Support / Full admin).
|
||||
//! 2. UI returns the raw token ONCE. The token never appears in any
|
||||
//! response afterward — only its sha256 hash is stored.
|
||||
//! 3. Agent uses `Authorization: Bearer <token>` like the master key.
|
||||
//! Endpoints that have been scope-wired check the agent's role
|
||||
//! grants the required scope; if not, 403.
|
||||
//! 4. Operator can revoke any key from the same UI; revoked tokens
|
||||
//! stop working immediately.
|
||||
//! 1. Operator mints a new key via the Settings → "Scoped API keys" panel
|
||||
//! in the admin SPA (or directly via `POST /v1/admin/api-keys`), picking a
|
||||
//! role from a fixed list (Read-only / License issuer / Support /
|
||||
//! Merchant onboard / Full admin).
|
||||
//! 2. The create response returns the raw token ONCE. The token never
|
||||
//! appears in any response afterward — only its sha256 hash is stored.
|
||||
//! 3. Agent uses `Authorization: Bearer <token>` like the master key. Each
|
||||
//! scope-gated endpoint checks the agent's role grants the required
|
||||
//! scope; if not, 403.
|
||||
//! 4. Operator can revoke any key (`DELETE /v1/admin/api-keys/:id`); revoked
|
||||
//! tokens stop working immediately.
|
||||
//!
|
||||
//! The master `admin_api_key` always works on every endpoint. Scoped keys
|
||||
//! work only on endpoints that have been migrated to call `require_scope`
|
||||
//! instead of `require_admin`. Endpoints not yet migrated reject scoped
|
||||
//! keys with 403 — secure-by-default.
|
||||
//! The master `admin_api_key` always works on every endpoint. Scoped keys are
|
||||
//! honored across the catalog/license/support surface: every read endpoint
|
||||
//! (`<resource>:read`), license writes (`licenses:write`), and the support
|
||||
//! writes (`subscriptions:write`, `machines:write`). A deliberate set of
|
||||
//! sensitive endpoints stays master-key-only — even a `full-admin` scoped key
|
||||
//! gets 403 on them: rotating the issuer signing key, connecting/disconnecting
|
||||
//! payment providers, setting the web-admin password, managing API keys
|
||||
//! themselves, changing server settings or license tiers, and DB
|
||||
//! introspection. When adding a new admin route, gate it with
|
||||
//! `require_scope(state, headers, "<resource>:<read|write>")` unless it belongs
|
||||
//! in that master-only set, in which case use `require_admin`.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
@@ -53,6 +62,16 @@ pub enum Role {
|
||||
/// Right shape for a customer-support agent that resolves common
|
||||
/// requests without touching catalog or settings.
|
||||
Support,
|
||||
/// Read-only + catalog *and* license writes: create/edit products,
|
||||
/// define policies/tiers, and issue licenses against them. The
|
||||
/// least-privilege credential for end-to-end self-serve onboarding —
|
||||
/// a merchant (or an integrating agent) standing up a fresh catalog
|
||||
/// via the API without the master key. Deliberately excludes the
|
||||
/// support writes (subs/machines) and every master-only gate
|
||||
/// (settings, tiers, payment connect, key mgmt, signing-key, db).
|
||||
/// Tier caps still bound it: a Creator-tier box stays at 5 products /
|
||||
/// 5 policies-per-product regardless of credential.
|
||||
MerchantOnboard,
|
||||
/// Every scope. Equivalent to the master `admin_api_key` for endpoints
|
||||
/// that use `require_scope`; still rejected by endpoints that gate on
|
||||
/// settings-write or tier-write where the master key is required.
|
||||
@@ -65,6 +84,7 @@ impl Role {
|
||||
Role::ReadOnly => "read-only",
|
||||
Role::LicenseIssuer => "license-issuer",
|
||||
Role::Support => "support",
|
||||
Role::MerchantOnboard => "merchant-onboard",
|
||||
Role::FullAdmin => "full-admin",
|
||||
}
|
||||
}
|
||||
@@ -73,6 +93,7 @@ impl Role {
|
||||
"read-only" => Some(Role::ReadOnly),
|
||||
"license-issuer" => Some(Role::LicenseIssuer),
|
||||
"support" => Some(Role::Support),
|
||||
"merchant-onboard" => Some(Role::MerchantOnboard),
|
||||
"full-admin" => Some(Role::FullAdmin),
|
||||
_ => None,
|
||||
}
|
||||
@@ -81,7 +102,13 @@ impl Role {
|
||||
/// `<resource>:<read|write>`, e.g. `licenses:write`.
|
||||
pub fn grants(self, scope: &str) -> bool {
|
||||
match self {
|
||||
Role::FullAdmin => true,
|
||||
// Every scope EXCEPT the à-la-carte-only ones (e.g.
|
||||
// `payment_providers:write`). Those are never role-grantable — only
|
||||
// a per-key `extra_scopes` entry grants them — so even a full-admin
|
||||
// *scoped* key can't reach payment-connect through its role. (The
|
||||
// master key still passes `require_scope` ahead of this, via the
|
||||
// early constant-time compare, and may do anything.)
|
||||
Role::FullAdmin => !GRANTABLE_EXTRA_SCOPES.contains(&scope),
|
||||
Role::ReadOnly => scope.ends_with(":read"),
|
||||
Role::LicenseIssuer => {
|
||||
scope.ends_with(":read")
|
||||
@@ -96,8 +123,36 @@ impl Role {
|
||||
| "machines:write"
|
||||
)
|
||||
}
|
||||
// Catalog + license writes only. Match scopes EXPLICITLY (never
|
||||
// by `:write` suffix) so this role can never widen into
|
||||
// settings:write / merchant_profiles:write / payment / webhooks
|
||||
// / rates — all of which would otherwise share the suffix. Adding
|
||||
// a write scope here is a deliberate per-string decision.
|
||||
Role::MerchantOnboard => {
|
||||
scope.ends_with(":read")
|
||||
|| matches!(
|
||||
scope,
|
||||
"products:write" | "policies:write" | "licenses:write"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scopes an operator may grant à-la-carte on a key (on top of its role), via
|
||||
/// the `scopes` field on create. Deliberately tiny: only sensitive
|
||||
/// capabilities that don't belong in any role. `payment_providers:write` is the
|
||||
/// first — it is further gated at the endpoint (daemon sandbox mode + a
|
||||
/// non-mainnet network check). See `plans/agent-payment-connect-scope.md`.
|
||||
pub const GRANTABLE_EXTRA_SCOPES: &[&str] = &["payment_providers:write"];
|
||||
|
||||
/// Parse a key's `extra_scopes` JSON array and test membership. Tolerant of
|
||||
/// NULL / malformed JSON (treated as "no extra scopes") so a bad row can never
|
||||
/// widen access — it only ever fails closed.
|
||||
fn extra_scopes_contains(json: Option<&str>, scope: &str) -> bool {
|
||||
json.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
.map(|v| v.iter().any(|s| s == scope))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Verify the request carries a credential that grants the named scope.
|
||||
@@ -138,14 +193,14 @@ pub async fn require_scope(
|
||||
hasher.update(token.as_bytes());
|
||||
let token_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?",
|
||||
let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?",
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (key_id, role_str, revoked_at) = match row {
|
||||
let (key_id, role_str, revoked_at, extra_scopes_json) = match row {
|
||||
Some(r) => r,
|
||||
None => return Err(AppError::Forbidden),
|
||||
};
|
||||
@@ -153,7 +208,11 @@ pub async fn require_scope(
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
|
||||
if !role.grants(scope) {
|
||||
// A key grants a scope via its role OR via an à-la-carte `extra_scopes`
|
||||
// entry (e.g. `payment_providers:write`, which is in no role).
|
||||
let granted =
|
||||
role.grants(scope) || extra_scopes_contains(extra_scopes_json.as_deref(), scope);
|
||||
if !granted {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
|
||||
@@ -168,12 +227,101 @@ pub async fn require_scope(
|
||||
Ok(token_hash)
|
||||
}
|
||||
|
||||
/// Who initiated a payment-provider connect — determines the network gate at
|
||||
/// callback time (`btcpay_authorize::finish_connect`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectInitiator {
|
||||
/// The master `admin_api_key`. May connect any network.
|
||||
Master,
|
||||
/// A scoped key carrying `payment_providers:write` on a sandbox daemon.
|
||||
/// Restricted to non-mainnet stores (enforced after the OAuth round-trip,
|
||||
/// once the store + network are known).
|
||||
Scoped,
|
||||
}
|
||||
|
||||
/// Gate for **starting** a BTCPay provider connect — the fund-redirection-
|
||||
/// sensitive operation. Stricter than `require_scope`: a scoped key reaches it
|
||||
/// ONLY with the à-la-carte `payment_providers:write` scope AND only on a
|
||||
/// **sandbox daemon** (the OUTER gate — on a production box scoped connect is
|
||||
/// disabled entirely, even for regtest, since a scoped key re-pointing
|
||||
/// settlement on a live box is denial-of-revenue). The INNER gate (target
|
||||
/// network must be non-mainnet) is enforced separately at callback time, once
|
||||
/// the store is known. See `plans/agent-payment-connect-scope.md` §5.
|
||||
///
|
||||
/// Returns `(actor_hash, initiator)`. The caller records `initiator` in the
|
||||
/// authorize-state row so the callback can apply the network gate. Master keys
|
||||
/// bypass both gates (still subject to BTCPay's own OAuth approval).
|
||||
pub async fn require_provider_connect(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> AppResult<(String, ConnectInitiator)> {
|
||||
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)?;
|
||||
|
||||
// Master admin key — full bypass, may connect any network.
|
||||
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());
|
||||
return Ok((hex::encode(hasher.finalize()), ConnectInitiator::Master));
|
||||
}
|
||||
|
||||
// Scoped key — must carry `payment_providers:write` (never role-granted;
|
||||
// only via à-la-carte `extra_scopes`) AND the daemon must be in sandbox mode.
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
let token_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?",
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (key_id, role_str, revoked_at, extra_scopes_json) = row.ok_or(AppError::Forbidden)?;
|
||||
if revoked_at.is_some() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
|
||||
let has_scope = role.grants("payment_providers:write")
|
||||
|| extra_scopes_contains(extra_scopes_json.as_deref(), "payment_providers:write");
|
||||
if !has_scope {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
// OUTER gate: scoped connect is permitted only on a sandbox daemon.
|
||||
if !state.config.sandbox_mode {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(&key_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok((token_hash, ConnectInitiator::Scoped))
|
||||
}
|
||||
|
||||
// ---------- CRUD endpoints (gated on master admin only) ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateApiKeyReq {
|
||||
pub label: String,
|
||||
pub role: String,
|
||||
/// Optional à-la-carte scopes granted on top of the role. Each must be in
|
||||
/// `GRANTABLE_EXTRA_SCOPES`. Omitted / empty = role scopes only.
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -181,6 +329,8 @@ pub struct CreateApiKeyResp {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub role: String,
|
||||
/// À-la-carte scopes granted on top of the role (echoed back).
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: String,
|
||||
/// The raw token. Returned ONCE on create and never again — operator
|
||||
/// must copy it now or generate a new key.
|
||||
@@ -204,10 +354,36 @@ pub async fn create(
|
||||
}
|
||||
let role = Role::parse(req.role.trim()).ok_or_else(|| {
|
||||
AppError::BadRequest(
|
||||
"role must be one of: read-only, license-issuer, support, full-admin".into(),
|
||||
"role must be one of: read-only, license-issuer, support, merchant-onboard, full-admin"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate à-la-carte extra scopes (granted on top of the role). Only the
|
||||
// capabilities in GRANTABLE_EXTRA_SCOPES may be granted this way; anything
|
||||
// else is rejected so a typo can't silently grant nothing (or something).
|
||||
let mut extra_scopes: Vec<String> = req
|
||||
.scopes
|
||||
.iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
extra_scopes.sort();
|
||||
extra_scopes.dedup();
|
||||
for s in &extra_scopes {
|
||||
if !GRANTABLE_EXTRA_SCOPES.contains(&s.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"scope '{s}' is not grantable on a key; allowed à-la-carte scopes: {}",
|
||||
GRANTABLE_EXTRA_SCOPES.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
let extra_scopes_json = if extra_scopes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_string(&extra_scopes).expect("Vec<String> serializes"))
|
||||
};
|
||||
|
||||
// 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars.
|
||||
// Prefix `ks_` so it's recognizable in logs as a Keysat-style token.
|
||||
use rand::RngCore;
|
||||
@@ -225,14 +401,15 @@ pub async fn create(
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at, extra_scopes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(label)
|
||||
.bind(&token_hash)
|
||||
.bind(role.as_str())
|
||||
.bind(&now)
|
||||
.bind(&extra_scopes_json)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
@@ -245,7 +422,7 @@ pub async fn create(
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "label": label, "role": role.as_str() }),
|
||||
&json!({ "label": label, "role": role.as_str(), "scopes": extra_scopes.clone() }),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -253,6 +430,7 @@ pub async fn create(
|
||||
id,
|
||||
label: label.to_string(),
|
||||
role: role.as_str().to_string(),
|
||||
scopes: extra_scopes,
|
||||
created_at: now,
|
||||
token,
|
||||
}))
|
||||
@@ -263,6 +441,8 @@ pub struct ApiKeyListEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub role: String,
|
||||
/// À-la-carte scopes granted on top of the role (empty for most keys).
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
@@ -275,23 +455,35 @@ pub async fn list(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, label, role, created_at, last_used_at, revoked_at
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT id, label, role, extra_scopes, created_at, last_used_at, revoked_at
|
||||
FROM scoped_api_keys ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let out: Vec<ApiKeyListEntry> = rows
|
||||
.into_iter()
|
||||
.map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
|
||||
.map(
|
||||
|(id, label, role, extra_scopes, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
|
||||
id,
|
||||
label,
|
||||
role,
|
||||
scopes: extra_scopes
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||||
.unwrap_or_default(),
|
||||
created_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
Ok(Json(json!({ "api_keys": out })))
|
||||
}
|
||||
@@ -340,3 +532,56 @@ pub async fn revoke(
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true, "revoked_at": now })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The invariant: à-la-carte-only scopes (e.g. `payment_providers:write`)
|
||||
/// are NEVER grantable by any role — not even `full-admin`. Only a per-key
|
||||
/// `extra_scopes` entry grants them. Guards the P1 regression where
|
||||
/// `FullAdmin => true` would let a scoped full-admin key reach
|
||||
/// payment-connect through its role.
|
||||
#[test]
|
||||
fn no_role_grants_alacarte_only_scopes() {
|
||||
let roles = [
|
||||
Role::ReadOnly,
|
||||
Role::LicenseIssuer,
|
||||
Role::Support,
|
||||
Role::MerchantOnboard,
|
||||
Role::FullAdmin,
|
||||
];
|
||||
for role in roles {
|
||||
for scope in GRANTABLE_EXTRA_SCOPES {
|
||||
assert!(
|
||||
!role.grants(scope),
|
||||
"role {} must NOT grant à-la-carte-only scope {scope}",
|
||||
role.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-admin still grants every *role* scope — the fix only carves out the
|
||||
/// à-la-carte-only set, nothing else.
|
||||
#[test]
|
||||
fn full_admin_still_grants_ordinary_scopes() {
|
||||
assert!(Role::FullAdmin.grants("products:write"));
|
||||
assert!(Role::FullAdmin.grants("policies:write"));
|
||||
assert!(Role::FullAdmin.grants("settings:read"));
|
||||
assert!(Role::FullAdmin.grants("payment_providers:read"));
|
||||
}
|
||||
|
||||
/// `extra_scopes` parsing fails closed: NULL / malformed / wrong-shape JSON
|
||||
/// grants nothing and never errors open.
|
||||
#[test]
|
||||
fn extra_scopes_contains_fails_closed() {
|
||||
let json = r#"["payment_providers:write"]"#;
|
||||
assert!(extra_scopes_contains(Some(json), "payment_providers:write"));
|
||||
assert!(!extra_scopes_contains(Some(json), "products:write"));
|
||||
assert!(!extra_scopes_contains(None, "payment_providers:write")); // NULL
|
||||
assert!(!extra_scopes_contains(Some("not json"), "payment_providers:write")); // malformed
|
||||
assert!(!extra_scopes_contains(Some("{}"), "payment_providers:write")); // wrong shape
|
||||
assert!(!extra_scopes_contains(Some("[]"), "payment_providers:write")); // empty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,14 @@
|
||||
//! 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::api::{
|
||||
admin::{require_admin, require_scope},
|
||||
api_keys::{require_provider_connect, ConnectInitiator},
|
||||
AppState,
|
||||
};
|
||||
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
|
||||
use crate::btcpay::config as btcpay_cfg;
|
||||
use crate::btcpay::network::BitcoinNetwork;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::btcpay::BtcpayProvider;
|
||||
use std::sync::Arc;
|
||||
@@ -56,25 +61,62 @@ pub struct ConnectResp {
|
||||
pub authorize_url: String,
|
||||
/// CSRF state token tied to this round trip.
|
||||
pub state: String,
|
||||
/// Merchant profile the resulting provider row will attach to.
|
||||
pub merchant_profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct StartConnectReq {
|
||||
/// Which merchant profile to attach the BTCPay provider to. NULL =
|
||||
/// the default profile (single-profile operators never see this).
|
||||
#[serde(default)]
|
||||
pub merchant_profile_id: Option<String>,
|
||||
/// Operator-set label for the resulting payment_providers row. NULL =
|
||||
/// auto-generated from the profile name.
|
||||
#[serde(default)]
|
||||
pub label: Option<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.
|
||||
///
|
||||
/// Accepts an optional `merchant_profile_id` so Pro/Patron operators can
|
||||
/// connect multiple BTCPay stores onto different profiles side-by-side.
|
||||
/// Single-profile operators (Creator tier, or anyone without an explicit
|
||||
/// pick) get the default profile.
|
||||
pub async fn start_connect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Option<Json<StartConnectReq>>,
|
||||
) -> AppResult<Json<ConnectResp>> {
|
||||
require_admin(&state, &headers)?;
|
||||
// Master key → connect any network. Scoped key with `payment_providers:write`
|
||||
// → permitted ONLY on a sandbox daemon (outer gate); the non-mainnet inner
|
||||
// gate is enforced at callback time once the store is known. See
|
||||
// `plans/agent-payment-connect-scope.md` §5.
|
||||
let (actor_hash, initiator) = require_provider_connect(&state, &headers).await?;
|
||||
let scoped_initiator = matches!(initiator, ConnectInitiator::Scoped);
|
||||
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||
|
||||
// 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 {
|
||||
// Resolve the target merchant profile (defaulting to the default).
|
||||
let profile = match req.merchant_profile_id.as_deref() {
|
||||
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))?,
|
||||
None => crate::merchant_profiles::require_default(&state.db).await?,
|
||||
};
|
||||
|
||||
// Idempotency: refuse to issue a new authorize URL if the same
|
||||
// profile already has a BTCPay provider attached. Re-clicking
|
||||
// Connect would otherwise INSERT-conflict at callback time (unique
|
||||
// index on (merchant_profile_id, kind)) AND register a duplicate
|
||||
// BTCPay webhook, producing duplicate-deliveries on every settle.
|
||||
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
||||
.await?;
|
||||
if existing.iter().any(|p| p.kind == "btcpay") {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
|
||||
existing.store_id,
|
||||
"merchant profile '{}' already has a BTCPay provider attached. \
|
||||
Disconnect it first if you want to re-authorize, or pick a different profile.",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -83,7 +125,15 @@ pub async fn start_connect(
|
||||
rand::thread_rng().fill_bytes(&mut raw);
|
||||
let state_token = BASE32_NOPAD.encode(&raw);
|
||||
|
||||
btcpay_cfg::record_authorize_state(&state.db, &state_token)
|
||||
btcpay_cfg::record_authorize_state(
|
||||
&state.db,
|
||||
&state_token,
|
||||
Some(&profile.id),
|
||||
scoped_initiator,
|
||||
// Only stored for scoped connects (the callback's audit row). Master
|
||||
// connects are covered by the StartOS action audit trail.
|
||||
scoped_initiator.then_some(actor_hash.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
@@ -124,9 +174,11 @@ pub async fn start_connect(
|
||||
urlencoding::encode(&redirect),
|
||||
);
|
||||
|
||||
let _ = req.label; // captured but not yet used — see finish_connect TODO for the future round-trip
|
||||
Ok(Json(ConnectResp {
|
||||
authorize_url,
|
||||
state: state_token,
|
||||
merchant_profile_id: profile.id,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -158,7 +210,7 @@ pub async fn callback(
|
||||
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."))
|
||||
Ok(success_page("BTCPay connected successfully. You can close this tab and return to Keysat."))
|
||||
}
|
||||
|
||||
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
|
||||
@@ -190,58 +242,81 @@ pub async fn callback_get(
|
||||
};
|
||||
match finish_connect(&state, &q.state, &api_key).await {
|
||||
Ok(()) => success_page(
|
||||
"BTCPay connected successfully. You can close this tab and return to StartOS.",
|
||||
"BTCPay connected successfully. You can close this tab and return to Keysat.",
|
||||
),
|
||||
Err(e) => Html(format!(
|
||||
// Carry the error's HTTP status onto the HTML page so a denied connect
|
||||
// (e.g. a scoped key targeting a mainnet store -> 400) surfaces as a
|
||||
// non-2xx an agent can detect, not a misleading 200. Matches the POST
|
||||
// callback, which propagates the status via `?`.
|
||||
Err(e) => (
|
||||
e.status_code(),
|
||||
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.
|
||||
/// BTCPay store. Defaults to the default-profile's BTCPay provider for
|
||||
/// back-compat with the existing admin UI; the new merchant-profile
|
||||
/// admin endpoint passes an explicit `provider_id` query param when
|
||||
/// multiple BTCPay providers exist.
|
||||
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)?
|
||||
require_scope(&state, &headers, "payment_providers:read").await?;
|
||||
let default = crate::merchant_profiles::require_default(&state.db).await?;
|
||||
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
|
||||
.await?;
|
||||
let row = rows
|
||||
.into_iter()
|
||||
.find(|p| p.kind == "btcpay")
|
||||
.ok_or(AppError::BtcpayNotConfigured)?;
|
||||
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
|
||||
let store_id = row.store_id.as_deref().unwrap_or("");
|
||||
let methods = btcpay_client::list_payment_methods(&row.base_url, &row.api_key, 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.
|
||||
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e:#}")))?;
|
||||
let count = methods.len();
|
||||
Ok(Json(json!({
|
||||
"store_id": cfg.store_id,
|
||||
"store_id": store_id,
|
||||
"count": count,
|
||||
"methods": methods,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Admin endpoint: report current BTCPay connection status.
|
||||
/// Admin endpoint: report BTCPay connection status for the default
|
||||
/// profile (back-compat with the existing admin UI's payment-providers
|
||||
/// card). Multi-profile operators use `/v1/admin/merchant-profiles` to
|
||||
/// see all attached providers.
|
||||
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 {
|
||||
require_scope(&state, &headers, "payment_providers:read").await?;
|
||||
let default = crate::merchant_profiles::get_default(&state.db).await?;
|
||||
let row = match &default {
|
||||
Some(profile) => {
|
||||
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
||||
.await?;
|
||||
rows.into_iter().find(|p| p.kind == "btcpay")
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(match row {
|
||||
None => json!({ "connected": false }),
|
||||
Some(c) => json!({
|
||||
Some(p) => json!({
|
||||
"connected": true,
|
||||
"store_id": c.store_id,
|
||||
"webhook_id": c.webhook_id,
|
||||
"base_url": c.base_url,
|
||||
"provider_id": p.id,
|
||||
"store_id": p.store_id,
|
||||
"webhook_id": p.webhook_id,
|
||||
"base_url": p.base_url,
|
||||
"label": p.label,
|
||||
"merchant_profile_id": default.as_ref().map(|d| d.id.clone()),
|
||||
"merchant_profile_name": default.as_ref().map(|d| d.name.clone()),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -249,9 +324,22 @@ pub async fn status(
|
||||
// --- internals ---
|
||||
|
||||
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
|
||||
btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
||||
// Recovers the `merchant_profile_id` recorded when the operator
|
||||
// kicked off the connect flow. NULL falls back to the default
|
||||
// profile (back-compat for state tokens from pre-0022 runs).
|
||||
let auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
||||
.await
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
let profile = match auth_state.merchant_profile_id.as_deref() {
|
||||
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest(format!(
|
||||
"merchant profile {id} no longer exists — the operator may have \
|
||||
deleted it during the authorize round-trip. Reconnect from a \
|
||||
valid profile."
|
||||
)))?,
|
||||
None => crate::merchant_profiles::require_default(&state.db).await?,
|
||||
};
|
||||
|
||||
let base_url = &state.config.btcpay_url;
|
||||
|
||||
@@ -260,7 +348,7 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
||||
// 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}")))?;
|
||||
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e:#}")))?;
|
||||
let store = stores
|
||||
.into_iter()
|
||||
.next()
|
||||
@@ -268,12 +356,56 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
||||
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
|
||||
))?;
|
||||
|
||||
// INNER gate (scoped initiators only): the target store must settle on a
|
||||
// non-mainnet network. This is the first point in the flow where we know
|
||||
// the store, so detection happens here — BEFORE registering any webhook or
|
||||
// persisting the provider. Fail closed: if the network can't be positively
|
||||
// determined as non-mainnet, treat it as mainnet and refuse. Master
|
||||
// initiators skip this entirely (they may connect any network).
|
||||
let resolved_network = if auth_state.scoped_initiator {
|
||||
let network = match btcpay_client::fetch_onchain_network(base_url, api_key, &store.id).await {
|
||||
Ok(Some(net)) => net,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
store = %store.id,
|
||||
"scoped BTCPay connect: on-chain network undetermined → fail-closed to mainnet (deny)"
|
||||
);
|
||||
BitcoinNetwork::Mainnet
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
store = %store.id, error = %format!("{e:#}"),
|
||||
"scoped BTCPay connect: network detection errored → fail-closed to mainnet (deny)"
|
||||
);
|
||||
BitcoinNetwork::Mainnet
|
||||
}
|
||||
};
|
||||
if network.is_mainnet() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Scoped payment-provider connect is restricted to non-mainnet \
|
||||
(regtest/testnet/signet) BTCPay stores; the selected store resolved \
|
||||
to '{}'. Use the master admin key to connect a mainnet store.",
|
||||
network.as_str()
|
||||
)));
|
||||
}
|
||||
Some(network)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 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);
|
||||
// Pre-generate the provider id so we can bake it into the webhook
|
||||
// URL we register with BTCPay. The webhook router routes by this
|
||||
// path-param id, isolating deliveries per-provider per-profile.
|
||||
let provider_id = uuid::Uuid::new_v4().to_string();
|
||||
let callback_url = format!(
|
||||
"{}/v1/btcpay/webhook/{}",
|
||||
state.config.public_base_url, provider_id
|
||||
);
|
||||
|
||||
let created_webhook = btcpay_client::create_webhook(
|
||||
base_url,
|
||||
@@ -283,51 +415,74 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
||||
&webhook_secret,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
|
||||
.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)?;
|
||||
// Persist as a payment_providers row attached to the chosen profile.
|
||||
let label = format!("BTCPay — {}", profile.name);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
crate::db::repo::create_payment_provider(
|
||||
&state.db,
|
||||
&provider_id,
|
||||
&profile.id,
|
||||
"btcpay",
|
||||
&label,
|
||||
api_key,
|
||||
base_url,
|
||||
Some(&created_webhook.id),
|
||||
Some(&webhook_secret),
|
||||
Some(&store.id),
|
||||
&now,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 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.
|
||||
// If this is the first provider on the default profile, also
|
||||
// populate the back-compat singleton so the few remaining
|
||||
// state.payment_provider() callers work without a daemon restart.
|
||||
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
||||
.await?;
|
||||
if profile.is_default && existing.len() == 1 {
|
||||
let client = BtcpayClient::new(base_url, api_key, &store.id);
|
||||
let provider = Arc::new(
|
||||
BtcpayProvider::new(client, webhook_secret)
|
||||
BtcpayProvider::new(client, webhook_secret.clone())
|
||||
.with_public_base(state.config.btcpay_public_url.clone()),
|
||||
);
|
||||
state.set_payment_provider(provider).await;
|
||||
// Persist active-provider preference so the boot-time loader
|
||||
// picks BTCPay on next restart even if Zaprite's config row
|
||||
// is also still in the DB. Failure here is non-fatal (BTCPay
|
||||
// is the historical default, so the fallback loader picks it
|
||||
// anyway) but logged.
|
||||
if let Err(e) = crate::payment::write_active_provider_preference(
|
||||
&state.db,
|
||||
crate::payment::ProviderKind::Btcpay,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to record BTCPay as active payment provider");
|
||||
}
|
||||
|
||||
let network_str = resolved_network.map(|n| n.as_str());
|
||||
tracing::info!(
|
||||
provider_id = %provider_id,
|
||||
merchant_profile_id = %profile.id,
|
||||
store = %store.id,
|
||||
store_name = %store.name,
|
||||
webhook_id = %created_webhook.id,
|
||||
scoped = auth_state.scoped_initiator,
|
||||
network = network_str.unwrap_or("master/any"),
|
||||
"BTCPay connected via authorize flow"
|
||||
);
|
||||
|
||||
// Audit every scoped connect (spec §7) — attributes the fund-redirection-
|
||||
// sensitive op to the initiating credential + the resolved network. Master
|
||||
// connects are already covered by the StartOS action audit trail.
|
||||
if auth_state.scoped_initiator {
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"scoped_api_key",
|
||||
auth_state.initiator_actor_hash.as_deref(),
|
||||
"payment_provider.connect_scoped",
|
||||
Some("payment_provider"),
|
||||
Some(&provider_id),
|
||||
None,
|
||||
None,
|
||||
&json!({
|
||||
"kind": "btcpay",
|
||||
"store_id": store.id,
|
||||
"merchant_profile_id": profile.id,
|
||||
"network": network_str,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -342,31 +497,52 @@ h2{{color:#0a7}}</style></head>
|
||||
(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.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct DisconnectReq {
|
||||
/// Which provider row to disconnect. NULL = the BTCPay provider on
|
||||
/// the default merchant profile (back-compat for the existing admin
|
||||
/// UI's single-button Disconnect).
|
||||
#[serde(default)]
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Admin endpoint: disconnect a BTCPay provider. Best-effort revocation
|
||||
/// of the webhook + API key on BTCPay's side, then unconditional delete
|
||||
/// of the local payment_providers 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,
|
||||
body: Option<Json<DisconnectReq>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = crate::api::admin::request_context(&headers);
|
||||
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||
|
||||
let cfg = btcpay_cfg::load(&state.db)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
let Some(cfg) = cfg else {
|
||||
let provider_row = match req.provider_id.as_deref() {
|
||||
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, pid)
|
||||
.await?
|
||||
.filter(|p| p.kind == "btcpay"),
|
||||
None => {
|
||||
// Default-profile fallback for the existing admin UI.
|
||||
let default = crate::merchant_profiles::require_default(&state.db).await?;
|
||||
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
|
||||
.await?;
|
||||
rows.into_iter().find(|p| p.kind == "btcpay")
|
||||
}
|
||||
};
|
||||
let Some(provider_row) = provider_row else {
|
||||
return Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": true,
|
||||
"message": "BTCPay was not connected; nothing to do.",
|
||||
"message": "no BTCPay provider connected on the named profile",
|
||||
})));
|
||||
};
|
||||
|
||||
// Capture metadata for the response BEFORE we clear local state.
|
||||
let store_id = cfg.store_id.clone();
|
||||
let webhook_id = cfg.webhook_id.clone();
|
||||
let provider_id = provider_row.id.clone();
|
||||
let store_id = provider_row.store_id.clone().unwrap_or_default();
|
||||
let webhook_id = provider_row.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
|
||||
@@ -377,9 +553,9 @@ pub async fn disconnect(
|
||||
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,
|
||||
&provider_row.base_url,
|
||||
&provider_row.api_key,
|
||||
&store_id,
|
||||
webhook_id,
|
||||
)
|
||||
.await
|
||||
@@ -390,52 +566,35 @@ pub async fn disconnect(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
|
||||
if let Err(e) = btcpay_client::revoke_api_key(&provider_row.base_url, &provider_row.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)?;
|
||||
crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?;
|
||||
|
||||
// Replace the runtime payment provider so subsequent purchase
|
||||
// attempts return BtcpayNotConfigured cleanly.
|
||||
// Clear the back-compat singleton if it was holding this one.
|
||||
state.clear_payment_provider().await;
|
||||
|
||||
// If BTCPay was the recorded active-provider preference, clear
|
||||
// it. Don't blindly clear if it was Zaprite — different operator
|
||||
// intent.
|
||||
if matches!(
|
||||
crate::payment::read_active_provider_preference(&state.db).await,
|
||||
Some(crate::payment::ProviderKind::Btcpay)
|
||||
) {
|
||||
let _ = crate::db::repo::settings_set(
|
||||
&state.db,
|
||||
crate::payment::SETTING_ACTIVE_PROVIDER,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"btcpay.disconnect",
|
||||
Some("btcpay_config"),
|
||||
None,
|
||||
"payment_provider.disconnect",
|
||||
Some("payment_provider"),
|
||||
Some(&provider_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
|
||||
&json!({ "kind": "btcpay", "store_id": store_id, "webhook_id": webhook_id }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": false,
|
||||
"provider_id": provider_id,
|
||||
"store_id": store_id,
|
||||
"webhook_id": webhook_id,
|
||||
"warnings": warnings,
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
//! buyer-facing surface — easy to deploy, no asset hosting required.
|
||||
|
||||
use crate::api::AppState;
|
||||
// Reuse the canonical HTML escaper (escapes `'` as well as `&<>"`) instead of a
|
||||
// private copy, so the buyer-facing page can't fall behind on attribute escaping.
|
||||
use crate::api::html_escape;
|
||||
use crate::db::repo;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
@@ -1533,13 +1536,6 @@ code{{background:#eee;padding:0.1em 0.4em;border-radius:4px;font-family:ui-monos
|
||||
)
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
use crate::analytics::{
|
||||
self, SETTING_COLLECTOR_URL, SETTING_ENABLED, SETTING_INSTALL_UUID,
|
||||
};
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -31,7 +31,7 @@ pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "community:read").await?;
|
||||
let enabled = analytics::is_enabled(&state).await;
|
||||
let collector_url = repo::settings_get(&state.db, SETTING_COLLECTOR_URL).await?;
|
||||
let install_uuid = repo::settings_get(&state.db, SETTING_INSTALL_UUID).await?;
|
||||
@@ -84,7 +84,7 @@ pub async fn set(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<SetReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "community:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Validate URL shape if one was supplied. We don't try to reach
|
||||
@@ -154,7 +154,7 @@ pub async fn reset(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "community:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::settings_set(&state.db, SETTING_INSTALL_UUID, None).await?;
|
||||
let _ = repo::insert_audit(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 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::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -67,7 +67,7 @@ pub async fn create(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateDiscountCodeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Tier-cap gate: Creator caps at 5 active discount codes.
|
||||
@@ -200,7 +200,7 @@ pub async fn list(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "discount_codes:read").await?;
|
||||
let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?;
|
||||
Ok(Json(json!({ "codes": codes })))
|
||||
}
|
||||
@@ -210,7 +210,7 @@ pub async fn get_one(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "discount_codes:read").await?;
|
||||
let code = repo::get_discount_code_by_id(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
|
||||
@@ -271,7 +271,7 @@ pub async fn update(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateDiscountCodeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Resolve policy_slugs → policy ids using the code's EXISTING product
|
||||
@@ -360,7 +360,7 @@ pub async fn set_active(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_discount_code_active(&state.db, &id, req.active).await?;
|
||||
let action = if req.active {
|
||||
@@ -392,7 +392,7 @@ pub async fn delete(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Look up the code so we can audit-log meaningful detail.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
//! 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::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::crypto;
|
||||
use crate::db::repo;
|
||||
@@ -261,7 +261,7 @@ pub async fn admin_list(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<AdminListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "machines:read").await?;
|
||||
|
||||
// Resolve product_slug → product_id if the caller passed the slug
|
||||
// form. Either works; product_id takes precedence on conflict.
|
||||
@@ -299,7 +299,7 @@ pub async fn admin_deactivate(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<AdminDeactivateReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "machines:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = if req.reason.is_empty() {
|
||||
"admin deactivate".to_string()
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Admin CRUD endpoints for merchant profiles + rail preferences.
|
||||
//!
|
||||
//! Thin Axum handlers wrapping the business-logic helpers in
|
||||
//! `crate::merchant_profiles` and the rail-preference repo helpers.
|
||||
//! Consumed by the new Merchant Profiles section of the admin UI.
|
||||
|
||||
use crate::api::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::merchant_profiles::{
|
||||
self, MerchantProfile, MerchantProfileUpdate, NewMerchantProfile,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn profile_to_json(p: &MerchantProfile, with_providers: Option<&[crate::db::repo::PaymentProviderRow]>) -> Value {
|
||||
let mut obj = json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"legal_name": p.legal_name,
|
||||
"support_url": p.support_url,
|
||||
"support_email": p.support_email,
|
||||
"brand_color": p.brand_color,
|
||||
"post_purchase_redirect_url": p.post_purchase_redirect_url,
|
||||
"is_default": p.is_default,
|
||||
// SMTP credentials are redacted in list/get responses — operators
|
||||
// see whether they're set, not the password itself. The edit
|
||||
// form submits new credentials only when the operator explicitly
|
||||
// wants to rotate them.
|
||||
"smtp_configured": p.smtp_host.is_some(),
|
||||
"smtp_host": p.smtp_host,
|
||||
"smtp_port": p.smtp_port,
|
||||
"smtp_username": p.smtp_username,
|
||||
"smtp_from_address": p.smtp_from_address,
|
||||
"smtp_from_name": p.smtp_from_name,
|
||||
"smtp_use_starttls": p.smtp_use_starttls,
|
||||
"created_at": p.created_at,
|
||||
"updated_at": p.updated_at,
|
||||
});
|
||||
if let Some(providers) = with_providers {
|
||||
let arr: Vec<Value> = providers
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let rails: Vec<&'static str> = crate::payment::ProviderKind::parse(&row.kind)
|
||||
.map(|kind| {
|
||||
crate::payment::rails_for_kind(kind)
|
||||
.into_iter()
|
||||
.map(|r| r.as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"label": row.label,
|
||||
"base_url": row.base_url,
|
||||
"store_id": row.store_id,
|
||||
"webhook_id": row.webhook_id,
|
||||
"connected_at": row.connected_at,
|
||||
"served_rails": rails,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
obj["providers"] = json!(arr);
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/merchant-profiles` — list every profile + a brief
|
||||
/// summary of attached providers per profile.
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_scope(&state, &headers, "merchant_profiles:read").await?;
|
||||
let profiles = merchant_profiles::list(&state.db).await?;
|
||||
let mut out: Vec<Value> = Vec::with_capacity(profiles.len());
|
||||
for p in &profiles {
|
||||
let providers = crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?;
|
||||
out.push(profile_to_json(p, Some(&providers)));
|
||||
}
|
||||
Ok(Json(json!({ "profiles": out })))
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/merchant-profiles/:id` — full detail for a profile,
|
||||
/// including providers + rail preferences.
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_scope(&state, &headers, "merchant_profiles:read").await?;
|
||||
let profile = merchant_profiles::get(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("merchant profile {id}")))?;
|
||||
let providers = crate::db::repo::list_payment_providers_for_profile(&state.db, &id).await?;
|
||||
let rail_prefs = crate::db::repo::list_rail_preferences_for_profile(&state.db, &id).await?;
|
||||
let mut obj = profile_to_json(&profile, Some(&providers));
|
||||
obj["rail_preferences"] = json!(rail_prefs
|
||||
.into_iter()
|
||||
.map(|p| json!({ "rail": p.rail, "payment_provider_id": p.payment_provider_id }))
|
||||
.collect::<Vec<_>>());
|
||||
let product_count =
|
||||
crate::db::repo::count_products_for_profile(&state.db, &id)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
let active_subscription_count =
|
||||
crate::db::repo::count_active_subscriptions_for_profile(&state.db, &id)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
obj["product_count"] = json!(product_count);
|
||||
obj["active_subscription_count"] = json!(active_subscription_count);
|
||||
Ok(Json(obj))
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/merchant-profiles` — create a new profile.
|
||||
/// Tier-gated: Creator hits cap on the second profile.
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<NewMerchantProfile>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let created = merchant_profiles::create(&state, req).await?;
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"merchant_profile.create",
|
||||
Some("merchant_profile"),
|
||||
Some(&created.id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "name": created.name }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(profile_to_json(&created, None)))
|
||||
}
|
||||
|
||||
/// `PATCH /v1/admin/merchant-profiles/:id` — partial update.
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<MerchantProfileUpdate>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let updated = merchant_profiles::update(&state.db, &id, patch).await?;
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"merchant_profile.update",
|
||||
Some("merchant_profile"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "name": updated.name }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(profile_to_json(&updated, None)))
|
||||
}
|
||||
|
||||
/// `DELETE /v1/admin/merchant-profiles/:id` — delete a non-default
|
||||
/// profile with no attached products or active subscriptions.
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
merchant_profiles::delete(&state.db, &id).await?;
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"merchant_profile.delete",
|
||||
Some("merchant_profile"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({}),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true, "id": id })))
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/merchant-profiles/:id/set-default` — flip the
|
||||
/// default-profile flag to this id.
|
||||
pub async fn set_default(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
merchant_profiles::set_default(&state.db, &id).await?;
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"merchant_profile.set_default",
|
||||
Some("merchant_profile"),
|
||||
Some(&id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({}),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({ "ok": true, "id": id })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Rail preferences
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetRailPreferenceReq {
|
||||
pub payment_provider_id: String,
|
||||
}
|
||||
|
||||
/// `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` —
|
||||
/// pin the provider that should serve this rail on this profile.
|
||||
/// Validates that the provider belongs to the profile AND serves
|
||||
/// the requested rail before persisting.
|
||||
pub async fn set_rail_preference(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((profile_id, rail)): Path<(String, String)>,
|
||||
Json(req): Json<SetRailPreferenceReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
// Validate the rail name.
|
||||
let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| {
|
||||
AppError::BadRequest(format!(
|
||||
"unknown rail '{rail}'; accepted: lightning, onchain, card"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Validate the provider exists, belongs to THIS profile, and serves
|
||||
// THIS rail.
|
||||
let provider_row = crate::db::repo::get_payment_provider_by_id(
|
||||
&state.db,
|
||||
&req.payment_provider_id,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("payment provider {} not found", req.payment_provider_id))
|
||||
})?;
|
||||
if provider_row.merchant_profile_id != profile_id {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"payment provider {} is not attached to merchant profile {profile_id}",
|
||||
req.payment_provider_id
|
||||
)));
|
||||
}
|
||||
let served = crate::payment::ProviderKind::parse(&provider_row.kind)
|
||||
.map(crate::payment::rails_for_kind)
|
||||
.unwrap_or_default();
|
||||
if !served.contains(&parsed_rail) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"payment provider {} (kind={}) does not serve the '{rail}' rail; \
|
||||
pick a provider that does, or remove this preference",
|
||||
req.payment_provider_id, provider_row.kind
|
||||
)));
|
||||
}
|
||||
|
||||
crate::db::repo::set_rail_preference(
|
||||
&state.db,
|
||||
&profile_id,
|
||||
parsed_rail.as_str(),
|
||||
&req.payment_provider_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"merchant_profile.rail_preference.set",
|
||||
Some("merchant_profile"),
|
||||
Some(&profile_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "rail": parsed_rail.as_str(), "payment_provider_id": req.payment_provider_id }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"merchant_profile_id": profile_id,
|
||||
"rail": parsed_rail.as_str(),
|
||||
"payment_provider_id": req.payment_provider_id,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `DELETE /v1/admin/merchant-profiles/:id/rail-preferences/:rail` —
|
||||
/// clear a rail preference, letting the deterministic-earliest-connected
|
||||
/// fallback take over.
|
||||
pub async fn clear_rail_preference(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((profile_id, rail)): Path<(String, String)>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| {
|
||||
AppError::BadRequest(format!(
|
||||
"unknown rail '{rail}'; accepted: lightning, onchain, card"
|
||||
))
|
||||
})?;
|
||||
crate::db::repo::clear_rail_preference(&state.db, &profile_id, parsed_rail.as_str()).await?;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"merchant_profile.rail_preference.clear",
|
||||
Some("merchant_profile"),
|
||||
Some(&profile_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "rail": parsed_rail.as_str() }),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"merchant_profile_id": profile_id,
|
||||
"rail": parsed_rail.as_str(),
|
||||
})))
|
||||
}
|
||||
@@ -75,6 +75,7 @@ pub mod tier;
|
||||
pub mod validate;
|
||||
pub mod community;
|
||||
pub mod db_info;
|
||||
pub mod merchant_profiles;
|
||||
pub mod payment_provider;
|
||||
pub mod rates_admin;
|
||||
pub mod recover;
|
||||
@@ -107,6 +108,15 @@ pub struct AppState {
|
||||
/// `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>>>>,
|
||||
/// Test-only injection seam. When `Some`, the merchant-profile
|
||||
/// resolver (`resolve_provider_for_profile_rail`, `payment_provider_by_id`)
|
||||
/// returns THIS provider instead of constructing a real BTCPay/Zaprite
|
||||
/// client from the DB row via `payment::build_provider`. The DB still
|
||||
/// drives profile/rail/row resolution, so that logic is exercised for
|
||||
/// real — only the network-talking impl is swapped. Always `None` in
|
||||
/// production (`main.rs`); set by integration tests so they can drive
|
||||
/// the real purchase/settle path with a `MockPaymentProvider`.
|
||||
pub provider_override: 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.
|
||||
@@ -174,6 +184,150 @@ impl AppState {
|
||||
let mut guard = self.payment.write().await;
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Merchant-profile-aware resolution layer (migration 0020+)
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// The legacy `payment_provider()` / `set_payment_provider()` accessors
|
||||
// above continue to work as a "default provider for the default
|
||||
// profile" compatibility shim during the multi-provider transition.
|
||||
// New call sites should use one of the methods below instead.
|
||||
|
||||
/// Look up a payment provider by its row id. Reads the row from the
|
||||
/// DB, instantiates a typed `PaymentProvider` impl via
|
||||
/// `payment::build_provider`. Not cached today — the caller is
|
||||
/// usually invoking it once per request lifecycle so the cost is
|
||||
/// nil. Add a cache here when profiling says we need one.
|
||||
pub async fn payment_provider_by_id(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
|
||||
let row = crate::db::repo::get_payment_provider_by_id(&self.db, provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("payment provider {provider_id}"))
|
||||
})?;
|
||||
self.provider_from_row(&row)
|
||||
}
|
||||
|
||||
/// Instantiate a `PaymentProvider` from a resolved DB row, honoring the
|
||||
/// test-only `provider_override` seam. In production `provider_override`
|
||||
/// is always `None`, so this just delegates to `payment::build_provider`.
|
||||
fn provider_from_row(
|
||||
&self,
|
||||
row: &crate::db::repo::PaymentProviderRow,
|
||||
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
|
||||
if let Some(p) = &self.provider_override {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
crate::payment::build_provider(row, self.config.btcpay_public_url.as_deref())
|
||||
.map_err(AppError::Internal)
|
||||
}
|
||||
|
||||
/// Resolve the merchant profile a product belongs to. Falls back to
|
||||
/// the default profile if the product has no `merchant_profile_id`
|
||||
/// set (defensive — shouldn't happen post-migration, but handles
|
||||
/// any rows that slip through).
|
||||
pub async fn merchant_profile_for_product(
|
||||
&self,
|
||||
product_id: &str,
|
||||
) -> AppResult<crate::merchant_profiles::MerchantProfile> {
|
||||
crate::merchant_profiles::for_product(self, product_id).await
|
||||
}
|
||||
|
||||
/// Pick the provider on `profile_id` that serves the given `rail`.
|
||||
/// Resolution order:
|
||||
/// 1. Honor `merchant_profile_rail_preferences` if the operator
|
||||
/// set an explicit preference for this (profile, rail) pair.
|
||||
/// 2. If exactly one attached provider serves the rail, use it.
|
||||
/// 3. If multiple serve the rail and no preference is set, use
|
||||
/// the earliest-`connected_at` one (deterministic) and log a
|
||||
/// warning so the operator knows to set an explicit preference.
|
||||
/// 4. If no attached provider serves the rail, return
|
||||
/// `AppError::BadRequest` — caller should treat this as
|
||||
/// "buyer's pick isn't available for this merchant."
|
||||
pub async fn resolve_provider_for_profile_rail(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
rail: crate::payment::Rail,
|
||||
) -> AppResult<(crate::db::repo::PaymentProviderRow, Arc<dyn crate::payment::PaymentProvider>)> {
|
||||
// 1. Check the explicit preference table first.
|
||||
let preferences = crate::db::repo::list_rail_preferences_for_profile(&self.db, profile_id).await?;
|
||||
if let Some(pref) = preferences.iter().find(|p| p.rail == rail.as_str()) {
|
||||
let row = crate::db::repo::get_payment_provider_by_id(&self.db, &pref.payment_provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"rail preference points at missing provider {}",
|
||||
pref.payment_provider_id
|
||||
))
|
||||
})?;
|
||||
let provider = self.provider_from_row(&row)?;
|
||||
return Ok((row, provider));
|
||||
}
|
||||
|
||||
// 2. + 3. No explicit preference — find providers on this profile
|
||||
// whose kind serves the requested rail.
|
||||
let providers = crate::db::repo::list_payment_providers_for_profile(&self.db, profile_id).await?;
|
||||
let mut candidates: Vec<&crate::db::repo::PaymentProviderRow> = providers
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
crate::payment::ProviderKind::parse(&row.kind)
|
||||
.map(|kind| crate::payment::rails_for_kind(kind).contains(&rail))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
// Earliest-connected-first is already the order from list_payment_providers_for_profile
|
||||
// (ORDER BY connected_at ASC), but be explicit for clarity.
|
||||
candidates.sort_by(|a, b| a.connected_at.cmp(&b.connected_at));
|
||||
|
||||
match candidates.as_slice() {
|
||||
[] => Err(AppError::BadRequest(format!(
|
||||
"merchant profile {profile_id} has no provider that serves the '{}' rail. \
|
||||
Connect one in the admin UI's Merchant Profiles page, or set a rail \
|
||||
preference if multiple providers serve this rail.",
|
||||
rail.as_str()
|
||||
))),
|
||||
[only] => {
|
||||
let row = (*only).clone();
|
||||
let provider = self.provider_from_row(&row)?;
|
||||
Ok((row, provider))
|
||||
}
|
||||
[first, ..] => {
|
||||
tracing::warn!(
|
||||
profile_id = %profile_id,
|
||||
rail = rail.as_str(),
|
||||
chosen = %first.id,
|
||||
candidate_count = candidates.len(),
|
||||
"multiple providers serve this rail on the profile; using earliest-connected \
|
||||
deterministically. Set an explicit rail preference in the admin UI to silence \
|
||||
this warning."
|
||||
);
|
||||
let row = (*first).clone();
|
||||
let provider = self.provider_from_row(&row)?;
|
||||
Ok((row, provider))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for the most common purchase-flow case: given a
|
||||
/// product id and a buyer-picked rail, resolve to (profile, provider
|
||||
/// row, provider impl). Used by `purchase.rs` and `subscriptions.rs`
|
||||
/// renewals.
|
||||
pub async fn resolve_provider_for_product_rail(
|
||||
&self,
|
||||
product_id: &str,
|
||||
rail: crate::payment::Rail,
|
||||
) -> AppResult<(
|
||||
crate::merchant_profiles::MerchantProfile,
|
||||
crate::db::repo::PaymentProviderRow,
|
||||
Arc<dyn crate::payment::PaymentProvider>,
|
||||
)> {
|
||||
let profile = self.merchant_profile_for_product(product_id).await?;
|
||||
let (row, provider) = self.resolve_provider_for_profile_rail(&profile.id, rail).await?;
|
||||
Ok((profile, row, provider))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for SqlitePool {
|
||||
@@ -215,6 +369,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/v1/machines/heartbeat", post(machines::heartbeat))
|
||||
.route("/v1/machines/deactivate", post(machines::deactivate))
|
||||
.route("/v1/btcpay/webhook", post(webhook::handle))
|
||||
.route("/v1/btcpay/webhook/:provider_id", post(webhook::handle_for_provider))
|
||||
.route(
|
||||
"/v1/admin/btcpay/connect",
|
||||
post(btcpay_authorize::start_connect),
|
||||
@@ -252,15 +407,35 @@ pub fn router(state: AppState) -> Router {
|
||||
get(zaprite_authorize::status),
|
||||
)
|
||||
// Provider-agnostic active-payment-provider control.
|
||||
// Operators with both BTCPay and Zaprite configured can flip
|
||||
// the active one without re-running Connect.
|
||||
// Back-compat snapshot of the default profile's providers. The
|
||||
// legacy `activate` endpoint is removed — in the merchant-profile
|
||||
// model providers attach to profiles and products pick a profile
|
||||
// at resolution time; there's no singleton "active" preference to
|
||||
// flip. Multi-profile operators should use the new
|
||||
// /v1/admin/merchant-profiles endpoints below.
|
||||
.route(
|
||||
"/v1/admin/payment-provider/status",
|
||||
get(payment_provider::status),
|
||||
)
|
||||
// Merchant profile CRUD + rail preferences.
|
||||
.route(
|
||||
"/v1/admin/payment-provider/activate",
|
||||
post(payment_provider::activate),
|
||||
"/v1/admin/merchant-profiles",
|
||||
get(merchant_profiles::list).post(merchant_profiles::create),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/merchant-profiles/:id",
|
||||
get(merchant_profiles::get)
|
||||
.patch(merchant_profiles::update)
|
||||
.delete(merchant_profiles::delete),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/merchant-profiles/:id/set-default",
|
||||
post(merchant_profiles::set_default),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/merchant-profiles/:id/rail-preferences/:rail",
|
||||
axum::routing::put(merchant_profiles::set_rail_preference)
|
||||
.delete(merchant_profiles::clear_rail_preference),
|
||||
)
|
||||
// Zaprite webhook landing — operator points Zaprite's
|
||||
// webhook setting at this URL. Same handler as
|
||||
@@ -268,6 +443,7 @@ pub fn router(state: AppState) -> Router {
|
||||
// is on the trait surface and the active provider self-
|
||||
// identifies its event shape.
|
||||
.route("/v1/zaprite/webhook", post(webhook::handle))
|
||||
.route("/v1/zaprite/webhook/:provider_id", post(webhook::handle_for_provider))
|
||||
.route("/v1/admin/products", post(admin::create_product))
|
||||
.route(
|
||||
"/v1/admin/products/:id",
|
||||
@@ -573,6 +749,75 @@ async fn thank_you(
|
||||
.or(state.config.operator_name.as_deref())
|
||||
.unwrap_or("Keysat");
|
||||
let operator = html_escape(operator_str);
|
||||
|
||||
// Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning
|
||||
// + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus
|
||||
// Bitcoin. The lede and the polling-status copy reflect which payment
|
||||
// rails actually settled THIS invoice, not "the currently active
|
||||
// provider" (which is meaningless in the multi-provider model).
|
||||
//
|
||||
// Look up the invoice's own `payment_provider_id` (recorded by
|
||||
// migration 0021) → resolve to its kind via payment_providers. Falls
|
||||
// back to whichever provider is attached to the default profile if
|
||||
// the invoice predates 0021, then to BTCPay if even THAT can't be
|
||||
// resolved (operator visited /thank-you with no providers connected
|
||||
// at all — rare).
|
||||
let invoice_provider_kind: Option<crate::payment::ProviderKind> = if !invoice_id.is_empty() {
|
||||
let row: Option<(Option<String>,)> = sqlx::query_as(
|
||||
"SELECT i.payment_provider_id FROM invoices i WHERE i.id = ? LIMIT 1",
|
||||
)
|
||||
.bind(&invoice_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match row.and_then(|(pid,)| pid) {
|
||||
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, &pid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|p| crate::payment::ProviderKind::parse(&p.kind)),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let provider_kind = match invoice_provider_kind {
|
||||
Some(k) => Some(k),
|
||||
None => {
|
||||
// Fall back to the default profile's first provider.
|
||||
let default = crate::merchant_profiles::get_default(&state.db).await.ok().flatten();
|
||||
match default {
|
||||
Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|rows| rows.into_iter().next())
|
||||
.and_then(|row| crate::payment::ProviderKind::parse(&row.kind)),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let (lede_text, provider_kind_str) = match provider_kind {
|
||||
Some(crate::payment::ProviderKind::Zaprite) => (
|
||||
"Your payment was received. We\u{2019}re waiting for it to settle and \
|
||||
for the license to be signed. Card payments confirm in seconds; \
|
||||
Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically \
|
||||
settles in 10\u{2013}20 minutes (one block confirmation).",
|
||||
"zaprite",
|
||||
),
|
||||
// BTCPay or unconfigured → original Bitcoin-only copy. Unconfigured
|
||||
// is rare on this page (operator hit /thank-you without a provider
|
||||
// connected) so we keep it Bitcoin-flavored rather than introducing
|
||||
// a third "unknown" branch.
|
||||
_ => (
|
||||
"Your Bitcoin payment was received. We\u{2019}re waiting for it to settle \
|
||||
and for the license to be signed. Lightning settles in seconds; on-chain \
|
||||
typically settles in 10\u{2013}20 minutes (one block confirmation).",
|
||||
"btcpay",
|
||||
),
|
||||
};
|
||||
let provider_kind_json = serde_json::to_string(provider_kind_str)
|
||||
.unwrap_or_else(|_| "\"btcpay\"".into());
|
||||
let body = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -748,7 +993,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
<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 and for the license to be signed. Lightning settles in seconds; on-chain typically settles in 10–20 minutes (one block confirmation).</p>
|
||||
<p class="lede" id="page-lede">{lede_text}</p>
|
||||
|
||||
<!-- pending state (default): polling for the license -->
|
||||
<div class="pending-card" id="pending-card">
|
||||
@@ -788,6 +1033,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
<script>
|
||||
(function() {{
|
||||
const INVOICE_ID = {invoice_id_json};
|
||||
// 'zaprite' | 'btcpay' — selects which payment-rail copy the
|
||||
// polling status uses (Zaprite: card + Lightning + on-chain; BTCPay:
|
||||
// Lightning + on-chain only).
|
||||
const PROVIDER_KIND = {provider_kind_json};
|
||||
if (!INVOICE_ID) {{
|
||||
document.getElementById('pending-card').classList.add('hide');
|
||||
document.getElementById('error-card').classList.remove('hide');
|
||||
@@ -857,10 +1106,21 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
|
||||
function waitingCopy(status) {{
|
||||
const min = Math.floor(elapsedMs / 60000);
|
||||
const isZaprite = PROVIDER_KIND === 'zaprite';
|
||||
if (status === 'pending' || status === 'processing') {{
|
||||
if (min < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
|
||||
if (min < 10) return 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
|
||||
return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
|
||||
if (min < 2) {{
|
||||
return isZaprite
|
||||
? 'invoice ' + status + ' — card payments confirm in seconds; Bitcoin Lightning in seconds; on-chain takes a block (~10 min).'
|
||||
: 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
|
||||
}}
|
||||
if (min < 10) {{
|
||||
return isZaprite
|
||||
? 'invoice ' + status + ' — waiting for confirmation. Card auth or on-chain Bitcoin can take a few minutes. Safe to leave this tab open or bookmark this URL.'
|
||||
: 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
|
||||
}}
|
||||
return isZaprite
|
||||
? 'invoice ' + status + ' — slow confirmation. Still polling. Bookmark this URL and refresh later if you close the tab.'
|
||||
: 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
|
||||
}}
|
||||
return 'invoice status: ' + (status || 'pending');
|
||||
}}
|
||||
@@ -933,3 +1193,22 @@ async fn pubkey(
|
||||
"public_key_pem": state.keypair.public_key_pem,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The canonical escaper must cover the single quote — operator/product/
|
||||
/// discount-code text renders into HTML attributes (incl. single-quoted),
|
||||
/// so omitting `'` is an injection hole. Guards against re-forking a copy
|
||||
/// that drops it (the bug that lived in `buy_page.rs`).
|
||||
#[test]
|
||||
fn html_escape_covers_single_quote_and_friends() {
|
||||
assert_eq!(html_escape("'"), "'");
|
||||
assert_eq!(
|
||||
html_escape(r#"<a href='x' title="y">&</a>"#),
|
||||
"<a href='x' title="y">&</a>"
|
||||
);
|
||||
assert_eq!(html_escape("plain"), "plain");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const SPEC_JSON: &str = r##"{
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, or full-admin."
|
||||
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, merchant-onboard, or full-admin."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
@@ -86,9 +86,9 @@ const SPEC_JSON: &str = r##"{
|
||||
"slug": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"price_sats": { "type": "integer", "nullable": true },
|
||||
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true },
|
||||
"price_value": { "type": "integer", "nullable": true },
|
||||
"price_sats": { "type": "integer", "nullable": true, "description": "Legacy SAT price. Still accepted on create for backward compatibility; new callers should send price_value + price_currency instead. Also returned in responses (derived from price_value when that path is used)." },
|
||||
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true, "description": "Currency for price_value. Defaults to SAT." },
|
||||
"price_value": { "type": "integer", "nullable": true, "description": "Write field: price in the smallest unit of price_currency (sats for SAT, cents for USD/EUR). Send together with price_currency." },
|
||||
"active": { "type": "boolean" },
|
||||
"entitlements_catalog": {
|
||||
"type": "array",
|
||||
@@ -263,7 +263,7 @@ const SPEC_JSON: &str = r##"{
|
||||
"/v1/admin/licenses": {
|
||||
"get": {
|
||||
"summary": "List licenses",
|
||||
"description": "Scope required: `licenses:read`. Filter by status, product_slug, buyer_email, expiring soon, etc. via query params.",
|
||||
"description": "Scope required: `licenses:read`. Requires `product_id=<uuid>` (the product's UUID, not its slug); returns that product's licenses. Use `GET /v1/admin/licenses/search` to look up by buyer_email or invoice id.",
|
||||
"responses": { "200": { "description": "License list" } }
|
||||
},
|
||||
"post": {
|
||||
@@ -272,6 +272,13 @@ const SPEC_JSON: &str = r##"{
|
||||
"responses": { "200": { "description": "Issued license" } }
|
||||
}
|
||||
},
|
||||
"/v1/admin/licenses/search": {
|
||||
"get": {
|
||||
"summary": "Search licenses",
|
||||
"description": "Scope required: `licenses:read`. Look up licenses by `buyer_email`, `nostr_npub`, or `invoice_id` (whichever is supplied). With no filter, returns the 100 most-recent licenses. The `license_key` is never returned here (only on issue / recover).",
|
||||
"responses": { "200": { "description": "Matching licenses" } }
|
||||
}
|
||||
},
|
||||
"/v1/admin/licenses/{id}/revoke": {
|
||||
"post": {
|
||||
"summary": "Revoke a license",
|
||||
@@ -301,11 +308,6 @@ const SPEC_JSON: &str = r##"{
|
||||
}
|
||||
},
|
||||
"/v1/admin/products": {
|
||||
"get": {
|
||||
"summary": "List products",
|
||||
"description": "Scope required: `products:read`.",
|
||||
"responses": { "200": { "description": "Product list" } }
|
||||
},
|
||||
"post": {
|
||||
"summary": "Create a product",
|
||||
"description": "Scope required: `products:write`.",
|
||||
@@ -398,7 +400,8 @@ const SPEC_JSON: &str = r##"{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
|
||||
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "full-admin"] }
|
||||
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] },
|
||||
"scopes": { "type": "array", "items": { "type": "string", "enum": ["payment_providers:write"] }, "description": "A-la-carte extra scopes granted on top of the role. Only payment_providers:write today: lets the key connect a non-mainnet BTCPay provider on a sandbox daemon. In no role by default." }
|
||||
},
|
||||
"required": ["label", "role"]
|
||||
} } }
|
||||
@@ -416,9 +419,44 @@ const SPEC_JSON: &str = r##"{
|
||||
"/v1/admin/tier": {
|
||||
"get": {
|
||||
"summary": "Get this daemon's tier + usage + caps",
|
||||
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.",
|
||||
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier. Includes a read-only `sandbox` boolean (true when KEYSAT_SANDBOX_MODE is set).",
|
||||
"responses": { "200": { "description": "Tier info" } }
|
||||
}
|
||||
},
|
||||
"/v1/admin/btcpay/connect": {
|
||||
"post": {
|
||||
"summary": "Start a BTCPay provider connect",
|
||||
"description": "Returns a one-time `state` token and the BTCPay authorize URL; complete the connect at /v1/btcpay/authorize/callback. The master key may connect any network. A scoped key needs the `payment_providers:write` extra scope AND a sandbox daemon (KEYSAT_SANDBOX_MODE); the target store must resolve to a non-mainnet network or the callback refuses. Optional JSON body: { merchant_profile_id }.",
|
||||
"responses": {
|
||||
"200": { "description": "{ authorize_url, state, merchant_profile_id }" },
|
||||
"403": { "description": "Scoped key without payment_providers:write, or not a sandbox daemon" },
|
||||
"409": { "description": "Profile already has a BTCPay provider; disconnect first" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/btcpay/authorize/callback": {
|
||||
"get": {
|
||||
"summary": "Complete a BTCPay connect",
|
||||
"description": "BTCPay redirects here after the operator approves in a browser, or an agent calls it directly with a pre-issued store API key. Query params: `state` (from /connect) and `apiKey` (a BTCPay store key with the same store-settings + invoice permissions the browser flow grants). Keysat resolves the store's network and, for a scoped initiator, refuses anything not provably non-mainnet (fail-closed). No auth header; the single-use `state` token is the tie. A refusal returns a 4xx on both the GET and POST forms.",
|
||||
"responses": {
|
||||
"200": { "description": "Connected (HTML confirmation page)" },
|
||||
"400": { "description": "Scoped connect to a mainnet/undetermined store; nothing persisted" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/admin/btcpay/status": {
|
||||
"get": {
|
||||
"summary": "BTCPay connection status (default profile)",
|
||||
"description": "Requires payment_providers:read. Returns { connected, store_id, base_url, webhook_id, ... }.",
|
||||
"responses": { "200": { "description": "Connection status" } }
|
||||
}
|
||||
},
|
||||
"/v1/admin/btcpay/disconnect": {
|
||||
"post": {
|
||||
"summary": "Disconnect a BTCPay provider",
|
||||
"description": "Master admin key required, on any daemon. Best-effort revokes the webhook + key on BTCPay, then clears the local provider row.",
|
||||
"responses": { "200": { "description": "Disconnected (or no-op)" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
|
||||
@@ -1,140 +1,64 @@
|
||||
//! Active-provider swap endpoint.
|
||||
//! Payment-provider status endpoint (multi-merchant-profile model).
|
||||
//!
|
||||
//! When an operator has both BTCPay AND Zaprite configured (i.e.,
|
||||
//! they ran Connect on both at some point), this lets them flip
|
||||
//! the active one without re-authorizing. The Connect flows are
|
||||
//! still where credentials live; this endpoint only changes which
|
||||
//! credentials the daemon currently routes through.
|
||||
//! Pre-:52 this module held two endpoints:
|
||||
//! - `GET /v1/admin/payment-provider/status` — which provider was
|
||||
//! active, plus configured flags for BTCPay + Zaprite.
|
||||
//! - `POST /v1/admin/payment-provider/activate` — flip the singleton
|
||||
//! active-provider preference between two configured ones.
|
||||
//!
|
||||
//! Both became meaningless in the merchant-profile model — providers
|
||||
//! aren't "active," they attach to profiles, and products pick a profile
|
||||
//! at the resolution layer. The activate endpoint is removed. The status
|
||||
//! endpoint stays as a back-compat shim so the existing admin UI's
|
||||
//! payment-providers card keeps rendering until the new Merchant
|
||||
//! Profiles UI replaces it: it now reports against the DEFAULT profile
|
||||
//! (single-profile operators see no change). Multi-profile operators
|
||||
//! should use the new `/v1/admin/merchant-profiles` endpoints to see
|
||||
//! all providers across all profiles.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::admin::require_scope;
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::{
|
||||
self, btcpay::BtcpayProvider, zaprite::ZapriteProvider, ProviderKind,
|
||||
};
|
||||
use crate::error::AppResult;
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ActivateReq {
|
||||
/// `'btcpay'` or `'zaprite'`. Other values → 400.
|
||||
pub provider: String,
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/payment-provider/status` — both providers'
|
||||
/// configuration state at a glance, plus the active preference.
|
||||
/// Lets the SPA render a "BTCPay [active] / Zaprite [configured,
|
||||
/// not active]" header without two separate fetches.
|
||||
/// `GET /v1/admin/payment-provider/status` — back-compat snapshot of
|
||||
/// providers attached to the default merchant profile. Returns the same
|
||||
/// shape as pre-:52 with `btcpay_configured` / `zaprite_configured` /
|
||||
/// `active` for compatibility with the existing admin UI; new code
|
||||
/// should use `/v1/admin/merchant-profiles/{id}` instead.
|
||||
pub async fn status(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let btcpay_configured = crate::btcpay::config::load(&state.db)
|
||||
.await
|
||||
.map(|o| o.is_some())
|
||||
.unwrap_or(false);
|
||||
let zaprite_configured = payment::zaprite::config::load(&state.db)
|
||||
.await
|
||||
.map(|o| o.is_some())
|
||||
.unwrap_or(false);
|
||||
let preference = payment::read_active_provider_preference(&state.db).await;
|
||||
let active_runtime = match state.payment.read().await.as_ref() {
|
||||
Some(p) => Some(p.kind().as_str().to_string()),
|
||||
None => None,
|
||||
require_scope(&state, &headers, "payment_providers:read").await?;
|
||||
let default = crate::merchant_profiles::get_default(&state.db).await?;
|
||||
let providers = match &default {
|
||||
Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let btcpay_row = providers.iter().find(|p| p.kind == "btcpay").cloned();
|
||||
let zaprite_row = providers.iter().find(|p| p.kind == "zaprite").cloned();
|
||||
// "active" used to mean "the singleton active-provider preference."
|
||||
// In the new model there isn't one. For back-compat we report the
|
||||
// FIRST provider on the default profile (which is what the legacy
|
||||
// boot-loader semantics would have picked) so the existing admin UI
|
||||
// shows a sensible active badge. Multi-rail operators get the full
|
||||
// picture from the new merchant-profile endpoints.
|
||||
let active_runtime = providers.first().map(|p| p.kind.clone());
|
||||
Ok(Json(json!({
|
||||
"btcpay_configured": btcpay_configured,
|
||||
"zaprite_configured": zaprite_configured,
|
||||
"preferred": preference.map(|k| k.as_str().to_string()),
|
||||
"btcpay_configured": btcpay_row.is_some(),
|
||||
"zaprite_configured": zaprite_row.is_some(),
|
||||
"preferred": active_runtime.clone(),
|
||||
"active": active_runtime,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/payment-provider/activate` — swap the active
|
||||
/// provider to whichever already-configured one the operator
|
||||
/// names. 400 if the named provider isn't configured (run Connect
|
||||
/// first).
|
||||
pub async fn activate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ActivateReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let kind = match req.provider.to_lowercase().as_str() {
|
||||
"btcpay" => ProviderKind::Btcpay,
|
||||
"zaprite" => ProviderKind::Zaprite,
|
||||
other => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"unknown provider '{other}'; accepted: btcpay, zaprite"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// Build the provider from its persisted config. Refuse if the
|
||||
// config row isn't there — operator has to run Connect first.
|
||||
match kind {
|
||||
ProviderKind::Btcpay => {
|
||||
let cfg = crate::btcpay::config::load(&state.db)
|
||||
.await
|
||||
.map_err(AppError::Internal)?
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(
|
||||
"BTCPay not configured. Run Connect BTCPay first.".into(),
|
||||
)
|
||||
})?;
|
||||
let client = crate::btcpay::client::BtcpayClient::new(
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.store_id,
|
||||
);
|
||||
let provider = Arc::new(
|
||||
BtcpayProvider::new(client, cfg.webhook_secret)
|
||||
.with_public_base(state.config.btcpay_public_url.clone()),
|
||||
);
|
||||
state.set_payment_provider(provider).await;
|
||||
}
|
||||
ProviderKind::Zaprite => {
|
||||
crate::api::tier::enforce_zaprite_feature(&state).await?;
|
||||
let cfg = payment::zaprite::config::load(&state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))?
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(
|
||||
"Zaprite not configured. Run Connect Zaprite first.".into(),
|
||||
)
|
||||
})?;
|
||||
let client = payment::zaprite::ZapriteClient::new(&cfg.base_url, &cfg.api_key);
|
||||
let provider = Arc::new(ZapriteProvider::new(client));
|
||||
state.set_payment_provider(provider).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the preference so the boot-time loader picks the
|
||||
// same one on next restart.
|
||||
payment::write_active_provider_preference(&state.db, kind)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("write preference: {e:#}")))?;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"payment_provider.activate",
|
||||
Some("payment_provider"),
|
||||
Some(kind.as_str()),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "provider": kind.as_str() }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"active": kind.as_str(),
|
||||
"merchant_profile_id": default.as_ref().map(|p| p.id.clone()),
|
||||
"merchant_profile_name": default.as_ref().map(|p| p.name.clone()),
|
||||
"providers": providers.iter().map(|p| json!({
|
||||
"id": p.id,
|
||||
"kind": p.kind,
|
||||
"label": p.label,
|
||||
"base_url": p.base_url,
|
||||
"store_id": p.store_id,
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! 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::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -158,7 +158,7 @@ pub async fn create(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreatePolicyReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
|
||||
.await?
|
||||
@@ -289,7 +289,7 @@ pub async fn list(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListPoliciesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "policies:read").await?;
|
||||
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
|
||||
@@ -314,7 +314,7 @@ pub async fn set_active(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_policy_active(&state.db, &id, req.active).await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -351,7 +351,7 @@ pub async fn set_archived(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetArchivedReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_policy_archived(&state.db, &id, req.archived).await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -389,7 +389,7 @@ pub async fn delete(
|
||||
Path(id): Path<String>,
|
||||
Query(opts): Query<PolicyDeleteOpts>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let policy = repo::get_policy_by_id(&state.db, &id)
|
||||
@@ -606,7 +606,7 @@ pub async fn update(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdatePolicyReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
if let Some(d) = req.duration_seconds {
|
||||
@@ -739,7 +739,7 @@ pub async fn set_public(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetPublicReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_policy_public(&state.db, &id, req.public).await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -933,7 +933,7 @@ pub async fn set_tip(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetTipReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -992,7 +992,7 @@ pub async fn list_tips(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListTipsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "policies:read").await?;
|
||||
let entries = repo::list_tip_attempts(
|
||||
&state.db,
|
||||
q.license_id.as_deref(),
|
||||
|
||||
@@ -37,6 +37,14 @@ pub struct StartPurchaseReq {
|
||||
/// issuance time. When omitted, the daemon falls back to the product's
|
||||
/// default policy at issuance — same as pre-:27 behaviour.
|
||||
pub policy_slug: Option<String>,
|
||||
/// Optional payment rail the buyer picked on the buy page. One of
|
||||
/// `lightning` / `onchain` / `card`. When omitted, the daemon picks
|
||||
/// the first rail the product's merchant profile exposes — which is
|
||||
/// the right behavior for single-rail profiles AND back-compat for
|
||||
/// pre-:52 callers that don't know about rails yet. When the buyer
|
||||
/// is on a multi-rail profile and the buy page surfaces a picker,
|
||||
/// this field carries the choice.
|
||||
pub rail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -419,29 +427,16 @@ pub async fn start(
|
||||
// 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);
|
||||
|
||||
// Step C: provider-agnostic invoice creation. The trait method
|
||||
// handles provider-specific concerns (HMAC-headered request, URL
|
||||
// rewriting from internal hostname to public, metadata enrichment
|
||||
// with `orderId`/`source`) inside its impl, so this code path is
|
||||
// identical for any future provider (Zaprite, etc.). On failure,
|
||||
// release the slot and bail.
|
||||
let provider = match state.payment_provider().await {
|
||||
// Step B.5: resolve the merchant profile + payment provider for THIS
|
||||
// purchase. The product is attached to exactly one merchant profile;
|
||||
// the profile exposes one or more payment providers (BTCPay / Zaprite).
|
||||
// The buyer (or their UA) names a rail via `req.rail` if the buy page's
|
||||
// multi-rail picker surfaced one — otherwise we pick the first rail the
|
||||
// profile exposes, which is the right behavior for the common
|
||||
// single-rail-per-profile case. The resolution layer also returns the
|
||||
// provider row so we can record its id on the invoice; the renewal
|
||||
// worker reads that id off the snapshot when auto-charging future cycles.
|
||||
let merchant_profile = match state.merchant_profile_for_product(&product.id).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
if let Some(code) = &reservation {
|
||||
@@ -450,6 +445,91 @@ pub async fn start(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let requested_rail = req
|
||||
.rail
|
||||
.as_deref()
|
||||
.and_then(crate::payment::Rail::parse);
|
||||
let rail = match requested_rail {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// No buyer pick — collect the union of rails this profile's
|
||||
// providers offer and use the first. With one provider this
|
||||
// is its primary rail; with multiple, this is whatever the
|
||||
// earliest-connected provider serves first.
|
||||
let providers = repo::list_payment_providers_for_profile(
|
||||
&state.db, &merchant_profile.id,
|
||||
)
|
||||
.await?;
|
||||
let first_rail = providers.iter().find_map(|row| {
|
||||
crate::payment::ProviderKind::parse(&row.kind)
|
||||
.and_then(|kind| crate::payment::rails_for_kind(kind).into_iter().next())
|
||||
});
|
||||
match first_rail {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
if let Some(code) = &reservation {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
}
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"merchant profile '{}' has no payment providers connected — \
|
||||
buyers can't pay yet. Connect one in the admin UI.",
|
||||
merchant_profile.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let (provider_row, provider) = match state
|
||||
.resolve_provider_for_profile_rail(&merchant_profile.id, rail)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
if let Some(code) = &reservation {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// If the caller didn't supply a redirect_url, prefer the merchant
|
||||
// profile's configured post_purchase_redirect_url (operator's app
|
||||
// landing page — e.g. recaps.cc/welcome). Fall back to Keysat's own
|
||||
// /thank-you?invoice_id=… page if neither is set.
|
||||
let default_redirect = format!(
|
||||
"{}/thank-you?invoice_id={}",
|
||||
state.config.public_base_url, internal_id
|
||||
);
|
||||
let profile_redirect = merchant_profile
|
||||
.post_purchase_redirect_url
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|tmpl| {
|
||||
// Allow `{invoice_id}` substitution so operators can land
|
||||
// buyers on a per-purchase URL on their own app.
|
||||
tmpl.replace("{invoice_id}", &internal_id)
|
||||
});
|
||||
let profile_redirect_ref = profile_redirect.as_deref();
|
||||
let redirect_url = req
|
||||
.redirect_url
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or(profile_redirect_ref)
|
||||
.unwrap_or(&default_redirect);
|
||||
// Recurring policy: ask the provider to prompt the buyer to
|
||||
// save their payment profile at checkout so the renewal worker
|
||||
// can later auto-charge it via `charge_order_with_profile`.
|
||||
// Zaprite honors this for autopay-supporting rails (Stripe card
|
||||
// via a connected merchant account); BTCPay has no equivalent
|
||||
// and silently ignores the flag. We always set this on
|
||||
// recurring purchases — if the buyer ends up paying with
|
||||
// Bitcoin / Lightning, or declines the save-card prompt at
|
||||
// Zaprite's checkout, no profile gets created and the post-
|
||||
// settle profile-capture step finds nothing. The sub then
|
||||
// behaves like a pre-feature recurring sub: renewals still
|
||||
// create fresh invoices the buyer pays manually.
|
||||
let allow_save_profile =
|
||||
chosen_policy.as_ref().map(|p| p.is_recurring).unwrap_or(false);
|
||||
let created = match provider
|
||||
.create_invoice(CreateInvoiceParams {
|
||||
amount: Money::sats(final_price),
|
||||
@@ -461,6 +541,7 @@ pub async fn start(
|
||||
metadata: json!({ "productId": product.id }),
|
||||
external_order_id: &internal_id,
|
||||
buyer_email: req.buyer_email.as_deref(),
|
||||
allow_save_payment_profile: if allow_save_profile { Some(true) } else { None },
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -469,8 +550,27 @@ pub async fn start(
|
||||
if let Some(code) = &reservation {
|
||||
let _ = repo::release_code_slot(&state.db, &code.id).await;
|
||||
}
|
||||
// `{e:#}` (alternate format) walks the anyhow error chain so
|
||||
// the buy page surfaces the underlying provider error directly
|
||||
// — e.g. "Zaprite create_order returned HTTP 400: {...}" —
|
||||
// instead of just the outermost `context()` wrapper. Without
|
||||
// this, a failed create-invoice shows only
|
||||
// "ZapriteProvider.create_invoice" to the operator, and the
|
||||
// real cause (currency mismatch / missing payment rail / API-
|
||||
// key scope / Zaprite-side validation error) is hidden. We
|
||||
// ALSO emit an explicit tracing::error! before returning so
|
||||
// the same chain shows up in the daemon logs — without this
|
||||
// line, the provider's underlying error string is never
|
||||
// logged anywhere (the trait method just RETURNS the
|
||||
// anyhow error; only the tower trace layer fires, and it
|
||||
// only sees the HTTP status code, not the body).
|
||||
tracing::error!(
|
||||
product_id = %product.id,
|
||||
error = format!("{e:#}"),
|
||||
"payment provider create_invoice failed"
|
||||
);
|
||||
return Err(AppError::Upstream(format!(
|
||||
"payment provider create-invoice failed: {e}"
|
||||
"payment provider create-invoice failed: {e:#}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
@@ -494,6 +594,7 @@ pub async fn start(
|
||||
listed_value,
|
||||
exchange_rate_centibps,
|
||||
exchange_rate_source.as_deref(),
|
||||
Some(&provider_row.id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! outage to confirm the
|
||||
//! chain works end-to-end.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::rates;
|
||||
@@ -24,7 +24,7 @@ pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "rates:read").await?;
|
||||
let snapshot = state.rates.snapshot().await;
|
||||
let rates_json: Vec<Value> = snapshot
|
||||
.into_iter()
|
||||
@@ -52,7 +52,7 @@ pub async fn refresh(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RefreshReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "rates:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let currency = req.currency.to_uppercase();
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
response::{Html, IntoResponse},
|
||||
Json,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
//! convention (Stripe, Zaprite, etc.) and avoids a UX where the
|
||||
//! buyer cancels mid-month and immediately loses what they paid for.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::{
|
||||
@@ -58,7 +58,7 @@ pub async fn admin_list(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "subscriptions:read").await?;
|
||||
if let Some(s) = q.status.as_deref() {
|
||||
if !["active", "past_due", "cancelled", "lapsed"].contains(&s) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -115,7 +115,7 @@ pub async fn admin_cancel(
|
||||
Path(id): Path<String>,
|
||||
body: Option<Json<CancelReq>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "subscriptions:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let reason = body.and_then(|Json(b)| b.reason).filter(|s| !s.trim().is_empty());
|
||||
|
||||
|
||||
@@ -90,36 +90,12 @@ impl TierInfo {
|
||||
/// to be fixed.
|
||||
pub async fn current(state: &AppState) -> TierInfo {
|
||||
let tier = state.self_tier.read().await;
|
||||
let mut entitlements = match &*tier {
|
||||
let entitlements = match &*tier {
|
||||
Tier::Licensed { entitlements, .. } => entitlements.clone(),
|
||||
Tier::Unlicensed { .. } => Vec::new(),
|
||||
};
|
||||
drop(tier);
|
||||
|
||||
// Patron implies Pro by design (see module docstring: "Patron: same
|
||||
// feature surface as Pro, plus a `patron` entitlement..."). Without
|
||||
// this expansion, every downstream `tier.has(<pro-entitlement>)`
|
||||
// check requires the Patron POLICY on the master Keysat to
|
||||
// redundantly list every Pro entitlement. That's brittle: a single
|
||||
// missing slug on the policy (e.g. operator forgets
|
||||
// `zaprite_payments`) breaks Pro-equivalence for every Patron
|
||||
// customer. Treating `patron` as a strict superset of Pro at the
|
||||
// resolution layer means policy authors can list `patron` alone
|
||||
// and have everything Pro grants flow through automatically.
|
||||
if entitlements.iter().any(|e| e == "patron") {
|
||||
for implied in [
|
||||
"unlimited_products",
|
||||
"unlimited_policies",
|
||||
"unlimited_codes",
|
||||
"recurring_billing",
|
||||
"zaprite_payments",
|
||||
] {
|
||||
if !entitlements.iter().any(|e| e == implied) {
|
||||
entitlements.push(implied.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let label: &'static str;
|
||||
let display_name: &'static str;
|
||||
if entitlements.iter().any(|e| e == "patron") {
|
||||
@@ -148,7 +124,7 @@ pub async fn admin_status(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> AppResult<axum::Json<serde_json::Value>> {
|
||||
crate::api::admin::require_admin(&state, &headers)?;
|
||||
crate::api::admin::require_scope(&state, &headers, "tier:read").await?;
|
||||
let tier = current(&state).await;
|
||||
let product_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
|
||||
.fetch_one(&state.db)
|
||||
@@ -186,6 +162,10 @@ pub async fn admin_status(
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"tier": tier.label,
|
||||
"tier_name": tier.display_name,
|
||||
// Daemon-level sandbox flag (env KEYSAT_SANDBOX_MODE, read-only here —
|
||||
// never settable via any API). The admin SPA renders a "SANDBOX"
|
||||
// banner on it; it also gates scoped payment-provider connect.
|
||||
"sandbox": state.config.sandbox_mode,
|
||||
"entitlements": tier.entitlements,
|
||||
"usage": {
|
||||
"products": product_count,
|
||||
@@ -245,6 +225,39 @@ pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse a new merchant profile if the operator is at the Creator-tier
|
||||
/// merchant-profile cap (= 1) and lacks `unlimited_merchant_profiles`.
|
||||
/// Counts every profile including the auto-created default. So Creator
|
||||
/// operators have the default profile (auto-created by migration 0020)
|
||||
/// and can't add more; Pro and Patron operators are unlimited.
|
||||
///
|
||||
/// The `unlimited_merchant_profiles` entitlement needs to be added to
|
||||
/// the master Keysat's Pro and Patron policies as a separate admin
|
||||
/// action — see plans/multi-provider-payment-model.md "Tier gating"
|
||||
/// section.
|
||||
pub async fn enforce_merchant_profile_cap(state: &AppState) -> AppResult<()> {
|
||||
let tier = current(state).await;
|
||||
if tier.has("unlimited_merchant_profiles") {
|
||||
return Ok(());
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM merchant_profiles")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
// Creator gets 1 (the default profile).
|
||||
if count >= 1 {
|
||||
return Err(AppError::PaymentRequired {
|
||||
message: format!(
|
||||
"Your {} tier allows a single merchant profile (the default). \
|
||||
You're at {}. Upgrade to Pro to run multiple businesses \
|
||||
from one Keysat instance.",
|
||||
tier.display_name, count
|
||||
),
|
||||
upgrade_url: UPGRADE_URL_PRO.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse to mark a policy as recurring unless the operator's self-tier
|
||||
/// carries the `recurring_billing` entitlement. Pro and Patron tiers
|
||||
/// have it; Creator does not. Called from both create-policy and
|
||||
|
||||
@@ -123,7 +123,40 @@ pub async fn start(
|
||||
// Create provider invoice. Same trait method the purchase + renewal
|
||||
// paths use, so any provider-specific concerns (URL rewriting,
|
||||
// metadata enrichment) live inside the impl.
|
||||
let provider = state.payment_provider().await?;
|
||||
//
|
||||
// Tier-change invoices ride on an existing license. The right provider
|
||||
// is whichever one the license's existing subscription is snapshotted
|
||||
// to — so the proration charge settles to the same merchant identity
|
||||
// that's been collecting renewal fees. Falls back to the license's
|
||||
// first-cycle invoice provider, then the legacy default, for licenses
|
||||
// with no subscription (one-shot upgrades) or pre-snapshot rows.
|
||||
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
|
||||
&state.db, &license.id,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.payment_provider_id);
|
||||
let provider_id_for_upgrade = match snapshot_provider_id {
|
||||
Some(p) => Some(p),
|
||||
None => {
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT i.payment_provider_id FROM invoices i \
|
||||
JOIN licenses l ON l.invoice_id = i.id \
|
||||
WHERE l.id = ?",
|
||||
)
|
||||
.bind(&license.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten()
|
||||
}
|
||||
};
|
||||
let provider = match provider_id_for_upgrade.as_deref() {
|
||||
Some(pid) => state.payment_provider_by_id(pid).await?,
|
||||
None => state.payment_provider().await?,
|
||||
};
|
||||
let internal_invoice_id = Uuid::new_v4().to_string();
|
||||
let default_redirect = format!(
|
||||
"{}/thank-you?invoice_id={}",
|
||||
@@ -148,6 +181,12 @@ pub async fn start(
|
||||
}),
|
||||
external_order_id: &internal_invoice_id,
|
||||
buyer_email: license.buyer_email.as_deref(),
|
||||
// Tier-change invoices ride on an existing license; if
|
||||
// the underlying subscription already captured a saved
|
||||
// payment profile on its first cycle, we keep using it
|
||||
// for future renewals. No need to re-prompt for
|
||||
// save-card here.
|
||||
allow_save_payment_profile: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
|
||||
@@ -169,6 +208,7 @@ pub async fn start(
|
||||
Some(quote.proration_charge_value),
|
||||
conversion.rate_centibps,
|
||||
Some(conversion.source.as_str()),
|
||||
provider_id_for_upgrade.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -450,7 +490,36 @@ pub async fn admin_change(
|
||||
.map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?;
|
||||
let amount_sats = conversion.sats.max(1);
|
||||
|
||||
let provider = state.payment_provider().await?;
|
||||
// Same provider-resolution pattern as the buyer-driven tier-change
|
||||
// above: prefer the license's snapshotted subscription provider so
|
||||
// the admin charge settles to the same merchant identity.
|
||||
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
|
||||
&state.db, &license.id,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.payment_provider_id);
|
||||
let provider_id_for_upgrade = match snapshot_provider_id {
|
||||
Some(p) => Some(p),
|
||||
None => {
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT i.payment_provider_id FROM invoices i \
|
||||
JOIN licenses l ON l.invoice_id = i.id \
|
||||
WHERE l.id = ?",
|
||||
)
|
||||
.bind(&license.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten()
|
||||
}
|
||||
};
|
||||
let provider = match provider_id_for_upgrade.as_deref() {
|
||||
Some(pid) => state.payment_provider_by_id(pid).await?,
|
||||
None => state.payment_provider().await?,
|
||||
};
|
||||
let internal_invoice_id = Uuid::new_v4().to_string();
|
||||
let default_redirect = format!(
|
||||
"{}/thank-you?invoice_id={}",
|
||||
@@ -470,6 +539,10 @@ pub async fn admin_change(
|
||||
}),
|
||||
external_order_id: &internal_invoice_id,
|
||||
buyer_email: license.buyer_email.as_deref(),
|
||||
// Admin-driven tier change — same as the buyer-driven
|
||||
// tier-change path above: existing subscription keeps
|
||||
// its saved profile (if any), so no re-prompt.
|
||||
allow_save_payment_profile: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
|
||||
@@ -488,6 +561,7 @@ pub async fn admin_change(
|
||||
Some(quote.proration_charge_value),
|
||||
conversion.rate_centibps,
|
||||
Some(conversion.source.as_str()),
|
||||
provider_id_for_upgrade.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -23,20 +23,51 @@ use crate::error::{AppError, AppResult};
|
||||
use crate::payment::ProviderWebhookEvent;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Multi-provider webhook landing: `/v1/{kind}/webhook/:provider_id`.
|
||||
/// The provider id picks WHICH provider's secret validates this delivery.
|
||||
/// Without that, an operator with two BTCPay providers across two merchant
|
||||
/// profiles would have indistinguishable webhook URLs and BTCPay payloads
|
||||
/// would round-robin to whoever happened to be "the active provider" at
|
||||
/// request time. The path-param resolution ensures every delivery is
|
||||
/// validated against the secret it was created with.
|
||||
pub async fn handle_for_provider(
|
||||
State(state): State<AppState>,
|
||||
Path(provider_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<StatusCode> {
|
||||
let provider = state.payment_provider_by_id(&provider_id).await?;
|
||||
handle_inner(state, provider, headers, body).await
|
||||
}
|
||||
|
||||
/// Back-compat landing for the pre-:52 URL shape. Routes to whichever
|
||||
/// provider is on the default merchant profile. New webhooks registered
|
||||
/// against `:52`+ use the path-keyed shape above; this exists so any
|
||||
/// in-flight pre-:52 delivery (or operator misconfiguration) doesn't
|
||||
/// silently drop on the floor.
|
||||
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?;
|
||||
handle_inner(state, provider, headers, body).await
|
||||
}
|
||||
|
||||
async fn handle_inner(
|
||||
state: AppState,
|
||||
provider: std::sync::Arc<dyn crate::payment::PaymentProvider>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<StatusCode> {
|
||||
// The resolved provider validates its own webhooks (each provider has
|
||||
// a different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
|
||||
// Zaprite's externalUniqId round-trip). On verification failure: 401.
|
||||
let event = provider
|
||||
.validate_webhook(&headers, &body)
|
||||
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
|
||||
@@ -86,6 +117,59 @@ pub async fn handle(
|
||||
"webhook event applied"
|
||||
);
|
||||
|
||||
// Anti-forgery: never settle on the webhook body's claim alone. Re-fetch
|
||||
// the authoritative status from the provider's own API and require it to
|
||||
// actually be Settled before we mark the invoice paid or take ANY
|
||||
// settle-derived action. This guard runs ahead of every downstream effect
|
||||
// — status persistence, tier-change application, subscription renewal, and
|
||||
// license issuance — so confirming once here gates all of them.
|
||||
// This is load-bearing for providers without webhook signatures: Zaprite
|
||||
// webhooks carry no HMAC, so a forged `order.change`/`status=PAID` POST
|
||||
// with a buyer-visible order id would otherwise mint a free license. The
|
||||
// re-fetch also defeats replay of a stale settled body against an invoice
|
||||
// that has since expired/refunded (the provider reports the current state,
|
||||
// not the replayed one). BTCPay is HMAC-verified upstream and is settled
|
||||
// already, so this is cheap belt-and-suspenders there. On a provider
|
||||
// error we fail closed — the reconcile loop re-confirms on its next tick.
|
||||
// `Some` once a settle is confirmed: the provider-reported amount, fed to
|
||||
// the advisory tripwire below (after the local invoice is loaded). `None`
|
||||
// for non-settle events and when the provider reports no parseable amount.
|
||||
let confirmed_amount = if new_status == "settled" {
|
||||
match provider.get_invoice_status(&provider_invoice_id).await {
|
||||
Ok(snapshot)
|
||||
if snapshot.status == crate::payment::ProviderInvoiceStatus::Settled =>
|
||||
{
|
||||
snapshot.amount
|
||||
}
|
||||
Ok(snapshot) => {
|
||||
tracing::warn!(
|
||||
provider = provider.kind().as_str(),
|
||||
provider_invoice_id = %provider_invoice_id,
|
||||
provider_status = ?snapshot.status,
|
||||
"settle webhook NOT confirmed by provider API; refusing to settle/issue"
|
||||
);
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
Err(e) => {
|
||||
// Ack 200 rather than erroring: a non-2xx makes BTCPay/Zaprite
|
||||
// re-deliver aggressively, so a transient provider-API outage
|
||||
// would turn every in-flight webhook into a retry storm. We
|
||||
// simply don't issue now — the reconcile loop re-fetches the
|
||||
// status on its next tick and issues then, so issuance is still
|
||||
// "fail closed" without depending on this delivery.
|
||||
tracing::warn!(
|
||||
provider = provider.kind().as_str(),
|
||||
provider_invoice_id = %provider_invoice_id,
|
||||
error = format!("{e:#}"),
|
||||
"could not reach provider to confirm settle; not issuing now, deferring to reconciler"
|
||||
);
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Persist status.
|
||||
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
|
||||
|
||||
@@ -124,6 +208,12 @@ pub async fn handle(
|
||||
return Ok(StatusCode::OK);
|
||||
};
|
||||
|
||||
// Advisory settle-amount tripwire. The Settled gate above already ensures
|
||||
// the provider considers this paid in full, so this never blocks issuance
|
||||
// — it logs + audits if the provider's recorded amount/currency ever
|
||||
// drifts from what we charged. See docs/guides/payments.md.
|
||||
audit_settle_amount(&state, &invoice, confirmed_amount.as_ref()).await;
|
||||
|
||||
// Tier-change branch: this settled invoice may be a tier upgrade
|
||||
// (recorded by POST /v1/upgrade or the future admin-change-tier
|
||||
// endpoint) rather than a fresh purchase or a subscription
|
||||
@@ -165,6 +255,65 @@ pub async fn handle(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Advisory settle-amount tripwire, shared by the webhook handler and the
|
||||
/// reconcile loop. The Settled gate at both call sites already guarantees the
|
||||
/// provider considers the invoice paid in full (BTCPay won't settle an unpaid
|
||||
/// invoice; Zaprite maps `UNDERPAID` → `Pending`), so this NEVER blocks
|
||||
/// issuance. It exists to surface drift: if the provider's recorded amount or
|
||||
/// currency ever differs from what we charged — a charge-vs-record bug on our
|
||||
/// side, or a currency-confusion bug — we log a warning and write an
|
||||
/// `invoice.amount_mismatch` audit row, then let issuance proceed.
|
||||
///
|
||||
/// `confirmed` is `None` ("no opinion") when the provider response carried no
|
||||
/// parseable amount; in that case the tripwire is skipped. Every invoice we
|
||||
/// create is SAT-denominated (`purchase.rs` passes `Money::sats`), so the
|
||||
/// expected value is `invoice.amount_sats` in `SAT`.
|
||||
pub(crate) async fn audit_settle_amount(
|
||||
state: &AppState,
|
||||
invoice: &crate::models::Invoice,
|
||||
confirmed: Option<&crate::payment::Money>,
|
||||
) {
|
||||
let Some(paid) = confirmed else { return };
|
||||
// The comparison basis is `invoice.amount_sats` (SAT), which equals what we
|
||||
// told the provider to charge ONLY for SAT-denominated orders — one-shot
|
||||
// purchases and SAT subscriptions (`purchase.rs` / `upgrades` pass
|
||||
// `Money::sats`). Fiat-priced subscription RENEWALS (`subscriptions.rs`)
|
||||
// create the order in the listed fiat currency, where `amount_sats` is not
|
||||
// the charged amount, so there's no clean SAT comparison — skip those (the
|
||||
// `Settled` gate already guarantees paid-in-full). A non-SAT provider
|
||||
// amount therefore means "no comparable basis", not a mismatch.
|
||||
if paid.currency != "SAT" {
|
||||
return;
|
||||
}
|
||||
if paid.amount == invoice.amount_sats {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
invoice_id = %invoice.id,
|
||||
provider_invoice_id = %invoice.btcpay_invoice_id,
|
||||
expected_amount_sats = invoice.amount_sats,
|
||||
provider_amount_sats = paid.amount,
|
||||
"settled invoice amount does NOT match the recorded charge; issuing \
|
||||
anyway (advisory) — investigate provider config or a charge-vs-record bug"
|
||||
);
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"system",
|
||||
None,
|
||||
"invoice.amount_mismatch",
|
||||
Some("invoice"),
|
||||
Some(&invoice.id),
|
||||
None,
|
||||
None,
|
||||
&serde_json::json!({
|
||||
"provider_invoice_id": invoice.btcpay_invoice_id,
|
||||
"expected_amount_sats": invoice.amount_sats,
|
||||
"provider_amount_sats": paid.amount,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -278,6 +427,21 @@ pub async fn issue_license_for_invoice(
|
||||
.ok()
|
||||
.flatten();
|
||||
if existing.is_none() {
|
||||
// Snapshot the merchant profile + payment provider that
|
||||
// settled this purchase, so the renewal worker uses the
|
||||
// SAME business + payment account on subsequent cycles
|
||||
// even if the operator later moves the product to a
|
||||
// different profile. Falls back to the product's
|
||||
// current profile (and the invoice's recorded provider)
|
||||
// when the snapshot fields aren't already on the invoice.
|
||||
let snapshot_profile_id = crate::db::repo::get_merchant_profile_for_product(
|
||||
&state.db, &invoice.product_id,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| p.id);
|
||||
let snapshot_provider_id = invoice.payment_provider_id.clone();
|
||||
match crate::subscriptions::create_subscription(
|
||||
&state.db,
|
||||
&license_id,
|
||||
@@ -287,6 +451,8 @@ pub async fn issue_license_for_invoice(
|
||||
&listed_currency,
|
||||
listed_value,
|
||||
&invoice.id,
|
||||
snapshot_profile_id.as_deref(),
|
||||
snapshot_provider_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! that was down for >6h during a license-issuance burst would
|
||||
//! silently lose those events forever.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo::{self, DeliveryStatusFilter};
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -46,7 +46,7 @@ pub async fn list(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListDeliveriesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "webhooks:read").await?;
|
||||
let status = match q.status.as_deref() {
|
||||
Some(s) => DeliveryStatusFilter::parse(s).ok_or_else(|| {
|
||||
AppError::BadRequest(format!(
|
||||
@@ -80,7 +80,7 @@ pub async fn retry(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let delivery = repo::requeue_delivery(&state.db, &id)
|
||||
.await?
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! 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::admin::{request_context, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::AppResult;
|
||||
@@ -48,7 +48,7 @@ pub async fn create(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateEndpointReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let secret = req.secret.unwrap_or_else(generate_secret);
|
||||
let ep = repo::create_webhook_endpoint(
|
||||
@@ -96,7 +96,7 @@ pub async fn list(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListEndpointsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "webhooks:read").await?;
|
||||
let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?;
|
||||
Ok(Json(json!({ "endpoints": rows })))
|
||||
}
|
||||
@@ -112,7 +112,7 @@ pub async fn set_active(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::set_webhook_active(&state.db, &id, req.active).await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -135,7 +135,7 @@ pub async fn delete(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
repo::delete_webhook_endpoint(&state.db, &id).await?;
|
||||
let _ = repo::insert_audit(
|
||||
|
||||
@@ -1,48 +1,58 @@
|
||||
//! Zaprite connect / disconnect / status admin endpoints.
|
||||
//!
|
||||
//! Zaprite doesn't expose an OAuth-style consent flow the way
|
||||
//! BTCPay does — there's no `/authorize` redirect chain. Operators
|
||||
//! just create an API key in their Zaprite dashboard and paste it
|
||||
//! in. So this module is much smaller than `btcpay_authorize.rs`:
|
||||
//! a single connect endpoint validates + stores the key, a
|
||||
//! disconnect endpoint wipes it, a status endpoint reports state.
|
||||
//! Zaprite doesn't expose an OAuth-style consent flow the way BTCPay
|
||||
//! does — there's no `/authorize` redirect chain. Operators just create
|
||||
//! an API key in their Zaprite dashboard and paste it in. So this
|
||||
//! module is much smaller than `btcpay_authorize.rs`: a single connect
|
||||
//! endpoint validates + stores the key, a disconnect endpoint wipes it,
|
||||
//! a status endpoint reports state.
|
||||
//!
|
||||
//! The active provider on `AppState` is swapped atomically as part
|
||||
//! of connect/disconnect so request handlers immediately see the
|
||||
//! new state without a daemon restart.
|
||||
//! Multi-merchant-profile model (migration 0020+): the connect endpoint
|
||||
//! now takes a `merchant_profile_id` (defaulting to the default profile)
|
||||
//! and INSERTs a row in `payment_providers` attached to that profile.
|
||||
//! The disconnect endpoint takes a provider id and deletes that row.
|
||||
//! Old "active provider" semantics are gone — profiles attach to
|
||||
//! products explicitly.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::admin::{request_context, require_admin, require_scope};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::zaprite::{
|
||||
config as zaprite_config, ZapriteClient, ZapriteProvider,
|
||||
};
|
||||
use crate::payment::zaprite::{ZapriteClient, ZapriteProvider};
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.zaprite.com";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConnectReq {
|
||||
pub api_key: String,
|
||||
/// Optional override — defaults to https://api.zaprite.com.
|
||||
/// Useful for sandbox orgs that point at a different host or
|
||||
/// for future regional endpoints.
|
||||
/// Optional override — defaults to https://api.zaprite.com. Useful
|
||||
/// for sandbox orgs (which point at a different host) or for future
|
||||
/// regional endpoints.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
/// Optional operator-set label distinguishing this Zaprite account
|
||||
/// from other providers in the admin UI (e.g. "Recaps Zaprite" vs
|
||||
/// "Keysat Zaprite"). Defaults to "Zaprite — {merchant profile name}".
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
/// Which merchant profile to attach this Zaprite account to. NULL =
|
||||
/// the default profile. Operators with Pro/Patron tier can name a
|
||||
/// non-default profile to set up per-business Zaprite orgs.
|
||||
#[serde(default)]
|
||||
pub merchant_profile_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/zaprite/connect` — validate + store an API
|
||||
/// key, then swap the active payment provider to Zaprite. The
|
||||
/// operator pastes the key from
|
||||
/// `app.zaprite.com/.../settings/api`.
|
||||
///
|
||||
/// `POST /v1/admin/zaprite/connect` — validate + store an API key as a
|
||||
/// `payment_providers` row attached to the requested merchant profile.
|
||||
/// Validates the key by calling `GET /v1/orders?limit=1` against
|
||||
/// Zaprite — auth-guarded, so a 200 confirms the key works for
|
||||
/// the right org. A 401 / 403 / network error short-circuits
|
||||
/// before we persist anything.
|
||||
/// Zaprite — auth-guarded, so a 200 confirms the key works for the
|
||||
/// right org. A 401 / 403 / network error short-circuits before we
|
||||
/// persist anything.
|
||||
pub async fn connect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -57,17 +67,32 @@ pub async fn connect(
|
||||
return Err(AppError::BadRequest("api_key is required".into()));
|
||||
}
|
||||
|
||||
// Short-circuit: refuse to overwrite an existing config silently.
|
||||
// Operators get confused when they re-run Connect after already
|
||||
// being connected — they expect a "you're already set up" message,
|
||||
// not a form re-prompt that can clobber their working config.
|
||||
if let Ok(Some(_)) = zaprite_config::load(&state.db).await {
|
||||
return Err(AppError::Conflict(
|
||||
"Zaprite is already connected. Run 'Disconnect Zaprite' first \
|
||||
if you want to rotate the API key or switch organizations."
|
||||
.into(),
|
||||
));
|
||||
// Resolve the target merchant profile. Defaults to the auto-created
|
||||
// default profile when not specified — single-profile operators
|
||||
// never see this concept.
|
||||
let profile = match req.merchant_profile_id.as_deref() {
|
||||
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("merchant profile {id} not found"))
|
||||
})?,
|
||||
None => crate::merchant_profiles::require_default(&state.db).await?,
|
||||
};
|
||||
|
||||
// Refuse if this profile already has a Zaprite provider attached —
|
||||
// the unique index on (merchant_profile_id, kind) would also catch
|
||||
// this but a clean 409 message is friendlier than a constraint error.
|
||||
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
||||
.await?;
|
||||
if existing.iter().any(|p| p.kind == "zaprite") {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"merchant profile '{}' already has a Zaprite provider attached. \
|
||||
Disconnect it first if you want to rotate the API key or switch \
|
||||
organizations, or pick a different merchant profile.",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
|
||||
let base_url = req
|
||||
.base_url
|
||||
.as_deref()
|
||||
@@ -81,10 +106,9 @@ pub async fn connect(
|
||||
));
|
||||
}
|
||||
|
||||
// Smoke-test the key before saving anything. Zaprite will
|
||||
// 401 a bad key — surface that as a clean operator-facing
|
||||
// error rather than letting it crash later in the purchase
|
||||
// flow.
|
||||
// Smoke-test the key before saving anything. Zaprite will 401 a
|
||||
// bad key — surface that as a clean operator-facing error rather
|
||||
// than letting it crash later in the purchase flow.
|
||||
let client = ZapriteClient::new(&base_url, &api_key);
|
||||
client.ping().await.map_err(|e| {
|
||||
AppError::Upstream(format!(
|
||||
@@ -92,159 +116,195 @@ pub async fn connect(
|
||||
))
|
||||
})?;
|
||||
|
||||
// Persist + swap.
|
||||
zaprite_config::save(
|
||||
// Persist the new payment_providers row.
|
||||
let label = req
|
||||
.label
|
||||
.as_deref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| format!("Zaprite — {}", profile.name));
|
||||
let provider_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
crate::db::repo::create_payment_provider(
|
||||
&state.db,
|
||||
&zaprite_config::ZapriteConfig {
|
||||
api_key: api_key.clone(),
|
||||
base_url: base_url.clone(),
|
||||
webhook_id: None, // operator configures the webhook in Zaprite's dashboard
|
||||
},
|
||||
&provider_id,
|
||||
&profile.id,
|
||||
"zaprite",
|
||||
&label,
|
||||
&api_key,
|
||||
&base_url,
|
||||
None, // webhook_id — operator configures the webhook on Zaprite's dashboard
|
||||
None, // webhook_secret — Zaprite doesn't sign webhooks
|
||||
None, // store_id — BTCPay only
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("save zaprite_config: {e:#}")))?;
|
||||
.await?;
|
||||
|
||||
// If this is the very first provider on the default profile, also
|
||||
// populate the legacy state.payment singleton so back-compat call
|
||||
// sites (the few that still use state.payment_provider()) work
|
||||
// without waiting for a daemon restart. Per-product resolution
|
||||
// doesn't use this singleton.
|
||||
if profile.is_default && existing.is_empty() {
|
||||
let provider = ZapriteProvider::new(client);
|
||||
state
|
||||
.set_payment_provider(Arc::new(provider))
|
||||
.await;
|
||||
// Persist the operator's preference so the boot-time loader
|
||||
// picks Zaprite on next restart, even if BTCPay's config row
|
||||
// is also still in the DB.
|
||||
crate::payment::write_active_provider_preference(
|
||||
&state.db,
|
||||
crate::payment::ProviderKind::Zaprite,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("write provider preference: {e:#}")))?;
|
||||
state.set_payment_provider(Arc::new(provider)).await;
|
||||
}
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"zaprite.connect",
|
||||
"payment_provider.connect",
|
||||
Some("payment_provider"),
|
||||
Some("zaprite"),
|
||||
Some(&provider_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({ "base_url": base_url }),
|
||||
&json!({
|
||||
"kind": "zaprite",
|
||||
"merchant_profile_id": profile.id,
|
||||
"base_url": base_url,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Compute the absolute webhook URL so the StartOS Action can
|
||||
// surface the full https://... endpoint to the operator. They
|
||||
// paste this into the Zaprite dashboard exactly. Zaprite's
|
||||
// webhook form requires a full URL, not a path; the previous
|
||||
// copy showed a placeholder which was confusing.
|
||||
// The webhook URL is now path-keyed by provider id so multiple
|
||||
// Zaprite orgs (one per profile) get isolated webhook deliveries.
|
||||
// Operator pastes this exact URL into the corresponding Zaprite
|
||||
// dashboard's webhooks page.
|
||||
let webhook_url = format!(
|
||||
"{}/v1/zaprite/webhook",
|
||||
state.config.public_base_url.trim_end_matches('/')
|
||||
"{}/v1/zaprite/webhook/{}",
|
||||
state.config.public_base_url.trim_end_matches('/'),
|
||||
provider_id
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"provider": "zaprite",
|
||||
"provider_id": provider_id,
|
||||
"merchant_profile_id": profile.id,
|
||||
"merchant_profile_name": profile.name,
|
||||
"label": label,
|
||||
"base_url": base_url,
|
||||
"webhook_url": webhook_url,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/zaprite/disconnect` — wipe the stored key,
|
||||
/// clear the active provider. Operator should also delete the
|
||||
/// corresponding webhook on Zaprite's side, but we don't reach
|
||||
/// out to Zaprite to delete it — the operator uses Zaprite's
|
||||
/// dashboard for that. We can't delete it programmatically because
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DisconnectReq {
|
||||
/// Which provider row to disconnect. NULL = disconnect the Zaprite
|
||||
/// provider on the default profile (back-compat for the single-
|
||||
/// profile case).
|
||||
#[serde(default)]
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /v1/admin/zaprite/disconnect` — delete the named provider
|
||||
/// row (or the default-profile Zaprite row when no id is supplied).
|
||||
/// Operator should also delete the corresponding webhook on Zaprite's
|
||||
/// dashboard — we don't reach out to Zaprite to delete it because
|
||||
/// Zaprite's webhook-management endpoints aren't on the public
|
||||
/// OpenAPI we have access to.
|
||||
pub async fn disconnect(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Option<Json<DisconnectReq>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||
|
||||
// No-op if nothing's connected.
|
||||
let existing = zaprite_config::load(&state.db).await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
|
||||
})?;
|
||||
if existing.is_none() {
|
||||
let provider_id = match req.provider_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
// Default-profile fallback: find the Zaprite provider on the
|
||||
// default profile, if any.
|
||||
let default = crate::merchant_profiles::require_default(&state.db).await?;
|
||||
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
|
||||
.await?;
|
||||
match rows.into_iter().find(|p| p.kind == "zaprite") {
|
||||
Some(row) => row.id,
|
||||
None => {
|
||||
return Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": true,
|
||||
"message": "Zaprite was not connected",
|
||||
"message": "no Zaprite provider connected on the default merchant profile",
|
||||
})));
|
||||
}
|
||||
|
||||
zaprite_config::clear(&state.db).await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
|
||||
})?;
|
||||
state.clear_payment_provider().await;
|
||||
// If the active-provider preference was Zaprite, clear it.
|
||||
// Don't blindly clear if it was BTCPay — that's a different
|
||||
// operator's choice we shouldn't undo just because they ran
|
||||
// Disconnect Zaprite.
|
||||
if matches!(
|
||||
crate::payment::read_active_provider_preference(&state.db).await,
|
||||
Some(crate::payment::ProviderKind::Zaprite)
|
||||
) {
|
||||
let _ = crate::db::repo::settings_set(
|
||||
&state.db,
|
||||
crate::payment::SETTING_ACTIVE_PROVIDER,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?;
|
||||
// Clear the back-compat singleton if it happens to be the one we
|
||||
// just deleted. This is best-effort — the singleton may be holding
|
||||
// a different provider entirely.
|
||||
state.clear_payment_provider().await;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"zaprite.disconnect",
|
||||
"payment_provider.disconnect",
|
||||
Some("payment_provider"),
|
||||
Some("zaprite"),
|
||||
Some(&provider_id),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({}),
|
||||
&json!({ "kind": "zaprite" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"noop": false,
|
||||
"message": "Zaprite disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
|
||||
"provider_id": provider_id,
|
||||
"message": "Zaprite provider disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
|
||||
})))
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/zaprite/status` — operator-facing connection
|
||||
/// snapshot. Reports whether Zaprite is the active provider, the
|
||||
/// base URL, and whether a webhook id has been recorded. Does NOT
|
||||
/// return the API key (mirroring how btcpay/status redacts).
|
||||
impl Default for DisconnectReq {
|
||||
fn default() -> Self {
|
||||
Self { provider_id: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/admin/zaprite/status` — connection snapshot for the
|
||||
/// default profile (back-compat with the existing admin UI's
|
||||
/// payment-providers card). Multi-profile operators should use the
|
||||
/// new `/v1/admin/merchant-profiles/{id}` endpoint instead, which
|
||||
/// lists ALL providers across all profiles.
|
||||
pub async fn status(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let cfg = zaprite_config::load(&state.db).await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
|
||||
})?;
|
||||
let active_provider = match state.payment.read().await.as_ref() {
|
||||
Some(p) => Some(p.kind().as_str().to_string()),
|
||||
require_scope(&state, &headers, "payment_providers:read").await?;
|
||||
let default = crate::merchant_profiles::get_default(&state.db).await?;
|
||||
let connected_row = match &default {
|
||||
Some(profile) => {
|
||||
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
||||
.await?;
|
||||
rows.into_iter().find(|p| p.kind == "zaprite")
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let webhook_url = format!(
|
||||
let webhook_url = match &connected_row {
|
||||
Some(row) => format!(
|
||||
"{}/v1/zaprite/webhook/{}",
|
||||
state.config.public_base_url.trim_end_matches('/'),
|
||||
row.id
|
||||
),
|
||||
None => format!(
|
||||
"{}/v1/zaprite/webhook",
|
||||
state.config.public_base_url.trim_end_matches('/')
|
||||
);
|
||||
),
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"connected": cfg.is_some(),
|
||||
"active_provider": active_provider,
|
||||
"base_url": cfg.as_ref().map(|c| c.base_url.clone()),
|
||||
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.clone()),
|
||||
// Surfaced unconditionally so an operator who lost the
|
||||
// first-connect message can still find the URL to paste
|
||||
// into Zaprite's dashboard. Webhook-not-yet-registered
|
||||
// doesn't change the URL — it's the same address Zaprite
|
||||
// would POST to once registered.
|
||||
"connected": connected_row.is_some(),
|
||||
"provider_id": connected_row.as_ref().map(|r| r.id.clone()),
|
||||
"base_url": connected_row.as_ref().map(|r| r.base_url.clone()),
|
||||
"label": connected_row.as_ref().map(|r| r.label.clone()),
|
||||
"webhook_id": connected_row.as_ref().and_then(|r| r.webhook_id.clone()),
|
||||
"merchant_profile_id": default.as_ref().map(|p| p.id.clone()),
|
||||
"merchant_profile_name": default.as_ref().map(|p| p.name.clone()),
|
||||
"webhook_url": webhook_url,
|
||||
"webhook_explainer": "Zaprite doesn't sign webhook deliveries. \
|
||||
Keysat authenticates each delivery via the externalUniqId we attach \
|
||||
|
||||
@@ -366,3 +366,87 @@ pub async fn list_payment_methods(
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Resolve the Bitcoin **network** a store settles on, for the scoped
|
||||
/// payment-connect gate (`plans/agent-payment-connect-scope.md` §6.1).
|
||||
///
|
||||
/// Lists the store's payment methods, finds the on-chain BTC method
|
||||
/// (`paymentMethodId` is `BTC-CHAIN` on BTCPay 2.x, `BTC` on 1.x — never
|
||||
/// hardcode), fetches a receive address, and classifies the address prefix.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(network))` when positively determined;
|
||||
/// - `Ok(None)` when it **cannot** be determined (no on-chain method, no
|
||||
/// address, Lightning-only store, BTCPay not yet synced → `503`, or an
|
||||
/// unrecognized prefix). The caller MUST fail closed (treat `None` as
|
||||
/// mainnet and deny the scoped connect).
|
||||
///
|
||||
/// The address endpoint requires `btcpay.store.canmodifystoresettings`, which
|
||||
/// the daemon's authorize flow already requests (see `REQUESTED_PERMISSIONS`).
|
||||
pub async fn fetch_onchain_network(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
) -> Result<Option<super::network::BitcoinNetwork>> {
|
||||
// Any failure to enumerate methods → undetermined → caller fails closed.
|
||||
// Swallow the error here (uniform with the non-2xx wallet/address branch
|
||||
// below) and log a body-free reason at warn; detail only at debug so an
|
||||
// upstream error body never lands in normal logs on this sensitive path.
|
||||
let methods = match list_payment_methods(base_url, api_key, store_id).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
store = %store_id,
|
||||
"fetch_onchain_network: could not list payment methods; network undetermined"
|
||||
);
|
||||
tracing::debug!(error = %format!("{e:#}"), "btcpay list-payment-methods error detail");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// Find the on-chain BTC method. Lightning ids (`BTC-LN`,
|
||||
// `BTC_LightningLike`, …) are deliberately excluded.
|
||||
let Some(pmid) = methods.iter().find_map(|m| {
|
||||
let id = m.get("paymentMethodId").and_then(|v| v.as_str())?;
|
||||
match id.to_ascii_uppercase().as_str() {
|
||||
"BTC-CHAIN" | "BTC" => Some(id.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}) else {
|
||||
return Ok(None); // no on-chain BTC method → undetermined → fail closed
|
||||
};
|
||||
|
||||
// `pmid` is BTCPay-supplied; percent-encode it as a path segment so a
|
||||
// hostile/buggy server returning an odd id can't corrupt the URL (it would
|
||||
// only ever 4xx → Ok(None) → deny anyway, but keep the request well-formed).
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/payment-methods/{}/wallet/address",
|
||||
base_url.trim_end_matches('/'),
|
||||
urlencoding::encode(&pmid),
|
||||
);
|
||||
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 wallet/address")?;
|
||||
if !resp.status().is_success() {
|
||||
// 503 (BTCPay not synced / on-chain service down), 404/422 (no wallet),
|
||||
// 403 (insufficient perms) — none let us positively determine the
|
||||
// network, so report undetermined and let the caller fail closed.
|
||||
return Ok(None);
|
||||
}
|
||||
// A 2xx with a non-JSON body (misconfigured BTCPay) is likewise "can't
|
||||
// determine" → Ok(None). Parsing via Ok(None) instead of `?` also keeps any
|
||||
// body snippet reqwest attaches to a parse error out of warn-level logs.
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "btcpay wallet/address: non-JSON body; network undetermined");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let address = body.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||
Ok(super::network::classify_address_network(address))
|
||||
}
|
||||
|
||||
@@ -79,15 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
||||
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<()> {
|
||||
/// An in-flight authorize round-trip, recovered at callback time. `Default`
|
||||
/// (no profile, `scoped_initiator = false`) is the back-compat reading of a
|
||||
/// pre-0025 / NULL row: "master connect to the default profile" — the only
|
||||
/// kind that existed before scoped connect.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthorizeState {
|
||||
/// Merchant profile the resulting provider row attaches to (migration
|
||||
/// 0022). None → "the default profile".
|
||||
pub merchant_profile_id: Option<String>,
|
||||
/// True when a *scoped* key (not the master key) started the connect
|
||||
/// (migration 0025). The callback applies the non-mainnet network gate
|
||||
/// only for scoped initiators.
|
||||
pub scoped_initiator: bool,
|
||||
/// sha256 of the initiating credential — for the callback's audit row.
|
||||
pub initiator_actor_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Record a new in-flight authorize state token. `merchant_profile_id`
|
||||
/// (multi-provider model, migration 0022) names which merchant profile
|
||||
/// the resulting provider row should attach to when the callback fires
|
||||
/// — None falls back to "the default profile" at consume-time.
|
||||
/// `scoped_initiator` / `actor_hash` (migration 0025) carry who started the
|
||||
/// connect so the callback can apply the network gate + attribute the audit.
|
||||
pub async fn record_authorize_state(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
merchant_profile_id: Option<&str>,
|
||||
scoped_initiator: bool,
|
||||
actor_hash: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
|
||||
"INSERT INTO btcpay_authorize_state \
|
||||
(state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(&now)
|
||||
.bind(scoped_initiator as i64)
|
||||
.bind(actor_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("recording btcpay authorize state")?;
|
||||
@@ -101,11 +133,18 @@ pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
/// Consumes (deletes) the token on success so a replay fails, and returns the
|
||||
/// recorded `AuthorizeState` (profile + initiator) so the callback knows which
|
||||
/// profile to attach to and whether to apply the scoped network gate.
|
||||
pub async fn consume_authorize_state(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
) -> Result<AuthorizeState> {
|
||||
use sqlx::Row;
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let row = sqlx::query(
|
||||
"SELECT state_token FROM btcpay_authorize_state \
|
||||
"SELECT merchant_profile_id, scoped_initiator, initiator_actor_hash \
|
||||
FROM btcpay_authorize_state \
|
||||
WHERE state_token = ? AND created_at >= ?",
|
||||
)
|
||||
.bind(token)
|
||||
@@ -113,13 +152,22 @@ pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<(
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if row.is_none() {
|
||||
let Some(row) = row else {
|
||||
return Err(anyhow!("unknown or expired authorize state token"));
|
||||
}
|
||||
};
|
||||
let state = AuthorizeState {
|
||||
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
|
||||
// Tolerant read: a NULL/absent column reads as 0 (master) — fail toward
|
||||
// the *less*-restrictive master path is acceptable here because the
|
||||
// column only exists to ADD the scoped restriction; a pre-0025 token
|
||||
// could only ever have been a master connect.
|
||||
scoped_initiator: row.try_get::<i64, _>("scoped_initiator").unwrap_or(0) != 0,
|
||||
initiator_actor_hash: row.try_get("initiator_actor_hash").ok().flatten(),
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod network;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Bitcoin network classification from an address string.
|
||||
//!
|
||||
//! Used by the agent-payment-connect gate (`plans/agent-payment-connect-scope.md`
|
||||
//! §6.1): a *scoped* key may connect a BTCPay store only when its target network
|
||||
//! is non-mainnet. Greenfield's `GET /api/v1/server/info` carries no chain-type
|
||||
//! field, so we determine the network from a **network-encoding artifact** — the
|
||||
//! store's on-chain receive address — and classify by its prefix.
|
||||
//!
|
||||
//! Validated against a live regtest BTCPay 2.x: `wallet/address` returns a
|
||||
//! `bcrt1…` address on regtest (see `onboarding-harness/stage2/btcpay-regtest/`).
|
||||
//!
|
||||
//! **Fail-closed:** an unrecognized / empty address yields `None`; the caller
|
||||
//! MUST treat `None` as mainnet (deny the scoped connect). Never assume
|
||||
//! non-mainnet from absence of evidence.
|
||||
|
||||
/// The Bitcoin network a BTCPay store settles on. Only the mainnet-vs-rest
|
||||
/// distinction gates the scoped connect, but the specific non-mainnet variant
|
||||
/// is kept for audit/logging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BitcoinNetwork {
|
||||
Mainnet,
|
||||
/// testnet3 — shares the `tb1` HRP and `m`/`n`/`2` base58 versions with signet.
|
||||
Testnet,
|
||||
/// Signet — indistinguishable from testnet by address alone (`tb1`), so the
|
||||
/// address classifier never yields this; reserved for a future
|
||||
/// derivation-scheme-based path. Kept distinct because it is a real,
|
||||
/// non-mainnet network the gate must allow.
|
||||
Signet,
|
||||
Regtest,
|
||||
}
|
||||
|
||||
impl BitcoinNetwork {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
BitcoinNetwork::Mainnet => "mainnet",
|
||||
BitcoinNetwork::Testnet => "testnet",
|
||||
BitcoinNetwork::Signet => "signet",
|
||||
BitcoinNetwork::Regtest => "regtest",
|
||||
}
|
||||
}
|
||||
|
||||
/// The only question the connect gate actually asks.
|
||||
pub fn is_mainnet(self) -> bool {
|
||||
matches!(self, BitcoinNetwork::Mainnet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a Bitcoin address by its network-encoding prefix. Returns `None`
|
||||
/// when the prefix is unrecognized or the string is empty — the caller
|
||||
/// **fails closed** (treats `None` as mainnet).
|
||||
///
|
||||
/// bech32/bech32m HRP: `bcrt1…`=regtest, `tb1…`=testnet/signet, `bc1…`=mainnet.
|
||||
/// Legacy base58: `1`/`3`=mainnet, `m`/`n`/`2`=test/regtest (the `tb1`/base58
|
||||
/// test versions are shared by testnet, signet, and regtest — all non-mainnet,
|
||||
/// which is all the gate needs; only the bech32 `bcrt1` HRP pins regtest
|
||||
/// specifically).
|
||||
pub fn classify_address_network(addr: &str) -> Option<BitcoinNetwork> {
|
||||
let s = addr.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// bech32/bech32m — HRP is case-insensitive. Check `bcrt1` before `bc1`
|
||||
// (it is not a prefix of the others, but order makes the intent explicit).
|
||||
let lower = s.to_ascii_lowercase();
|
||||
if lower.starts_with("bcrt1") {
|
||||
return Some(BitcoinNetwork::Regtest);
|
||||
}
|
||||
if lower.starts_with("tb1") {
|
||||
// testnet and signet share the `tb` HRP and are indistinguishable from
|
||||
// the address alone. Both non-mainnet; report Testnet.
|
||||
return Some(BitcoinNetwork::Testnet);
|
||||
}
|
||||
if lower.starts_with("bc1") {
|
||||
return Some(BitcoinNetwork::Mainnet);
|
||||
}
|
||||
// Legacy base58check — version byte encoded in the leading character.
|
||||
// Only classify when the whole string is a *plausible* base58 address
|
||||
// (correct alphabet + length): otherwise arbitrary text that merely begins
|
||||
// with `n`/`m`/`2` (e.g. "not-an-address") would be mis-read as non-mainnet
|
||||
// and the gate would fail OPEN. Junk falls through to `None` (fail closed).
|
||||
// Case-sensitive, so classify off the original string.
|
||||
if (26..=35).contains(&s.len()) && s.chars().all(is_base58) {
|
||||
return match s.chars().next() {
|
||||
Some('1') | Some('3') => Some(BitcoinNetwork::Mainnet),
|
||||
Some('m') | Some('n') | Some('2') => Some(BitcoinNetwork::Testnet),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Base58 alphabet membership (Bitcoin's: omits `0`, `O`, `I`, `l`).
|
||||
fn is_base58(c: char) -> bool {
|
||||
matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bech32_prefixes() {
|
||||
// The exact address the live regtest BTCPay 2.x returned.
|
||||
assert_eq!(
|
||||
classify_address_network("bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt"),
|
||||
Some(BitcoinNetwork::Regtest)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_address_network("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
|
||||
Some(BitcoinNetwork::Testnet)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_address_network("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
|
||||
Some(BitcoinNetwork::Mainnet)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bech32_is_case_insensitive() {
|
||||
assert_eq!(
|
||||
classify_address_network("BCRT1QWSH9UA5QEUTSHVRHZ474UDUWQLW8GFUKFPC8VT"),
|
||||
Some(BitcoinNetwork::Regtest)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_address_network("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"),
|
||||
Some(BitcoinNetwork::Mainnet)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_base58() {
|
||||
assert_eq!(classify_address_network("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), Some(BitcoinNetwork::Mainnet)); // P2PKH
|
||||
assert_eq!(classify_address_network("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), Some(BitcoinNetwork::Mainnet)); // P2SH
|
||||
assert_eq!(classify_address_network("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"), Some(BitcoinNetwork::Testnet)); // testnet P2PKH
|
||||
assert_eq!(classify_address_network("n2ZNV88uQbede7C5M5jzi6SyG4GVuPpng6"), Some(BitcoinNetwork::Testnet));
|
||||
assert_eq!(classify_address_network("2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"), Some(BitcoinNetwork::Testnet)); // test P2SH
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_on_unknown_or_empty() {
|
||||
assert_eq!(classify_address_network(""), None);
|
||||
assert_eq!(classify_address_network(" "), None);
|
||||
assert_eq!(classify_address_network("not-an-address"), None);
|
||||
assert_eq!(classify_address_network("ltc1qxyz"), None); // not bitcoin
|
||||
assert_eq!(classify_address_network("zzz"), None);
|
||||
// The dangerous direction: a base58-length, all-base58 string that does
|
||||
// NOT begin with a version char (1/3/m/n/2) must stay None, never be
|
||||
// mis-read as non-mainnet. (And a real mainnet address always begins
|
||||
// with 1/3/bc1, so it can never fall into the non-mainnet arms.)
|
||||
assert_eq!(classify_address_network("bQ8vZ2mN4pR7sT1uW3xY5zA6dE9fG"), None); // 29 chars, starts 'b'
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_mainnet_only_true_for_mainnet() {
|
||||
assert!(BitcoinNetwork::Mainnet.is_mainnet());
|
||||
assert!(!BitcoinNetwork::Testnet.is_mainnet());
|
||||
assert!(!BitcoinNetwork::Signet.is_mainnet());
|
||||
assert!(!BitcoinNetwork::Regtest.is_mainnet());
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,16 @@ pub struct Config {
|
||||
|
||||
/// Optional human-readable operator name shown in `/` index responses.
|
||||
pub operator_name: Option<String>,
|
||||
|
||||
/// When true, this daemon is a disposable dev / sandbox instance. It is
|
||||
/// the OUTER gate for agent-delegated payment-provider connect: only on a
|
||||
/// sandbox daemon may a scoped `payment_providers:write` key connect a
|
||||
/// provider (and then only a non-mainnet one — see the network gate). On a
|
||||
/// production daemon (false) scoped payment-connect is refused outright, so
|
||||
/// a scoped key can never disrupt a live store's payments. Daemon-level
|
||||
/// only (env `KEYSAT_SANDBOX_MODE`) and **never settable via any API** —
|
||||
/// otherwise a scoped key could flip it on, then connect.
|
||||
pub sandbox_mode: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -102,6 +112,9 @@ impl Config {
|
||||
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");
|
||||
let sandbox_mode = optional_nonempty("KEYSAT_SANDBOX_MODE")
|
||||
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Self {
|
||||
bind,
|
||||
@@ -115,6 +128,7 @@ impl Config {
|
||||
btcpay_webhook_secret,
|
||||
public_base_url: public_base_url.trim_end_matches('/').to_string(),
|
||||
operator_name,
|
||||
sandbox_mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ use uuid::Uuid;
|
||||
|
||||
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
|
||||
let q = if only_active {
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products WHERE active = 1 ORDER BY name"
|
||||
} else {
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products ORDER BY name"
|
||||
};
|
||||
let rows = sqlx::query(q).fetch_all(pool).await?;
|
||||
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
|
||||
|
||||
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
|
||||
|
||||
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
|
||||
FROM products WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -301,6 +301,41 @@ pub async fn set_product_entitlements_catalog(
|
||||
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
||||
}
|
||||
|
||||
/// Attach a product to a merchant profile (migration 0020). Pass
|
||||
/// `Some(profile_id)` to set it, `None` to clear it (the product then
|
||||
/// resolves to the default profile). The target profile is validated to
|
||||
/// exist first so a bad id returns a clean 404 rather than surfacing as
|
||||
/// a raw foreign-key-violation 500.
|
||||
pub async fn set_product_merchant_profile(
|
||||
pool: &SqlitePool,
|
||||
product_id: &str,
|
||||
merchant_profile_id: Option<&str>,
|
||||
) -> AppResult<Product> {
|
||||
if let Some(profile_id) = merchant_profile_id {
|
||||
if get_merchant_profile_by_id(pool, profile_id).await?.is_none() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"merchant profile {profile_id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
"UPDATE products SET merchant_profile_id = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(&now)
|
||||
.bind(product_id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("product {product_id}")));
|
||||
}
|
||||
get_product_by_id(pool, product_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
|
||||
}
|
||||
|
||||
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
let metadata_json: String = row.try_get("metadata_json")?;
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||
@@ -326,6 +361,13 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
.flatten()
|
||||
.and_then(|s| serde_json::from_str::<Vec<crate::models::EntitlementDef>>(&s).ok())
|
||||
.filter(|v| !v.is_empty());
|
||||
// merchant_profile_id lands in migration 0020. NULL = resolves to
|
||||
// the default profile (back-compat); try_get is tolerant of older
|
||||
// rows / SELECTs that predate the column.
|
||||
let merchant_profile_id: Option<String> = row
|
||||
.try_get::<Option<String>, _>("merchant_profile_id")
|
||||
.ok()
|
||||
.flatten();
|
||||
Ok(Product {
|
||||
id: row.try_get("id")?,
|
||||
slug: row.try_get("slug")?,
|
||||
@@ -337,6 +379,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
active: active_int != 0,
|
||||
metadata,
|
||||
entitlements_catalog,
|
||||
merchant_profile_id,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
@@ -355,6 +398,7 @@ pub async fn create_invoice(
|
||||
buyer_email: Option<&str>,
|
||||
buyer_note: Option<&str>,
|
||||
policy_id: Option<&str>,
|
||||
payment_provider_id: Option<&str>,
|
||||
) -> AppResult<Invoice> {
|
||||
create_invoice_with_currency(
|
||||
pool,
|
||||
@@ -370,6 +414,7 @@ pub async fn create_invoice(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
payment_provider_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -395,6 +440,7 @@ pub async fn create_invoice_with_currency(
|
||||
listed_value: Option<i64>,
|
||||
exchange_rate_centibps: Option<i64>,
|
||||
exchange_rate_source: Option<&str>,
|
||||
payment_provider_id: Option<&str>,
|
||||
) -> AppResult<Invoice> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
@@ -402,8 +448,9 @@ pub async fn create_invoice_with_currency(
|
||||
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, policy_id,
|
||||
listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source,
|
||||
payment_provider_id,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(btcpay_invoice_id)
|
||||
@@ -417,6 +464,7 @@ pub async fn create_invoice_with_currency(
|
||||
.bind(listed_value)
|
||||
.bind(exchange_rate_centibps)
|
||||
.bind(exchange_rate_source)
|
||||
.bind(payment_provider_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -466,7 +514,8 @@ pub async fn create_free_invoice(
|
||||
pub async fn get_invoice_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Invoice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id,
|
||||
listed_currency, listed_value, payment_provider_id
|
||||
FROM invoices WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -481,7 +530,8 @@ pub async fn get_invoice_by_btcpay_id(
|
||||
) -> AppResult<Option<Invoice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id,
|
||||
listed_currency, listed_value, payment_provider_id
|
||||
FROM invoices WHERE btcpay_invoice_id = ?",
|
||||
)
|
||||
.bind(btcpay_invoice_id)
|
||||
@@ -517,7 +567,8 @@ pub async fn list_pending_invoices(
|
||||
let cutoff = (Utc::now() - chrono::Duration::hours(max_age_hours)).to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id,
|
||||
listed_currency, listed_value, payment_provider_id
|
||||
FROM invoices
|
||||
WHERE status = 'pending' AND created_at >= ?
|
||||
ORDER BY created_at ASC",
|
||||
@@ -546,6 +597,10 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
|
||||
.ok()
|
||||
.flatten(),
|
||||
listed_value: row.try_get::<Option<i64>, _>("listed_value").ok().flatten(),
|
||||
payment_provider_id: row
|
||||
.try_get::<Option<String>, _>("payment_provider_id")
|
||||
.ok()
|
||||
.flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2891,3 +2946,477 @@ pub async fn settings_set(pool: &SqlitePool, key: &str, value: Option<&str>) ->
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merchant profiles (migration 0020)
|
||||
// =========================================================================
|
||||
|
||||
const MERCHANT_PROFILE_COLS: &str =
|
||||
"id, name, legal_name, support_url, support_email, brand_color, \
|
||||
post_purchase_redirect_url, is_default, \
|
||||
smtp_host, smtp_port, smtp_username, smtp_password, \
|
||||
smtp_from_address, smtp_from_name, smtp_use_starttls, \
|
||||
created_at, updated_at";
|
||||
|
||||
fn row_to_merchant_profile(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
) -> crate::merchant_profiles::MerchantProfile {
|
||||
use sqlx::Row;
|
||||
crate::merchant_profiles::MerchantProfile {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
legal_name: row.try_get("legal_name").ok(),
|
||||
support_url: row.try_get("support_url").ok(),
|
||||
support_email: row.try_get("support_email").ok(),
|
||||
brand_color: row.try_get("brand_color").ok(),
|
||||
post_purchase_redirect_url: row.try_get("post_purchase_redirect_url").ok(),
|
||||
is_default: row.get::<i64, _>("is_default") != 0,
|
||||
smtp_host: row.try_get("smtp_host").ok(),
|
||||
smtp_port: row.try_get("smtp_port").ok(),
|
||||
smtp_username: row.try_get("smtp_username").ok(),
|
||||
smtp_password: row.try_get("smtp_password").ok(),
|
||||
smtp_from_address: row.try_get("smtp_from_address").ok(),
|
||||
smtp_from_name: row.try_get("smtp_from_name").ok(),
|
||||
smtp_use_starttls: row.get::<i64, _>("smtp_use_starttls") != 0,
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_merchant_profile(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: &str,
|
||||
legal_name: Option<&str>,
|
||||
support_url: Option<&str>,
|
||||
support_email: Option<&str>,
|
||||
brand_color: Option<&str>,
|
||||
post_purchase_redirect_url: Option<&str>,
|
||||
is_default: bool,
|
||||
now: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO merchant_profiles(\
|
||||
id, name, legal_name, support_url, support_email, brand_color, \
|
||||
post_purchase_redirect_url, is_default, \
|
||||
smtp_use_starttls, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(legal_name)
|
||||
.bind(support_url)
|
||||
.bind(support_email)
|
||||
.bind(brand_color)
|
||||
.bind(post_purchase_redirect_url)
|
||||
.bind(is_default as i64)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_merchant_profile_by_id(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
) -> AppResult<Option<crate::merchant_profiles::MerchantProfile>> {
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles WHERE id = ?"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_merchant_profile))
|
||||
}
|
||||
|
||||
pub async fn get_default_merchant_profile(
|
||||
pool: &SqlitePool,
|
||||
) -> AppResult<Option<crate::merchant_profiles::MerchantProfile>> {
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles WHERE is_default = 1 LIMIT 1"
|
||||
))
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_merchant_profile))
|
||||
}
|
||||
|
||||
pub async fn get_merchant_profile_for_product(
|
||||
pool: &SqlitePool,
|
||||
product_id: &str,
|
||||
) -> AppResult<Option<crate::merchant_profiles::MerchantProfile>> {
|
||||
// Subquery rather than a JOIN: `MERCHANT_PROFILE_COLS` is a bare
|
||||
// column list (`id, name, …`) shared with the non-JOIN profile
|
||||
// queries, and `products` also has an `id`, so a JOIN here makes the
|
||||
// SELECT list's `id` ambiguous. The subquery keeps `merchant_profiles`
|
||||
// the only table in FROM. A product with a NULL `merchant_profile_id`
|
||||
// yields no match (subquery → NULL), so callers fall back to default.
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles \
|
||||
WHERE id = (SELECT merchant_profile_id FROM products WHERE id = ?) LIMIT 1"
|
||||
))
|
||||
.bind(product_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_merchant_profile))
|
||||
}
|
||||
|
||||
pub async fn list_merchant_profiles(
|
||||
pool: &SqlitePool,
|
||||
) -> AppResult<Vec<crate::merchant_profiles::MerchantProfile>> {
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles \
|
||||
ORDER BY is_default DESC, created_at DESC"
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_merchant_profile).collect())
|
||||
}
|
||||
|
||||
pub async fn update_merchant_profile(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
patch: &crate::merchant_profiles::MerchantProfileUpdate,
|
||||
) -> AppResult<()> {
|
||||
use crate::merchant_profiles::MerchantProfileUpdate;
|
||||
let MerchantProfileUpdate {
|
||||
name,
|
||||
legal_name,
|
||||
support_url,
|
||||
support_email,
|
||||
brand_color,
|
||||
post_purchase_redirect_url,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
smtp_username,
|
||||
smtp_password,
|
||||
smtp_from_address,
|
||||
smtp_from_name,
|
||||
smtp_use_starttls,
|
||||
} = patch;
|
||||
|
||||
// Build the SET clause dynamically — only update fields the caller
|
||||
// explicitly set. Outer Option means "skip if None"; inner Option
|
||||
// (on nullable fields) means "set to NULL if Some(None), set to a
|
||||
// value if Some(Some(value))."
|
||||
let mut sets: Vec<&'static str> = Vec::new();
|
||||
if name.is_some() { sets.push("name = ?"); }
|
||||
if legal_name.is_some() { sets.push("legal_name = ?"); }
|
||||
if support_url.is_some() { sets.push("support_url = ?"); }
|
||||
if support_email.is_some() { sets.push("support_email = ?"); }
|
||||
if brand_color.is_some() { sets.push("brand_color = ?"); }
|
||||
if post_purchase_redirect_url.is_some() { sets.push("post_purchase_redirect_url = ?"); }
|
||||
if smtp_host.is_some() { sets.push("smtp_host = ?"); }
|
||||
if smtp_port.is_some() { sets.push("smtp_port = ?"); }
|
||||
if smtp_username.is_some() { sets.push("smtp_username = ?"); }
|
||||
if smtp_password.is_some() { sets.push("smtp_password = ?"); }
|
||||
if smtp_from_address.is_some() { sets.push("smtp_from_address = ?"); }
|
||||
if smtp_from_name.is_some() { sets.push("smtp_from_name = ?"); }
|
||||
if smtp_use_starttls.is_some() { sets.push("smtp_use_starttls = ?"); }
|
||||
|
||||
if sets.is_empty() {
|
||||
return Ok(()); // nothing to update
|
||||
}
|
||||
sets.push("updated_at = ?");
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE merchant_profiles SET {} WHERE id = ?",
|
||||
sets.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(v) = name { q = q.bind(v); }
|
||||
if let Some(v) = legal_name { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = support_url { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = support_email { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = brand_color { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = post_purchase_redirect_url { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = smtp_host { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = smtp_port { q = q.bind(*v); }
|
||||
if let Some(v) = smtp_username { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = smtp_password { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = smtp_from_address { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = smtp_from_name { q = q.bind(v.as_deref()); }
|
||||
if let Some(v) = smtp_use_starttls { q = q.bind(*v as i64); }
|
||||
let now = Utc::now().to_rfc3339();
|
||||
q = q.bind(&now).bind(id);
|
||||
q.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flip a profile to be the default. Two-step UPDATE in a single
|
||||
/// transaction to maintain the partial unique index on is_default = 1.
|
||||
pub async fn set_default_merchant_profile(
|
||||
pool: &SqlitePool,
|
||||
new_default_id: &str,
|
||||
) -> AppResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE merchant_profiles SET is_default = 0, updated_at = ? WHERE is_default = 1")
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let rows = sqlx::query("UPDATE merchant_profiles SET is_default = 1, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(new_default_id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("merchant profile {new_default_id}")));
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_merchant_profile(pool: &SqlitePool, id: &str) -> AppResult<()> {
|
||||
// Also cascade the rail_preferences entries (no ON DELETE CASCADE
|
||||
// on that table since it's a composite primary key; cleaner to
|
||||
// delete explicitly).
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("DELETE FROM merchant_profile_rail_preferences WHERE merchant_profile_id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let rows = sqlx::query("DELETE FROM merchant_profiles WHERE id = ? AND is_default = 0")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"merchant profile {id} not found or is the default"
|
||||
)));
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn count_products_for_profile(pool: &SqlitePool, profile_id: &str) -> anyhow::Result<i64> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM products WHERE merchant_profile_id = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub async fn count_active_subscriptions_for_profile(
|
||||
pool: &SqlitePool,
|
||||
profile_id: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM subscriptions \
|
||||
WHERE merchant_profile_id = ? AND status IN ('active', 'past_due')",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Payment providers (migration 0020) — replaces btcpay_config + zaprite_config
|
||||
// =========================================================================
|
||||
|
||||
/// Stored shape of a payment_providers row. Used by the provider factory
|
||||
/// in `payment::build_provider` to reconstruct a typed PaymentProvider
|
||||
/// trait object from a row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaymentProviderRow {
|
||||
pub id: String,
|
||||
pub merchant_profile_id: String,
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub webhook_id: Option<String>,
|
||||
pub webhook_secret: Option<String>,
|
||||
pub store_id: Option<String>,
|
||||
pub connected_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
const PAYMENT_PROVIDER_COLS: &str =
|
||||
"id, merchant_profile_id, kind, label, api_key, base_url, \
|
||||
webhook_id, webhook_secret, store_id, connected_at, updated_at";
|
||||
|
||||
fn row_to_payment_provider(row: sqlx::sqlite::SqliteRow) -> PaymentProviderRow {
|
||||
use sqlx::Row;
|
||||
PaymentProviderRow {
|
||||
id: row.get("id"),
|
||||
merchant_profile_id: row.get("merchant_profile_id"),
|
||||
kind: row.get("kind"),
|
||||
label: row.get("label"),
|
||||
api_key: row.get("api_key"),
|
||||
base_url: row.get("base_url"),
|
||||
webhook_id: row.try_get("webhook_id").ok(),
|
||||
webhook_secret: row.try_get("webhook_secret").ok(),
|
||||
store_id: row.try_get("store_id").ok(),
|
||||
connected_at: row.get("connected_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_payment_provider(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
merchant_profile_id: &str,
|
||||
kind: &str,
|
||||
label: &str,
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
webhook_id: Option<&str>,
|
||||
webhook_secret: Option<&str>,
|
||||
store_id: Option<&str>,
|
||||
now: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO payment_providers(\
|
||||
id, merchant_profile_id, kind, label, api_key, base_url, \
|
||||
webhook_id, webhook_secret, store_id, connected_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(kind)
|
||||
.bind(label)
|
||||
.bind(api_key)
|
||||
.bind(base_url)
|
||||
.bind(webhook_id)
|
||||
.bind(webhook_secret)
|
||||
.bind(store_id)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_payment_provider_by_id(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
) -> AppResult<Option<PaymentProviderRow>> {
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {PAYMENT_PROVIDER_COLS} FROM payment_providers WHERE id = ?"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_payment_provider))
|
||||
}
|
||||
|
||||
pub async fn list_payment_providers_for_profile(
|
||||
pool: &SqlitePool,
|
||||
profile_id: &str,
|
||||
) -> AppResult<Vec<PaymentProviderRow>> {
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT {PAYMENT_PROVIDER_COLS} FROM payment_providers \
|
||||
WHERE merchant_profile_id = ? ORDER BY connected_at ASC"
|
||||
))
|
||||
.bind(profile_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_payment_provider).collect())
|
||||
}
|
||||
|
||||
pub async fn list_all_payment_providers(pool: &SqlitePool) -> AppResult<Vec<PaymentProviderRow>> {
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT {PAYMENT_PROVIDER_COLS} FROM payment_providers ORDER BY connected_at ASC"
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_payment_provider).collect())
|
||||
}
|
||||
|
||||
pub async fn delete_payment_provider(pool: &SqlitePool, id: &str) -> AppResult<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
// Cascade rail preferences pointing at this provider.
|
||||
sqlx::query("DELETE FROM merchant_profile_rail_preferences WHERE payment_provider_id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let rows = sqlx::query("DELETE FROM payment_providers WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound(format!("payment provider {id}")));
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merchant profile rail preferences
|
||||
// =========================================================================
|
||||
|
||||
/// (rail, provider_id) tuple representing one preference row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RailPreference {
|
||||
pub rail: String,
|
||||
pub payment_provider_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_rail_preferences_for_profile(
|
||||
pool: &SqlitePool,
|
||||
profile_id: &str,
|
||||
) -> AppResult<Vec<RailPreference>> {
|
||||
use sqlx::Row;
|
||||
let rows = sqlx::query(
|
||||
"SELECT rail, payment_provider_id FROM merchant_profile_rail_preferences \
|
||||
WHERE merchant_profile_id = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| RailPreference {
|
||||
rail: r.get("rail"),
|
||||
payment_provider_id: r.get("payment_provider_id"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Upsert a (profile, rail) → provider mapping. Replaces any existing
|
||||
/// preference for the same (profile, rail) pair.
|
||||
pub async fn set_rail_preference(
|
||||
pool: &SqlitePool,
|
||||
profile_id: &str,
|
||||
rail: &str,
|
||||
provider_id: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO merchant_profile_rail_preferences(\
|
||||
merchant_profile_id, rail, payment_provider_id) \
|
||||
VALUES (?, ?, ?) \
|
||||
ON CONFLICT(merchant_profile_id, rail) DO UPDATE SET \
|
||||
payment_provider_id = excluded.payment_provider_id",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(rail)
|
||||
.bind(provider_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_rail_preference(
|
||||
pool: &SqlitePool,
|
||||
profile_id: &str,
|
||||
rail: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM merchant_profile_rail_preferences \
|
||||
WHERE merchant_profile_id = ? AND rail = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(rail)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -59,23 +59,46 @@ pub enum AppError {
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
/// HTTP status this error maps to. Exposed so handlers that render a
|
||||
/// non-JSON body (e.g. the BTCPay callback's HTML page) still return the
|
||||
/// correct status instead of a misleading 200 on a denied request.
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
AppError::Forbidden => StatusCode::FORBIDDEN,
|
||||
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||
AppError::LicenseInvalid(_) => StatusCode::OK,
|
||||
AppError::Upstream(_) => StatusCode::BAD_GATEWAY,
|
||||
AppError::BtcpayNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
|
||||
AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
AppError::PaymentRequired { .. } => StatusCode::PAYMENT_REQUIRED,
|
||||
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_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::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
|
||||
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"),
|
||||
AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"),
|
||||
let status = self.status_code();
|
||||
let code = match &self {
|
||||
AppError::NotFound(_) => "not_found",
|
||||
AppError::BadRequest(_) => "bad_request",
|
||||
AppError::Unauthorized => "unauthorized",
|
||||
AppError::Forbidden => "forbidden",
|
||||
AppError::Conflict(_) => "conflict",
|
||||
AppError::LicenseInvalid(_) => "invalid",
|
||||
AppError::Upstream(_) => "upstream_error",
|
||||
AppError::BtcpayNotConfigured => "btcpay_not_configured",
|
||||
AppError::TooManyRequests(_) => "rate_limited",
|
||||
AppError::ServiceUnavailable(_) => "service_unavailable",
|
||||
AppError::PaymentRequired { .. } => "tier_cap",
|
||||
AppError::Database(_) | AppError::Internal(_) => {
|
||||
tracing::error!(error = %self, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
"internal_error"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod license_self;
|
||||
pub mod merchant_profiles;
|
||||
pub mod models;
|
||||
pub mod payment;
|
||||
pub mod rate_limit;
|
||||
|
||||
@@ -201,43 +201,34 @@ fn log_licensed(tier: &Tier) {
|
||||
|
||||
/// Live-refresh the daemon's self-tier from the local `licenses` row.
|
||||
///
|
||||
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
|
||||
/// extracts entitlements from the SIGNED PAYLOAD. Those entitlements
|
||||
/// are immutable for the life of that key — the operator can't ever
|
||||
/// downgrade themselves by editing the DB row, because the daemon
|
||||
/// trusts the signature, not the DB.
|
||||
///
|
||||
/// In practice that means tier upgrades / downgrades / revocations
|
||||
/// applied via admin (or eventually, via an upstream master) don't
|
||||
/// propagate to a running daemon — even though the daemon is online
|
||||
/// and the data is right there in its own DB. This function is the
|
||||
/// fix: re-read the licenses row by license_id and use the LIVE
|
||||
/// entitlements + revocation status. The on-disk signed key is kept
|
||||
/// as proof-of-authenticity (signature still verifies) but the live
|
||||
/// DB row is the source of tier truth.
|
||||
/// `check_at_boot` verifies the on-disk LIC1 key against the embedded
|
||||
/// trust root and reads its entitlements from the signed payload. That
|
||||
/// signed set is the ceiling. This function lets issuer-applied changes
|
||||
/// reach a running daemon without a restart — revocations, suspensions,
|
||||
/// downgrades, and the key's own expiry — by re-verifying the on-disk
|
||||
/// key and re-reading the `licenses` row by license_id. The signed key
|
||||
/// stays authoritative: the DB row may *narrow* the tier but never
|
||||
/// *widen* it beyond what the signature grants (see
|
||||
/// `clamp_to_signed_ceiling`).
|
||||
///
|
||||
/// Behavior:
|
||||
/// - If the on-disk tier is `Unlicensed`, do nothing — there's no
|
||||
/// license_id to look up.
|
||||
/// - If the licenses row is missing in the DB (legitimate for a
|
||||
/// daemon that's never been online to sync, e.g.), keep the
|
||||
/// signed-payload tier as last-known.
|
||||
/// - If the row is revoked, demote to `Unlicensed { reason: "revoked" }`.
|
||||
/// - Otherwise, replace the entitlements vec with whatever the DB
|
||||
/// row currently says.
|
||||
/// - On-disk tier is `Unlicensed` → no-op (no license_id to look up).
|
||||
/// - Signed key no longer verifies (expired, tampered, corrupt) → demote
|
||||
/// to `Unlicensed`.
|
||||
/// - `licenses` row missing → keep the signed-payload tier as last-known
|
||||
/// (legitimate for a daemon that's never synced its row).
|
||||
/// - Row revoked or suspended → demote to `Unlicensed`.
|
||||
/// - Otherwise → keep the signed product/expiry, with entitlements taken
|
||||
/// from the DB row clamped to the signed ceiling.
|
||||
///
|
||||
/// Run from main.rs at boot (after `check_at_boot`) and on a 1-hour
|
||||
/// interval thereafter. Also surfaced as an admin "Refresh
|
||||
/// self-license tier" action for operators who want to trigger
|
||||
/// immediately after a change instead of waiting for the next tick.
|
||||
/// interval thereafter. Also surfaced as an admin "Refresh self-license
|
||||
/// tier" action for an immediate pass instead of waiting for the tick.
|
||||
///
|
||||
/// Non-master operators in v0.3+ can extend this to call
|
||||
/// `https://licensing.keysat.xyz/v1/validate` instead of (or in
|
||||
/// addition to) the local DB. For v0.2.x, local-DB-only — which is
|
||||
/// the right thing for the master Keysat (which is selling its own
|
||||
/// licenses) and a no-op-but-safe for downstream operators (their
|
||||
/// own DB row hasn't been mutated, so live read returns the same
|
||||
/// thing as the boot-time signed-payload extraction).
|
||||
/// Non-master operators in v0.3+ can extend this to consult
|
||||
/// `https://licensing.keysat.xyz/v1/validate` in addition to the local
|
||||
/// DB. For v0.2.x it is local-DB-only; an honest downstream operator's
|
||||
/// DB row matches its signed key, so the clamp is a no-op there.
|
||||
pub async fn refresh_self_tier_from_db(
|
||||
pool: &sqlx::SqlitePool,
|
||||
current: &Tier,
|
||||
@@ -247,6 +238,43 @@ pub async fn refresh_self_tier_from_db(
|
||||
Tier::Unlicensed { .. } => return current.clone(),
|
||||
};
|
||||
|
||||
// Re-read and re-verify the on-disk/env self-license key on every
|
||||
// pass. This is what makes the key's own EXPIRY (and any tampering or
|
||||
// corruption) take effect on a *running* daemon, not just at the next
|
||||
// restart — mirroring how the licenses we issue are re-checked on
|
||||
// every `/v1/validate`. Done before the DB lookup so an expired key
|
||||
// demotes even when the daemon has no synced `licenses` row. The
|
||||
// verified entitlements double as the ceiling the DB row is clamped
|
||||
// to below.
|
||||
let signed_ceiling = match read_license_string() {
|
||||
Some(key) => match verify_license(&key) {
|
||||
Ok(tier) => Some(entitlements_of(&tier)),
|
||||
// Present but no longer verifies — expired, tampered, or
|
||||
// corrupt. Demote to Creator (free), same as revoked/suspended.
|
||||
// A read racing a concurrent `activate` file-write could trip
|
||||
// this transiently; it self-heals on the next pass.
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
license_id = %license_id,
|
||||
"self-tier refresh: self-license no longer verifies ({e:#}); demoting to Creator (free) tier"
|
||||
);
|
||||
return Tier::Unlicensed {
|
||||
reason: format!("self-license re-verification failed: {e:#}"),
|
||||
};
|
||||
}
|
||||
},
|
||||
// No key on disk or in env though we booted Licensed — the source
|
||||
// was removed. Keep last-known entitlements as the ceiling (offline
|
||||
// grace), but log it.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
license_id = %license_id,
|
||||
"self-tier refresh: self-license source missing; keeping last-known entitlements"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let row = match crate::db::repo::get_license_by_id(pool, &license_id).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
@@ -281,10 +309,16 @@ pub async fn refresh_self_tier_from_db(
|
||||
};
|
||||
}
|
||||
|
||||
// Pull the LIVE entitlements from the DB. These can differ from
|
||||
// the signed payload's entitlements (which were baked at signing
|
||||
// time) if an admin has done a Change Tier on this license.
|
||||
let entitlements = row.entitlements.clone();
|
||||
// Clamp the live DB row to the signed ceiling derived above: the row
|
||||
// may narrow the tier (an issuer-applied downgrade) but must never
|
||||
// widen it beyond what the signature authorizes. If the key source
|
||||
// was missing, fall back to the in-effect entitlements — themselves
|
||||
// already clamped on a prior pass — so a DB edit still can't widen.
|
||||
let ceiling = match &signed_ceiling {
|
||||
Some(c) => c.clone(),
|
||||
None => entitlements_of(current),
|
||||
};
|
||||
let entitlements = clamp_to_signed_ceiling(row.entitlements.clone(), &ceiling);
|
||||
|
||||
// Same product / license / expiry — only the entitlement set is
|
||||
// live. Cheap rebuild.
|
||||
@@ -308,3 +342,91 @@ pub async fn refresh_self_tier_from_db(
|
||||
current.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Entitlements a tier carries; `Unlicensed` carries none.
|
||||
fn entitlements_of(tier: &Tier) -> Vec<String> {
|
||||
match tier {
|
||||
Tier::Licensed { entitlements, .. } => entitlements.clone(),
|
||||
Tier::Unlicensed { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict a DB-sourced entitlement set to the signed ceiling.
|
||||
///
|
||||
/// The signed self-license key bounds what the tier may grant. The
|
||||
/// local `licenses` row may *narrow* the tier — an issuer-applied
|
||||
/// downgrade — but anything in it that the signature does not grant is
|
||||
/// dropped, so the row can never *widen* the tier past the ceiling.
|
||||
/// Kept standalone so the invariant is unit-testable without the
|
||||
/// offline signing key needed to mint a verifiable self-license.
|
||||
fn clamp_to_signed_ceiling(db_entitlements: Vec<String>, signed: &[String]) -> Vec<String> {
|
||||
db_entitlements
|
||||
.into_iter()
|
||||
.filter(|e| signed.iter().any(|s| s == e))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_row_cannot_widen_beyond_signed_ceiling() {
|
||||
// Signed key grants only the free tier; a tampered DB row
|
||||
// claiming top-tier entitlements is stripped to the signed set.
|
||||
let signed = v(&["creator_only"]);
|
||||
let tampered = v(&[
|
||||
"unlimited_products",
|
||||
"unlimited_policies",
|
||||
"recurring_billing",
|
||||
"zaprite_payments",
|
||||
"patron",
|
||||
"creator_only",
|
||||
]);
|
||||
assert_eq!(
|
||||
clamp_to_signed_ceiling(tampered, &signed),
|
||||
v(&["creator_only"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_row_may_narrow_below_signed_ceiling() {
|
||||
// Signed key grants a broad set; an issuer-applied downgrade to
|
||||
// a smaller set in the DB row is honored (narrowing is allowed).
|
||||
let signed = v(&["unlimited_products", "recurring_billing", "zaprite_payments"]);
|
||||
let downgraded = v(&["unlimited_products"]);
|
||||
assert_eq!(
|
||||
clamp_to_signed_ceiling(downgraded, &signed),
|
||||
v(&["unlimited_products"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_entitlements_pass_through_unchanged() {
|
||||
let signed = v(&["unlimited_products", "recurring_billing"]);
|
||||
let db = v(&["unlimited_products", "recurring_billing"]);
|
||||
assert_eq!(clamp_to_signed_ceiling(db.clone(), &signed), db);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_signed_ceiling_strips_everything() {
|
||||
let db = v(&["unlimited_products", "patron"]);
|
||||
assert!(clamp_to_signed_ceiling(db, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_downgrade_keeps_the_still_granted_entitlements() {
|
||||
// Multi-entitlement signed key; the DB row drops one of them
|
||||
// (an issuer-applied partial downgrade) and keeps the rest.
|
||||
let signed = v(&["unlimited_products", "recurring_billing", "zaprite_payments"]);
|
||||
let db = v(&["unlimited_products", "zaprite_payments"]);
|
||||
assert_eq!(
|
||||
clamp_to_signed_ceiling(db, &signed),
|
||||
v(&["unlimited_products", "zaprite_payments"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,61 +57,70 @@ async fn main() -> anyhow::Result<()> {
|
||||
keypair.public_key_pem.trim()
|
||||
);
|
||||
|
||||
// --- payment provider (may be None until operator connects) ---
|
||||
// Resolution order:
|
||||
// 1. operator's explicit preference from the
|
||||
// active_payment_provider setting (set by the most recent
|
||||
// Connect or Activate action),
|
||||
// 2. fallback for legacy installs without the setting:
|
||||
// BTCPay first, Zaprite second. Once we ship v0.3 with the
|
||||
// multi-provider routing layer this fallback retires.
|
||||
let preferred = payment::read_active_provider_preference(&pool).await;
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> = match preferred {
|
||||
Some(payment::ProviderKind::Zaprite) => {
|
||||
// Operator explicitly chose Zaprite. Try Zaprite; if it
|
||||
// can't be loaded (e.g., the row was deleted out from
|
||||
// under the setting), fall through to BTCPay rather
|
||||
// than booting unconfigured.
|
||||
load_zaprite_provider(&pool)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>)
|
||||
.or_else(|| {
|
||||
// --- payment provider boot-time warm-up ---
|
||||
//
|
||||
// With the multi-merchant-profile model (migration 0020+) we no longer
|
||||
// load a single "active" provider at boot. Providers are looked up by
|
||||
// id on demand via `AppState::payment_provider_by_id` (which builds
|
||||
// from a `payment_providers` row each time it's called) and resolved
|
||||
// per purchase via `resolve_provider_for_product_rail`.
|
||||
//
|
||||
// For back-compat we still populate the legacy `state.payment`
|
||||
// singleton with the FIRST provider attached to the default merchant
|
||||
// profile — this is what `state.payment_provider()` returns to the
|
||||
// remaining legacy call sites (and is a sensible fallback for any
|
||||
// code path that runs before the operator has linked a product to a
|
||||
// specific profile). Empty profile → empty singleton; the on-demand
|
||||
// resolution layer takes over from there.
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> = match keysat::db::repo::get_default_merchant_profile(&pool).await {
|
||||
Ok(Some(profile)) => match keysat::db::repo::list_payment_providers_for_profile(&pool, &profile.id).await {
|
||||
Ok(rows) => match rows.first() {
|
||||
Some(row) => match payment::build_provider(row, cfg.btcpay_public_url.as_deref()) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"active_payment_provider=zaprite but zaprite_config is missing; \
|
||||
falling back to BTCPay"
|
||||
provider_id = %row.id,
|
||||
error = %e,
|
||||
"failed to build provider from default-profile row; \
|
||||
leaving legacy state.payment empty"
|
||||
);
|
||||
None
|
||||
})
|
||||
.or(load_btcpay_provider(&pool, &cfg)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>))
|
||||
}
|
||||
Some(payment::ProviderKind::Btcpay) | None => {
|
||||
// Either operator chose BTCPay, or no preference recorded
|
||||
// yet (legacy install). Either way, BTCPay wins if
|
||||
// configured; Zaprite as fallback.
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
profile_id = %profile.id,
|
||||
error = %e,
|
||||
"failed to list providers on default profile at boot"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
// Pre-migration: no default profile exists yet (operator hasn't
|
||||
// installed :52 yet). Fall back to the legacy singleton-config
|
||||
// loaders so the daemon still boots cleanly during the upgrade
|
||||
// window — these run against btcpay_config / zaprite_config
|
||||
// until migration 0020 drops those tables.
|
||||
load_btcpay_provider(&pool, &cfg)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>)
|
||||
.or_else(|| {
|
||||
if preferred == Some(payment::ProviderKind::Btcpay) {
|
||||
tracing::warn!(
|
||||
"active_payment_provider=btcpay but btcpay_config is missing; \
|
||||
falling back to Zaprite"
|
||||
);
|
||||
}
|
||||
None
|
||||
})
|
||||
.or(load_zaprite_provider(&pool)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read default merchant profile at boot");
|
||||
None
|
||||
}
|
||||
};
|
||||
match &provider {
|
||||
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
|
||||
Some(p) => tracing::info!(provider = p.kind().as_str(), "default payment provider warmed up"),
|
||||
None => tracing::warn!(
|
||||
"no payment provider yet configured — purchases will return 503 until the \
|
||||
operator completes the 'Connect BTCPay' or 'Connect Zaprite' flow"
|
||||
operator completes 'Connect BTCPay' or 'Connect Zaprite' in the admin UI"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -119,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
db: pool,
|
||||
keypair: Arc::new(keypair),
|
||||
payment: Arc::new(tokio::sync::RwLock::new(provider)),
|
||||
provider_override: None,
|
||||
config: Arc::new(cfg.clone()),
|
||||
self_tier,
|
||||
rates: keysat::rates::RateCache::new(),
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Merchant profile layer.
|
||||
//!
|
||||
//! A merchant profile represents one "business" the operator is running
|
||||
//! on a Keysat instance. Owns business identity (brand, support contact,
|
||||
//! redirect URL, optional SMTP) and a set of payment providers attached
|
||||
//! to it (BTCPay + Zaprite + future kinds). Products attach to a
|
||||
//! merchant profile, not directly to a provider.
|
||||
//!
|
||||
//! Tier gating:
|
||||
//! - **Creator (free)**: exactly 1 profile (the auto-created default).
|
||||
//! - **Pro / Patron**: unlimited profiles.
|
||||
//!
|
||||
//! The schema lives in `migrations/0020_merchant_profiles.sql`. Repo
|
||||
//! helpers (raw SQL) live in `db::repo`; this module wraps them with
|
||||
//! business-logic guards (tier check, single-default enforcement, etc.).
|
||||
//!
|
||||
//! See `plans/multi-provider-payment-model.md` for the design rationale.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use anyhow::Context;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A merchant profile row. Mirrors the `merchant_profiles` table.
|
||||
///
|
||||
/// NOTE: the `smtp_*` fields are DORMANT and not consumed by anything.
|
||||
/// They were laid down in migration 0020 ahead of the keysat-smtp-emails
|
||||
/// plan, which was SUPERSEDED 2026-06-18: Keysat will never send buyer
|
||||
/// email itself (operators own that via their own app + the existing
|
||||
/// webhooks). The columns are left in place because a removal migration
|
||||
/// isn't worth it — do not build a send path against them. See
|
||||
/// `plans/keysat-smtp-emails.md` (superseded banner) and the
|
||||
/// "Operability & alerts" ROADMAP item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MerchantProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub legal_name: Option<String>,
|
||||
pub support_url: Option<String>,
|
||||
pub support_email: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub post_purchase_redirect_url: Option<String>,
|
||||
pub is_default: bool,
|
||||
|
||||
// Dormant SMTP-override columns (see struct doc) — stored/returned
|
||||
// but never read to send mail; no send path exists or is planned.
|
||||
pub smtp_host: Option<String>,
|
||||
pub smtp_port: Option<i64>,
|
||||
pub smtp_username: Option<String>,
|
||||
pub smtp_password: Option<String>,
|
||||
pub smtp_from_address: Option<String>,
|
||||
pub smtp_from_name: Option<String>,
|
||||
pub smtp_use_starttls: bool,
|
||||
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Input for `create` — only the operator-set fields. id, is_default,
|
||||
/// created_at, updated_at are filled in by this layer.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct NewMerchantProfile {
|
||||
pub name: String,
|
||||
pub legal_name: Option<String>,
|
||||
pub support_url: Option<String>,
|
||||
pub support_email: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub post_purchase_redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for `update` — every field optional. None means "leave unchanged."
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct MerchantProfileUpdate {
|
||||
pub name: Option<String>,
|
||||
pub legal_name: Option<Option<String>>,
|
||||
pub support_url: Option<Option<String>>,
|
||||
pub support_email: Option<Option<String>>,
|
||||
pub brand_color: Option<Option<String>>,
|
||||
pub post_purchase_redirect_url: Option<Option<String>>,
|
||||
|
||||
pub smtp_host: Option<Option<String>>,
|
||||
pub smtp_port: Option<Option<i64>>,
|
||||
pub smtp_username: Option<Option<String>>,
|
||||
pub smtp_password: Option<Option<String>>,
|
||||
pub smtp_from_address: Option<Option<String>>,
|
||||
pub smtp_from_name: Option<Option<String>>,
|
||||
pub smtp_use_starttls: Option<bool>,
|
||||
}
|
||||
|
||||
/// Look up a profile by id. Returns `Ok(None)` if not found.
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> AppResult<Option<MerchantProfile>> {
|
||||
repo::get_merchant_profile_by_id(pool, id).await
|
||||
}
|
||||
|
||||
/// Return the default profile. Migration 0020 guarantees exactly one
|
||||
/// exists post-migration, so this returning None at runtime is an
|
||||
/// invariant violation and the caller should treat it as fatal.
|
||||
pub async fn get_default(pool: &SqlitePool) -> AppResult<Option<MerchantProfile>> {
|
||||
repo::get_default_merchant_profile(pool).await
|
||||
}
|
||||
|
||||
/// Required default profile lookup. Returns AppError::Internal if no
|
||||
/// default exists (which would mean the migration was skipped or the
|
||||
/// row was somehow deleted — neither should happen in normal operation).
|
||||
pub async fn require_default(pool: &SqlitePool) -> AppResult<MerchantProfile> {
|
||||
get_default(pool).await?.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"no default merchant profile — migration 0020 may not have run"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// List all merchant profiles, newest-first.
|
||||
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<MerchantProfile>> {
|
||||
repo::list_merchant_profiles(pool).await
|
||||
}
|
||||
|
||||
/// Look up the merchant profile a product belongs to. Resolves via
|
||||
/// `products.merchant_profile_id`. Returns the DEFAULT profile if the
|
||||
/// product has no profile id set (back-compat for any rows that slipped
|
||||
/// through the migration with NULL — shouldn't happen but defensive).
|
||||
pub async fn for_product(state: &AppState, product_id: &str) -> AppResult<MerchantProfile> {
|
||||
if let Some(p) = repo::get_merchant_profile_for_product(&state.db, product_id).await? {
|
||||
return Ok(p);
|
||||
}
|
||||
require_default(&state.db).await
|
||||
}
|
||||
|
||||
/// Create a new merchant profile. Enforces the Creator tier cap: if the
|
||||
/// operator's current tier returns a `merchant_profile` cap of 1 and
|
||||
/// at least one profile already exists, returns `AppError::TierCap`
|
||||
/// pointing at the upgrade URL.
|
||||
///
|
||||
/// New profiles default to `is_default = 0`. Use `set_default` to flip
|
||||
/// the default flag explicitly — the auto-created post-migration profile
|
||||
/// is always the default; subsequent profiles never become default by
|
||||
/// creation alone.
|
||||
pub async fn create(
|
||||
state: &AppState,
|
||||
input: NewMerchantProfile,
|
||||
) -> AppResult<MerchantProfile> {
|
||||
// Tier gate: Creator gets 1 profile (the auto-created default).
|
||||
// Pro / Patron with `unlimited_merchant_profiles` get N. Returns
|
||||
// AppError::PaymentRequired (HTTP 402) with the upgrade URL so the
|
||||
// admin UI can render the existing tier-cap modal.
|
||||
crate::api::tier::enforce_merchant_profile_cap(state).await?;
|
||||
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("merchant profile name required".into()));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
repo::create_merchant_profile(
|
||||
&state.db,
|
||||
&id,
|
||||
&input.name,
|
||||
input.legal_name.as_deref(),
|
||||
input.support_url.as_deref(),
|
||||
input.support_email.as_deref(),
|
||||
input.brand_color.as_deref(),
|
||||
input.post_purchase_redirect_url.as_deref(),
|
||||
false, // is_default
|
||||
&now,
|
||||
)
|
||||
.await?;
|
||||
get(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("created profile not found")))
|
||||
}
|
||||
|
||||
/// Update a profile. Only fields with `Some(...)` are written;
|
||||
/// double-Option wraps nullable fields so callers can distinguish
|
||||
/// "leave unchanged" (`None`) from "set to NULL" (`Some(None)`).
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
patch: MerchantProfileUpdate,
|
||||
) -> AppResult<MerchantProfile> {
|
||||
repo::update_merchant_profile(pool, id, &patch).await?;
|
||||
get(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))
|
||||
}
|
||||
|
||||
/// Flip a profile to be the default. Atomic: clears the previous
|
||||
/// default in the same transaction so the partial unique index holds.
|
||||
pub async fn set_default(pool: &SqlitePool, id: &str) -> AppResult<()> {
|
||||
repo::set_default_merchant_profile(pool, id).await
|
||||
}
|
||||
|
||||
/// Delete a profile. Refuses if any product OR active subscription
|
||||
/// is still attached. Refuses if it's the default profile (operator
|
||||
/// must set another profile as default first).
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> AppResult<()> {
|
||||
let profile = get(pool, id).await?.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("merchant profile {id} not found"))
|
||||
})?;
|
||||
if profile.is_default {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot delete the default merchant profile — set another profile as default first"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let product_count = repo::count_products_for_profile(pool, id)
|
||||
.await
|
||||
.context("count_products_for_profile")
|
||||
.map_err(AppError::Internal)?;
|
||||
if product_count > 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"cannot delete merchant profile: {product_count} products still attached. \
|
||||
Move or delete the products first."
|
||||
)));
|
||||
}
|
||||
let active_sub_count = repo::count_active_subscriptions_for_profile(pool, id)
|
||||
.await
|
||||
.context("count_active_subscriptions_for_profile")
|
||||
.map_err(AppError::Internal)?;
|
||||
if active_sub_count > 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"cannot delete merchant profile: {active_sub_count} active subscriptions \
|
||||
still attached. Cancel them first or migrate them to another profile."
|
||||
)));
|
||||
}
|
||||
repo::delete_merchant_profile(pool, id).await
|
||||
}
|
||||
@@ -34,6 +34,12 @@ pub struct Product {
|
||||
/// behavior); operators can opt-in by adding rows.
|
||||
#[serde(default)]
|
||||
pub entitlements_catalog: Option<Vec<EntitlementDef>>,
|
||||
/// Merchant profile this product belongs to (migration 0020). None
|
||||
/// resolves to the default profile (back-compat for rows created
|
||||
/// before the operator ran more than one profile). Set via the admin
|
||||
/// product form when >1 profile exists.
|
||||
#[serde(default)]
|
||||
pub merchant_profile_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -114,6 +120,13 @@ pub struct Invoice {
|
||||
/// `amount_sats` (which is correct for SAT-priced products).
|
||||
#[serde(default)]
|
||||
pub listed_value: Option<i64>,
|
||||
/// Which payment provider settled this invoice. Added by migration
|
||||
/// 0021 alongside the multi-merchant-profile work; NULL on pre-0021
|
||||
/// invoices (backfilled by the migration to the first provider on
|
||||
/// the default profile). Required for reconcile.rs to dispatch
|
||||
/// status checks to the right provider when multiple are configured.
|
||||
#[serde(default)]
|
||||
pub payment_provider_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use super::{
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
|
||||
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
ProviderInvoiceSnapshot, ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
};
|
||||
use crate::btcpay::client::BtcpayClient;
|
||||
use crate::btcpay::webhook::{verify_signature, WebhookEvent as BtcpayWebhookEvent};
|
||||
@@ -155,17 +155,13 @@ impl PaymentProvider for BtcpayProvider {
|
||||
async fn get_invoice_status(
|
||||
&self,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<ProviderInvoiceStatus> {
|
||||
) -> Result<ProviderInvoiceSnapshot> {
|
||||
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 {
|
||||
let status = match raw.get("status").and_then(|v| v.as_str()).unwrap_or("Pending") {
|
||||
"Settled" | "Complete" => ProviderInvoiceStatus::Settled,
|
||||
"Expired" => ProviderInvoiceStatus::Expired,
|
||||
"Invalid" => ProviderInvoiceStatus::Invalid,
|
||||
@@ -173,7 +169,36 @@ impl PaymentProvider for BtcpayProvider {
|
||||
// reports it via metadata we'd handle here. For now it falls
|
||||
// through to Pending.
|
||||
_ => ProviderInvoiceStatus::Pending,
|
||||
})
|
||||
};
|
||||
// The amount the invoice is denominated for, for the advisory
|
||||
// settle-amount tripwire (see docs/guides/payments.md). We price
|
||||
// BTCPay invoices in "BTC" with a decimal amount = sats / 1e8 (see
|
||||
// btcpay/client.rs::create_invoice), so convert that back to sats —
|
||||
// f64 is exact for sat-magnitude integers and mirrors the inverse
|
||||
// conversion already used in the client. Any other currency
|
||||
// shouldn't occur in our flow; pass it through verbatim so the
|
||||
// tripwire downstream flags the unexpected currency. Absent or
|
||||
// unparseable amount → None ("no opinion"; tripwire skips it).
|
||||
let amount = match (
|
||||
raw.get("currency").and_then(|v| v.as_str()),
|
||||
raw.get("amount").and_then(|v| v.as_str()),
|
||||
) {
|
||||
(Some("BTC"), Some(amt)) => amt
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|btc| (btc * 100_000_000.0).round() as i64)
|
||||
// Guard against garbage from the provider (negative/zero/NaN
|
||||
// → 0): a real invoice amount is positive. Non-positive → None
|
||||
// ("no opinion"), so the advisory tripwire skips it.
|
||||
.filter(|&sats| sats > 0)
|
||||
.map(Money::sats),
|
||||
(Some(cur), Some(amt)) => amt.parse::<i64>().ok().map(|v| Money {
|
||||
currency: cur.to_string(),
|
||||
amount: v,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(ProviderInvoiceSnapshot { status, amount })
|
||||
}
|
||||
|
||||
fn validate_webhook(
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
//!
|
||||
//! - `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)
|
||||
//! - `get_invoice_status` — authoritative status + amount, for the reconcile
|
||||
//! loop (webhook misses) and the webhook settle-confirmation gate
|
||||
//! - `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
|
||||
@@ -40,43 +41,63 @@ use std::any::Any;
|
||||
pub mod btcpay;
|
||||
pub mod zaprite;
|
||||
|
||||
/// Settings-table key that records which provider the operator
|
||||
/// last activated. Used by the boot-time loader to pick which
|
||||
/// provider to load when both `btcpay_config` and `zaprite_config`
|
||||
/// are populated. Values: `'btcpay'` | `'zaprite'`. Absent means
|
||||
/// "use whichever single provider is configured" (back-compat
|
||||
/// for installs that pre-date this setting).
|
||||
// =========================================================================
|
||||
// Legacy compatibility shims — DEPRECATED, will be removed once all call
|
||||
// sites migrate to the merchant-profile-aware resolution layer.
|
||||
// =========================================================================
|
||||
//
|
||||
// During the multi-provider transition the singleton-config-and-active-
|
||||
// provider-preference helpers stay callable so the existing connect flows
|
||||
// (`btcpay_authorize.rs`, `zaprite_authorize.rs`) and the boot loader in
|
||||
// `main.rs` keep working. Each shim wraps the new schema with the old
|
||||
// semantics: `read_active_provider_preference` looks up the first provider
|
||||
// attached to the default merchant profile and returns its kind;
|
||||
// `write_active_provider_preference` is a no-op (the new model doesn't
|
||||
// track an "active provider" preference — providers attach to profiles,
|
||||
// profiles attach to products).
|
||||
|
||||
#[deprecated(
|
||||
note = "use merchant-profile-aware resolution: \
|
||||
state.payment_provider_for(product_id, rail)"
|
||||
)]
|
||||
pub const SETTING_ACTIVE_PROVIDER: &str = "active_payment_provider";
|
||||
|
||||
/// Convenience getter for the active-provider setting. Returns
|
||||
/// `Some(ProviderKind)` if the operator has explicitly chosen
|
||||
/// one, `None` if they haven't (caller falls back to the
|
||||
/// load-order heuristic).
|
||||
#[deprecated(
|
||||
note = "look up providers via list_payment_providers_for_profile or \
|
||||
payment_provider_by_id on AppState"
|
||||
)]
|
||||
pub async fn read_active_provider_preference(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Option<ProviderKind> {
|
||||
// Post-migration: derive from the first provider attached to the
|
||||
// default merchant profile (deterministic by connected_at ASC).
|
||||
// Pre-migration (if the migration hasn't run yet on this DB):
|
||||
// fall back to the legacy settings-table read.
|
||||
let default_profile = crate::db::repo::get_default_merchant_profile(pool).await.ok().flatten();
|
||||
if let Some(profile) = default_profile {
|
||||
if let Ok(rows) = crate::db::repo::list_payment_providers_for_profile(pool, &profile.id).await {
|
||||
if let Some(first) = rows.first() {
|
||||
return ProviderKind::parse(&first.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy fallback for the pre-migration window.
|
||||
match crate::db::repo::settings_get(pool, SETTING_ACTIVE_PROVIDER).await {
|
||||
Ok(Some(s)) => match s.as_str() {
|
||||
"btcpay" => Some(ProviderKind::Btcpay),
|
||||
"zaprite" => Some(ProviderKind::Zaprite),
|
||||
_ => None,
|
||||
},
|
||||
Ok(Some(s)) => ProviderKind::parse(&s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the operator's active-provider preference. Called by
|
||||
/// the connect endpoints (Connect BTCPay, Connect Zaprite) and
|
||||
/// by the new "Activate <provider>" endpoint that flips between
|
||||
/// already-configured providers without re-authorizing.
|
||||
#[deprecated(
|
||||
note = "providers are now attached to merchant profiles, not implicitly active. \
|
||||
This shim is a no-op; remove the call."
|
||||
)]
|
||||
pub async fn write_active_provider_preference(
|
||||
pool: &sqlx::SqlitePool,
|
||||
kind: ProviderKind,
|
||||
_pool: &sqlx::SqlitePool,
|
||||
_kind: ProviderKind,
|
||||
) -> anyhow::Result<()> {
|
||||
let value = kind.as_str();
|
||||
crate::db::repo::settings_set(pool, SETTING_ACTIVE_PROVIDER, Some(value))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("write active provider preference: {e:#}"))?;
|
||||
// No-op. In the multi-provider model there's no "active" preference
|
||||
// to write — providers are looked up by id (per-product) or by profile.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -94,6 +115,95 @@ impl ProviderKind {
|
||||
ProviderKind::Zaprite => "zaprite",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"btcpay" => Some(Self::Btcpay),
|
||||
"zaprite" => Some(Self::Zaprite),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Buyer-facing payment method. The buy page renders a picker over these
|
||||
/// (when a merchant profile exposes more than one); the routing layer maps
|
||||
/// the buyer's pick to a specific provider via the profile's attached
|
||||
/// providers + optional `merchant_profile_rail_preferences` tie-breakers.
|
||||
///
|
||||
/// Rails-per-provider-kind are **inherent** (declared by each provider
|
||||
/// impl's `served_rails()` trait method), not configurable per provider
|
||||
/// row. BTCPay serves Lightning + OnChain. Zaprite serves Card +
|
||||
/// Lightning + OnChain.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Rail {
|
||||
Lightning,
|
||||
Onchain,
|
||||
Card,
|
||||
}
|
||||
|
||||
impl Rail {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Rail::Lightning => "lightning",
|
||||
Rail::Onchain => "onchain",
|
||||
Rail::Card => "card",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"lightning" => Some(Self::Lightning),
|
||||
"onchain" | "on-chain" | "on_chain" => Some(Self::Onchain),
|
||||
"card" => Some(Self::Card),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static rails served by a provider kind. Returned by
|
||||
/// `PaymentProvider::served_rails()`; centralized here so callers that
|
||||
/// just want to know "what does kind X support" (e.g., the admin UI's
|
||||
/// connect-flow guidance) don't have to instantiate a provider.
|
||||
pub fn rails_for_kind(kind: ProviderKind) -> Vec<Rail> {
|
||||
match kind {
|
||||
ProviderKind::Btcpay => vec![Rail::Lightning, Rail::Onchain],
|
||||
ProviderKind::Zaprite => vec![Rail::Card, Rail::Lightning, Rail::Onchain],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a typed `PaymentProvider` trait object from a `payment_providers`
|
||||
/// row. Dispatch on `kind`. Used by the AppState provider cache when
|
||||
/// resolving by provider id.
|
||||
pub fn build_provider(
|
||||
row: &crate::db::repo::PaymentProviderRow,
|
||||
public_base_url: Option<&str>,
|
||||
) -> anyhow::Result<std::sync::Arc<dyn PaymentProvider>> {
|
||||
use crate::btcpay::client::BtcpayClient;
|
||||
use crate::payment::btcpay::BtcpayProvider;
|
||||
use crate::payment::zaprite::{ZapriteClient, ZapriteProvider};
|
||||
|
||||
match ProviderKind::parse(&row.kind) {
|
||||
Some(ProviderKind::Btcpay) => {
|
||||
let store_id = row.store_id.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("BTCPay provider row {} missing store_id", row.id)
|
||||
})?;
|
||||
let webhook_secret = row.webhook_secret.clone().unwrap_or_default();
|
||||
let client = BtcpayClient::new(&row.base_url, &row.api_key, store_id);
|
||||
let provider = BtcpayProvider::new(client, webhook_secret)
|
||||
.with_public_base(public_base_url.map(|s| s.to_string()));
|
||||
Ok(std::sync::Arc::new(provider))
|
||||
}
|
||||
Some(ProviderKind::Zaprite) => {
|
||||
let client = ZapriteClient::new(row.base_url.clone(), row.api_key.clone());
|
||||
Ok(std::sync::Arc::new(ZapriteProvider::new(client)))
|
||||
}
|
||||
None => Err(anyhow::anyhow!(
|
||||
"unknown payment provider kind {:?} on row {}",
|
||||
row.kind,
|
||||
row.id
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A monetary amount + the unit it's denominated in.
|
||||
@@ -137,6 +247,15 @@ pub struct CreateInvoiceParams<'a> {
|
||||
pub external_order_id: &'a str,
|
||||
/// Buyer email if known. Some providers use this for receipts.
|
||||
pub buyer_email: Option<&'a str>,
|
||||
/// Ask the provider to prompt the buyer to save their payment
|
||||
/// profile for future merchant-initiated charges. Zaprite honors
|
||||
/// this for autopay-supporting rails (Stripe card, etc.); BTCPay
|
||||
/// has no equivalent concept and silently ignores it. Set
|
||||
/// `Some(true)` on the FIRST cycle of a recurring purchase so the
|
||||
/// renewal worker can later call `charge_order_with_profile`
|
||||
/// against the saved profile. `None` / `Some(false)` is the
|
||||
/// one-shot default.
|
||||
pub allow_save_payment_profile: Option<bool>,
|
||||
}
|
||||
|
||||
/// Result of `create_invoice`. Whatever the provider returned, narrowed
|
||||
@@ -162,6 +281,23 @@ pub enum ProviderInvoiceStatus {
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// The provider's current view of an invoice: its `status` plus the amount
|
||||
/// the provider has the invoice denominated for. Returned by
|
||||
/// `PaymentProvider::get_invoice_status`.
|
||||
///
|
||||
/// `amount` is the price the provider has on record for the invoice (what we
|
||||
/// asked it to charge), normalized to `SAT` when the provider used a Bitcoin
|
||||
/// unit. It is `None` when the response carried no parseable amount/currency.
|
||||
/// `status` is the load-bearing settle gate; `amount` feeds only the
|
||||
/// **advisory** settle-amount tripwire in `api::webhook` / `reconcile` —
|
||||
/// callers treat `None` as "no opinion" and MUST NOT gate issuance on it.
|
||||
/// See docs/guides/payments.md.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderInvoiceSnapshot {
|
||||
pub status: ProviderInvoiceStatus,
|
||||
pub amount: Option<Money>,
|
||||
}
|
||||
|
||||
/// Parsed webhook event. Only the kinds Keysat actually acts on are
|
||||
/// modeled; everything else falls into `Other` and is ignored.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -221,6 +357,14 @@ pub struct PaymentReceipt {
|
||||
pub trait PaymentProvider: Send + Sync + Any {
|
||||
fn kind(&self) -> ProviderKind;
|
||||
|
||||
/// Payment rails this provider can settle. Default impl uses the
|
||||
/// static `rails_for_kind()` mapping; impls only override if they
|
||||
/// expose a non-default set (e.g., a degraded BTCPay configured
|
||||
/// without Lightning support — not currently a Keysat concern).
|
||||
fn served_rails(&self) -> Vec<Rail> {
|
||||
rails_for_kind(self.kind())
|
||||
}
|
||||
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: CreateInvoiceParams<'_>,
|
||||
@@ -229,7 +373,7 @@ pub trait PaymentProvider: Send + Sync + Any {
|
||||
async fn get_invoice_status(
|
||||
&self,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<ProviderInvoiceStatus>;
|
||||
) -> Result<ProviderInvoiceSnapshot>;
|
||||
|
||||
/// Verify and parse a webhook delivery. Implementations are
|
||||
/// responsible for reading whatever signature header their provider
|
||||
|
||||
@@ -51,6 +51,15 @@ pub struct CreateOrderBody<'a> {
|
||||
/// recurring charges. Set when the policy is recurring.
|
||||
#[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")]
|
||||
pub allow_save_payment_profile: Option<bool>,
|
||||
/// Zaprite contact id to attach this order to. REQUIRED by
|
||||
/// Zaprite when `allow_save_payment_profile` is true — without
|
||||
/// it the create-order call returns
|
||||
/// `400 contactId is required when allowSavePaymentProfile is true`.
|
||||
/// Optional otherwise; passing it for one-shot purchases just
|
||||
/// associates the order with a known contact in the operator's
|
||||
/// Zaprite dashboard.
|
||||
#[serde(rename = "contactId", skip_serializing_if = "Option::is_none")]
|
||||
pub contact_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ZapriteClient {
|
||||
@@ -157,6 +166,84 @@ impl ZapriteClient {
|
||||
serde_json::from_str(&raw).context("parse charge response")
|
||||
}
|
||||
|
||||
/// `POST /v1/contacts` — create a Zaprite contact. Required
|
||||
/// upstream step before creating an order with
|
||||
/// `allowSavePaymentProfile: true` (Zaprite needs to know which
|
||||
/// contact the saved profile attaches to). Returns the full
|
||||
/// contact JSON; the caller extracts `id` to pass as
|
||||
/// `contactId` on the subsequent order create.
|
||||
///
|
||||
/// `legal_name` is required by Zaprite's schema; we fall back to
|
||||
/// the email itself when the buyer didn't supply a name. The
|
||||
/// operator can rename the contact in the Zaprite dashboard if
|
||||
/// they care about display polish.
|
||||
///
|
||||
/// NOTE on duplicates: Zaprite's duplicate-email behavior on
|
||||
/// `POST /v1/contacts` is undocumented (their llms.txt explicitly
|
||||
/// says "Not documented"). Empirically we accept whatever Zaprite
|
||||
/// does — if they create a duplicate, the operator's Zaprite
|
||||
/// contact list gets a row per recurring purchase from the same
|
||||
/// buyer. The multi-provider work (planned `:47+`) will introduce
|
||||
/// a Keysat-side `zaprite_contacts` cache keyed on (email,
|
||||
/// provider_id) to dedup upfront. For sandbox testing + early
|
||||
/// production this is acceptable noise.
|
||||
pub async fn create_contact(
|
||||
&self,
|
||||
email: &str,
|
||||
name: Option<&str>,
|
||||
) -> Result<Value> {
|
||||
let legal_name = name.unwrap_or(email);
|
||||
let url = format!("{}/v1/contacts", self.base_url);
|
||||
let body = serde_json::json!({
|
||||
"email": email,
|
||||
"legalName": legal_name,
|
||||
});
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.auth_headers()?)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Zaprite create_contact request")?;
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.context("read create_contact body")?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!(
|
||||
"Zaprite create_contact returned HTTP {status}: {raw}"
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&raw).context("parse create_contact response")
|
||||
}
|
||||
|
||||
/// `GET /v1/contacts/{id}` — fetch a Zaprite contact, which
|
||||
/// includes the `paymentProfiles[]` array we mine for the
|
||||
/// saved-card id after a recurring first-cycle settle. Each
|
||||
/// profile has `id`, `method`, `expiresAt`, and a `sourceOrder`
|
||||
/// nested object whose `externalUniqId` is the invoice UUID we
|
||||
/// passed when creating the order — that's how we identify the
|
||||
/// profile the buyer just saved on the order that triggered
|
||||
/// this lookup.
|
||||
pub async fn get_contact(&self, contact_id: &str) -> Result<Value> {
|
||||
let encoded = urlencoding::encode(contact_id);
|
||||
let url = format!("{}/v1/contacts/{encoded}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.auth_headers()?)
|
||||
.send()
|
||||
.await
|
||||
.context("Zaprite get_contact request")?;
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.context("read get_contact body")?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!(
|
||||
"Zaprite get_contact({contact_id}) returned HTTP {status}: {raw}"
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&raw).context("parse get_contact response")
|
||||
}
|
||||
|
||||
/// Smoke test for Connect-flow validation. Pings `GET /v1/orders`
|
||||
/// (the list endpoint) — auth-guarded, so a 200 confirms the
|
||||
/// API key works against the right org.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::payment::{
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
|
||||
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
ProviderInvoiceSnapshot, ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -61,6 +61,63 @@ impl PaymentProvider for ZapriteProvider {
|
||||
}
|
||||
};
|
||||
|
||||
// If we're going to ask Zaprite to save the buyer's payment
|
||||
// profile (recurring first cycle), Zaprite REQUIRES an
|
||||
// explicit `contactId` on the order — passing only
|
||||
// `customerData: { email }` returns
|
||||
// `400 contactId is required when allowSavePaymentProfile is true`
|
||||
// even though their llms.txt docs claim contactId is
|
||||
// optional. The API is the source of truth, so we create a
|
||||
// contact first and pass its id below.
|
||||
//
|
||||
// Three paths:
|
||||
// 1. Recurring + buyer_email present → create contact,
|
||||
// attach contactId, set allow_save_payment_profile=true.
|
||||
// 2. Recurring + buyer_email MISSING → can't create a
|
||||
// contact (Zaprite requires email). Log a warning and
|
||||
// degrade to one-shot mode for THIS cycle — the buyer
|
||||
// gets a license, but subsequent renewals will fall
|
||||
// through to manual-pay (zaprite_payment_profile_id
|
||||
// stays NULL). Reason for degrading rather than failing:
|
||||
// blocking the purchase entirely is worse than letting
|
||||
// the operator collect cycle-1 revenue and prompt the
|
||||
// buyer for an email at next renewal.
|
||||
// 3. Non-recurring → no contact needed; pass customerData
|
||||
// only (current behavior preserved).
|
||||
let want_save_profile = params.allow_save_payment_profile == Some(true);
|
||||
let (contact_id, effective_allow_save) = if want_save_profile {
|
||||
match params.buyer_email {
|
||||
Some(email) => {
|
||||
let contact = self
|
||||
.client
|
||||
.create_contact(email, None)
|
||||
.await
|
||||
.context("ZapriteProvider.create_invoice: create_contact")?;
|
||||
let id = contact
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Zaprite create_contact response missing 'id': {contact}"
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
(Some(id), Some(true))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
external_order_id = %params.external_order_id,
|
||||
"recurring purchase has no buyer_email; degrading to one-shot \
|
||||
(allow_save_payment_profile=false). Renewals for this \
|
||||
subscription will fall back to manual-pay."
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, params.allow_save_payment_profile)
|
||||
};
|
||||
|
||||
// Build the Zaprite order. externalUniqId carries OUR
|
||||
// invoice UUID; this is what the webhook handler uses as
|
||||
// the trust anchor (see `validate_webhook` below).
|
||||
@@ -75,12 +132,18 @@ impl PaymentProvider for ZapriteProvider {
|
||||
customer_data: params.buyer_email.map(|email| {
|
||||
serde_json::json!({ "email": email })
|
||||
}),
|
||||
// For one-shot purchases, don't prompt the buyer to
|
||||
// save their card. The recurring-subscriptions
|
||||
// renewal flow sets this to true on the FIRST
|
||||
// purchase of a sub so subsequent cycles can charge
|
||||
// the saved profile.
|
||||
allow_save_payment_profile: None,
|
||||
// For one-shot purchases (`None` / `Some(false)`) we
|
||||
// don't prompt the buyer to save their card. The
|
||||
// recurring-subscriptions purchase path sets this to
|
||||
// `Some(true)` on the FIRST cycle of a sub so Zaprite
|
||||
// shows the save-payment-profile prompt; subsequent
|
||||
// cycles are then merchant-initiated charges against
|
||||
// the saved profile via
|
||||
// `charge_order_with_profile`. May be reset to None
|
||||
// above if we couldn't satisfy Zaprite's contactId
|
||||
// requirement.
|
||||
allow_save_payment_profile: effective_allow_save,
|
||||
contact_id,
|
||||
};
|
||||
|
||||
let order = self
|
||||
@@ -112,7 +175,7 @@ impl PaymentProvider for ZapriteProvider {
|
||||
async fn get_invoice_status(
|
||||
&self,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<ProviderInvoiceStatus> {
|
||||
) -> Result<ProviderInvoiceSnapshot> {
|
||||
let order = self
|
||||
.client
|
||||
.get_order(provider_invoice_id)
|
||||
@@ -135,7 +198,7 @@ impl PaymentProvider for ZapriteProvider {
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
Ok(match status_str {
|
||||
let status = match status_str {
|
||||
"PAID" | "COMPLETE" | "OVERPAID" => ProviderInvoiceStatus::Settled,
|
||||
"PENDING" | "PROCESSING" | "UNDERPAID" => ProviderInvoiceStatus::Pending,
|
||||
// Zaprite doesn't have explicit Expired/Refunded states
|
||||
@@ -144,7 +207,30 @@ impl PaymentProvider for ZapriteProvider {
|
||||
// doesn't change. Fall-through covers any future
|
||||
// additions defensively.
|
||||
_ => ProviderInvoiceStatus::Invalid,
|
||||
})
|
||||
};
|
||||
// The amount the order is denominated for, for the advisory
|
||||
// settle-amount tripwire (see docs/guides/payments.md). We create
|
||||
// Zaprite orders priced in "BTC" with the amount already in sats
|
||||
// (see create_invoice above), so a Bitcoin currency maps straight
|
||||
// to sats. Zaprite's order schema isn't fully documented, so this
|
||||
// is best-effort: an absent/unparseable amount yields None and the
|
||||
// tripwire is skipped. A non-Bitcoin currency is passed through so
|
||||
// the tripwire can flag the unexpected currency.
|
||||
let amount = match (
|
||||
order.get("currency").and_then(|v| v.as_str()),
|
||||
order.get("amount").and_then(|v| v.as_i64()),
|
||||
) {
|
||||
// Zaprite spells Bitcoin as "BTC" with the amount already in sats
|
||||
// (see create_invoice above); "SAT" is accepted defensively. Both
|
||||
// map to our canonical sat unit. Non-positive → None (skip).
|
||||
(Some("BTC") | Some("SAT"), Some(sats)) if sats > 0 => Some(Money::sats(sats)),
|
||||
(Some(cur), Some(v)) if v > 0 => Some(Money {
|
||||
currency: cur.to_string(),
|
||||
amount: v,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(ProviderInvoiceSnapshot { status, amount })
|
||||
}
|
||||
|
||||
/// Validate an incoming webhook delivery from Zaprite.
|
||||
@@ -176,19 +262,47 @@ impl PaymentProvider for ZapriteProvider {
|
||||
let v: Value = serde_json::from_slice(body)
|
||||
.context("Zaprite webhook body must be JSON")?;
|
||||
|
||||
// Zaprite event shape (from OpenAPI excerpt + ecosystem
|
||||
// conventions): top-level `event` string + `data.id`
|
||||
// (the order UUID). Examples expected:
|
||||
// order.paid, order.complete, order.overpaid, order.underpaid,
|
||||
// order.pending, order.expired, order.refunded
|
||||
// We map liberally and let unknowns fall through to Other.
|
||||
let event_type = v
|
||||
.get("event")
|
||||
// Zaprite event shape: their docs don't enumerate event names
|
||||
// or payload shape. The `:49` sandbox test surfaced an empty
|
||||
// event_type because we were only checking the top-level
|
||||
// `event` field; Zaprite seems to put it elsewhere. We now
|
||||
// probe four common top-level field names — first non-empty
|
||||
// string wins. If even that fails, dump the raw payload at
|
||||
// WARN so we can see what Zaprite actually sends and add the
|
||||
// correct field name here.
|
||||
let event_type = ["event", "eventType", "type", "name"]
|
||||
.iter()
|
||||
.find_map(|field| {
|
||||
v.get(*field)
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if event_type.is_empty() {
|
||||
// Truncated to 2KB to bound log volume on weird payloads.
|
||||
let raw_preview = String::from_utf8_lossy(body);
|
||||
let truncated = if raw_preview.len() > 2048 {
|
||||
format!(
|
||||
"{}…[truncated {} bytes]",
|
||||
&raw_preview[..2048],
|
||||
raw_preview.len() - 2048
|
||||
)
|
||||
} else {
|
||||
raw_preview.to_string()
|
||||
};
|
||||
tracing::warn!(
|
||||
payload = %truncated,
|
||||
"Zaprite webhook: no event/eventType/type/name field found at top \
|
||||
level — webhook will be treated as non-actionable. Inspect the \
|
||||
payload above to find the actual event-name field and add it to \
|
||||
the probe list in validate_webhook."
|
||||
);
|
||||
}
|
||||
let provider_invoice_id = v
|
||||
.pointer("/data/id")
|
||||
.or_else(|| v.pointer("/data/object/id"))
|
||||
.or_else(|| v.get("orderId"))
|
||||
.or_else(|| v.get("id"))
|
||||
.and_then(|s| s.as_str())
|
||||
@@ -214,6 +328,55 @@ impl PaymentProvider for ZapriteProvider {
|
||||
provider_invoice_id: id,
|
||||
refunded_amount: None, // amount field shape TBD when we see a real refund event
|
||||
},
|
||||
// Zaprite's primary delivery shape (sandbox-confirmed :50):
|
||||
// a generic `order.change` event that just says "something
|
||||
// about this order changed" — the receiver has to look at
|
||||
// `/data/status` to figure out what actually changed. They
|
||||
// do NOT (empirically) send the convention-suggested
|
||||
// `order.paid` / `order.complete` events — every state
|
||||
// transition comes through as `order.change` and the
|
||||
// payload's status field tells the story. Branch on
|
||||
// status here so we dispatch the right action.
|
||||
//
|
||||
// Status values from Zaprite's get_invoice_status mapping:
|
||||
// PAID | COMPLETE | OVERPAID → settled
|
||||
// EXPIRED → expired
|
||||
// INVALID | CANCELLED → invalid
|
||||
// PENDING | PROCESSING |
|
||||
// UNDERPAID → in-flight; no action yet
|
||||
// <anything else> → Other (logged + ignored)
|
||||
"order.change" => {
|
||||
let status = v
|
||||
.pointer("/data/status")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
match status {
|
||||
"PAID" | "COMPLETE" | "OVERPAID" => {
|
||||
ProviderWebhookEvent::InvoiceSettled {
|
||||
provider_invoice_id: id,
|
||||
}
|
||||
}
|
||||
"EXPIRED" => ProviderWebhookEvent::InvoiceExpired {
|
||||
provider_invoice_id: id,
|
||||
},
|
||||
"INVALID" | "CANCELLED" => {
|
||||
ProviderWebhookEvent::InvoiceInvalid {
|
||||
provider_invoice_id: id,
|
||||
}
|
||||
}
|
||||
// In-flight transitions (PENDING/PROCESSING/UNDERPAID)
|
||||
// and anything unfamiliar fall through to Other — the
|
||||
// handler logs them as non-actionable, which is right:
|
||||
// we don't want to fire the settle hook every time
|
||||
// Zaprite transitions an order from PENDING to
|
||||
// PROCESSING on the way to PAID. The terminal-state
|
||||
// delivery is what actually drives our state machine.
|
||||
_ => ProviderWebhookEvent::Other {
|
||||
kind: format!("order.change[status={status}]"),
|
||||
provider_invoice_id: provider_invoice_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
other => ProviderWebhookEvent::Other {
|
||||
kind: other.to_string(),
|
||||
provider_invoice_id: provider_invoice_id,
|
||||
|
||||
@@ -45,11 +45,15 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
// provider-specific status-string normalization (BTCPay's
|
||||
// "Settled"/"Complete"/"Expired"/"Invalid" → ProviderInvoiceStatus
|
||||
// enum); this loop just operates on the typed result.
|
||||
let provider = match state.payment_provider().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()), // not configured yet — skip silently
|
||||
};
|
||||
|
||||
//
|
||||
// With multi-provider, each pending invoice is reconciled against
|
||||
// its OWN provider (recorded on the invoice row, migration 0021).
|
||||
// We can't iterate against a single global provider because the
|
||||
// operator may have multiple providers configured across multiple
|
||||
// merchant profiles. Pre-0021 invoices that slipped through with
|
||||
// a NULL provider id fall back to the legacy `payment_provider()`
|
||||
// accessor (which the migration's backfill should prevent from
|
||||
// ever being needed in practice).
|
||||
let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?;
|
||||
@@ -60,10 +64,28 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
tracing::debug!(count = pending.len(), "reconciling pending invoices");
|
||||
|
||||
for inv in pending {
|
||||
let provider = match inv.payment_provider_id.as_deref() {
|
||||
Some(pid) => match state.payment_provider_by_id(pid).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
error = %e,
|
||||
invoice_id = %inv.id,
|
||||
provider_id = pid,
|
||||
"reconciler skipping invoice — its provider is unavailable"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => match state.payment_provider().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue, // not configured yet — skip silently
|
||||
},
|
||||
};
|
||||
match provider.get_invoice_status(&inv.btcpay_invoice_id).await {
|
||||
Ok(status) => {
|
||||
Ok(snapshot) => {
|
||||
use crate::payment::ProviderInvoiceStatus::*;
|
||||
let new_status = match status {
|
||||
let new_status = match snapshot.status {
|
||||
Settled => "settled",
|
||||
Expired => "expired",
|
||||
Invalid => "invalid",
|
||||
@@ -102,6 +124,16 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
if new_status == "settled" {
|
||||
// Same advisory amount tripwire the webhook path applies
|
||||
// (see crate::api::webhook::audit_settle_amount). Never
|
||||
// blocks issuance — logs + audits any amount/currency
|
||||
// drift from what we charged.
|
||||
crate::api::webhook::audit_settle_amount(
|
||||
state,
|
||||
&inv,
|
||||
snapshot.amount.as_ref(),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = ensure_license(state, &inv).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
@@ -137,10 +169,46 @@ async fn ensure_license(
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?
|
||||
.is_some()
|
||||
{
|
||||
// Even if the license already exists, the reconciler may be
|
||||
// running because the webhook never delivered. In that case
|
||||
// `on_invoice_settled` (which runs the Zaprite-saved-profile
|
||||
// capture for recurring first-cycle subs) never fired either.
|
||||
// Try the post-settle hook now — it's idempotent (early-returns
|
||||
// if the sub already has a captured profile, or if the active
|
||||
// provider isn't Zaprite, or if no matching profile exists on
|
||||
// the contact). Without this, a subscription created via the
|
||||
// reconciler path never gets its `zaprite_payment_profile_id`
|
||||
// populated, and renewals fall back to manual-pay forever
|
||||
// even though the saved profile is sitting on Zaprite's side.
|
||||
if let Err(e) =
|
||||
crate::subscriptions::on_invoice_settled(state, invoice).await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
invoice_id = %invoice.id,
|
||||
"reconciler post-settle hook failed (non-fatal — license already exists)"
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
crate::api::webhook::issue_license_for_invoice(state, invoice)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||
|
||||
// Same rationale as the early-return branch above — if the
|
||||
// reconciler is running, the webhook may have missed; run the
|
||||
// post-settle hook so a brand-new recurring sub also captures its
|
||||
// Zaprite saved profile. issue_license_for_invoice already created
|
||||
// the subscription row by this point, so on_invoice_settled can
|
||||
// find it.
|
||||
if let Err(e) =
|
||||
crate::subscriptions::on_invoice_settled(state, invoice).await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
invoice_id = %invoice.id,
|
||||
"reconciler post-settle hook failed (non-fatal — license issued ok)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -35,10 +35,29 @@
|
||||
//! through the end of the current cycle.
|
||||
//!
|
||||
//! Auto-charge via saved payment profiles (Zaprite's
|
||||
//! `paymentProfileId` flow) is NOT in this version. The first
|
||||
//! renewal-worker iteration creates fresh invoices that the buyer
|
||||
//! pays manually. v0.2.0:5+ adds the auto-charge path so cycles
|
||||
//! after the first don't require buyer interaction.
|
||||
//! `paymentProfileId` flow) is now wired. When a buyer pays the
|
||||
//! first cycle of a recurring subscription via Zaprite AND saves
|
||||
//! a card at checkout, the renewal worker calls
|
||||
//! `POST /v1/orders/charge` against the saved profile on each
|
||||
//! cycle instead of waiting for manual pay. The wiring lives in
|
||||
//! three places:
|
||||
//! - `api::purchase` sets `allow_save_payment_profile=Some(true)`
|
||||
//! on the first-cycle invoice when the policy is recurring,
|
||||
//! prompting Zaprite to show the save-card UI at checkout.
|
||||
//! - `on_invoice_settled` here calls
|
||||
//! `capture_zaprite_payment_profile`, which fetches the
|
||||
//! buyer's contact from Zaprite and persists the resulting
|
||||
//! profile id onto the subscriptions row.
|
||||
//! - `renew_one` here invokes `try_auto_charge_zaprite` after
|
||||
//! creating each renewal order. On success the buyer does
|
||||
//! nothing — the order settles via the usual webhook. On
|
||||
//! failure (decline, expired card, network) we fall through
|
||||
//! to the existing manual-pay `subscription.renewal_pending`
|
||||
//! event so the buyer can still recover the cycle.
|
||||
//! BTCPay subscriptions and Zaprite subscriptions whose buyer
|
||||
//! paid with Bitcoin / declined the save-card prompt have NULL
|
||||
//! profile fields and continue to use the manual-pay branch
|
||||
//! exclusively.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
@@ -48,7 +67,7 @@ use crate::payment::CreateInvoiceParams;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::time::Duration as StdDuration;
|
||||
use uuid::Uuid;
|
||||
@@ -80,6 +99,42 @@ pub struct Subscription {
|
||||
pub next_renewal_at: Option<String>,
|
||||
pub cancelled_at: Option<String>,
|
||||
pub consecutive_failures: i64,
|
||||
/// Zaprite contact id for the buyer who paid the first cycle.
|
||||
/// Only ever populated for subs whose first-cycle invoice was
|
||||
/// settled via Zaprite AND whose buyer saved a payment profile
|
||||
/// at checkout. NULL otherwise (BTCPay subs, Bitcoin-paid
|
||||
/// Zaprite subs, declined-the-save-prompt Zaprite subs).
|
||||
pub zaprite_contact_id: Option<String>,
|
||||
/// Zaprite saved-profile id used by the renewal worker to
|
||||
/// auto-charge subsequent cycles via
|
||||
/// `POST /v1/orders/charge`. NULL means "no saved profile,
|
||||
/// fall through to manual-pay renewal" — the pre-feature
|
||||
/// behavior.
|
||||
pub zaprite_payment_profile_id: Option<String>,
|
||||
/// e.g. "CARD" / "BANK" — informational for the admin UI's
|
||||
/// subscription detail card. Not consulted by the worker
|
||||
/// today; Zaprite returns a decline error if the method
|
||||
/// doesn't support merchant-initiated charges.
|
||||
pub zaprite_payment_profile_method: Option<String>,
|
||||
/// ISO-8601. Informational for the admin UI ("card expires
|
||||
/// 03/27"). The renewal worker doesn't gate on this — if
|
||||
/// Zaprite reports the profile as expired we'll see it as
|
||||
/// an `/v1/orders/charge` failure and fall through to the
|
||||
/// manual-pay branch.
|
||||
pub zaprite_payment_profile_expires_at: Option<String>,
|
||||
/// Merchant profile the subscription was attached to at
|
||||
/// creation. Frozen for the lifetime of the sub so an operator
|
||||
/// editing the product's profile attachment doesn't redirect
|
||||
/// existing buyers to a different business mid-cycle. NULL on
|
||||
/// subs created pre-migration 0020 (backfilled to the default
|
||||
/// profile during the migration).
|
||||
pub merchant_profile_id: Option<String>,
|
||||
/// Payment provider used for THIS subscription's billing cycle.
|
||||
/// Frozen at creation (same rationale as merchant_profile_id).
|
||||
/// The renewal worker uses this to look up the provider — it
|
||||
/// never re-resolves from the product (which might have moved
|
||||
/// to a different profile / different providers).
|
||||
pub payment_provider_id: Option<String>,
|
||||
}
|
||||
|
||||
fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
|
||||
@@ -96,12 +151,23 @@ fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
|
||||
next_renewal_at: row.get("next_renewal_at"),
|
||||
cancelled_at: row.get("cancelled_at"),
|
||||
consecutive_failures: row.get("consecutive_failures"),
|
||||
zaprite_contact_id: row.try_get("zaprite_contact_id").ok(),
|
||||
zaprite_payment_profile_id: row.try_get("zaprite_payment_profile_id").ok(),
|
||||
zaprite_payment_profile_method: row.try_get("zaprite_payment_profile_method").ok(),
|
||||
zaprite_payment_profile_expires_at: row
|
||||
.try_get("zaprite_payment_profile_expires_at")
|
||||
.ok(),
|
||||
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
|
||||
payment_provider_id: row.try_get("payment_provider_id").ok().flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
const SUB_COLS: &str = "id, license_id, policy_id, product_id, period_days, \
|
||||
listed_currency, listed_value, status, started_at, next_renewal_at, \
|
||||
cancelled_at, consecutive_failures";
|
||||
cancelled_at, consecutive_failures, \
|
||||
zaprite_contact_id, zaprite_payment_profile_id, \
|
||||
zaprite_payment_profile_method, zaprite_payment_profile_expires_at, \
|
||||
merchant_profile_id, payment_provider_id";
|
||||
|
||||
/// Subs that are due for the worker to act on right now: status
|
||||
/// is `active` or `past_due`, `next_renewal_at` is in the past,
|
||||
@@ -143,7 +209,11 @@ pub async fn find_lapsing_subscriptions(
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT s.id AS id, s.license_id, s.policy_id, s.product_id, s.period_days, \
|
||||
s.listed_currency, s.listed_value, s.status, s.started_at, \
|
||||
s.next_renewal_at, s.cancelled_at, s.consecutive_failures \
|
||||
s.next_renewal_at, s.cancelled_at, s.consecutive_failures, \
|
||||
s.zaprite_contact_id, s.zaprite_payment_profile_id, \
|
||||
s.zaprite_payment_profile_method, \
|
||||
s.zaprite_payment_profile_expires_at, \
|
||||
s.merchant_profile_id, s.payment_provider_id \
|
||||
FROM subscriptions s \
|
||||
JOIN policies p ON p.id = s.policy_id \
|
||||
WHERE s.status = 'past_due' \
|
||||
@@ -320,6 +390,8 @@ pub async fn create_subscription(
|
||||
listed_currency: &str,
|
||||
listed_value: i64,
|
||||
first_cycle_invoice_id: &str,
|
||||
merchant_profile_id: Option<&str>,
|
||||
payment_provider_id: Option<&str>,
|
||||
) -> Result<Subscription> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now();
|
||||
@@ -329,8 +401,9 @@ pub async fn create_subscription(
|
||||
sqlx::query(
|
||||
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
|
||||
listed_currency, listed_value, status, started_at, next_renewal_at, \
|
||||
consecutive_failures, created_at, updated_at) \
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?)",
|
||||
consecutive_failures, merchant_profile_id, payment_provider_id, \
|
||||
created_at, updated_at) \
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(license_id)
|
||||
@@ -341,6 +414,8 @@ pub async fn create_subscription(
|
||||
.bind(listed_value)
|
||||
.bind(&started_at)
|
||||
.bind(&next_renewal_at)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(payment_provider_id)
|
||||
.bind(&started_at)
|
||||
.bind(&started_at)
|
||||
.execute(pool)
|
||||
@@ -375,6 +450,16 @@ pub async fn create_subscription(
|
||||
next_renewal_at: Some(next_renewal_at),
|
||||
cancelled_at: None,
|
||||
consecutive_failures: 0,
|
||||
// Zaprite saved-profile metadata is populated by a separate
|
||||
// post-settle hook (see `capture_zaprite_payment_profile`),
|
||||
// not here — at create-subscription time we don't yet know
|
||||
// whether the buyer saved a card.
|
||||
zaprite_contact_id: None,
|
||||
zaprite_payment_profile_id: None,
|
||||
zaprite_payment_profile_method: None,
|
||||
zaprite_payment_profile_expires_at: None,
|
||||
merchant_profile_id: merchant_profile_id.map(|s| s.to_string()),
|
||||
payment_provider_id: payment_provider_id.map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -635,12 +720,24 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
|
||||
.context("rate conversion")?;
|
||||
let amount_sats = conversion.sats.max(1);
|
||||
|
||||
// 2. Get the active provider. If no provider is configured
|
||||
// we can't bill — surfaces as a renewal failure that
|
||||
// backs off (operator probably mid-Disconnect).
|
||||
let provider = state.payment_provider().await.map_err(|e| {
|
||||
anyhow!("payment provider unavailable for renewal: {e:#}")
|
||||
})?;
|
||||
// 2. Get the provider snapshotted on this sub at creation. The
|
||||
// snapshot semantics protect existing buyers from operator-side
|
||||
// re-routing: if the product was moved to a different merchant
|
||||
// profile or its providers changed, this sub keeps renewing
|
||||
// through the same business + payment account it started with.
|
||||
// Falls back to the default profile's first provider if the
|
||||
// snapshot is NULL (pre-migration subs that the 0020 backfill
|
||||
// missed, or any rows that slipped through with NULL).
|
||||
let provider = match sub.payment_provider_id.as_deref() {
|
||||
Some(pid) => state
|
||||
.payment_provider_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?,
|
||||
None => state
|
||||
.payment_provider()
|
||||
.await
|
||||
.map_err(|e| anyhow!("payment provider unavailable for renewal: {e:#}"))?,
|
||||
};
|
||||
|
||||
// 3. Compute the next cycle window.
|
||||
let now = Utc::now();
|
||||
@@ -679,6 +776,14 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
|
||||
metadata,
|
||||
external_order_id: &internal_invoice_id,
|
||||
buyer_email: None, // renewal email comes from the license, not solicited fresh
|
||||
// The save-card prompt only matters on the FIRST cycle.
|
||||
// By the time we're here the sub either already has a
|
||||
// `zaprite_payment_profile_id` (we'll auto-charge below)
|
||||
// or doesn't (it never will — buyer paid with Bitcoin /
|
||||
// declined the prompt). Either way, re-prompting on
|
||||
// every renewal would be confusing UX; renewals always
|
||||
// pass `None` here.
|
||||
allow_save_payment_profile: None,
|
||||
})
|
||||
.await
|
||||
.context("provider.create_invoice for renewal")?;
|
||||
@@ -717,6 +822,7 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
|
||||
} else {
|
||||
Some(conversion.source.as_str())
|
||||
},
|
||||
None, // payment_provider_id — set when this call site is ported to the multi-provider resolution layer
|
||||
)
|
||||
.await
|
||||
.map_err(|e: AppError| anyhow!("repo create_invoice: {e:?}"))?;
|
||||
@@ -765,7 +871,93 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
|
||||
.await
|
||||
.context("UPDATE subscriptions on renewal create")?;
|
||||
|
||||
// 9. Webhook event: operator's app gets notified that a
|
||||
// 9. If this subscription has a saved Zaprite payment profile
|
||||
// (captured on first-cycle settle via
|
||||
// `capture_zaprite_payment_profile`), try to merchant-
|
||||
// initiate the charge against it now. On success, the buyer
|
||||
// is NOT expected to do anything — Zaprite will run the
|
||||
// charge and fire the usual `order.paid` webhook, which
|
||||
// `on_invoice_settled` will pick up to flip the sub back to
|
||||
// `active` and dispatch `subscription.renewed`. On failure
|
||||
// (declined card, expired profile, Zaprite hiccup) we log
|
||||
// + audit + fall through to the manual-pay
|
||||
// `subscription.renewal_pending` event below so the buyer
|
||||
// still has a path to recover this cycle.
|
||||
let auto_charged = match try_auto_charge_zaprite(
|
||||
state,
|
||||
sub,
|
||||
&handle.provider_invoice_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(charged) => charged,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
sub_id = %sub.id,
|
||||
invoice_id = %internal_invoice_id,
|
||||
error = %e,
|
||||
"Zaprite auto-charge failed; falling back to manual-pay renewal"
|
||||
);
|
||||
let _ = repo::insert_audit(
|
||||
&state.db,
|
||||
"renewal_worker",
|
||||
None,
|
||||
"subscription.auto_charge_failed",
|
||||
Some("subscription"),
|
||||
Some(&sub.id),
|
||||
None,
|
||||
None,
|
||||
&json!({
|
||||
"invoice_id": internal_invoice_id,
|
||||
"provider_invoice_id": handle.provider_invoice_id,
|
||||
"error": format!("{e:#}"),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
crate::webhooks::dispatch(
|
||||
state,
|
||||
"subscription.auto_charge_failed",
|
||||
&json!({
|
||||
"subscription_id": sub.id,
|
||||
"license_id": sub.license_id,
|
||||
"invoice_id": internal_invoice_id,
|
||||
"reason": format!("{e:#}"),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if auto_charged {
|
||||
// Auto-charge succeeded — Zaprite will fire `order.paid`
|
||||
// shortly and the webhook handler runs the rest of the
|
||||
// renewal lifecycle. Fire an operator-visible event so
|
||||
// the operator's app can render "renewed automatically"
|
||||
// copy in their notification UI, distinct from "buyer
|
||||
// needs to pay" copy.
|
||||
crate::webhooks::dispatch(
|
||||
state,
|
||||
"subscription.auto_charge_initiated",
|
||||
&json!({
|
||||
"subscription_id": sub.id,
|
||||
"license_id": sub.license_id,
|
||||
"product_id": sub.product_id,
|
||||
"policy_id": sub.policy_id,
|
||||
"invoice_id": internal_invoice_id,
|
||||
"amount_sats": amount_sats,
|
||||
"listed_currency": sub.listed_currency,
|
||||
"listed_value": sub.listed_value,
|
||||
"cycle_number": next_cycle_num,
|
||||
"cycle_start_at": cycle_start.to_rfc3339(),
|
||||
"cycle_end_at": cycle_end.to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 10. Manual-pay path. Operator's app gets notified that a
|
||||
// renewal invoice exists and the buyer needs to pay. The
|
||||
// operator's webhook receiver renders an email / push /
|
||||
// in-app notification with `checkout_url` and sends it to
|
||||
@@ -874,6 +1066,22 @@ pub async fn on_invoice_settled(state: &AppState, invoice: &Invoice) -> Result<(
|
||||
None => return Ok(()), // not a subscription invoice
|
||||
};
|
||||
mark_active_after_settle(&state.db, &sub_id).await?;
|
||||
|
||||
// Best-effort: if this was the FIRST cycle of a Zaprite-paid
|
||||
// recurring subscription AND the buyer saved a payment profile
|
||||
// at checkout, capture the profile id so the renewal worker can
|
||||
// auto-charge subsequent cycles. Failures here are logged but
|
||||
// never block — the sub stays valid; renewals just fall back to
|
||||
// the manual-pay branch.
|
||||
if let Err(e) = capture_zaprite_payment_profile(state, &sub_id, invoice).await {
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id,
|
||||
invoice_id = %invoice.id,
|
||||
error = %e,
|
||||
"capture_zaprite_payment_profile failed; renewals will fall back to manual pay"
|
||||
);
|
||||
}
|
||||
|
||||
crate::webhooks::dispatch(
|
||||
state,
|
||||
"subscription.renewed",
|
||||
@@ -886,3 +1094,397 @@ pub async fn on_invoice_settled(state: &AppState, invoice: &Invoice) -> Result<(
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort capture of the Zaprite saved-payment-profile after a
|
||||
/// first-cycle settle. No-ops in any of these cases:
|
||||
/// - sub already has `zaprite_payment_profile_id` set (idempotent
|
||||
/// re-delivery of the same settle webhook)
|
||||
/// - active provider isn't Zaprite (BTCPay subs have no equivalent)
|
||||
/// - the invoice predates the saved-profile feature (pre-:44
|
||||
/// Zaprite subs)
|
||||
/// - buyer paid with Bitcoin/Lightning, or declined the save-card
|
||||
/// prompt — no profile gets created on Zaprite's side
|
||||
///
|
||||
/// When it does fire, we:
|
||||
/// 1. Fetch the Zaprite order to find the buyer's `contact.id`
|
||||
/// 2. Fetch the contact to enumerate `paymentProfiles[]`
|
||||
/// 3. Find the profile whose `sourceOrder.externalUniqId` matches
|
||||
/// our local invoice id (= the externalUniqId we set at order
|
||||
/// creation) — that's the profile saved on THIS purchase
|
||||
/// 4. UPDATE the subscriptions row with id / method / expiresAt
|
||||
pub async fn capture_zaprite_payment_profile(
|
||||
state: &AppState,
|
||||
sub_id: &str,
|
||||
invoice: &Invoice,
|
||||
) -> Result<()> {
|
||||
use crate::payment::ProviderKind;
|
||||
|
||||
tracing::info!(
|
||||
sub_id = %sub_id,
|
||||
invoice_id = %invoice.id,
|
||||
provider_invoice_id = %invoice.btcpay_invoice_id,
|
||||
"capture_zaprite_payment_profile: starting"
|
||||
);
|
||||
|
||||
// Idempotency: already captured?
|
||||
let existing: Option<String> = sqlx::query_scalar(
|
||||
"SELECT zaprite_payment_profile_id FROM subscriptions WHERE id = ?",
|
||||
)
|
||||
.bind(sub_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.context("read existing zaprite_payment_profile_id")?
|
||||
.flatten();
|
||||
if existing.is_some() {
|
||||
tracing::info!(sub_id = %sub_id, "capture: already captured, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The provider that settled THIS invoice — not "the active one." With
|
||||
// multi-merchant-profile, the operator may have several providers
|
||||
// configured across different profiles; capturing the saved profile
|
||||
// has to talk to the SAME Zaprite org that the order was created
|
||||
// against (saved-profile ids are scoped per org).
|
||||
let provider = match invoice.payment_provider_id.as_deref() {
|
||||
Some(pid) => match state.payment_provider_by_id(pid).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id,
|
||||
provider_id = pid,
|
||||
error = %e,
|
||||
"capture: invoice's provider unavailable — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// Pre-0021 invoice with NULL provider — fall back to the legacy
|
||||
// default. The 0021 backfill should have populated this column
|
||||
// on the first migration run, so this branch is defensive only.
|
||||
match state.payment_provider().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id, error = %e,
|
||||
"capture: no active payment provider AND invoice has no \
|
||||
payment_provider_id — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if provider.kind() != ProviderKind::Zaprite {
|
||||
tracing::info!(
|
||||
sub_id = %sub_id, kind = ?provider.kind(),
|
||||
"capture: active provider is not Zaprite — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let zaprite = match provider
|
||||
.as_any()
|
||||
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
|
||||
{
|
||||
Some(z) => z,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id,
|
||||
"capture: provider kind is Zaprite but downcast failed — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let client = zaprite.client();
|
||||
|
||||
// 1. Fetch the order so we can read its contact.
|
||||
let order = client
|
||||
.get_order(&invoice.btcpay_invoice_id)
|
||||
.await
|
||||
.context("fetch Zaprite order for profile capture")?;
|
||||
let contact_id = order
|
||||
.pointer("/contact/id")
|
||||
.or_else(|| order.get("contactId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let contact_id = match contact_id {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// Order has no contact — buyer paid without an email /
|
||||
// Zaprite didn't materialize a contact. No profile to
|
||||
// capture; renewals fall back to manual pay.
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id,
|
||||
order_status = order.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
|
||||
order_has_contact = order.get("contact").is_some(),
|
||||
order_has_contactId = order.get("contactId").is_some(),
|
||||
"capture: order has no contact.id / contactId — cannot capture profile. \
|
||||
Check that buyer_email was present at purchase + that :47+ contact \
|
||||
creation ran."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
sub_id = %sub_id, contact_id = %contact_id,
|
||||
"capture: resolved contact_id from order"
|
||||
);
|
||||
|
||||
// 2. Fetch the contact and enumerate its payment profiles.
|
||||
let contact = client
|
||||
.get_contact(&contact_id)
|
||||
.await
|
||||
.context("fetch Zaprite contact for profile capture")?;
|
||||
let profiles = match contact.get("paymentProfiles").and_then(|v| v.as_array()) {
|
||||
Some(arr) => arr,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id, contact_id = %contact_id,
|
||||
"capture: contact has no paymentProfiles array — likely the buyer \
|
||||
didn't check 'save card' at Zaprite checkout, OR profile creation \
|
||||
is async on Zaprite's side and not yet visible at webhook time"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
sub_id = %sub_id, contact_id = %contact_id,
|
||||
profile_count = profiles.len(),
|
||||
"capture: enumerated contact's payment profiles"
|
||||
);
|
||||
|
||||
// 3. Find the profile whose sourceOrder.externalUniqId is
|
||||
// THIS invoice. Zaprite scopes saved profiles to a contact,
|
||||
// but a contact may have multiple profiles from prior
|
||||
// purchases (e.g. the buyer subscribed to another product
|
||||
// too). The sourceOrder pin is how we identify the one
|
||||
// Zaprite just minted on this purchase.
|
||||
let matching = profiles.iter().find(|p| {
|
||||
p.pointer("/sourceOrder/externalUniqId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == invoice.id)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let profile = match matching {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
// Most common reason: buyer paid with Bitcoin / Lightning
|
||||
// (no autopay-supporting rail) OR declined the save-
|
||||
// payment-profile prompt on the card form. Both are
|
||||
// legitimate; renewals fall back to manual pay.
|
||||
//
|
||||
// Also possible: race condition — Zaprite's profile-save
|
||||
// step hasn't finished by the time the order.paid webhook
|
||||
// fires. If you see this with profile_count > 0 but no
|
||||
// match for invoice.id, that's the race.
|
||||
let sample = profiles.iter().take(3).map(|p| {
|
||||
p.pointer("/sourceOrder/externalUniqId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>")
|
||||
.to_string()
|
||||
}).collect::<Vec<_>>();
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id,
|
||||
contact_id = %contact_id,
|
||||
invoice_id = %invoice.id,
|
||||
profile_count = profiles.len(),
|
||||
sample_source_external_uniq_ids = ?sample,
|
||||
"capture: no profile matches sourceOrder.externalUniqId == invoice.id — \
|
||||
either the buyer declined the save-card prompt, paid via a non-saving \
|
||||
rail (BTC/Lightning), OR Zaprite's profile-attach is racing the \
|
||||
webhook delivery"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let profile_id = match profile.get("id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
sub_id = %sub_id, contact_id = %contact_id,
|
||||
"capture: matched profile has no 'id' field — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let method = profile.get("method").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let expires_at = profile
|
||||
.get("expiresAt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 4. Persist.
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE subscriptions \
|
||||
SET zaprite_contact_id = ?, zaprite_payment_profile_id = ?, \
|
||||
zaprite_payment_profile_method = ?, \
|
||||
zaprite_payment_profile_expires_at = ?, \
|
||||
updated_at = ? \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&contact_id)
|
||||
.bind(&profile_id)
|
||||
.bind(&method)
|
||||
.bind(&expires_at)
|
||||
.bind(&now)
|
||||
.bind(sub_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.context("UPDATE subscriptions with Zaprite profile metadata")?;
|
||||
|
||||
tracing::info!(
|
||||
sub_id = %sub_id,
|
||||
contact_id = %contact_id,
|
||||
profile_id = %profile_id,
|
||||
method = method.as_deref().unwrap_or("?"),
|
||||
"captured Zaprite saved payment profile for auto-charge on renewal"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt a merchant-initiated charge against the saved Zaprite
|
||||
/// payment profile on this subscription. Called by the renewal
|
||||
/// worker *after* it has created the order; this turns the order
|
||||
/// from "buyer must pay" into "auto-charged, will settle via the
|
||||
/// usual webhook." Returns:
|
||||
/// - `Ok(true)` — the charge settled (order status PAID/COMPLETE/
|
||||
/// OVERPAID); the buyer is not expected to pay
|
||||
/// manually. The settle webhook will fire on its
|
||||
/// own and flip the sub to `active` via
|
||||
/// `on_invoice_settled`.
|
||||
/// - `Ok(false)` — sub has no saved profile, active provider isn't
|
||||
/// Zaprite, OR Zaprite accepted the request (HTTP
|
||||
/// 2xx) but the order did NOT reach a settled status
|
||||
/// (declined/expired/in-flight/unknown). In every
|
||||
/// `Ok(false)` case the caller proceeds with the
|
||||
/// manual-pay fallback (`subscription.renewal_pending`)
|
||||
/// so the buyer keeps a path to recover the cycle.
|
||||
/// - `Err(_)` — Zaprite returned an error (declined card,
|
||||
/// expired profile, network blip). Caller treats
|
||||
/// this as a soft failure: log, audit, and ALSO
|
||||
/// fall through to manual-pay so the buyer has
|
||||
/// a path to recover.
|
||||
async fn try_auto_charge_zaprite(
|
||||
state: &AppState,
|
||||
sub: &Subscription,
|
||||
provider_invoice_id: &str,
|
||||
) -> Result<bool> {
|
||||
use crate::payment::ProviderKind;
|
||||
|
||||
let profile_id = match sub.zaprite_payment_profile_id.as_deref() {
|
||||
Some(p) if !p.is_empty() => p,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
|
||||
// Use the provider snapshotted on the sub — saved-profile ids are
|
||||
// scoped per Zaprite org, so we can't fall back to "the active
|
||||
// provider" if the operator added another Zaprite provider since.
|
||||
let provider = match sub.payment_provider_id.as_deref() {
|
||||
Some(pid) => state
|
||||
.payment_provider_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?,
|
||||
None => state
|
||||
.payment_provider()
|
||||
.await
|
||||
.map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?,
|
||||
};
|
||||
if provider.kind() != ProviderKind::Zaprite {
|
||||
return Ok(false);
|
||||
}
|
||||
let zaprite = provider
|
||||
.as_any()
|
||||
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
|
||||
.ok_or_else(|| anyhow!("provider.kind is Zaprite but downcast failed"))?;
|
||||
|
||||
let resp = zaprite
|
||||
.client()
|
||||
.charge_order_with_profile(provider_invoice_id, profile_id)
|
||||
.await
|
||||
.context("Zaprite charge_order_with_profile")?;
|
||||
|
||||
// A 2xx from `/v1/orders/charge` only means Zaprite ACCEPTED the
|
||||
// request — the order's `status` says whether the money actually
|
||||
// moved. A charge that came back declined/expired/in-flight (or any
|
||||
// status we don't positively recognize as settled) leaves no settle
|
||||
// webhook to wait for, so returning Ok(true) here would silently
|
||||
// lapse the sub: we'd suppress the manual-pay notification and wait
|
||||
// forever for an `order.paid` that never arrives. Fail safe — only
|
||||
// suppress manual-pay when the order is in a recognized settled
|
||||
// state; otherwise fall through (Ok(false)) so the buyer still gets
|
||||
// a pay link and can recover the cycle.
|
||||
let order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
if !zaprite_charge_settled(&resp) {
|
||||
tracing::warn!(
|
||||
sub_id = %sub.id,
|
||||
order_id = %provider_invoice_id,
|
||||
profile_id = %profile_id,
|
||||
order_status,
|
||||
"Zaprite auto-charge accepted (HTTP 2xx) but order is not settled; \
|
||||
falling back to manual-pay renewal"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
sub_id = %sub.id,
|
||||
order_id = %provider_invoice_id,
|
||||
profile_id = %profile_id,
|
||||
order_status,
|
||||
"Zaprite auto-charge settled; awaiting settle webhook"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Does a Zaprite `/v1/orders/charge` response (HTTP 2xx already
|
||||
/// confirmed by the client) indicate the charge actually settled?
|
||||
///
|
||||
/// Mirrors the PAID/COMPLETE/OVERPAID → `Settled` mapping in
|
||||
/// `ZapriteProvider::get_invoice_status`. Deliberately an **allowlist**,
|
||||
/// not a failure blocklist: Zaprite's confirmed order-status enum is
|
||||
/// PENDING|PROCESSING|PAID|COMPLETE|OVERPAID|UNDERPAID with no documented
|
||||
/// terminal-failure string, so any unrecognized or missing status must be
|
||||
/// treated as "not settled" and routed to manual-pay rather than
|
||||
/// optimistically assumed paid.
|
||||
fn zaprite_charge_settled(resp: &Value) -> bool {
|
||||
matches!(
|
||||
resp.get("status").and_then(|v| v.as_str()),
|
||||
Some("PAID") | Some("COMPLETE") | Some("OVERPAID")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::zaprite_charge_settled;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn charge_settled_only_for_recognized_paid_statuses() {
|
||||
// Settled states → suppress manual-pay (Ok(true) upstream).
|
||||
for s in ["PAID", "COMPLETE", "OVERPAID"] {
|
||||
assert!(
|
||||
zaprite_charge_settled(&json!({ "status": s })),
|
||||
"{s} should count as settled"
|
||||
);
|
||||
}
|
||||
// The silent-lapse guard: a 2xx carrying any non-settled status
|
||||
// must NOT be treated as success. In-flight, underpaid,
|
||||
// terminal-failure, and unknown statuses all fall through to the
|
||||
// manual-pay path.
|
||||
for s in [
|
||||
"PENDING", "PROCESSING", "UNDERPAID", "FAILED", "DECLINED", "EXPIRED",
|
||||
"CANCELED", "REFUNDED", "",
|
||||
] {
|
||||
assert!(
|
||||
!zaprite_charge_settled(&json!({ "status": s })),
|
||||
"{s} must NOT count as settled"
|
||||
);
|
||||
}
|
||||
// Malformed / absent / non-string status fields fall through too.
|
||||
assert!(!zaprite_charge_settled(&json!({})));
|
||||
assert!(!zaprite_charge_settled(&json!({ "status": null })));
|
||||
assert!(!zaprite_charge_settled(&json!({ "status": 200 })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,12 +199,29 @@ async fn run_tip(
|
||||
}
|
||||
};
|
||||
|
||||
// Pay it via the active provider's LN node. Provider-agnostic;
|
||||
// BTCPay implements `pay_lightning_invoice` today, future
|
||||
// providers either implement it (Zaprite via Strike?) or fall
|
||||
// through to the trait default which returns a "not supported"
|
||||
// error that we record as a failed tip.
|
||||
let provider = match state.payment_provider().await {
|
||||
// Pay it via the provider's LN node — same provider that settled
|
||||
// this license's purchase invoice (so the tip draws from the right
|
||||
// Bitcoin balance). Provider-agnostic; BTCPay implements
|
||||
// `pay_lightning_invoice` today, future providers either implement
|
||||
// it (Zaprite via Strike?) or fall through to the trait default
|
||||
// which returns a "not supported" error that we record as a failed
|
||||
// tip. Falls back to the legacy active-provider accessor if the
|
||||
// license's invoice has no payment_provider_id set (pre-0021).
|
||||
let invoice_provider_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT i.payment_provider_id FROM invoices i \
|
||||
JOIN licenses l ON l.invoice_id = i.id \
|
||||
WHERE l.id = ?",
|
||||
)
|
||||
.bind(license_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let provider_result = match invoice_provider_id.as_deref() {
|
||||
Some(pid) => state.payment_provider_by_id(pid).await,
|
||||
None => state.payment_provider().await,
|
||||
};
|
||||
let provider = match provider_result {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let detail = format!("payment provider unavailable: {e:?}");
|
||||
|
||||
+1192
-170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
//! Live re-validation of the agent-payment-connect network detection against a
|
||||
//! real BTCPay regtest box. Exercises the daemon's ACTUAL
|
||||
//! `btcpay::client::fetch_onchain_network` (not a curl reimplementation), which
|
||||
//! is what the scoped-connect gate calls at callback time.
|
||||
//!
|
||||
//! `#[ignore]` by default — it needs a running BTCPay regtest stack and reads
|
||||
//! its connection params from the environment (no secrets in the tree). Bring
|
||||
//! the box up and run:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cd ../onboarding-harness/stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d
|
||||
//! # mint a canmodifystoresettings token + a store with an on-chain wallet, then:
|
||||
//! source ../onboarding-harness/stage2/btcpay-regtest/.live-env
|
||||
//! cargo test --test btcpay_network_live -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Spec: `plans/agent-payment-connect-scope.md` §6.1 — "BTCPay on-chain address
|
||||
//! network detection MUST be validated against a live regtest box."
|
||||
|
||||
use keysat::btcpay::client::fetch_onchain_network;
|
||||
use keysat::btcpay::network::BitcoinNetwork;
|
||||
|
||||
fn env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a live BTCPay regtest box; set KEYSAT_LIVE_BTCPAY_* env"]
|
||||
async fn regtest_store_resolves_to_regtest() {
|
||||
let (Some(base), Some(key), Some(store)) = (
|
||||
env("KEYSAT_LIVE_BTCPAY_URL"),
|
||||
env("KEYSAT_LIVE_BTCPAY_KEY"),
|
||||
env("KEYSAT_LIVE_BTCPAY_STORE_REGTEST"),
|
||||
) else {
|
||||
eprintln!("SKIP: set KEYSAT_LIVE_BTCPAY_URL / _KEY / _STORE_REGTEST");
|
||||
return;
|
||||
};
|
||||
|
||||
let net = fetch_onchain_network(&base, &key, &store)
|
||||
.await
|
||||
.expect("detection call should not transport-error against a live box");
|
||||
println!("regtest store {store} resolved to {net:?}");
|
||||
assert_eq!(
|
||||
net,
|
||||
Some(BitcoinNetwork::Regtest),
|
||||
"the on-chain wallet's bcrt1 address must classify as Regtest (non-mainnet → scoped connect allowed)"
|
||||
);
|
||||
assert!(!net.unwrap().is_mainnet(), "regtest must not be mainnet");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a live BTCPay regtest box; set KEYSAT_LIVE_BTCPAY_* env"]
|
||||
async fn store_without_onchain_wallet_is_undetermined() {
|
||||
let (Some(base), Some(key), Some(store)) = (
|
||||
env("KEYSAT_LIVE_BTCPAY_URL"),
|
||||
env("KEYSAT_LIVE_BTCPAY_KEY"),
|
||||
env("KEYSAT_LIVE_BTCPAY_STORE_NOWALLET"),
|
||||
) else {
|
||||
eprintln!("SKIP: set KEYSAT_LIVE_BTCPAY_URL / _KEY / _STORE_NOWALLET");
|
||||
return;
|
||||
};
|
||||
|
||||
let net = fetch_onchain_network(&base, &key, &store)
|
||||
.await
|
||||
.expect("detection call should not transport-error");
|
||||
println!("no-wallet store {store} resolved to {net:?}");
|
||||
// No on-chain wallet → undetermined → caller fails closed to mainnet → deny.
|
||||
assert_eq!(
|
||||
net, None,
|
||||
"a store with no on-chain wallet must be undetermined so the gate fails closed"
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,8 @@ use keysat::api::AppState;
|
||||
use keysat::config::Config;
|
||||
use keysat::license_self::Tier;
|
||||
use keysat::payment::{
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
|
||||
ProviderKind, ProviderWebhookEvent,
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceSnapshot,
|
||||
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
};
|
||||
use keysat::subscriptions;
|
||||
use serde_json::{json, Value};
|
||||
@@ -77,6 +77,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
|
||||
btcpay_webhook_secret: None,
|
||||
public_base_url: "http://keysat.test".to_string(),
|
||||
operator_name: None,
|
||||
sandbox_mode: false,
|
||||
};
|
||||
let mock = Arc::new(MockProvider::new());
|
||||
let state = AppState {
|
||||
@@ -85,6 +86,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
|
||||
payment: Arc::new(RwLock::new(Some(
|
||||
mock.clone() as Arc<dyn PaymentProvider>,
|
||||
))),
|
||||
provider_override: None,
|
||||
config: Arc::new(cfg),
|
||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||
reason: "test".into(),
|
||||
@@ -132,8 +134,11 @@ impl PaymentProvider for MockProvider {
|
||||
checkout_url: format!("http://mock.test/checkout/{n}"),
|
||||
})
|
||||
}
|
||||
async fn get_invoice_status(&self, _id: &str) -> Result<ProviderInvoiceStatus> {
|
||||
Ok(ProviderInvoiceStatus::Pending)
|
||||
async fn get_invoice_status(&self, _id: &str) -> Result<ProviderInvoiceSnapshot> {
|
||||
Ok(ProviderInvoiceSnapshot {
|
||||
status: ProviderInvoiceStatus::Pending,
|
||||
amount: None,
|
||||
})
|
||||
}
|
||||
fn validate_webhook(&self, _h: &HeaderMap, _b: &[u8]) -> Result<ProviderWebhookEvent> {
|
||||
anyhow::bail!("not exercised by renewal-worker tests")
|
||||
|
||||
@@ -61,11 +61,13 @@ async fn make_state() -> (AppState, NamedTempFile) {
|
||||
btcpay_webhook_secret: None,
|
||||
public_base_url: "http://keysat.test".to_string(),
|
||||
operator_name: None,
|
||||
sandbox_mode: false,
|
||||
};
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
keypair: Arc::new(keypair),
|
||||
payment: Arc::new(RwLock::new(None)),
|
||||
provider_override: None,
|
||||
config: Arc::new(cfg),
|
||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||
reason: "test".into(),
|
||||
@@ -733,8 +735,8 @@ async fn apply_tier_change_mutates_license_and_subscription() {
|
||||
#[tokio::test]
|
||||
async fn renewal_worker_applies_pending_tier_change_before_billing() {
|
||||
use keysat::payment::{
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
|
||||
ProviderKind, ProviderWebhookEvent,
|
||||
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceSnapshot,
|
||||
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -764,8 +766,11 @@ async fn renewal_worker_applies_pending_tier_change_before_billing() {
|
||||
checkout_url: format!("http://cap/{n}"),
|
||||
})
|
||||
}
|
||||
async fn get_invoice_status(&self, _id: &str) -> anyhow::Result<ProviderInvoiceStatus> {
|
||||
Ok(ProviderInvoiceStatus::Pending)
|
||||
async fn get_invoice_status(&self, _id: &str) -> anyhow::Result<ProviderInvoiceSnapshot> {
|
||||
Ok(ProviderInvoiceSnapshot {
|
||||
status: ProviderInvoiceStatus::Pending,
|
||||
amount: None,
|
||||
})
|
||||
}
|
||||
fn validate_webhook(
|
||||
&self,
|
||||
|
||||
@@ -60,11 +60,13 @@ async fn make_state() -> (AppState, NamedTempFile) {
|
||||
btcpay_webhook_secret: None,
|
||||
public_base_url: "http://keysat.test".to_string(),
|
||||
operator_name: None,
|
||||
sandbox_mode: false,
|
||||
};
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
keypair: Arc::new(keypair),
|
||||
payment: Arc::new(RwLock::new(None)),
|
||||
provider_override: None,
|
||||
config: Arc::new(cfg),
|
||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||
reason: "test".into(),
|
||||
|
||||
@@ -180,6 +180,14 @@ table.t {
|
||||
border-radius:10px; overflow:hidden;
|
||||
}
|
||||
.card > table.t { border:0; border-radius:0 0 10px 10px; }
|
||||
/* Horizontally scrollable wrapper for tables on narrow screens. When the
|
||||
table is wider than the card, the wrapper scrolls instead of the row
|
||||
clipping. The wrapper carries the bottom rounding so the table itself
|
||||
can stay square; otherwise the rounded table corners would clip mid-row
|
||||
when scrolled. */
|
||||
.t-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
||||
.card > .t-wrap { border-radius:0 0 10px 10px; }
|
||||
.card > .t-wrap > table.t { border:0; border-radius:0; }
|
||||
table.t thead th {
|
||||
text-align:left; font-size:11px; font-weight:700;
|
||||
letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-500);
|
||||
@@ -315,6 +323,29 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
margin-top:22px;
|
||||
}
|
||||
|
||||
/* ---------- Mobile nav (hamburger + off-canvas drawer) ---------- */
|
||||
/* The hamburger button lives in the topbar and is hidden by default; the
|
||||
≤720px breakpoint below reveals it and reframes the sidebar as a slide-in
|
||||
drawer. The backdrop dims the content and provides a tap target for
|
||||
closing. */
|
||||
.nav-toggle {
|
||||
display:none;
|
||||
align-items:center; justify-content:center;
|
||||
width:38px; height:38px; padding:0;
|
||||
background:transparent; color:var(--navy-900);
|
||||
border:1px solid var(--border-2); border-radius:7px;
|
||||
cursor:pointer; transition:all 120ms;
|
||||
}
|
||||
.nav-toggle:hover { background:var(--cream-200); }
|
||||
.nav-toggle [data-lucide] { width:20px; height:20px; }
|
||||
.sidebar-backdrop {
|
||||
display:none;
|
||||
position:fixed; inset:0; background:rgba(14,31,51,0.45);
|
||||
z-index:40; opacity:0; pointer-events:none;
|
||||
transition:opacity 200ms;
|
||||
}
|
||||
.sidebar-backdrop.open { opacity:1; pointer-events:auto; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app { grid-template-columns:1fr; }
|
||||
.sidebar { position:static; max-height:none; height:auto; }
|
||||
@@ -324,6 +355,41 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
.topbar { padding:14px 20px; }
|
||||
}
|
||||
|
||||
/* Tablet/phone: collapse the sidebar into a true off-canvas drawer. Above
|
||||
720px the stacked-sidebar layout from the 980px breakpoint is fine; below
|
||||
720px the sidebar takes too much vertical space before content, so we
|
||||
convert it to a slide-in instead. */
|
||||
@media (max-width: 720px) {
|
||||
.nav-toggle { display:inline-flex; }
|
||||
.sidebar-backdrop { display:block; }
|
||||
.sidebar {
|
||||
position:fixed; top:0; left:0; bottom:0;
|
||||
width:min(280px, 80vw); height:100vh; max-height:100vh;
|
||||
padding:20px 12px;
|
||||
transform:translateX(-100%); transition:transform 200ms ease;
|
||||
z-index:50; overflow-y:auto;
|
||||
}
|
||||
.sidebar.open { transform:translateX(0); }
|
||||
.sidebar a.nav { padding:12px 12px; font-size:14.5px; }
|
||||
.sidebar a.nav [data-lucide] { width:18px; height:18px; }
|
||||
}
|
||||
|
||||
/* Phone tier: tighten chrome, drop stats to a single column, let toolbar
|
||||
inputs fill the row, bump button tap targets. */
|
||||
@media (max-width: 640px) {
|
||||
.stats { grid-template-columns:1fr; }
|
||||
.content { padding:14px 14px 56px; }
|
||||
.topbar { padding:12px 14px; gap:10px; }
|
||||
.topbar h1 { font-size:18px; }
|
||||
.topbar .who { display:none; }
|
||||
.toolbar .input, .toolbar .select { min-width:0; width:100%; }
|
||||
.card .card-head { padding:12px 14px; flex-wrap:wrap; }
|
||||
.card .card-head .sub { margin-left:0; flex-basis:100%; }
|
||||
.card .card-body { padding:14px; }
|
||||
.btn { padding:10px 14px; }
|
||||
.btn.sm { padding:8px 12px; }
|
||||
}
|
||||
|
||||
/* Featured (launch special) pill toggle — used on the Discount Codes
|
||||
create + edit forms. Click anywhere on the pill to flip the
|
||||
underlying hidden checkbox. Off = muted; on = gold accent. Reads as
|
||||
@@ -349,16 +415,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
border:1px solid var(--border-1);
|
||||
}
|
||||
.featured-pill-toggle.on {
|
||||
background:var(--gold-500); color:var(--navy-950);
|
||||
border-color:var(--gold-500);
|
||||
box-shadow:0 2px 6px rgba(191,160,104,0.25);
|
||||
background:var(--navy-800); color:var(--cream-50);
|
||||
border-color:var(--navy-800);
|
||||
box-shadow:0 2px 6px rgba(14,31,51,0.18);
|
||||
}
|
||||
.featured-pill-toggle.on .state {
|
||||
background:var(--navy-950); color:var(--gold-500);
|
||||
border-color:var(--navy-950);
|
||||
background:var(--cream-50); color:var(--navy-900);
|
||||
border-color:var(--cream-50);
|
||||
}
|
||||
.featured-pill-toggle.on:hover {
|
||||
background:var(--gold-400);
|
||||
background:var(--navy-900);
|
||||
}
|
||||
|
||||
/* Tier-card drag affordance — cursor signals draggability on hover,
|
||||
@@ -422,7 +488,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
<!-- Main app shell (shown after login) -->
|
||||
<section id="app-view" class="hide">
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true" style="width:26px; height:26px">
|
||||
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
|
||||
@@ -445,6 +511,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
<a class="nav" data-route="licenses"><i data-lucide="key-round"></i>Licenses</a>
|
||||
<a class="nav" data-route="machines"><i data-lucide="cpu"></i>Machines</a>
|
||||
<div class="group-label">System</div>
|
||||
<a class="nav" data-route="merchant-profiles"><i data-lucide="store"></i>Merchant profiles</a>
|
||||
<a class="nav" data-route="webhooks"><i data-lucide="webhook"></i>Webhooks</a>
|
||||
<a class="nav" data-route="audit"><i data-lucide="scroll-text"></i>Audit log</a>
|
||||
<a class="nav" data-route="settings"><i data-lucide="settings"></i>Settings</a>
|
||||
@@ -467,12 +534,12 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
<div id="tier-banner-msg" style="margin-bottom:8px;"></div>
|
||||
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
|
||||
display:inline-block; padding:5px 10px;
|
||||
background:var(--gold-500); color:var(--navy-950);
|
||||
background:var(--cream-50); color:var(--navy-900);
|
||||
font-weight:700; font-size:11px;
|
||||
border-radius:5px; text-decoration:none;
|
||||
border-radius:8px; text-decoration:none;
|
||||
transition:background 120ms;
|
||||
" onmouseover="this.style.background='var(--gold-400)'"
|
||||
onmouseout="this.style.background='var(--gold-500)'"></a>
|
||||
" onmouseover="this.style.background='var(--cream-200)'"
|
||||
onmouseout="this.style.background='var(--cream-50)'"></a>
|
||||
</div>
|
||||
<div class="footer" id="sidebar-footer">
|
||||
<span class="dot warn"></span>
|
||||
@@ -495,6 +562,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="nav-toggle" id="nav-toggle" type="button"
|
||||
aria-label="Open navigation" aria-expanded="false" aria-controls="sidebar">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="crumb" id="crumb">Workspace</div>
|
||||
<h1 id="page-title">Overview</h1>
|
||||
@@ -506,6 +577,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
</header>
|
||||
<div class="content" id="route-target"></div>
|
||||
</main>
|
||||
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1129,7 +1201,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
const tb = el('tbody')
|
||||
for (const r of rows) tb.appendChild(r)
|
||||
t.appendChild(tb)
|
||||
return el('div', { class: 'card' }, [head, t])
|
||||
return el('div', { class: 'card' }, [head, el('div', { class: 't-wrap' }, t)])
|
||||
}
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
@@ -1157,6 +1229,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
codes: { title: 'Discount codes', crumb: 'Workspace · Discount codes' },
|
||||
licenses: { title: 'Licenses', crumb: 'Workspace · Licenses' },
|
||||
machines: { title: 'Machines', crumb: 'Workspace · Machines' },
|
||||
'merchant-profiles': { title: 'Merchant profiles', crumb: 'System · Merchant profiles' },
|
||||
webhooks: { title: 'Webhooks', crumb: 'System · Webhooks' },
|
||||
settings: { title: 'Settings', crumb: 'System · Settings' },
|
||||
audit: { title: 'Audit log', crumb: 'System · Audit log' },
|
||||
@@ -1501,11 +1574,32 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
}
|
||||
|
||||
// -------- Products --------
|
||||
// Merchant-profile picker for the product create/edit forms. Returns
|
||||
// null when the operator runs 0 or 1 profile — there's nothing to
|
||||
// choose, and the product resolves to the default profile. With >1
|
||||
// profile it returns { element, value() }; `selectedId` pre-selects a
|
||||
// profile, falling back to the default when null.
|
||||
function profileSelectField(profiles, selectedId) {
|
||||
if (!profiles || profiles.length <= 1) return null
|
||||
const sel = el('select', { class: 'input', name: 'p_merchant_profile' },
|
||||
profiles.map((pr) => el('option', { value: pr.id },
|
||||
pr.name + (pr.is_default ? ' (default)' : ''))))
|
||||
const fallback = (profiles.find((pr) => pr.is_default) || profiles[0]).id
|
||||
sel.value = selectedId || fallback
|
||||
const element = el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:0 0 4px' }, 'Merchant profile'),
|
||||
sel,
|
||||
el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
|
||||
'Which business this product sells under — sets the payment provider and branding buyers see at checkout.'),
|
||||
])
|
||||
return { element, value: () => sel.value }
|
||||
}
|
||||
|
||||
// Edit-product modal. Opens when the operator clicks Edit on a product
|
||||
// row. Mutable: name, description, price (currency + value). Slug is
|
||||
// intentionally not editable (it's part of the public buy URL —
|
||||
// changing it would break bookmarks).
|
||||
function openEditProduct(p) {
|
||||
// row. Mutable: name, description, price (currency + value), merchant
|
||||
// profile. Slug is intentionally not editable (it's part of the public
|
||||
// buy URL — changing it would break bookmarks).
|
||||
function openEditProduct(p, profiles) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
@@ -1513,6 +1607,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
|
||||
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
|
||||
const editCatalog = catalogEditor(p.entitlements_catalog || null)
|
||||
const editProfile = profileSelectField(profiles, p.merchant_profile_id || null)
|
||||
|
||||
// Currency-aware price inputs. For SAT-currency products, show
|
||||
// the integer sat amount. For USD/EUR, render the cents value
|
||||
@@ -1570,6 +1665,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||
editCatalog.element,
|
||||
]),
|
||||
editProfile && editProfile.element,
|
||||
status,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||||
el('button', { class: 'btn primary', onclick: async function () {
|
||||
@@ -1590,6 +1686,9 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
// the server treats null as "set to NULL", absent as
|
||||
// "leave alone".
|
||||
body.entitlements_catalog = editCatalog.read()
|
||||
// Only present when >1 profile; always a concrete id when
|
||||
// shown, so this is a Some(Some(id)) reassignment server-side.
|
||||
if (editProfile) body.merchant_profile_id = editProfile.value()
|
||||
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
||||
overlay.remove()
|
||||
routes.products()
|
||||
@@ -1618,6 +1717,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
const gfBanner = grandfatherBanner(tierStatus, 'products', 'products')
|
||||
if (gfBanner) target.appendChild(gfBanner)
|
||||
|
||||
// Merchant profiles drive the optional profile picker on the create
|
||||
// + edit forms (rendered only when >1 profile exists). Non-fatal on
|
||||
// error: an empty list just hides the picker, and products resolve
|
||||
// to the default profile.
|
||||
let profiles = []
|
||||
try {
|
||||
const pj = await api('/v1/admin/merchant-profiles')
|
||||
profiles = (pj && pj.profiles) || []
|
||||
} catch (e) { profiles = [] }
|
||||
|
||||
// Create form. Currency picker swaps the price-input units in
|
||||
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
|
||||
// we convert to cents on the way out (the backend stores
|
||||
@@ -1648,6 +1757,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
}
|
||||
})
|
||||
const createCatalog = catalogEditor(null)
|
||||
const createProfile = profileSelectField(profiles, null)
|
||||
const create = el('details', { class: 'disclosure' }, [
|
||||
el('summary', null, 'Create a new product'),
|
||||
el('div', { class: 'body' }, [
|
||||
@@ -1677,6 +1787,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
|
||||
createCatalog.element,
|
||||
]),
|
||||
createProfile && createProfile.element,
|
||||
// Pre-check warning when the operator is at cap-1 (or already
|
||||
// over) for products. Renders inline above the submit so they
|
||||
// know what to expect before clicking.
|
||||
@@ -1708,6 +1819,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
metadata: {},
|
||||
}
|
||||
if (catalog) body.entitlements_catalog = catalog
|
||||
if (createProfile) body.merchant_profile_id = createProfile.value()
|
||||
await api('/v1/admin/products', { method: 'POST', body })
|
||||
status.replaceWith(ok('Created. Reloading…'))
|
||||
setTimeout(routes.products, 600)
|
||||
@@ -1765,7 +1877,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
el('td', null, el('div', { class: 'actions-row' }, [
|
||||
el('button', {
|
||||
class: 'btn sm secondary',
|
||||
onclick: function () { openEditProduct(p) },
|
||||
onclick: function () { openEditProduct(p, profiles) },
|
||||
}, 'Edit'),
|
||||
el('button', {
|
||||
class: 'btn sm danger',
|
||||
@@ -3827,7 +3939,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
const tb = el('tbody')
|
||||
rows.forEach((r) => tb.appendChild(r))
|
||||
t.appendChild(tb)
|
||||
return t
|
||||
return el('div', { class: 't-wrap' }, t)
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -5648,6 +5760,439 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
load()
|
||||
}
|
||||
|
||||
// -------- Merchant profiles (multi-provider model, :52+) --------
|
||||
// Each profile represents one "business" the operator is running on
|
||||
// this Keysat instance. Owns business identity (brand, support contact,
|
||||
// post-purchase redirect; SMTP-override cols are dormant/unused) and a set of payment
|
||||
// providers (BTCPay / Zaprite) that legally settle to that business.
|
||||
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
|
||||
// get unlimited.
|
||||
routes['merchant-profiles'] = async function () {
|
||||
const target = document.getElementById('route-target')
|
||||
target.innerHTML = ''
|
||||
|
||||
// Lead-in explainer + Create button.
|
||||
const createBtn = el('button', { class: 'btn primary' }, [
|
||||
el('i', { 'data-lucide': 'plus' }), 'Add merchant profile',
|
||||
])
|
||||
createBtn.addEventListener('click', () => openCreateMerchantProfileModal(reload))
|
||||
target.appendChild(plainCard([
|
||||
el('p', { class: 'muted', style: 'margin:0 0 12px' },
|
||||
'A merchant profile bundles one business’s brand, post-purchase redirect, ' +
|
||||
'and a set of payment providers (BTCPay / Zaprite). Products attach to a profile; ' +
|
||||
'the buyer sees the profile’s brand at checkout and the payment-method picker ' +
|
||||
'reflects whichever rails its providers serve.'),
|
||||
el('div', { class: 'toolbar' }, [createBtn]),
|
||||
]))
|
||||
|
||||
const listHost = el('div', { style: 'margin-top:18px' })
|
||||
target.appendChild(listHost)
|
||||
|
||||
async function reload() {
|
||||
listHost.innerHTML = ''
|
||||
listHost.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
|
||||
try {
|
||||
const j = await api('/v1/admin/merchant-profiles')
|
||||
const profiles = j.profiles || []
|
||||
listHost.innerHTML = ''
|
||||
if (profiles.length === 0) {
|
||||
listHost.appendChild(plainCard([el('div', { class: 'empty' }, 'No merchant profiles yet.')]))
|
||||
return
|
||||
}
|
||||
for (const p of profiles) {
|
||||
listHost.appendChild(renderMerchantProfileCard(p, reload))
|
||||
}
|
||||
} catch (e) {
|
||||
listHost.innerHTML = ''
|
||||
listHost.appendChild(plainCard([err(e.message)]))
|
||||
}
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
function renderMerchantProfileCard(p, reload) {
|
||||
const head = el('div', { class: 'card-head' }, [
|
||||
el('div', null, [
|
||||
el('h3', null, [
|
||||
p.name,
|
||||
p.is_default ? el('span', {
|
||||
class: 'badge b-gold',
|
||||
style: 'margin-left:10px; vertical-align:middle;',
|
||||
}, 'default') : null,
|
||||
].filter(Boolean)),
|
||||
p.support_email
|
||||
? el('div', { class: 'sub' }, p.support_email)
|
||||
: null,
|
||||
].filter(Boolean)),
|
||||
el('div', { class: 'actions-row' }, [
|
||||
!p.is_default
|
||||
? el('button', { class: 'btn ghost sm' }, [
|
||||
el('i', { 'data-lucide': 'star' }), 'Set default',
|
||||
])
|
||||
: null,
|
||||
el('button', { class: 'btn ghost sm' }, [
|
||||
el('i', { 'data-lucide': 'pencil' }), 'Edit',
|
||||
]),
|
||||
!p.is_default
|
||||
? el('button', { class: 'btn danger sm' }, [
|
||||
el('i', { 'data-lucide': 'trash-2' }), 'Delete',
|
||||
])
|
||||
: null,
|
||||
].filter(Boolean)),
|
||||
])
|
||||
// Wire action buttons.
|
||||
const setDefaultBtn = head.querySelectorAll('button')[0]
|
||||
if (setDefaultBtn && !p.is_default) {
|
||||
setDefaultBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Make "' + p.name + '" the default profile?')) return
|
||||
try {
|
||||
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id) + '/set-default', {
|
||||
method: 'POST',
|
||||
})
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Set-default failed: ' + e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
const editBtn = head.querySelector('button.ghost:not(:first-child)') ||
|
||||
head.querySelectorAll('button.ghost')[p.is_default ? 0 : 1]
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', () => openEditMerchantProfileModal(p, reload))
|
||||
}
|
||||
const deleteBtn = head.querySelector('button.danger')
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete merchant profile "' + p.name + '"? Refused if products or active subs are attached.')) return
|
||||
try {
|
||||
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id), { method: 'DELETE' })
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Delete failed: ' + e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const body = el('div', { class: 'card-body' })
|
||||
|
||||
// Brand / redirect summary.
|
||||
const meta = el('div', { class: 'row-2', style: 'margin-bottom:14px' }, [
|
||||
el('div', null, [
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
|
||||
el('div', { class: p.post_purchase_redirect_url ? '' : 'muted' },
|
||||
p.post_purchase_redirect_url || 'Keysat default /thank-you page'),
|
||||
]),
|
||||
]),
|
||||
el('div', null, [
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Brand color'),
|
||||
el('div', { class: p.brand_color ? '' : 'muted' },
|
||||
p.brand_color || 'Keysat default navy'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
body.appendChild(meta)
|
||||
|
||||
// Providers list.
|
||||
body.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Payment providers'))
|
||||
const providers = p.providers || []
|
||||
if (providers.length === 0) {
|
||||
body.appendChild(el('div', { class: 'empty', style: 'padding:14px' },
|
||||
'No providers connected. Buyers can’t pay on products attached to this profile until you add one.'))
|
||||
} else {
|
||||
const tb = el('tbody')
|
||||
for (const pr of providers) {
|
||||
const rails = (pr.served_rails || []).join(', ')
|
||||
const disconnectBtn = el('button', { class: 'btn danger sm' }, [
|
||||
el('i', { 'data-lucide': 'unplug' }), 'Disconnect',
|
||||
])
|
||||
disconnectBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Disconnect ' + pr.kind + ' provider "' + pr.label + '"?')) return
|
||||
try {
|
||||
const path = pr.kind === 'btcpay' ? '/v1/admin/btcpay/disconnect' : '/v1/admin/zaprite/disconnect'
|
||||
await api(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_id: pr.id }),
|
||||
})
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Disconnect failed: ' + e.message)
|
||||
}
|
||||
})
|
||||
tb.appendChild(el('tr', null, [
|
||||
el('td', null, el('strong', null, pr.label || pr.kind)),
|
||||
el('td', { class: 'muted' }, pr.kind),
|
||||
el('td', { class: 'muted' }, rails || '—'),
|
||||
el('td', null, disconnectBtn),
|
||||
]))
|
||||
}
|
||||
const table = el('table', { class: 't' }, [
|
||||
el('thead', null, el('tr', null, [
|
||||
el('th', null, 'Label'),
|
||||
el('th', null, 'Kind'),
|
||||
el('th', null, 'Rails'),
|
||||
el('th', null, ''),
|
||||
])),
|
||||
tb,
|
||||
])
|
||||
body.appendChild(el('div', { class: 't-wrap' }, table))
|
||||
}
|
||||
|
||||
// Connect buttons (offer whichever provider kinds aren't yet attached).
|
||||
const haveBtcpay = providers.some((pr) => pr.kind === 'btcpay')
|
||||
const haveZaprite = providers.some((pr) => pr.kind === 'zaprite')
|
||||
const connectActions = el('div', { class: 'toolbar', style: 'margin-top:14px' }, [])
|
||||
if (!haveBtcpay) {
|
||||
const btn = el('button', { class: 'btn secondary' }, [
|
||||
el('i', { 'data-lucide': 'bitcoin' }), 'Connect BTCPay',
|
||||
])
|
||||
btn.addEventListener('click', () => connectBtcpayForProfile(p.id, reload))
|
||||
connectActions.appendChild(btn)
|
||||
}
|
||||
if (!haveZaprite) {
|
||||
const btn = el('button', { class: 'btn secondary' }, [
|
||||
el('i', { 'data-lucide': 'credit-card' }), 'Connect Zaprite',
|
||||
])
|
||||
btn.addEventListener('click', () => connectZapriteForProfile(p.id, reload))
|
||||
connectActions.appendChild(btn)
|
||||
}
|
||||
if (connectActions.children.length) body.appendChild(connectActions)
|
||||
|
||||
if (window.lucide) lucide.createIcons()
|
||||
return el('div', { class: 'card' }, [head, body])
|
||||
}
|
||||
|
||||
function openCreateMerchantProfileModal(onDone) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
})
|
||||
const nameInput = el('input', { class: 'input', placeholder: 'e.g. Recaps' })
|
||||
const supportUrlInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/support (optional)' })
|
||||
const supportEmailInput = el('input', { class: 'input', placeholder: 'support@recaps.cc (optional)' })
|
||||
const redirectInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/welcome?invoice_id={invoice_id} (optional)' })
|
||||
const brandColorInput = el('input', { class: 'input', type: 'color', value: '#1E3A5F' })
|
||||
const errBox = el('div')
|
||||
const submitBtn = el('button', { class: 'btn primary' }, 'Create profile')
|
||||
const card = el('div', {
|
||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||
'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;',
|
||||
}, [
|
||||
el('h3', { style: 'margin:0 0 14px' }, 'New merchant profile'),
|
||||
el('p', { class: 'muted', style: 'margin:0 0 18px; font-size:13px' },
|
||||
'Each profile is one business identity. Buyers see the brand on the buy page; ' +
|
||||
'products attached to this profile route their payments through the providers you connect to it.'),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Support URL'),
|
||||
supportUrlInput,
|
||||
el('div', { class: 'hint' }, 'Linked from the buy page so buyers can contact your team.'),
|
||||
]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Support email'),
|
||||
supportEmailInput,
|
||||
]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
|
||||
redirectInput,
|
||||
el('div', { class: 'hint' }, '{invoice_id} is substituted at purchase time. Leave blank to use Keysat’s /thank-you page.'),
|
||||
]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Brand color'),
|
||||
brandColorInput,
|
||||
]),
|
||||
errBox,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [
|
||||
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
|
||||
submitBtn,
|
||||
]),
|
||||
])
|
||||
overlay.appendChild(card)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
document.body.appendChild(overlay)
|
||||
nameInput.focus()
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
errBox.innerHTML = ''
|
||||
const name = nameInput.value.trim()
|
||||
if (!name) {
|
||||
errBox.appendChild(err('Name is required.'))
|
||||
return
|
||||
}
|
||||
submitBtn.disabled = true
|
||||
try {
|
||||
await api('/v1/admin/merchant-profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
support_url: supportUrlInput.value.trim() || null,
|
||||
support_email: supportEmailInput.value.trim() || null,
|
||||
post_purchase_redirect_url: redirectInput.value.trim() || null,
|
||||
brand_color: brandColorInput.value || null,
|
||||
}),
|
||||
})
|
||||
overlay.remove()
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
errBox.appendChild(err(e.message))
|
||||
submitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openEditMerchantProfileModal(profile, onDone) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
})
|
||||
const nameInput = el('input', { class: 'input', value: profile.name || '' })
|
||||
const supportUrlInput = el('input', { class: 'input', value: profile.support_url || '' })
|
||||
const supportEmailInput = el('input', { class: 'input', value: profile.support_email || '' })
|
||||
const redirectInput = el('input', { class: 'input', value: profile.post_purchase_redirect_url || '' })
|
||||
const brandColorInput = el('input', { class: 'input', type: 'color', value: profile.brand_color || '#1E3A5F' })
|
||||
const errBox = el('div')
|
||||
const submitBtn = el('button', { class: 'btn primary' }, 'Save')
|
||||
const card = el('div', {
|
||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||
'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;',
|
||||
}, [
|
||||
el('h3', { style: 'margin:0 0 14px' }, 'Edit merchant profile'),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support URL'), supportUrlInput]),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support email'), supportEmailInput]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
|
||||
redirectInput,
|
||||
el('div', { class: 'hint' }, '{invoice_id} substituted at purchase time.'),
|
||||
]),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Brand color'), brandColorInput]),
|
||||
errBox,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [
|
||||
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
|
||||
submitBtn,
|
||||
]),
|
||||
])
|
||||
overlay.appendChild(card)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
errBox.innerHTML = ''
|
||||
submitBtn.disabled = true
|
||||
try {
|
||||
// Build the patch: outer Option means "leave unchanged" (omit
|
||||
// the key); we always send all the editable fields so the
|
||||
// operator can also CLEAR them by leaving the input empty.
|
||||
const patch = {
|
||||
name: nameInput.value.trim() || null,
|
||||
support_url: [supportUrlInput.value.trim() || null],
|
||||
support_email: [supportEmailInput.value.trim() || null],
|
||||
post_purchase_redirect_url: [redirectInput.value.trim() || null],
|
||||
brand_color: [brandColorInput.value || null],
|
||||
}
|
||||
// Wire-format note: the Rust patch uses double-Option (outer Some
|
||||
// wraps inner None for "set to NULL"). serde_json deserializes
|
||||
// a single value into outer Some(value); arrays into Some(Some(v)).
|
||||
// We use the bare value for outer Some/Some(v), and the [null]
|
||||
// array trick to express Some(None) (clear). For simplicity here
|
||||
// we just send the bare value — Rust treats null → Some(None) →
|
||||
// sets to NULL; a string → Some(Some(s)) → updates.
|
||||
const wirePatch = {}
|
||||
if (patch.name !== null) wirePatch.name = patch.name
|
||||
wirePatch.support_url = patch.support_url[0]
|
||||
wirePatch.support_email = patch.support_email[0]
|
||||
wirePatch.post_purchase_redirect_url = patch.post_purchase_redirect_url[0]
|
||||
wirePatch.brand_color = patch.brand_color[0]
|
||||
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(profile.id), {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(wirePatch),
|
||||
})
|
||||
overlay.remove()
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
errBox.appendChild(err(e.message))
|
||||
submitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function connectBtcpayForProfile(profileId, onDone) {
|
||||
try {
|
||||
const r = await api('/v1/admin/btcpay/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ merchant_profile_id: profileId }),
|
||||
})
|
||||
if (r.authorize_url) {
|
||||
if (confirm('Open BTCPay’s consent page in a new tab to complete connection?')) {
|
||||
window.open(r.authorize_url, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
alert('Connect BTCPay failed: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function connectZapriteForProfile(profileId, onDone) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
})
|
||||
const apiKeyInput = el('input', { class: 'input', type: 'password', placeholder: 'paste Zaprite API key' })
|
||||
const baseUrlInput = el('input', { class: 'input', placeholder: 'https://api.zaprite.com (default)' })
|
||||
const errBox = el('div')
|
||||
const submitBtn = el('button', { class: 'btn primary' }, 'Connect')
|
||||
const card = el('div', {
|
||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||
'border-radius:12px; max-width:480px; width:100%; padding:24px;',
|
||||
}, [
|
||||
el('h3', { style: 'margin:0 0 12px' }, 'Connect Zaprite'),
|
||||
el('p', { class: 'muted', style: 'margin:0 0 14px; font-size:13px' },
|
||||
'Paste an API key from app.zaprite.com Settings → API.'),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'API key'), apiKeyInput]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Base URL (optional)'),
|
||||
baseUrlInput,
|
||||
el('div', { class: 'hint' }, 'Override only for sandbox orgs that point at a different host.'),
|
||||
]),
|
||||
errBox,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:14px; justify-content:flex-end;' }, [
|
||||
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
|
||||
submitBtn,
|
||||
]),
|
||||
])
|
||||
overlay.appendChild(card)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
document.body.appendChild(overlay)
|
||||
apiKeyInput.focus()
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
errBox.innerHTML = ''
|
||||
const key = apiKeyInput.value.trim()
|
||||
if (!key) { errBox.appendChild(err('API key required.')); return }
|
||||
submitBtn.disabled = true
|
||||
try {
|
||||
const r = await api('/v1/admin/zaprite/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
api_key: key,
|
||||
base_url: baseUrlInput.value.trim() || undefined,
|
||||
merchant_profile_id: profileId,
|
||||
}),
|
||||
})
|
||||
overlay.remove()
|
||||
if (r.webhook_url) {
|
||||
alert('Zaprite connected. Register this webhook URL on the Zaprite dashboard:\n\n' + r.webhook_url)
|
||||
}
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
errBox.appendChild(err(e.message))
|
||||
submitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// -------- Settings --------
|
||||
// Three subsections:
|
||||
// 1. Operator name — the human-readable name on /buy/<slug> + thank-you.
|
||||
@@ -6073,6 +6618,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
el('option', { value: 'read-only' }, 'Read-only — list everything; mutate nothing'),
|
||||
el('option', { value: 'license-issuer' }, 'License issuer — read + issue / revoke / change-tier licenses'),
|
||||
el('option', { value: 'support' }, 'Support — license issuer + cancel subs + deactivate machines'),
|
||||
el('option', { value: 'merchant-onboard' }, 'Merchant onboard — read + create products / policies + issue licenses (self-serve catalog setup)'),
|
||||
el('option', { value: 'full-admin' }, 'Full admin — every scope (use sparingly)'),
|
||||
])
|
||||
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
|
||||
@@ -6467,8 +7013,35 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
|
||||
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
setRoute(a.getAttribute('data-route'))
|
||||
// Close the off-canvas drawer on phones after navigating, otherwise
|
||||
// the sidebar stays parked over the content the operator just opened.
|
||||
closeSidebarDrawer()
|
||||
})
|
||||
})
|
||||
|
||||
// Mobile nav drawer. Above 720px the sidebar is a static column and these
|
||||
// toggles are no-ops (the CSS keeps it visible regardless of `.open`).
|
||||
const sidebarEl = document.getElementById('sidebar')
|
||||
const backdropEl = document.getElementById('sidebar-backdrop')
|
||||
const toggleEl = document.getElementById('nav-toggle')
|
||||
function openSidebarDrawer() {
|
||||
sidebarEl.classList.add('open')
|
||||
backdropEl.classList.add('open')
|
||||
toggleEl.setAttribute('aria-expanded', 'true')
|
||||
}
|
||||
function closeSidebarDrawer() {
|
||||
sidebarEl.classList.remove('open')
|
||||
backdropEl.classList.remove('open')
|
||||
toggleEl.setAttribute('aria-expanded', 'false')
|
||||
}
|
||||
toggleEl.addEventListener('click', () => {
|
||||
if (sidebarEl.classList.contains('open')) closeSidebarDrawer()
|
||||
else openSidebarDrawer()
|
||||
})
|
||||
backdropEl.addEventListener('click', closeSidebarDrawer)
|
||||
|
||||
// Tier status (label + usage + caps) — cached after first fetch so
|
||||
// multiple consumers within a single route render don't all re-hit
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-run scratch: live daemon DBs, logs, tokens, the symlink to the active run.
|
||||
# Disposable and may contain (worthless, post-teardown) fixture tokens.
|
||||
runs/
|
||||
@@ -0,0 +1,79 @@
|
||||
# Keysat onboarding harness
|
||||
|
||||
A disposable test rig that runs the global **`onboarding-tester`** agent against
|
||||
Keysat's developer SDK-integration journey, to find every place the *published
|
||||
docs* leave a newcomer stuck — and, on a clean run, to harvest a publishable
|
||||
"all it took was X, Y, Z" walkthrough.
|
||||
|
||||
The premise (from `~/Projects/standards/guides/onboarding-tester.md`): the agent
|
||||
is a fresh adopter who may use **only the published docs corpus**, never Keysat
|
||||
source. The harness builder (you) may read Keysat freely; the agent may not.
|
||||
|
||||
## What a run sets up
|
||||
|
||||
| Piece | What it is | Disposable via |
|
||||
|-------|------------|----------------|
|
||||
| Fixture daemon | a fresh `keysat` release binary on `127.0.0.1:<port>`, throwaway SQLite, fresh issuer keypair | `teardown.sh` |
|
||||
| Provisioning | a **merchant-onboard** scoped key minted with the fixture's master key (the operator's job, not the agent's) | — |
|
||||
| Docs corpus | `keysat-docs/` served over HTTP — the only how-to source the agent may read | `teardown.sh` |
|
||||
| Sandbox | a pristine Next.js/TS proof-of-work (`sandbox-template/`) copied to `/tmp/onboarding-tester/`, with one ungated "Pro export" to gate | `teardown.sh` |
|
||||
|
||||
The fixture's dummy `BTCPAY_URL` is never dialed in this path: **Stage 1 is
|
||||
license issuance + SDK integration, no payments.**
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./run.sh # boot + provision + serve docs + sandbox; writes AGENT_BRIEF.md
|
||||
# → feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||
./teardown.sh runs/<id> # stop daemon + docs server, remove sandbox
|
||||
./teardown.sh runs/<id> --purge # also delete the run dir
|
||||
```
|
||||
|
||||
Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`,
|
||||
`make-sandbox.sh`) can be run on their own; each reads/writes
|
||||
`runs/<id>/state.env` and `runs/current` points at the active run.
|
||||
|
||||
## The loop
|
||||
|
||||
1. `./run.sh`, then run the `onboarding-tester` agent on the brief.
|
||||
2. Read `runs/<id>/reports/friction.md`. If `completed-clean`, harvest the
|
||||
walkthrough into `keysat-docs/agent.html`. Otherwise fix the highest-severity
|
||||
**doc** gaps (additively — document missing API/how-to; don't rewrite
|
||||
marketing copy), tear down, and re-run on a fresh fixture.
|
||||
3. Repeat until `completed-clean`.
|
||||
|
||||
## Stage 2 (buyer pays on regtest) — built, `completed-clean`
|
||||
|
||||
Lives in `stage2/`. Boots a **sandbox** daemon (`KEYSAT_SANDBOX_MODE=1`) wired to
|
||||
a Dockerized BTCPay **regtest** stack and grants the agent `merchant-onboard` +
|
||||
`payment_providers:write` so it connects BTCPay (regtest) and drives a test buyer
|
||||
payment end to end. Connecting a *mainnet* wallet stays operator-only by design —
|
||||
that boundary is a feature, not a gap.
|
||||
|
||||
```sh
|
||||
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
|
||||
./stage2/btcpay-regtest/probe.sh # mints the BTCPay store token into .live-env (one-time)
|
||||
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
|
||||
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||
./stage2/teardown-stage2.sh # WHEN DONE: stop daemon(s) + docs + sandbox dev server + BTCPay stack
|
||||
```
|
||||
|
||||
- `stage2/btcpay-regtest/` — the BTCPay regtest compose + de-risk probe (`FINDINGS.md`).
|
||||
- `stage2/validate-gate.sh` — end-to-end gate check (deny mainnet/undetermined, allow regtest).
|
||||
- `stage2/buyer-pay.sh` — the test buyer's wallet (pay invoice on regtest + mine).
|
||||
- `stage2/teardown-stage2.sh` — full cleanup: tears down every Stage 2 run, kills any orphaned
|
||||
sandbox dev server (`:4311`), and stops the BTCPay docker stack + volumes (`--keep-btcpay`
|
||||
to leave it up between runs). **Always run this when finished** — the agent can leave a
|
||||
daemon, a docs server, or an `npm run dev` behind.
|
||||
- `stage2/STAGE2-RESULT.md` — convergence + the publishable walkthrough.
|
||||
|
||||
**Harvesting on a clean run:** do NOT reflexively bolt a new success story onto the public
|
||||
HTML. First check whether `keysat-docs/agent.html` (the connect workflow + worked example)
|
||||
and the docs already cover the buyer-pays + SDK-gating case well enough; only propose
|
||||
additions for a genuine gap, with operator approval.
|
||||
|
||||
## Requirements
|
||||
|
||||
`cargo`, `node`/`npm`, `python3`, `curl`, `jq`, `openssl`. (Docker is only
|
||||
needed for Stage 2.)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Stage 1 result — developer SDK-integration journey (no payments)
|
||||
|
||||
**Verdict: `completed-clean` on run 3.** A fresh adopter, using only the published
|
||||
docs, can stand up a product, issue a license under a non-master `merchant-onboard`
|
||||
key, integrate the TypeScript SDK into a Next.js app, and gate a feature so a valid
|
||||
license unlocks it and an absent/invalid one blocks it.
|
||||
|
||||
## Method
|
||||
|
||||
The harness (`./run.sh`) boots a disposable `keysat` fixture (fresh SQLite, fresh
|
||||
issuer keypair), mints a `merchant-onboard` scoped key with the fixture's master
|
||||
key, serves `keysat-docs/` as the published corpus, and materializes a pristine
|
||||
Next.js/TS proof-of-work (`sandbox-template/` → `/tmp/onboarding-tester/`). The
|
||||
global `onboarding-tester` agent then drives the journey **docs-only** — it never
|
||||
reads Keysat source. Corpus declared in-scope: the docs site, the daemon's
|
||||
`/v1/openapi.json`, and the npm `@keysat/licensing-client` README.
|
||||
|
||||
## Convergence
|
||||
|
||||
| Run | Verdict | Findings |
|
||||
|-----|---------|----------|
|
||||
| 1 | completed-with-stumbles (5) + 1 nit | SDK `verify()` shape wrong in integrate.html; product `price_value` vs `price_sats`; licenses filter param; `merchant-onboard` role undocumented; issuer-pubkey response shape; phantom `GET /v1/admin/products`. |
|
||||
| 2 | completed-with-stumbles (1) + 1 nit | "Find a license by email" pointed at the wrong endpoint; server-side key transport unstated. |
|
||||
| 3 | **completed-clean** | none. Walkthrough harvested to `agent.html`. |
|
||||
|
||||
Each finding was verified against Keysat source before the doc was changed (the
|
||||
agent can't read source; the harness builder can).
|
||||
|
||||
## Doc fixes shipped this loop
|
||||
|
||||
**`keysat-docs/` (static site — deploys independently):**
|
||||
- `integrate.html`: rewrote the verify/error examples (TS/Rust/Python) to the real
|
||||
v0.3 SDK — `verify()` throws/returns `Err` and yields `VerifyOk{payload,…}`; no
|
||||
`valid` boolean; entitlements at `payload.entitlements`; errors are `LicensingError`
|
||||
(`.code` in TS, `.kind` in Python; Rust `Error::BadSignature`/`BadFormat`). Replaced the
|
||||
result-fields table; added an offline-expiry note (`isExpiredAt`/`is_expired_at`; TS/Rust
|
||||
`verifyWithTime`) and server-side key-transport guidance.
|
||||
- `agent.html`: added the `merchant-onboard` role row; added "Create a product" and
|
||||
"Add a tier (policy)" workflows with the `price_value`/`price_sats` distinction;
|
||||
fixed the comp-license field name (`buyer_note` → `note`); pointed "Find a license
|
||||
by email" at `/v1/admin/licenses/search`; **added the publishable worked example**
|
||||
(the harvested walkthrough).
|
||||
- `wire-format.html`: corrected the `GET /v1/issuer/public-key` response shape.
|
||||
|
||||
**`licensing-service/src/api/openapi.rs` (served spec — ships with the next daemon
|
||||
release; the local fixture was rebuilt so the agent saw the fixes):**
|
||||
- `GET /v1/admin/licenses` description: requires `product_id=<uuid>`, not a slug.
|
||||
- Removed the phantom `GET /v1/admin/products` (only POST exists; list is the public
|
||||
`GET /v1/products`).
|
||||
- Added the `/v1/admin/licenses/search` path (was referenced but undefined).
|
||||
- Product schema: marked `price_value` as the write field, `price_sats` as derived.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
./run.sh # prints the fixture URL, docs URL, merchant key, sandbox path
|
||||
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||
./teardown.sh runs/<id> # leaves nothing running
|
||||
```
|
||||
|
||||
Per-run logs and the three friction reports live under `runs/` (gitignored; the
|
||||
tokens there are worthless after teardown).
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot a fresh, disposable Keysat daemon on a throwaway SQLite DB.
|
||||
# Creates a new run dir, writes its state file, points runs/current at it.
|
||||
# Echoes the run id on success.
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
require curl; require openssl; require node
|
||||
|
||||
# Build the daemon if the release binary is missing.
|
||||
if [[ ! -x "$DAEMON_BIN" ]]; then
|
||||
log "release binary missing; building (cargo build --release)…"
|
||||
( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"
|
||||
fi
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
RUN_DIR="$RUNS_DIR/$RUN_ID"
|
||||
mkdir -p "$RUN_DIR"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
: > "$STATE"
|
||||
|
||||
PORT="$(free_port)"
|
||||
MASTER="$(openssl rand -hex 32)"
|
||||
DB_DIR="$RUN_DIR/data"
|
||||
mkdir -p "$DB_DIR"
|
||||
|
||||
state_set "$STATE" RUN_ID "$RUN_ID"
|
||||
state_set "$STATE" RUN_DIR "$RUN_DIR"
|
||||
state_set "$STATE" PORT "$PORT"
|
||||
state_set "$STATE" BASE_URL "http://127.0.0.1:$PORT"
|
||||
state_set "$STATE" MASTER_KEY "$MASTER"
|
||||
|
||||
log "booting keysat fixture on 127.0.0.1:$PORT (db: $DB_DIR/keysat.db)"
|
||||
KEYSAT_BIND="127.0.0.1:$PORT" \
|
||||
KEYSAT_DB_PATH="$DB_DIR/keysat.db" \
|
||||
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||
BTCPAY_URL="http://127.0.0.1:1" \
|
||||
KEYSAT_PUBLIC_URL="http://127.0.0.1:$PORT" \
|
||||
KEYSAT_OPERATOR_NAME="Onboarding Fixture" \
|
||||
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
state_set "$STATE" DAEMON_PID "$DAEMON_PID"
|
||||
|
||||
if ! wait_http "http://127.0.0.1:$PORT/healthz" 75; then
|
||||
warn "daemon did not become healthy; last log lines:"
|
||||
tail -20 "$RUN_DIR/daemon.log" >&2 || true
|
||||
kill "$DAEMON_PID" 2>/dev/null || true
|
||||
die "fixture failed to start"
|
||||
fi
|
||||
|
||||
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
|
||||
ok "fixture healthy (pid $DAEMON_PID) at http://127.0.0.1:$PORT"
|
||||
echo "$RUN_ID"
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared config + helpers for the Keysat onboarding harness.
|
||||
# Sourced by the stage scripts; not run directly.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HARNESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# onboarding-harness/ -> licensing-service-startos/ -> workspace root
|
||||
WORKSPACE="$(cd "$HARNESS_DIR/../.." && pwd)"
|
||||
DAEMON_DIR="$WORKSPACE/licensing-service-startos/licensing-service"
|
||||
DAEMON_BIN="$DAEMON_DIR/target/release/keysat"
|
||||
DOCS_DIR="$WORKSPACE/keysat-docs"
|
||||
TEMPLATE_DIR="$HARNESS_DIR/sandbox-template"
|
||||
|
||||
# Per-run scratch lives under runs/ (gitignored). The agent's sandbox copy
|
||||
# lives under /tmp/onboarding-tester/ per the onboarding-tester guide.
|
||||
RUNS_DIR="$HARNESS_DIR/runs"
|
||||
SANDBOX_BASE="/tmp/onboarding-tester"
|
||||
|
||||
# The active run's state file is pointed to by runs/current.
|
||||
CURRENT_LINK="$RUNS_DIR/current"
|
||||
|
||||
log() { printf '\033[1;34m[harness]\033[0m %s\n' "$*" >&2; }
|
||||
ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*" >&2; }
|
||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# state_set KEY VALUE — append/update a KEY=VALUE line in the run state file.
|
||||
# Not concurrency-safe (uses a fixed temp suffix); the stages call it serially.
|
||||
state_set() {
|
||||
local f="$1" k="$2" v="$3"
|
||||
touch "$f"
|
||||
# strip any existing line for this key, then append
|
||||
grep -v "^${k}=" "$f" > "$f.tmp" 2>/dev/null || true
|
||||
mv "$f.tmp" "$f"
|
||||
printf '%s=%s\n' "$k" "$v" >> "$f"
|
||||
}
|
||||
|
||||
# state_get FILE KEY
|
||||
state_get() { grep "^${2}=" "$1" | head -1 | cut -d= -f2-; }
|
||||
|
||||
# free_port — echo an unused TCP port on 127.0.0.1.
|
||||
free_port() {
|
||||
node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();});'
|
||||
}
|
||||
|
||||
# wait_http URL TRIES — poll until URL returns 2xx/3xx, or die.
|
||||
wait_http() {
|
||||
local url="$1" tries="${2:-50}" i
|
||||
for i in $(seq 1 "$tries"); do
|
||||
if curl -fsS -o /dev/null "$url" 2>/dev/null; then return 0; fi
|
||||
sleep 0.2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
require() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Materialize a fresh, pristine proof-of-work app for the agent to integrate
|
||||
# into. Copies sandbox-template/ to /tmp/onboarding-tester/sandbox-<run>/ and
|
||||
# runs `npm install` so the app is known-good before the agent touches it.
|
||||
# The agent mutates ONLY this copy. Usage: make-sandbox.sh [RUN_DIR]
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
require node; require npm
|
||||
|
||||
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
RUN_ID="$(state_get "$STATE" RUN_ID)"
|
||||
|
||||
mkdir -p "$SANDBOX_BASE"
|
||||
SANDBOX="$SANDBOX_BASE/sandbox-$RUN_ID"
|
||||
rm -rf "$SANDBOX"
|
||||
log "copying pristine proof-of-work to $SANDBOX"
|
||||
# copy template without any stray build artifacts
|
||||
( cd "$TEMPLATE_DIR" && find . -type d \( -name node_modules -o -name .next \) -prune -o -type f -print \
|
||||
| while IFS= read -r f; do mkdir -p "$SANDBOX/$(dirname "$f")"; cp "$f" "$SANDBOX/$f"; done )
|
||||
|
||||
log "installing base app dependencies (npm install)…"
|
||||
( cd "$SANDBOX" && npm install --no-audit --no-fund >"$RUN_DIR/sandbox-npm.log" 2>&1 ) \
|
||||
|| { tail -20 "$RUN_DIR/sandbox-npm.log" >&2; die "sandbox npm install failed"; }
|
||||
|
||||
state_set "$STATE" SANDBOX "$SANDBOX"
|
||||
ok "pristine sandbox ready at $SANDBOX"
|
||||
echo "$SANDBOX"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Provisioner step (the human operator's job, NOT the agent's): with the
|
||||
# fixture's master key, mint a merchant-onboard scoped key and capture the
|
||||
# issuer public key. Writes both into the run state file.
|
||||
# Usage: provision.sh [RUN_DIR] (defaults to runs/current)
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
require curl; require jq
|
||||
|
||||
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
BASE_URL="$(state_get "$STATE" BASE_URL)"
|
||||
MASTER="$(state_get "$STATE" MASTER_KEY)"
|
||||
|
||||
log "minting merchant-onboard scoped key via master key"
|
||||
RESP="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" \
|
||||
-H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
|
||||
-d '{"label":"onboarding-agent","role":"merchant-onboard","scopes":[]}')" \
|
||||
|| die "key mint failed"
|
||||
TOKEN="$(echo "$RESP" | jq -r '.token')"
|
||||
[[ "$TOKEN" == ks_* ]] || die "unexpected mint response: $RESP"
|
||||
state_set "$STATE" MERCHANT_KEY "$TOKEN"
|
||||
|
||||
log "fetching issuer public key"
|
||||
PUBKEY_PEM="$(curl -fsS "$BASE_URL/v1/issuer/public-key" | jq -r '.public_key_pem')"
|
||||
[[ "$PUBKEY_PEM" == *"BEGIN PUBLIC KEY"* ]] || die "could not fetch issuer public key"
|
||||
printf '%s' "$PUBKEY_PEM" > "$RUN_DIR/issuer.pub"
|
||||
state_set "$STATE" ISSUER_PUBKEY_FILE "$RUN_DIR/issuer.pub"
|
||||
|
||||
ok "merchant-onboard key minted; issuer pubkey saved to $RUN_DIR/issuer.pub"
|
||||
echo "$TOKEN"
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot Stage 1 setup: boot fixture, provision the merchant-onboard key,
|
||||
# serve the docs corpus, materialize a pristine sandbox, then emit the agent
|
||||
# brief (AGENT_BRIEF.md) with the live URLs + credentials interpolated in.
|
||||
#
|
||||
# This script sets the stage; it does NOT run the agent (the orchestrator does
|
||||
# that with the global onboarding-tester agent, feeding it AGENT_BRIEF.md).
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
RUN_ID="$("$HARNESS_DIR/boot-fixture.sh")"
|
||||
RUN_DIR="$RUNS_DIR/$RUN_ID"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
"$HARNESS_DIR/provision.sh" "$RUN_DIR" >/dev/null
|
||||
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
|
||||
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
|
||||
|
||||
BASE_URL="$(state_get "$STATE" BASE_URL)"
|
||||
DOCS_URL="$(state_get "$STATE" DOCS_URL)"
|
||||
MERCHANT_KEY="$(state_get "$STATE" MERCHANT_KEY)"
|
||||
SANDBOX="$(state_get "$STATE" SANDBOX)"
|
||||
mkdir -p "$RUN_DIR/reports"
|
||||
|
||||
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
|
||||
# Onboarding-tester brief — Keysat SDK integration (Stage 1, no payments)
|
||||
|
||||
You are a **fresh adopter**, following your operating guide
|
||||
(\`~/Projects/standards/guides/onboarding-tester.md\`). Reach the goal below
|
||||
using **only the docs corpus**. Never read Keysat's server or SDK source to
|
||||
unblock yourself — if the docs don't get you there, that is a finding.
|
||||
|
||||
## Goal (checkable end-state)
|
||||
A developer with a Next.js/TypeScript app wants to sell it. Using a **scoped,
|
||||
non-master API key**, and the published docs only:
|
||||
|
||||
1. Define the product in Keysat's catalog.
|
||||
2. Add at least one tier/policy with an entitlement.
|
||||
3. Manually issue a license for that product/tier (a comp/dev license — no
|
||||
payment in this path).
|
||||
4. Integrate the TypeScript SDK into the proof-of-work app so the **Pro export**
|
||||
(\`GET /api/export\`) is gated: it returns the CSV only with a valid license.
|
||||
5. Verify the gate both ways: a **valid** license unlocks the export; **no**
|
||||
license and a **tampered/invalid** license are blocked (4xx, not the CSV).
|
||||
|
||||
Success = the gate demonstrably works both ways, reached from the docs alone.
|
||||
|
||||
## Docs corpus (the ONLY how-to sources you may consult)
|
||||
- The Keysat docs site, served at: **$DOCS_URL** (start at \`/integrate.html\`
|
||||
and \`/agent.html\`; the whole site is in-corpus).
|
||||
- The daemon's published OpenAPI spec: **$BASE_URL/v1/openapi.json**
|
||||
(unauthenticated; the docs explicitly point adopters here).
|
||||
- The npm package README for \`@keysat/licensing-client\` (\`npm view\`, or the
|
||||
package page). The SDK's published README is in-corpus.
|
||||
|
||||
**Out of corpus (do not open):** anything under the Keysat source tree
|
||||
(\`$WORKSPACE/licensing-service-startos\`, \`$WORKSPACE/licensing-client-*\`,
|
||||
migrations, tests, this harness). Reading any of it invalidates the run — say so
|
||||
if you do.
|
||||
|
||||
## Your sandbox (mutate ONLY this)
|
||||
\`$SANDBOX\` — a pristine copy of the "Acme Reports" app. Read its own
|
||||
\`README.md\` freely (it's your app). Deps are already installed. Run it with
|
||||
\`npm run dev\` (it serves on http://localhost:4311). Put all scratch under
|
||||
\`/tmp/onboarding-tester/\`.
|
||||
|
||||
## Credentials you were handed (a real adopter would get these from their operator)
|
||||
- Keysat server URL: **$BASE_URL**
|
||||
- Scoped API key (merchant-onboard role): **$MERCHANT_KEY**
|
||||
- (The issuer public key is fetchable per the docs — find how.)
|
||||
|
||||
You were NOT given the master admin key. If a step seems to require it, that is
|
||||
either an intended operator-only boundary (note it) or a doc gap (log it).
|
||||
|
||||
## Output
|
||||
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as
|
||||
your final message, exactly in the format from your guide (Verdict, Corpus &
|
||||
goal, Friction log most-severe-first, Path walked, Confidence). On a
|
||||
\`completed-clean\` verdict only, also emit the publishable walkthrough
|
||||
(secret-free, placeholders for URL/key). Record commands and doc locations as
|
||||
you go; do not work from memory.
|
||||
EOF
|
||||
|
||||
ok "Stage 1 staged. Run id: $RUN_ID"
|
||||
cat >&2 <<EOF
|
||||
|
||||
Fixture URL : $BASE_URL
|
||||
Docs corpus : $DOCS_URL
|
||||
Merchant key: $MERCHANT_KEY
|
||||
Sandbox : $SANDBOX
|
||||
Agent brief : $RUN_DIR/AGENT_BRIEF.md
|
||||
Reports dir : $RUN_DIR/reports/
|
||||
|
||||
Tear down with: $HARNESS_DIR/teardown.sh "$RUN_DIR"
|
||||
EOF
|
||||
echo "$RUN_ID"
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
.env*.local
|
||||
@@ -0,0 +1,34 @@
|
||||
# Acme Reports — proof-of-work app
|
||||
|
||||
A deliberately tiny Next.js (App Router) + TypeScript app. It shows a small
|
||||
analytics table for free and offers a **Pro export** (CSV download) at
|
||||
`GET /api/export`.
|
||||
|
||||
**In its pristine state the Pro export is ungated** — anyone can download it.
|
||||
Your job, as the integrator, is to put it behind a Keysat license: only a
|
||||
holder of a valid license for this product should be able to export.
|
||||
|
||||
This README describes *your own app* — you may read it freely. It tells you
|
||||
nothing about how Keysat works; for that, use only the Keysat docs you were
|
||||
pointed at.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
npm install # already done for you in the sandbox
|
||||
npm run dev # starts on http://localhost:4311
|
||||
```
|
||||
|
||||
- `GET http://localhost:4311/` — the free report view.
|
||||
- `GET http://localhost:4311/api/export` — the Pro export (CSV). Currently free.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
After integration:
|
||||
|
||||
- `GET /api/export` returns the CSV **only** when a valid license is present.
|
||||
- With **no** license, or a **tampered/invalid** one, `/api/export` is blocked
|
||||
(a 4xx, not the CSV).
|
||||
|
||||
How the app learns the user's license key (env var, file, header) is your
|
||||
call — pick whatever the Keysat docs suggest and note it.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ROWS, toCsv } from "@/lib/reports";
|
||||
|
||||
// The "Pro export" endpoint.
|
||||
//
|
||||
// PRISTINE STATE: this feature is currently FREE — anyone who hits it gets the
|
||||
// CSV. The goal of this proof-of-work is to gate it behind a valid Keysat
|
||||
// license so that only paying customers can export.
|
||||
//
|
||||
// (How you wire that in is up to the integrator following the Keysat docs.)
|
||||
|
||||
export async function GET() {
|
||||
const csv = toCsv(ROWS);
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": 'attachment; filename="acme-report.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Acme Reports",
|
||||
description: "A tiny analytics tool with a paid Pro export.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "3rem auto", padding: "0 1rem" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ROWS } from "@/lib/reports";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Acme Reports</h1>
|
||||
<p>Your signups and revenue by region. Viewing is free.</p>
|
||||
<table cellPadding={6} style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left">Region</th>
|
||||
<th align="right">Signups</th>
|
||||
<th align="right">Revenue (sats)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ROWS.map((r) => (
|
||||
<tr key={r.region}>
|
||||
<td>{r.region}</td>
|
||||
<td align="right">{r.signups}</td>
|
||||
<td align="right">{r.revenueSats.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style={{ marginTop: "2rem" }}>Pro export</h2>
|
||||
<p>
|
||||
Download the full dataset as CSV. This is a paid feature:{" "}
|
||||
<a href="/api/export">/api/export</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The "data" behind Acme Reports. The free tier lets you view it on screen;
|
||||
// the paid "Pro export" feature lets you download it as CSV. That export is
|
||||
// the feature we want to gate behind a Keysat license.
|
||||
|
||||
export type Row = { region: string; signups: number; revenueSats: number };
|
||||
|
||||
export const ROWS: Row[] = [
|
||||
{ region: "North", signups: 412, revenueSats: 1_240_000 },
|
||||
{ region: "South", signups: 318, revenueSats: 980_500 },
|
||||
{ region: "East", signups: 521, revenueSats: 1_702_300 },
|
||||
{ region: "West", signups: 274, revenueSats: 731_900 },
|
||||
];
|
||||
|
||||
export function toCsv(rows: Row[]): string {
|
||||
const header = "region,signups,revenue_sats";
|
||||
const body = rows.map((r) => `${r.region},${r.signups},${r.revenueSats}`);
|
||||
return [header, ...body].join("\n") + "\n";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Keep the proof-of-work app deliberately boring: no experimental flags,
|
||||
// so any onboarding friction is attributable to Keysat, not to Next.js.
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "acme-reports",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pristine proof-of-work app for the Keysat onboarding harness. A tiny Next.js report tool whose 'Pro export' feature is meant to be gated behind a Keysat license.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4311",
|
||||
"build": "next build",
|
||||
"start": "next start -p 4311"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serve the keysat-docs/ site over HTTP as the "published docs corpus" the
|
||||
# agent is allowed to read. Writes the docs URL + server pid into state.
|
||||
# Usage: serve-docs.sh [RUN_DIR]
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||
STATE="$RUN_DIR/state.env"
|
||||
[[ -d "$DOCS_DIR" ]] || die "keysat-docs not found at $DOCS_DIR"
|
||||
|
||||
PORT="$(free_port)"
|
||||
log "serving published docs corpus from $DOCS_DIR on 127.0.0.1:$PORT"
|
||||
# --directory avoids a `cd` subshell, so $! is the real python PID (not a
|
||||
# wrapper shell that would orphan the server on teardown). nohup survives the
|
||||
# SIGHUP when this script exits.
|
||||
nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$DOCS_DIR" \
|
||||
>"$RUN_DIR/docs-server.log" 2>&1 &
|
||||
DOCS_PID=$!
|
||||
state_set "$STATE" DOCS_PID "$DOCS_PID"
|
||||
state_set "$STATE" DOCS_PORT "$PORT"
|
||||
state_set "$STATE" DOCS_URL "http://127.0.0.1:$PORT"
|
||||
|
||||
if ! wait_http "http://127.0.0.1:$PORT/" 25; then
|
||||
die "docs server failed to come up"
|
||||
fi
|
||||
ok "docs corpus served at http://127.0.0.1:$PORT (pid $DOCS_PID)"
|
||||
echo "http://127.0.0.1:$PORT"
|
||||
@@ -0,0 +1,73 @@
|
||||
# Stage 2 result — agent connects BTCPay (regtest) + buyer pays (payments)
|
||||
|
||||
**Verdict: `completed-clean` on run 3 (0 findings).** A fresh adopter, using only the
|
||||
published docs and a **scoped** key (`merchant-onboard` + `payment_providers:write`, no
|
||||
master key), can connect a regtest BTCPay over the API with **no browser step**, stand up
|
||||
a paid product, produce a buyer checkout, and have a **real (regtest) on-chain payment
|
||||
settle into a signed license** that validates offline.
|
||||
|
||||
This is the buyer-pays half of the onboarding harness (Stage 1 = no-payments SDK
|
||||
integration). It is gated on the **agent-payment-connect** daemon feature (slices 3-4):
|
||||
the scoped BTCPay connect is allowed only on a **sandbox** daemon for a **non-mainnet**
|
||||
network. See `plans/agent-payment-connect-scope.md` and `stage2/FINDINGS.md`.
|
||||
|
||||
## Method
|
||||
|
||||
`stage2/run-stage2.sh` boots a disposable Keysat daemon in **sandbox mode**
|
||||
(`KEYSAT_SANDBOX_MODE=1`) wired to the regtest BTCPay stack (`stage2/btcpay-regtest/`),
|
||||
mints a scoped key carrying `payment_providers:write`, serves `keysat-docs/` as the
|
||||
corpus, and materializes a sandbox app. The daemon binds `0.0.0.0` and registers its
|
||||
settle webhook via `host.docker.internal` so the BTCPay container can reach it. The
|
||||
global `onboarding-tester` agent then drives the journey **docs-only**. The test buyer's
|
||||
wallet is `stage2/buyer-pay.sh` (pays the invoice on regtest + mines a confirmation).
|
||||
|
||||
## Convergence
|
||||
|
||||
| Run | Verdict | Findings |
|
||||
|-----|---------|----------|
|
||||
| 1 | blocked-at-step-1 (docs) | 2 blockers (agent.html#not-exposed said provider-connect is master-only; the connect/status/callback endpoints absent from OpenAPI) + 2 stumbles (headless callback pattern undocumented; `payment_providers:write` scope undocumented) + 1 nit. |
|
||||
| 2 | **completed-clean** | 1 doc nit (install.html BTCPay permission list wrong) + 1 harness-script bug (`buyer-pay.sh` missing `-rpcwallet`). |
|
||||
| 3 | **completed-clean (0)** | none. Walkthrough harvested below. |
|
||||
|
||||
The capability worked end to end from run 1 (the agent connected BTCPay headlessly and got
|
||||
a license); the blockers were purely that the docs *said it was impossible* and didn't
|
||||
document the path.
|
||||
|
||||
## Doc fixes shipped this loop
|
||||
|
||||
**`keysat-docs/` (deploys independently):**
|
||||
- `agent.html`: corrected the `#auth` master-only statement; added an **A-la-carte extra
|
||||
scopes** subsection (`payment_providers:write`); narrowed `#not-exposed` to the accurate
|
||||
gate (scoped connect allowed only sandbox + non-mainnet; disconnect + production/mainnet
|
||||
stay master-only); added the **Connect BTCPay programmatically (sandbox)** workflow
|
||||
(`#connect-btcpay`) with the 3-step API flow.
|
||||
- `install.html`: corrected the BTCPay permission list to the five the daemon actually
|
||||
requests; added an "automating setup?" pointer to the agent path.
|
||||
|
||||
**`licensing-service/src/api/openapi.rs` (served spec; ships next daemon release):**
|
||||
- Added `/v1/admin/btcpay/connect`, `/v1/btcpay/authorize/callback`,
|
||||
`/v1/admin/btcpay/status`, `/v1/admin/btcpay/disconnect`; added the `scopes` field to
|
||||
scoped-key creation; noted the read-only `sandbox` flag on `/v1/admin/tier`.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
|
||||
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
|
||||
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||
./teardown.sh runs/<id> # stops daemon + docs server
|
||||
```
|
||||
|
||||
## Publishable walkthrough (harvested, run 3)
|
||||
|
||||
All it took, on a sandbox Keysat with a scoped `payment_providers:write` key and a regtest
|
||||
BTCPay store key (no master key, no browser):
|
||||
|
||||
1. **Connect BTCPay** — `POST /v1/admin/btcpay/connect` -> `state`; then
|
||||
`GET /v1/btcpay/authorize/callback?state=<state>&apiKey=<btcpay_store_key>`; confirm with
|
||||
`GET /v1/admin/btcpay/status`.
|
||||
2. **Define a paid product** — `POST /v1/admin/products` + `POST /v1/admin/policies`.
|
||||
3. **Create a checkout** — `POST /v1/purchase` -> `checkout_url` + `amount_sats`.
|
||||
4. **Buyer pays** (regtest on-chain), daemon settles via webhook, `GET /v1/purchase/<id>`
|
||||
returns `status: settled` + a signed `license_key`.
|
||||
5. **Validate** — `POST /v1/validate` -> `ok: true` with the tier's entitlements.
|
||||
@@ -0,0 +1,2 @@
|
||||
probe-out/
|
||||
.live-env
|
||||
@@ -0,0 +1,66 @@
|
||||
# De-risk result — BTCPay regtest network detection (agent-payment-connect slice 3)
|
||||
|
||||
**Verdict: the spec's primary network-detection assumption (§6.1) is VALIDATED against
|
||||
a live regtest BTCPay 2.x. No blocker; slice 3 needs no extra OAuth permission.**
|
||||
|
||||
Rig: `docker-compose.yml` in this dir — bitcoind(regtest) + NBXplorer + postgres +
|
||||
btcpayserver `2.0.6`. Validated 2026-06-16. Probe: `probe.sh`; raw payloads in
|
||||
`probe-out/`. Bring up `docker compose -p keysat-btcpay up -d`; tear down
|
||||
`docker compose -p keysat-btcpay down -v`.
|
||||
|
||||
## What the gate will actually see
|
||||
|
||||
1. **Payment-method id is `BTC-CHAIN`** on BTCPay 2.x. Posting to the legacy `.../BTC/...`
|
||||
path is normalized to `BTC-CHAIN`. **Do not hardcode** — BTCPay 1.x used `BTC`. Slice 3
|
||||
should read `paymentMethodId` from the list and pick the on-chain BTC method
|
||||
(id ∈ {`BTC-CHAIN`,`BTC`}, not Lightning).
|
||||
|
||||
2. **Primary signal — receive address HRP (spec §6.1 primary), CONFIRMED:**
|
||||
`GET /api/v1/stores/{id}/payment-methods/BTC-CHAIN/wallet/address`
|
||||
→ `{"address":"bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt","keyPath":"0/0","paymentLink":...}`
|
||||
`bcrt1…` HRP ⇒ **regtest** ⇒ non-mainnet ⇒ scoped connect allowed (on a sandbox daemon).
|
||||
Classification table (validated regtest arm; others by HRP spec):
|
||||
`bc1`/base58 `1`,`3` → mainnet (deny scoped) · `tb1` → testnet/signet · `bcrt1` → regtest ·
|
||||
base58 `m`,`n`,`2` → test/regtest.
|
||||
|
||||
3. **Secondary signal — derivation, CONFIRMED but field name differs from the spec.**
|
||||
The spec says `derivationScheme`; on BTCPay 2.x Greenfield it is
|
||||
**`config.accountDerivation`** (and `config.signingKey`, `config.accountKeySettings[].accountKey`),
|
||||
value `tpubDC…` for regtest/testnet (mainnet → `xpub/ypub/zpub`). The BIP-84 account path
|
||||
is `84'/1'/0'` — coin-type `1'` is itself a testnet/regtest marker. **Requires
|
||||
`?includeConfig=true`** — see permission note below.
|
||||
|
||||
## Permission — the daemon already has enough
|
||||
|
||||
- The daemon's BTCPay OAuth (`REQUESTED_PERMISSIONS`, `btcpay_authorize.rs:45`) already
|
||||
requests **`btcpay.store.canmodifystoresettings`** (for webhook registration).
|
||||
- Empirically, with a token holding only `canmodifystoresettings`:
|
||||
`wallet/address` → **HTTP 200**, and `payment-methods?includeConfig=true` → config **visible**.
|
||||
- `wallet/address` specifically needs `canmodifystoresettings` (`canviewstoresettings` →
|
||||
**403**). The `config`/derivation path needs only `canviewstoresettings`.
|
||||
- ⇒ **Slice 3 can use EITHER signal with the key it already obtains at connect. No new
|
||||
OAuth scope.** Recommend the **address-HRP path** (spec's primary; one call; unambiguous).
|
||||
|
||||
## Fail-closed cases (all confirmed → treat as mainnet → master-only)
|
||||
|
||||
- No on-chain wallet configured → `GET payment-methods` returns `[]` (no BTC-CHAIN method).
|
||||
- `wallet/address` on a store with no wallet → **HTTP 503** `"BTC-CHAIN services are not
|
||||
currently available"`. (Same 503 also appears transiently while BTCPay is not yet
|
||||
`synchronized:true` — at operator connect time it will be synced, but treat any non-2xx /
|
||||
missing address / unrecognized HRP as "cannot determine" ⇒ deny scoped, require master.)
|
||||
|
||||
## Implication for the daemon client (slice 3)
|
||||
|
||||
The existing `btcpay/client.rs::list_payment_methods` calls `GET .../payment-methods`
|
||||
**without** `includeConfig`, so today it sees `config:null` (confirmed). To detect network,
|
||||
add a small client fn that GETs `.../payment-methods/{pmid}/wallet/address` and classifies
|
||||
the HRP (preferred), or pass `?includeConfig=true` and read `config.accountDerivation`.
|
||||
Resolve target network **before persisting** the provider (spec §7).
|
||||
|
||||
## Rig gotcha (for whoever rebuilds this)
|
||||
|
||||
NBXplorer defaults to cookie auth; with separate datadir volumes BTCPay can't read the
|
||||
cookie → `401` → BTCPay never reaches `synchronized:true` → on-chain `BTC-CHAIN` service
|
||||
stays unavailable (`503`). Fix used here: `NBXPLORER_NOAUTH=1` (fine for a throwaway
|
||||
regtest box). A production-faithful harness would instead share NBXplorer's datadir volume
|
||||
into BTCPay so the cookie is shared.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Throwaway BTCPay Server regtest stack — de-risk rig for agent-payment-connect
|
||||
# network detection (spec §6.1). NOT a production deployment, NOT yet wired into
|
||||
# the Stage 2 harness. Bring up: docker compose -p keysat-btcpay up -d
|
||||
# Tear down (incl. volumes): docker compose -p keysat-btcpay down -v
|
||||
#
|
||||
# Ports published to the host:
|
||||
# BTCPay UI/Greenfield API → http://127.0.0.1:49392
|
||||
# bitcoind regtest RPC → 127.0.0.1:43782 (user/pass keysat/keysat)
|
||||
services:
|
||||
bitcoind:
|
||||
image: btcpayserver/bitcoin:28.1
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
BITCOIN_EXTRA_ARGS: |
|
||||
rpcuser=keysat
|
||||
rpcpassword=keysat
|
||||
rpcbind=0.0.0.0:43782
|
||||
rpcallowip=0.0.0.0/0
|
||||
port=39388
|
||||
whitelist=0.0.0.0/0
|
||||
zmqpubrawblock=tcp://0.0.0.0:28332
|
||||
zmqpubrawtx=tcp://0.0.0.0:28333
|
||||
fallbackfee=0.0002
|
||||
txindex=1
|
||||
expose:
|
||||
- "43782"
|
||||
- "39388"
|
||||
- "28332"
|
||||
- "28333"
|
||||
ports:
|
||||
- "127.0.0.1:43782:43782"
|
||||
volumes:
|
||||
- bitcoin_datadir:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:13.13
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
volumes:
|
||||
- postgres_datadir:/var/lib/postgresql/data
|
||||
|
||||
nbxplorer:
|
||||
image: nicolasdorier/nbxplorer:2.5.22
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NBXPLORER_NETWORK: regtest
|
||||
NBXPLORER_NOAUTH: "1"
|
||||
NBXPLORER_BIND: 0.0.0.0:32838
|
||||
NBXPLORER_TRIMEVENTS: "10000"
|
||||
NBXPLORER_SIGNALFILESDIR: /datadir
|
||||
NBXPLORER_CHAINS: "btc"
|
||||
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
|
||||
NBXPLORER_BTCRPCUSER: keysat
|
||||
NBXPLORER_BTCRPCPASSWORD: keysat
|
||||
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
|
||||
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer
|
||||
depends_on:
|
||||
- bitcoind
|
||||
- postgres
|
||||
volumes:
|
||||
- nbxplorer_datadir:/datadir
|
||||
|
||||
btcpayserver:
|
||||
image: btcpayserver/btcpayserver:2.0.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=20;Database=btcpayserver
|
||||
BTCPAY_NETWORK: regtest
|
||||
BTCPAY_BIND: 0.0.0.0:49392
|
||||
BTCPAY_ROOTPATH: /
|
||||
BTCPAY_PROTOCOL: http
|
||||
BTCPAY_CHAINS: "btc"
|
||||
BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/
|
||||
BTCPAY_DEBUGLOG: btcpay.log
|
||||
ports:
|
||||
- "127.0.0.1:49392:49392"
|
||||
depends_on:
|
||||
- nbxplorer
|
||||
- postgres
|
||||
volumes:
|
||||
- btcpay_datadir:/datadir
|
||||
|
||||
volumes:
|
||||
bitcoin_datadir:
|
||||
postgres_datadir:
|
||||
nbxplorer_datadir:
|
||||
btcpay_datadir:
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# De-risk probe + .live-env minter for the Stage 2 / combined onboarding harness.
|
||||
# Run once after `docker compose -p keysat-btcpay up -d`.
|
||||
#
|
||||
# Two jobs:
|
||||
# A. Mint .live-env — create the two stores the harness needs (one with an
|
||||
# on-chain regtest wallet, one without) plus store-scoped BTCPay API tokens
|
||||
# carrying the five permissions the Connect-BTCPay flow documents
|
||||
# (install.html#connect-btcpay), and write them to .live-env for
|
||||
# run-stage2.sh / validate-gate.sh to source.
|
||||
# B. De-risk (spec §6.1) — dump the exact Greenfield responses the slice-3
|
||||
# network gate consults (payment-methods, wallet/address) into probe-out/
|
||||
# and classify the receive-address HRP.
|
||||
#
|
||||
# Idempotency: assumes a FRESH instance (compose `up -d` after `down -v`).
|
||||
# Re-running against a live instance creates duplicate stores — tear down first.
|
||||
# Read-only against Keysat; only mutates the throwaway BTCPay instance.
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${BTCPAY_BASE:-http://127.0.0.1:49392}"
|
||||
ADMIN_EMAIL="admin@keysat.local"
|
||||
ADMIN_PW="keysatregtest1!"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$HERE/probe-out"
|
||||
LIVE_ENV="$HERE/.live-env"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# Permissions the documented Connect-BTCPay flow grants (install.html#connect-btcpay).
|
||||
STORE_PERMS='canviewstoresettings canmodifystoresettings canviewinvoices cancreateinvoice canmodifyinvoices'
|
||||
BTND=keysat-btcpay-bitcoind-1
|
||||
|
||||
hr(){ printf '\n\033[1;36m=== %s ===\033[0m\n' "$*"; }
|
||||
jqp(){ jq . 2>/dev/null || cat; }
|
||||
AUTH=(-u "$ADMIN_EMAIL:$ADMIN_PW")
|
||||
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
|
||||
|
||||
create_store(){ # NAME -> store id
|
||||
curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/stores" \
|
||||
-H 'Content-Type: application/json' -d "{\"name\":\"$1\"}" | jq -r '.id'
|
||||
}
|
||||
store_token(){ # STORE_ID -> store-scoped API key with the 5 documented perms
|
||||
local sid="$1" perms="" p
|
||||
for p in $STORE_PERMS; do perms="$perms\"btcpay.store.$p:$sid\","; done
|
||||
curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/api-keys" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"label\":\"keysat-$sid\",\"permissions\":[${perms%,}]}" | jq -r '.apiKey'
|
||||
}
|
||||
|
||||
# --- 0. wait for BTCPay --------------------------------------------------------
|
||||
hr "0. waiting for BTCPay health at $BASE"
|
||||
for i in $(seq 1 120); do
|
||||
curl -fsS "$BASE/api/v1/health" >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
[[ $i == 120 ]] && { echo "BTCPay never became healthy"; exit 1; }
|
||||
done
|
||||
curl -fsS "$BASE/api/v1/health" | jqp
|
||||
|
||||
# --- 1. create first admin (unauthenticated, only works on a fresh instance) ---
|
||||
hr "1. create first admin (idempotent: 'already exists' is fine)"
|
||||
curl -sS -X POST "$BASE/api/v1/users" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PW\",\"isAdministrator\":true}" | jqp
|
||||
|
||||
# --- 2. admin user API key (KEYSAT_LIVE_BTCPAY_KEY; broad, for ad-hoc admin use) -
|
||||
hr "2. mint admin user API key"
|
||||
ADMIN_KEY="$(curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/api-keys" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"label":"keysat-admin","permissions":["btcpay.server.canmodifyserversettings","btcpay.store.canmodifystoresettings","btcpay.store.canmodifyinvoices"]}' \
|
||||
| jq -r '.apiKey')"
|
||||
echo "ADMIN_KEY=${ADMIN_KEY:0:8}…"
|
||||
|
||||
# --- 3. regtest store WITH an on-chain wallet ----------------------------------
|
||||
hr "3. create regtest store (with on-chain wallet)"
|
||||
STORE_REGTEST="$(create_store 'Keysat Regtest Co')"
|
||||
echo "STORE_REGTEST=$STORE_REGTEST"
|
||||
[[ -z "$STORE_REGTEST" || "$STORE_REGTEST" == null ]] && { echo "no regtest store id"; exit 1; }
|
||||
|
||||
gen_body='{"savePrivateKeys":false,"importKeysToRPC":false,"wordList":"English","wordCount":12,"scriptPubKeyType":"Segwit"}'
|
||||
PMID=""
|
||||
for cand in BTC-CHAIN BTC; do
|
||||
hr "3b. generate wallet on pmid=$cand"
|
||||
code="$(curl -sS -o "$OUT_DIR/gen-$cand.json" -w '%{http_code}' "${AUTH[@]}" \
|
||||
-X POST "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods/$cand/wallet/generate" \
|
||||
-H 'Content-Type: application/json' -d "$gen_body")"
|
||||
echo "HTTP $code"; cat "$OUT_DIR/gen-$cand.json" | jqp
|
||||
[[ "$code" == 2* ]] && { PMID="$cand"; break; }
|
||||
done
|
||||
[[ -z "$PMID" ]] && { echo "!! wallet generate failed for both pmid forms"; exit 1; }
|
||||
|
||||
# --- 4. mine regtest blocks so the wallet has a usable address -----------------
|
||||
hr "4. mine regtest blocks"
|
||||
ADDR_FOR_MINE="$(cli getnewaddress 2>/dev/null || true)"
|
||||
echo "miner address: ${ADDR_FOR_MINE:-<none>}"
|
||||
[[ -n "$ADDR_FOR_MINE" ]] && { cli generatetoaddress 101 "$ADDR_FOR_MINE" >/dev/null 2>&1 \
|
||||
&& echo "mined 101 blocks" || echo "mine failed (non-fatal for detection probe)"; }
|
||||
|
||||
# --- 5. no-wallet store (fail-closed arm of the gate) --------------------------
|
||||
hr "5. create no-wallet store"
|
||||
STORE_NOWALLET="$(create_store 'Keysat NoWallet Co')"
|
||||
echo "STORE_NOWALLET=$STORE_NOWALLET"
|
||||
[[ -z "$STORE_NOWALLET" || "$STORE_NOWALLET" == null ]] && { echo "no nowallet store id"; exit 1; }
|
||||
|
||||
# --- 6. store-scoped tokens (what the agent/harness hand Keysat at connect) -----
|
||||
hr "6. mint store-scoped tokens"
|
||||
GATE_TOK_REGTEST="$(store_token "$STORE_REGTEST")"
|
||||
GATE_TOK_NOWALLET="$(store_token "$STORE_NOWALLET")"
|
||||
echo "GATE_TOK_REGTEST=${GATE_TOK_REGTEST:0:8}… GATE_TOK_NOWALLET=${GATE_TOK_NOWALLET:0:8}…"
|
||||
[[ "$GATE_TOK_REGTEST" == null || -z "$GATE_TOK_REGTEST" ]] && { echo "regtest token mint failed"; exit 1; }
|
||||
|
||||
# --- 7. THE PAYLOADS the slice-3 gate consults --------------------------------
|
||||
hr "7a. GET payment-methods (does it expose derivationScheme? what pmid?)"
|
||||
curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods?includeConfig=true" \
|
||||
| tee "$OUT_DIR/payment-methods.json" | jqp
|
||||
|
||||
hr "7b. GET wallet/address (THE network artifact — expect bcrt1…)"
|
||||
ADDR_JSON="$(curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods/${PMID:-BTC-CHAIN}/wallet/address")"
|
||||
echo "$ADDR_JSON" | tee "$OUT_DIR/wallet-address.json" | jqp
|
||||
ADDR="$(echo "$ADDR_JSON" | jq -r '.address // empty')"
|
||||
|
||||
# --- 8. classify --------------------------------------------------------------
|
||||
hr "8. network classification"
|
||||
echo "pmid used : ${PMID:-BTC-CHAIN}"
|
||||
echo "receive address: ${ADDR:-<none>}"
|
||||
case "$ADDR" in
|
||||
bcrt1*) echo "=> prefix bcrt1 => REGTEST ✅ (non-mainnet → scoped connect allowed)";;
|
||||
tb1*) echo "=> prefix tb1 => TESTNET/SIGNET (non-mainnet)";;
|
||||
bc1*) echo "=> prefix bc1 => MAINNET ❌";;
|
||||
[mn2]*) echo "=> legacy base58 m/n/2 => TEST/REGTEST (non-mainnet)";;
|
||||
[13]*) echo "=> legacy base58 1/3 => MAINNET ❌";;
|
||||
"") echo "=> NO ADDRESS (Lightning-only / unconfigured) => FAIL-CLOSED → mainnet → master-only";;
|
||||
*) echo "=> UNRECOGNIZED prefix => FAIL-CLOSED → mainnet → master-only";;
|
||||
esac
|
||||
|
||||
# --- 9. write .live-env -------------------------------------------------------
|
||||
hr "9. write .live-env"
|
||||
cat > "$LIVE_ENV" <<EOF
|
||||
export KEYSAT_LIVE_BTCPAY_URL=$BASE
|
||||
export KEYSAT_LIVE_BTCPAY_KEY=$ADMIN_KEY
|
||||
export KEYSAT_LIVE_BTCPAY_STORE_REGTEST=$STORE_REGTEST
|
||||
export KEYSAT_LIVE_BTCPAY_STORE_NOWALLET=$STORE_NOWALLET
|
||||
export GATE_TOK_REGTEST=$GATE_TOK_REGTEST
|
||||
export GATE_TOK_NOWALLET=$GATE_TOK_NOWALLET
|
||||
EOF
|
||||
echo "wrote $LIVE_ENV"
|
||||
|
||||
hr "done — raw payloads under $OUT_DIR/, credentials in $LIVE_ENV"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# The "test buyer's wallet": pay a BTCPay invoice on regtest by sending to its
|
||||
# on-chain address from the regtest bitcoind and mining a confirmation. Used by
|
||||
# the Stage 2 harness to drive settlement (BTCPay → webhook → Keysat issues the
|
||||
# license) once the merchant journey has produced a checkout invoice.
|
||||
#
|
||||
# Usage: buyer-pay.sh <btcpay_base_url> <store_api_key> <store_id> <invoice_id>
|
||||
# Prints the funding txid on success.
|
||||
set -euo pipefail
|
||||
BASE="${1:?btcpay base url}"; KEY="${2:?store api key}"; STORE="${3:?store id}"; INV="${4:?invoice id}"
|
||||
BTND=keysat-btcpay-bitcoind-1
|
||||
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
|
||||
# Wallet RPCs must name the wallet explicitly: NBXplorer loads its own wallet, so
|
||||
# bitcoind has >1 loaded and a bare wallet call errors "Wallet file not specified".
|
||||
wcli(){ cli -rpcwallet=miner "$@"; }
|
||||
|
||||
# Pull the invoice's on-chain payment address + BTC amount from BTCPay.
|
||||
PM="$(curl -fsS -H "Authorization: token $KEY" \
|
||||
"$BASE/api/v1/stores/$STORE/invoices/$INV/payment-methods")"
|
||||
ADDR="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].destination // empty')"
|
||||
AMT="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].amount // empty')"
|
||||
[[ -n "$ADDR" && -n "$AMT" ]] || { echo "no on-chain payment method on invoice $INV" >&2; echo "$PM" >&2; exit 1; }
|
||||
|
||||
# Ensure the miner wallet has spendable coins, then pay + confirm.
|
||||
cli -named createwallet wallet_name=miner load_on_startup=true >/dev/null 2>&1 || cli loadwallet miner >/dev/null 2>&1 || true
|
||||
MINE_ADDR="$(wcli getnewaddress)"
|
||||
cli generatetoaddress 101 "$MINE_ADDR" >/dev/null # generatetoaddress is node-level (no wallet needed)
|
||||
TXID="$(wcli sendtoaddress "$ADDR" "$AMT")"
|
||||
cli generatetoaddress 1 "$MINE_ADDR" >/dev/null # 1 conf (BTCPay HighSpeed settles at 0-conf seen / 1-conf)
|
||||
echo "$TXID"
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stage 2 setup: a sandbox Keysat daemon wired to the regtest BTCPay stack, a
|
||||
# scoped key that can BOTH onboard a catalog AND connect a payment provider
|
||||
# (merchant-onboard + payment_providers:write), the docs corpus, and a sandbox
|
||||
# app — then the agent brief for the COMBINED journey: gate a paid product
|
||||
# (define product + paid tier, integrate the SDK, prove the export is BLOCKED),
|
||||
# then prove it end to end (connect BTCPay regtest, a buyer pays, and the
|
||||
# PURCHASED license UNLOCKS the gated export).
|
||||
#
|
||||
# Networking: the daemon binds 0.0.0.0 and registers its BTCPay webhook via
|
||||
# host.docker.internal so the BTCPay *container* can reach it on settle; the
|
||||
# agent/harness reach the daemon on 127.0.0.1. Sandbox mode + a non-mainnet
|
||||
# (regtest) store are what let the scoped key connect BTCPay at all.
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib.sh"
|
||||
require curl; require jq; require openssl; require node
|
||||
STAGE2_DIR="$HARNESS_DIR/stage2"
|
||||
BTCPAY_URL="$(grep -h KEYSAT_LIVE_BTCPAY_URL "$STAGE2_DIR/btcpay-regtest/.live-env" 2>/dev/null | cut -d= -f2-)"
|
||||
BTCPAY_URL="${BTCPAY_URL:-http://127.0.0.1:49392}"
|
||||
|
||||
curl -fsS "$BTCPAY_URL/api/v1/health" >/dev/null 2>&1 \
|
||||
|| die "regtest BTCPay not reachable at $BTCPAY_URL — run: (cd $STAGE2_DIR/btcpay-regtest && docker compose -p keysat-btcpay up -d)"
|
||||
|
||||
[[ -x "$DAEMON_BIN" ]] || { log "building daemon (cargo build --release)…"; ( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"; }
|
||||
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-stage2-$$"
|
||||
RUN_DIR="$RUNS_DIR/$RUN_ID"; mkdir -p "$RUN_DIR/data" "$RUN_DIR/reports"
|
||||
STATE="$RUN_DIR/state.env"; : > "$STATE"
|
||||
PORT="$(free_port)"; MASTER="$(openssl rand -hex 32)"
|
||||
BASE_URL="http://127.0.0.1:$PORT" # agent/harness-facing
|
||||
PUBLIC_URL="http://host.docker.internal:$PORT" # BTCPay-container-facing (webhooks)
|
||||
|
||||
state_set "$STATE" RUN_ID "$RUN_ID"; state_set "$STATE" RUN_DIR "$RUN_DIR"
|
||||
state_set "$STATE" PORT "$PORT"; state_set "$STATE" BASE_URL "$BASE_URL"
|
||||
state_set "$STATE" MASTER_KEY "$MASTER"; state_set "$STATE" BTCPAY_URL "$BTCPAY_URL"
|
||||
|
||||
log "booting sandbox daemon on 0.0.0.0:$PORT (btcpay → $BTCPAY_URL)"
|
||||
KEYSAT_BIND="0.0.0.0:$PORT" \
|
||||
KEYSAT_DB_PATH="$RUN_DIR/data/keysat.db" \
|
||||
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||
KEYSAT_SANDBOX_MODE=1 \
|
||||
BTCPAY_URL="$BTCPAY_URL" \
|
||||
KEYSAT_PUBLIC_URL="$PUBLIC_URL" \
|
||||
KEYSAT_OPERATOR_NAME="Stage 2 Sandbox" \
|
||||
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
|
||||
state_set "$STATE" DAEMON_PID "$!"
|
||||
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
|
||||
wait_http "$BASE_URL/healthz" 75 || { tail -20 "$RUN_DIR/daemon.log" >&2; die "daemon failed to start"; }
|
||||
|
||||
# Confirm the sandbox flag is actually on (the whole gate depends on it).
|
||||
[[ "$(curl -fsS -H "Authorization: Bearer $MASTER" "$BASE_URL/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] \
|
||||
|| die "daemon did not report sandbox mode"
|
||||
|
||||
log "minting scoped key: merchant-onboard + payment_providers:write"
|
||||
SK="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" -H "Authorization: Bearer $MASTER" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"label":"stage2-agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' \
|
||||
| jq -r '.token')"
|
||||
[[ "$SK" == ks_* ]] || die "scoped key mint failed"
|
||||
state_set "$STATE" MERCHANT_KEY "$SK"
|
||||
|
||||
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
|
||||
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
|
||||
DOCS_URL="$(state_get "$STATE" DOCS_URL)"; SANDBOX="$(state_get "$STATE" SANDBOX)"
|
||||
|
||||
# Two BTCPay store contexts the test buyer/agent can use (regtest store has an
|
||||
# on-chain wallet; created during de-risk). The agent connects via the scoped
|
||||
# key; the BTCPay credential it needs is provided as the "operator's BTCPay".
|
||||
[[ -f "$STAGE2_DIR/btcpay-regtest/.live-env" ]] \
|
||||
|| die ".live-env missing — run stage2/btcpay-regtest/probe.sh first to mint the BTCPay store token (GATE_TOK_REGTEST)"
|
||||
source "$STAGE2_DIR/btcpay-regtest/.live-env"
|
||||
|
||||
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
|
||||
# Onboarding-tester brief — Keysat combined journey (gate a paid product, then prove it with a real buyer payment)
|
||||
|
||||
You are a **fresh adopter**, following \`~/Projects/standards/guides/onboarding-tester.md\`.
|
||||
Reach the goal using **only the docs corpus**. Never read Keysat's server or SDK source to
|
||||
unblock yourself — if the docs don't get you there, that is a finding.
|
||||
|
||||
## Goal (checkable end-state)
|
||||
You are an operator selling a Next.js/TypeScript app. Do the **whole flow in the order an
|
||||
operator actually works** — gate the paid feature first, then prove it end to end with a
|
||||
real buyer payment. Use a **scoped, non-master** API key (it carries
|
||||
\`payment_providers:write\`), a **sandbox** Keysat instance, and the published docs only:
|
||||
|
||||
1. **Define the product + a paid tier that grants an entitlement.** Register the product in
|
||||
Keysat's catalog and add a **paid** policy/tier whose purchase grants a named
|
||||
**entitlement** (the thing your gate will check for). Note the entitlement key.
|
||||
2. **Integrate the SDK and gate the Pro export — verify the BLOCKED path FIRST.** Wire
|
||||
\`@keysat/licensing-client\` into your app so \`GET /api/export\` returns the CSV **only**
|
||||
when the caller holds a valid license carrying that entitlement. Then prove it is shut:
|
||||
with **no** license and with a **tampered/invalid** license, \`/api/export\` returns a
|
||||
**4xx, not the CSV**. (At this point no real license exists yet — that's expected.)
|
||||
3. **Connect BTCPay (regtest) and drive a real buyer payment → license issued.** Connect
|
||||
the regtest BTCPay to Keysat over the API (no master key, no browser — you hold a BTCPay
|
||||
credential the way an operator delegating setup would hand you one). Produce a **buyer
|
||||
checkout** for the paid product, then have the buyer pay it. The settled payment must
|
||||
issue a **real, signed license** carrying the entitlement from step 1. (The harness will
|
||||
pay the regtest invoice for you if the docs leave that last on-chain step to plumbing —
|
||||
note where, but the *checkout* itself must come from the docs.)
|
||||
4. **Paste the PURCHASED license into the app → verify the UNLOCKED path.** Feed that
|
||||
purchased license to your app and confirm \`GET /api/export\` now returns the **CSV**.
|
||||
This is the step that ties the two halves together: the license a *buyer's payment*
|
||||
produced unlocks the feature your *gate* protects.
|
||||
|
||||
Success = the same gate that was demonstrably shut in step 2 is opened in step 4 by a
|
||||
license that a real (regtest) buyer payment produced in step 3 — reached from the docs
|
||||
alone, under a scoped key, with BTCPay connected by you.
|
||||
|
||||
## Docs corpus (the ONLY how-to sources you may consult)
|
||||
- Keysat docs site: **$DOCS_URL** — start at \`/integrate.html\` (SDK + gating) and
|
||||
\`/agent.html\` (scoped-key + connect-BTCPay workflow); the whole site is in-corpus.
|
||||
- Daemon OpenAPI: **$BASE_URL/v1/openapi.json** (unauthenticated; the docs point here).
|
||||
- The npm package README for \`@keysat/licensing-client\` is in-corpus (\`npm view\` / the
|
||||
package page).
|
||||
|
||||
## Your sandbox app (mutate ONLY this)
|
||||
\`$SANDBOX\` — a pristine copy of the "Acme Reports" app whose **Pro export**
|
||||
(\`GET /api/export\`) is currently ungated. Read its own \`README.md\` freely (it's your
|
||||
app; it tells you nothing about Keysat). Deps are installed. Run it with \`npm run dev\`
|
||||
(serves on http://localhost:4311). How the app learns the license key (env var, file,
|
||||
header) is your call — pick what the docs suggest and note it. Put scratch under
|
||||
\`/tmp/onboarding-tester/\`.
|
||||
|
||||
## Credentials you were handed (an operator delegating setup would hand you these)
|
||||
- Keysat server: **$BASE_URL**
|
||||
- Scoped API key (merchant-onboard + payment_providers:write): **$SK**
|
||||
- Regtest BTCPay server: **${KEYSAT_LIVE_BTCPAY_URL:-$BTCPAY_URL}**, store
|
||||
**${KEYSAT_LIVE_BTCPAY_STORE_REGTEST:-<regtest store id>}**, BTCPay token
|
||||
**${GATE_TOK_REGTEST:-<btcpay store token>}** (your "operator's BTCPay" access).
|
||||
- You were NOT given the master Keysat admin key. If a step seems to need it, that is
|
||||
either an intended operator-only boundary (note it) or a doc gap (log it).
|
||||
|
||||
## Out of corpus (do not open)
|
||||
Anything under the Keysat source tree (\`$WORKSPACE/licensing-service-startos\`,
|
||||
\`$WORKSPACE/licensing-client-*\`), migrations, tests, or this harness. Reading any of it
|
||||
invalidates the run — say so if you do.
|
||||
|
||||
## Output
|
||||
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as your final
|
||||
message, in your guide's format. Most-severe-first. On \`completed-clean\`, also emit the
|
||||
publishable "all the agent had to do was X, Y, Z" walkthrough (secret-free).
|
||||
EOF
|
||||
|
||||
ok "Stage 2 staged. Run id: $RUN_ID"
|
||||
cat >&2 <<EOF
|
||||
|
||||
Daemon (agent) : $BASE_URL (sandbox, btcpay → $BTCPAY_URL)
|
||||
Docs corpus : $DOCS_URL
|
||||
Scoped key : $SK
|
||||
Sandbox app : $SANDBOX
|
||||
Agent brief : $RUN_DIR/AGENT_BRIEF.md
|
||||
Buyer-pay helper: $STAGE2_DIR/buyer-pay.sh
|
||||
Tear down : $HARNESS_DIR/teardown.sh "$RUN_DIR"
|
||||
EOF
|
||||
echo "$RUN_ID"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full Stage 2 teardown — run when the onboarding test is done so nothing keeps
|
||||
# running. Stops, for each Stage 2 run: the ephemeral daemon + docs server +
|
||||
# sandbox copy (via the shared teardown.sh); then kills any sandbox dev server
|
||||
# the onboarding-tester left behind; then stops the shared regtest BTCPay docker
|
||||
# stack (containers + volumes).
|
||||
#
|
||||
# Usage:
|
||||
# ./teardown-stage2.sh # tear down ALL Stage 2 runs + dev servers + BTCPay stack
|
||||
# ./teardown-stage2.sh --keep-btcpay # same, but leave the BTCPay stack up (iterating)
|
||||
# ./teardown-stage2.sh runs/<id> # one specific run dir (path relative to onboarding-harness/)
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$HERE/../lib.sh"
|
||||
|
||||
KEEP_BTCPAY=0; ONE_RUN=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--keep-btcpay) KEEP_BTCPAY=1 ;;
|
||||
*) ONE_RUN="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1. Per-run teardown (daemon + docs server + sandbox copy + freed ports).
|
||||
if [[ -n "$ONE_RUN" ]]; then
|
||||
"$HARNESS_DIR/teardown.sh" "$ONE_RUN" || true
|
||||
else
|
||||
shopt -s nullglob
|
||||
any=0
|
||||
for d in "$RUNS_DIR"/*stage2*/; do
|
||||
[[ -f "${d}state.env" ]] || continue
|
||||
"$HARNESS_DIR/teardown.sh" "${d%/}" || true
|
||||
any=1
|
||||
done
|
||||
[[ "$any" == 0 ]] && warn "no Stage 2 run dirs found under $RUNS_DIR"
|
||||
fi
|
||||
|
||||
# 2. Kill any sandbox dev server the agent left running. The proof-of-work app
|
||||
# serves on :4311 (npm run dev); the onboarding-tester may start it and not
|
||||
# stop it.
|
||||
for pid in $(lsof -ti tcp:4311 -sTCP:LISTEN 2>/dev/null || true); do
|
||||
kill "$pid" 2>/dev/null && log "stopped orphaned sandbox dev server (pid $pid on :4311)" || true
|
||||
done
|
||||
|
||||
# 3. Stop the shared regtest BTCPay stack (containers + volumes) unless told to keep it.
|
||||
if [[ "$KEEP_BTCPAY" == 1 ]]; then
|
||||
ok "left BTCPay regtest stack running (--keep-btcpay)"
|
||||
elif docker ps -a --filter "name=keysat-btcpay" --format '{{.Names}}' 2>/dev/null | grep -q .; then
|
||||
( cd "$HERE/btcpay-regtest" && docker compose -p keysat-btcpay down -v ) >/dev/null 2>&1 \
|
||||
&& ok "stopped BTCPay regtest stack (containers + volumes removed)" \
|
||||
|| warn "could not fully stop BTCPay — check: docker ps -a --filter name=keysat-btcpay"
|
||||
else
|
||||
ok "BTCPay regtest stack already stopped"
|
||||
fi
|
||||
|
||||
ok "Stage 2 teardown complete"
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end validation of the agent-payment-connect gate against the LIVE
|
||||
# regtest BTCPay (the spec's hard requirement). Boots a throwaway Keysat daemon
|
||||
# in sandbox mode pointed at the regtest BTCPay stack, mints a scoped
|
||||
# `payment_providers:write` key, and drives the full OAuth round-trip for two
|
||||
# stores:
|
||||
# - no-wallet store → network undetermined → FAIL CLOSED → connect DENIED (400)
|
||||
# - regtest store → bcrt1 address → non-mainnet → connect ALLOWED (persisted)
|
||||
#
|
||||
# Requires the regtest stack up (docker compose -p keysat-btcpay up -d) and
|
||||
# .live-env populated (GATE_TOK_REGTEST / GATE_TOK_NOWALLET — single-store BTCPay
|
||||
# tokens). Reads the daemon release binary built by `cargo build --release`.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$HERE/btcpay-regtest/.live-env"
|
||||
BIN="$HERE/../../licensing-service/target/release/keysat"
|
||||
[[ -x "$BIN" ]] || { echo "FAIL: release binary missing ($BIN) — run cargo build --release"; exit 1; }
|
||||
|
||||
PORT=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();})')
|
||||
MASTER=$(openssl rand -hex 32)
|
||||
TMP=$(mktemp -d)
|
||||
BASE="http://127.0.0.1:$PORT"
|
||||
pass=0; fail=0
|
||||
ok(){ echo " ✅ $*"; pass=$((pass+1)); }
|
||||
no(){ echo " ❌ $*"; fail=$((fail+1)); }
|
||||
|
||||
echo "== booting sandbox daemon on $BASE (btcpay → $KEYSAT_LIVE_BTCPAY_URL) =="
|
||||
KEYSAT_BIND="127.0.0.1:$PORT" \
|
||||
KEYSAT_DB_PATH="$TMP/keysat.db" \
|
||||
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||
KEYSAT_SANDBOX_MODE=1 \
|
||||
BTCPAY_URL="$KEYSAT_LIVE_BTCPAY_URL" \
|
||||
KEYSAT_PUBLIC_URL="$BASE" \
|
||||
KEYSAT_OPERATOR_NAME="Stage2 Gate Validation" \
|
||||
nohup "$BIN" >"$TMP/daemon.log" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
trap 'kill $DAEMON_PID 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||
for i in $(seq 1 75); do curl -fsS "$BASE/healthz" >/dev/null 2>&1 && break; sleep 0.2; [[ $i == 75 ]] && { echo "FAIL: daemon never healthy"; tail -20 "$TMP/daemon.log"; exit 1; }; done
|
||||
|
||||
M=(-H "Authorization: Bearer $MASTER")
|
||||
|
||||
echo "== 1. sandbox flag surfaced read-only in /v1/admin/tier =="
|
||||
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] && ok "tier.sandbox == true" || no "sandbox flag not surfaced"
|
||||
|
||||
echo "== 2. mint scoped merchant-onboard + payment_providers:write key =="
|
||||
SK="$(curl -sS "${M[@]}" -X POST "$BASE/v1/admin/api-keys" -H 'Content-Type: application/json' \
|
||||
-d '{"label":"agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' | jq -r '.token')"
|
||||
[[ "$SK" == ks_* ]] && ok "scoped key minted" || { no "mint failed"; }
|
||||
S=(-H "Authorization: Bearer $SK")
|
||||
|
||||
# drive a connect: returns HTTP status of the callback. $1=btcpay token
|
||||
drive_connect(){
|
||||
local tok="$1"
|
||||
local st; st="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
|
||||
[[ -n "$st" && "$st" != null ]] || { echo "000"; return; }
|
||||
curl -sS -o /tmp/gate-cb.out -w '%{http_code}' -X POST "$BASE/v1/btcpay/authorize/callback?state=$st" \
|
||||
--data-urlencode "apiKey=$tok"
|
||||
}
|
||||
|
||||
echo "== 3. DENY: scoped connect to a no-wallet store (undetermined → fail-closed) =="
|
||||
code="$(drive_connect "$GATE_TOK_NOWALLET")"
|
||||
if [[ "$code" == 400 ]]; then
|
||||
ok "callback rejected with HTTP 400"
|
||||
grep -qi "non-mainnet" /tmp/gate-cb.out && ok "rejection cites the non-mainnet restriction" || no "rejection message unexpected: $(cat /tmp/gate-cb.out | head -c200)"
|
||||
else
|
||||
no "expected 400, got $code ($(cat /tmp/gate-cb.out | head -c200))"
|
||||
fi
|
||||
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status" | jq -r '.connected')" == "false" ]] && ok "no provider persisted on deny" || no "a provider was persisted despite deny!"
|
||||
# The GET callback form (what the agent docs show) must ALSO deny with a 4xx,
|
||||
# not a 200 error page (regression guard for the GET-handler status fix).
|
||||
gst="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
|
||||
gcode="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/v1/btcpay/authorize/callback?state=$gst&apiKey=$GATE_TOK_NOWALLET")"
|
||||
[[ "$gcode" == 4* ]] && ok "GET callback form denies with HTTP $gcode (not a 200 error page)" || no "GET callback returned $gcode (expected 4xx)"
|
||||
|
||||
echo "== 4. ALLOW: scoped connect to the regtest store (bcrt1 → non-mainnet) =="
|
||||
code="$(drive_connect "$GATE_TOK_REGTEST")"
|
||||
if [[ "$code" == 200 ]]; then ok "callback succeeded with HTTP 200"; else no "expected 200, got $code ($(cat /tmp/gate-cb.out | head -c300))"; fi
|
||||
ST_JSON="$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status")"
|
||||
[[ "$(echo "$ST_JSON" | jq -r '.connected')" == "true" ]] && ok "provider persisted" || no "provider not persisted on allow"
|
||||
[[ "$(echo "$ST_JSON" | jq -r '.store_id')" == "$KEYSAT_LIVE_BTCPAY_STORE_REGTEST" ]] && ok "persisted store is the regtest store" || no "wrong store persisted: $(echo "$ST_JSON" | jq -c '.store_id')"
|
||||
|
||||
echo "== 5. scoped connect is audited with the resolved network =="
|
||||
AUD="$(curl -sS "${M[@]}" "$BASE/v1/admin/audit?action=payment_provider.connect_scoped" | jq -c '.entries[0] // empty')"
|
||||
echo " audit: $AUD"
|
||||
echo "$AUD" | grep -qi "regtest" && ok "audit row records network=regtest" || no "audit row missing/!regtest"
|
||||
|
||||
echo
|
||||
echo "==== RESULT: $pass passed, $fail failed ===="
|
||||
[[ $fail == 0 ]] || exit 1
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tear down a run: stop the daemon + docs server, remove the agent's sandbox
|
||||
# copy. Keeps the run dir (logs + reports) unless --purge is given.
|
||||
# Usage: teardown.sh [RUN_DIR] [--purge]
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||
|
||||
PURGE=0; RUN_DIR=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--purge) PURGE=1 ;;
|
||||
*) RUN_DIR="$a" ;;
|
||||
esac
|
||||
done
|
||||
RUN_DIR="${RUN_DIR:-$(readlink "$CURRENT_LINK" 2>/dev/null || true)}"
|
||||
[[ -n "$RUN_DIR" && -d "$RUN_DIR" ]] || { warn "no run dir to tear down"; exit 0; }
|
||||
STATE="$RUN_DIR/state.env"
|
||||
|
||||
for key in DAEMON_PID DOCS_PID; do
|
||||
pid="$(state_get "$STATE" "$key" 2>/dev/null || true)"
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
log "stopped $key ($pid)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Belt-and-suspenders: free the recorded ports in case a PID drifted.
|
||||
for portkey in PORT DOCS_PORT; do
|
||||
port="$(state_get "$STATE" "$portkey" 2>/dev/null || true)"
|
||||
[[ -z "$port" ]] && continue
|
||||
for lpid in $(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true); do
|
||||
kill "$lpid" 2>/dev/null && log "freed port $port (pid $lpid)" || true
|
||||
done
|
||||
done
|
||||
|
||||
SANDBOX="$(state_get "$STATE" SANDBOX 2>/dev/null || true)"
|
||||
if [[ -n "$SANDBOX" && -d "$SANDBOX" ]]; then rm -rf "$SANDBOX"; log "removed sandbox $SANDBOX"; fi
|
||||
|
||||
if [[ "$PURGE" == 1 ]]; then
|
||||
rm -rf "$RUN_DIR"; log "purged run dir $RUN_DIR"
|
||||
[[ "$(readlink "$CURRENT_LINK" 2>/dev/null)" == "$RUN_DIR" ]] && rm -f "$CURRENT_LINK"
|
||||
fi
|
||||
ok "teardown complete"
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# prepare.sh — bootstrap a clean Debian/Ubuntu box to build the Keysat s9pk.
|
||||
#
|
||||
# Start9's build-from-source flow clones this repo onto a fresh box, then runs a
|
||||
# bootstrap script followed by `make`. This installs every HOST prerequisite that
|
||||
# `make` needs (npm → wrapper bundle; start-cli s9pk pack → Docker image build).
|
||||
# It mirrors the official StartOS 0.4.0.x environment-setup page:
|
||||
# https://docs.start9.com/packaging/0.4.0.x/environment-setup.html
|
||||
#
|
||||
# Note: `prepare.sh` is a 0.3.5.x community-submission convention; the 0.4.x docs
|
||||
# don't mention it, so the 0.4.x submission flow may not invoke it. This file is
|
||||
# still the runnable, single-source record of what a clean build box needs.
|
||||
#
|
||||
# The Rust daemon is NOT built on the host — it compiles inside this package's
|
||||
# Dockerfile (FROM rust:1.88-slim-bookworm), so no rustup/cargo is installed here.
|
||||
#
|
||||
# Idempotent: re-running skips tools already present. Targets apt-based distros.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Use sudo only when not already root (Start9's build box may run either way).
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
NODE_MAJOR=22
|
||||
|
||||
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
# --- apt prerequisites -------------------------------------------------------
|
||||
# build-essential → make/gcc; squashfs-tools(-ng) → start-cli s9pk packing;
|
||||
# jq → used by s9pk.mk's build summary; git → the s9pk embeds the commit hash.
|
||||
log "Installing apt prerequisites (make, jq, git, squashfs, curl)"
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
jq \
|
||||
squashfs-tools \
|
||||
squashfs-tools-ng
|
||||
|
||||
# --- Node.js 22 --------------------------------------------------------------
|
||||
# The wrapper (@start9labs/start-sdk + @vercel/ncc bundle) needs Node 22. We
|
||||
# install it system-wide via NodeSource so it's on PATH for the non-interactive
|
||||
# `make` that follows (the docs' nvm method would need a shell rc sourced first).
|
||||
if command -v node >/dev/null 2>&1 && node -v | grep -q "^v${NODE_MAJOR}\."; then
|
||||
log "Node.js $(node -v) already present — skipping"
|
||||
else
|
||||
log "Installing Node.js ${NODE_MAJOR} (NodeSource)"
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | $SUDO -E bash -
|
||||
$SUDO apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
# --- Docker (+ buildx) -------------------------------------------------------
|
||||
# start-cli s9pk pack builds the daemon image from the Dockerfile via Docker
|
||||
# buildx. get.docker.com is Docker's official installer and bundles the buildx
|
||||
# plugin.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
log "Docker $(docker --version | awk '{print $3}' | tr -d ,) already present — skipping"
|
||||
else
|
||||
log "Installing Docker (official get.docker.com installer)"
|
||||
curl -fsSL https://get.docker.com | $SUDO sh
|
||||
fi
|
||||
|
||||
# Cross-architecture builds (`make universal` / `make arm` on an x86 host) need
|
||||
# QEMU binfmt handlers registered. Best-effort: requires the Docker daemon to be
|
||||
# running. Harmless to skip if you only build the host's native arch (`make x86`).
|
||||
if $SUDO docker info >/dev/null 2>&1; then
|
||||
log "Registering QEMU binfmt handlers for cross-arch builds (best-effort)"
|
||||
$SUDO docker run --privileged --rm tonistiigi/binfmt --install all ||
|
||||
echo " (binfmt registration skipped — only native-arch builds will work)"
|
||||
else
|
||||
echo " (Docker daemon not reachable yet — skipping binfmt setup; start Docker"
|
||||
echo " and re-run this script if you need cross-arch/universal builds.)"
|
||||
fi
|
||||
|
||||
# --- start-cli (StartOS 0.4.x SDK) -------------------------------------------
|
||||
# Official installer: fetches the latest prebuilt binary into ~/.local/bin.
|
||||
# For a reproducible build, pin a release instead, e.g.:
|
||||
# curl -fsSLo ~/.local/bin/start-cli \
|
||||
# https://github.com/Start9Labs/start-os/releases/download/<tag>/start-cli_x86_64-linux
|
||||
# chmod +x ~/.local/bin/start-cli
|
||||
if command -v start-cli >/dev/null 2>&1; then
|
||||
log "start-cli $(start-cli --version 2>/dev/null | awk '{print $2}') already present — skipping"
|
||||
else
|
||||
log "Installing start-cli (StartOS 0.4.x SDK)"
|
||||
curl -fsSL https://start9.com/start-cli/install.sh | sh
|
||||
fi
|
||||
|
||||
# The installer drops start-cli in ~/.local/bin and appends it to your shell rc.
|
||||
# Persist it to .profile for future shells (only if not already recorded, so
|
||||
# re-runs don't pile up duplicates), and export it for the rest of THIS session.
|
||||
if ! grep -qsF '.local/bin' "${HOME}/.profile"; then
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >>"${HOME}/.profile"
|
||||
fi
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
|
||||
log "Done. Initialise your signing key with 'start-cli init', then run 'make' (or 'make x86')."
|
||||
@@ -6,12 +6,10 @@
|
||||
// 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.
|
||||
// The daemon always boots regardless of license state (enforce mode was
|
||||
// retired — see license_self.rs::check_at_boot). With no valid self-license
|
||||
// it runs at the free Creator tier with Creator caps; this action records
|
||||
// the license and lifts those caps without a restart.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
@@ -36,9 +34,9 @@ export const activateLicense = sdk.Action.withInput(
|
||||
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).',
|
||||
'Activate this Keysat install. Optional — Keysat runs at the free ' +
|
||||
'Creator tier without it. Activating lifts the Creator caps, unlocks ' +
|
||||
'recurring billing + Zaprite payments, and shows your tier in the admin UI.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'License',
|
||||
@@ -80,7 +78,6 @@ export const activateLicense = sdk.Action.withInput(
|
||||
product_id?: string
|
||||
expires_at?: number
|
||||
entitlements?: string[]
|
||||
mode: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
@@ -132,7 +129,6 @@ export const showLicenseStatus = sdk.Action.withoutInput(
|
||||
expires_at?: number
|
||||
entitlements?: string[]
|
||||
reason?: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
if (j.tier === 'licensed') {
|
||||
@@ -146,20 +142,19 @@ export const showLicenseStatus = sdk.Action.withoutInput(
|
||||
message:
|
||||
`License id: ${j.license_id}\n` +
|
||||
`Expires: ${exp}\n` +
|
||||
`Entitlements: ${ents}\n` +
|
||||
`Build mode: ${j.mode}`,
|
||||
`Entitlements: ${ents}`,
|
||||
result: null,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Unlicensed',
|
||||
title: 'Creator (free tier)',
|
||||
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.'),
|
||||
`This install is running at the free Creator tier.\n` +
|
||||
`Reason: ${j.reason || 'no license configured'}\n\n` +
|
||||
`Creator caps: 5 products, 5 policies per product, 10 active ` +
|
||||
`discount codes. Activating a license lifts these caps and unlocks ` +
|
||||
`recurring billing + Zaprite payments (the "Activate Keysat license" action).`,
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Action: reveal the auto-generated admin API key.
|
||||
//
|
||||
// The operator rarely needs this — every other action in StartOS already
|
||||
// carries the key for them — but it's useful if they want to script against
|
||||
// the admin HTTP API directly.
|
||||
// The operator needs this on first install to sign into the admin web UI
|
||||
// (until they set a web UI password); afterward it's mainly for scripting
|
||||
// the admin HTTP API directly, since every other StartOS action already
|
||||
// carries the key for them.
|
||||
//
|
||||
// The BTCPay webhook secret used to live in the StartOS store; it now lives
|
||||
// inside the daemon's own SQLite database, generated automatically during
|
||||
@@ -35,9 +36,11 @@ export const showCredentials = sdk.Action.withoutInput(
|
||||
version: '1',
|
||||
title: 'Admin API key',
|
||||
message:
|
||||
`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.`,
|
||||
`This is your admin API key — the 'Authorization: Bearer <key>' ` +
|
||||
`credential for /v1/admin/*. Use it to sign into the admin web UI on ` +
|
||||
`first install (until you set a web UI password). Every StartOS action ` +
|
||||
`already supplies it for you, so you only need to export it to script ` +
|
||||
`the admin API yourself.`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: storeData.admin_api_key,
|
||||
|
||||
@@ -14,14 +14,16 @@ import { short, long } from './i18n'
|
||||
export const manifest = setupManifest({
|
||||
id: 'keysat',
|
||||
title: 'Keysat Licensing',
|
||||
license: 'LicenseRef-Proprietary',
|
||||
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
|
||||
license: 'LicenseRef-Keysat-1.0',
|
||||
// packageRepo (the s9pk wrapper source) and upstreamRepo (the daemon source)
|
||||
// are the same URL: the StartOS wrapper and the Rust daemon share one monorepo.
|
||||
packageRepo: 'https://github.com/keysat-xyz/keysat',
|
||||
upstreamRepo: 'https://github.com/keysat-xyz/keysat',
|
||||
marketingUrl: 'https://keysat.xyz',
|
||||
donationUrl: null,
|
||||
docsUrls: [
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/README.md',
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/docs/INTEGRATION.md',
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/KEYSAT_INTEGRATION.md',
|
||||
],
|
||||
description: { short, long },
|
||||
// A single data volume holds the SQLite database (which in turn holds the
|
||||
|
||||
+48
-25
@@ -1,27 +1,8 @@
|
||||
// Draft of the v0.2.0 milestone version entry.
|
||||
//
|
||||
// NOT YET WIRED INTO `versions/index.ts` — this file sits ready to
|
||||
// use when we cut v0.2.0:0 from the alpha-iteration line. To
|
||||
// activate:
|
||||
// 1. In `versions/index.ts`:
|
||||
// import { v0_2_0 } from './v0.2.0'
|
||||
// export const versions = VersionGraph.of({
|
||||
// current: v0_2_0,
|
||||
// other: [v0_1_0], // ← so installs on 0.1.0:N can upgrade
|
||||
// })
|
||||
// 2. Build the .s9pk (`make x86`).
|
||||
// 3. Publish via `~/.keysat/publish.sh` (the version-changed gate
|
||||
// will fire because `0.2.0:0` differs from the recorded
|
||||
// `0.1.0:N`).
|
||||
//
|
||||
// Why this draft exists separately:
|
||||
// - The cut is an irreversible release decision for already-installed
|
||||
// operators (downgrade paths exist in StartOS but they're sticky).
|
||||
// - Wiring it in changes how StartOS computes the upgrade dialog
|
||||
// shown to operators on registry refresh — best to QA the
|
||||
// release-notes content in this file before flipping the switch.
|
||||
// - Lets us write the v0.2.0 release notes carefully and then ship
|
||||
// them all at once, rather than amending mid-build.
|
||||
// The v0.2.0 milestone version entry — the current, active version on
|
||||
// the v0.2 line. Wired into `versions/index.ts` as `current: v0_2_0`,
|
||||
// with `v0_1_0` in `other` so installs on 0.1.0:N can upgrade. Routine
|
||||
// wrapper updates bump the downstream revision here (`0.2.0:N`) before
|
||||
// each build/publish; see startos-packaging.md.
|
||||
//
|
||||
// Version-string format reminder: ExVer is `<upstream>:<downstream>`.
|
||||
// The `<upstream>` bump from 0.1.0 → 0.2.0 marks the milestone; the
|
||||
@@ -58,6 +39,48 @@ const RELEASE_NOTES = [
|
||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||
// append here.
|
||||
const ROUTINE_NOTES = [
|
||||
"0.2.0:62 — **Escape single quotes on the buyer-facing buy page.** The public buy page (`/buy/:slug`) carried its own HTML escaper that omitted the single-quote (`'`) escape the canonical escaper applies, so operator-controlled text rendered into HTML attributes (product name, description, discount code, operator name) was under-escaped. Replaced the forked escaper with the canonical implementation (which escapes `'` as `'` alongside `&<>\"`) and added a unit test covering the single quote. No schema change, no SDK change — straight drop-in over :61.",
|
||||
'',
|
||||
"0.2.0:61 — **Security hardening for self-license tier enforcement.** The daemon now re-verifies its own self-license against the signed key on every hourly tier refresh, not only at boot. Issuer-applied tier changes — downgrade, suspension, revocation, and the license's own expiry — now take effect on a running daemon within the hour instead of waiting for the next restart. Master and honest downstream instances behave exactly as before. No schema change, no SDK change — straight drop-in over :60.",
|
||||
'',
|
||||
'0.2.0:60 — **Fix a Zaprite auto-charge silent-lapse on the recurring-renewal money path.** `charge_order_with_profile` already errors on a non-2xx response (those route correctly to WARN + `auto_charge_failed` audit + manual-pay fallback), but on a 2xx `try_auto_charge_zaprite` returned `Ok(true)` regardless of the order\'s actual status — it read `status` only for a log line. So a 200 carrying a non-settled status (a declined or expired charge, or an in-flight PENDING/PROCESSING) suppressed the manual-pay renewal notification and left the worker waiting for an `order.paid` webhook that never arrives: the subscription silently lapsed, the buyer got no pay link, and the operator saw no error. Fixed by classifying the charge response — the auto-charge is treated as successful (and manual-pay suppressed) only when the order is in a recognized settled state (`PAID`/`COMPLETE`/`OVERPAID`, mirroring `get_invoice_status`\'s `Settled` mapping); any other or unrecognized status logs a WARN and falls through to the existing manual-pay `subscription.renewal_pending` path so the buyer can always recover the cycle. Allowlist by design — Zaprite has no documented terminal-failure status string, so an unknown or missing status is treated as not-settled rather than optimistically assumed paid. Adds a unit test on the new `zaprite_charge_settled` helper covering settled / in-flight / failed / unknown statuses. BTCPay subscriptions and any sub without a saved Zaprite profile are unaffected. Also docs-only: flagged the dormant `merchant_profiles.smtp_*` columns (the buyer-email / SMTP plan was dropped — Keysat does not send email). No schema change, no SDK change — straight drop-in over :59.',
|
||||
'',
|
||||
'0.2.0:59 — **Admin UI: drop the gold button-fill design-contract violations.** Two admin-SPA controls filled with gold, which the brand contract (`design/DESIGN.md`) and the admin-UI pill convention forbid (gold is a marketing accent, never a button fill): the "Featured" tier toggle\'s on-state and the sidebar tier-upgrade CTA. Both now follow the convention — the Featured toggle is navy-filled with a cream pip when on; the upgrade CTA is cream-filled with navy text (the on-brand high-contrast treatment for a primary action on the navy sidebar), and its corner radius is aligned to the 8px button spec. CSS / inline-style only in the embedded `web/index.html` — no schema, no SDK, no behavior change. Straight drop-in over :58. (The matching public-landing fix — the Buy button\'s pill radius set to 8px — ships in the keysat-xyz-landing repo, deployed separately.)',
|
||||
'',
|
||||
'0.2.0:58 — **Agent-delegable BTCPay connect, gated to sandbox + non-mainnet.** Makes Keysat fully agent-operable for *dev/test setup*: an operator can hand an agent a scoped key that connects a BTCPay payment provider over the API — no master key, no browser click — but only on a sandbox daemon and only for a non-mainnet (regtest/testnet/signet) store. On a production daemon, or for a mainnet store, connecting a provider stays master-only, and disconnect is always master-only. The reasoning: a credential that can repoint where settlement lands is a fund-redirection key, so the capability is deliberately narrow and fails closed. **Gated in three layers:** (1) a daemon-level `KEYSAT_SANDBOX_MODE` flag, read at boot and never settable via any API, is the outer gate — scoped connect is disabled entirely on a production box; (2) `payment_providers:write` is an à-la-carte per-key scope that belongs to no role (not even full-admin), granted explicitly when an operator mints a key; (3) at OAuth-callback time the daemon resolves the target store\'s Bitcoin network from its on-chain receive address and refuses anything not provably non-mainnet, failing closed to mainnet on any ambiguity (no on-chain wallet, unreachable BTCPay, unrecognized address) — it denies rather than guesses. Migrations 0024 (`scoped_api_keys.extra_scopes`) and 0025 (`btcpay_authorize_state.scoped_initiator` + actor hash, to carry the initiator across the browser round-trip) are additive — straight drop-in over :57. The served OpenAPI spec now documents the BTCPay connect/callback/status/disconnect paths and the key-creation `scopes` field, and `/v1/admin/tier` surfaces a read-only `sandbox` flag. Also hardened: the GET authorize-callback now returns the real HTTP status on a denied connect (was a misleading 200 with an error page). Validated end-to-end against a live regtest BTCPay; the docs-onboarding harness (a fresh agent integrating from the published docs alone) converged completed-clean on the full buyer-pays journey. Daemon api test suite is at 65, up from 57. Zaprite connect stays master-only. No SDK change.',
|
||||
'',
|
||||
'0.2.0:57 —**New `merchant-onboard` scoped-API-key role for least-privilege self-serve onboarding.** A fifth scoped-key role sits between `license-issuer` and `full-admin`, granting read access plus `products:write` + `policies:write` + `licenses:write` — the minimum a merchant (or an integrating agent) needs to stand up a catalog end-to-end over the API: create a product, define its policies/tiers, and issue licenses against them, all without holding the master key. The catalog write *scopes* already existed and were enforced on the endpoints since :55; only a role that expands to them was missing, so this is a `Role`-variant addition, not a scope-model change. `Role::grants` matches the write scopes explicitly (never by `:write` suffix), so the role can never widen into settings / payment-provider / merchant-profile / webhook writes, and every master-only operation (signing-key rotation, payment connect, web-admin password, API-key management, server settings, per-license tier change, DB introspection) stays behind `require_admin` and is structurally unreachable from any scoped key. Existing Creator-tier caps still bound it (5 products / 5 policies per product / 10 active codes). **Caveat:** the role covers catalog setup + manual license issuance fully, but connecting a BTCPay/Zaprite payment provider stays master-only by design, so the buyer-paid purchase flow still needs a one-time operator step. Migration 0023 rebuilds `scoped_api_keys` to widen the role CHECK constraint (SQLite can\'t alter a CHECK in place; the table has no foreign keys, so it\'s a plain copy/drop/rename) — additive, a straight drop-in over :56. Daemon api test suite is at 57, up from 56. No SDK change.',
|
||||
'',
|
||||
'0.2.0:56 — **Product→merchant-profile write path — multi-profile is now functional end-to-end.** The multi-profile *resolver* has been complete since :52, but products had no way to be *assigned* to a profile, so every product stuck to the auto-created default profile. This cut wires the missing write half. `Product.merchant_profile_id` now threads through all four product SELECTs + `row_to_product`; a new `repo::set_product_merchant_profile` validates the target profile exists first (returns a clean 404 rather than a raw FK 500); it is threaded through `CreateProductReq` (applied as a post-write step) and `UpdateProductReq` (double-`Option` semantics, where `Some(None)` clears a product back to the default profile). The admin SPA shows a merchant-profile `<select>` on the product form only when more than one profile exists, so single-profile operators see no change. No schema migration (highest is still 0022) — straight drop-in over :55. No SDK change.',
|
||||
'',
|
||||
'0.2.0:55 — **Scoped API keys, an advisory settle-amount tripwire, and multi-arch packaging.** Three things land over :54, with no schema migration (highest is still 0022) — straight drop-in. **(1) Scoped admin API keys.** 58 admin endpoints move from the blanket `require_admin` gate to role-scoped `require_scope` checks, so an operator can mint reduced-privilege keys (for example, read-only access to dashboards and licenses) instead of handing out the master key; 12 sensitive endpoints stay master-only (issuer key, provider connect/disconnect, set-password, API-key CRUD, db-info, operator-name, per-license tier change). The master admin key keeps full access, so existing automation is unaffected. **(2) Advisory settle-amount tripwire** — the follow-up flagged in :54. On settle, `audit_settle_amount` (shared by the webhook and reconcile issue paths) compares the provider-reported paid amount against what was invoiced; on drift it WARN-logs and writes an `invoice.amount_mismatch` audit row, then issues anyway. It is an advisory signal, not a payment gate (a hard gate would fight BTCPay payment tolerance). SAT-denominated invoices only; fiat-subscription renewals and amount-less snapshots are skipped so there are no false positives. **(3) StartOS packaging and multi-arch.** The package now ships as a single universal s9pk built for both `x86_64` and `aarch64` (previously x86-only), so it installs on ARM StartOS hardware. Adds the required `instructions.md`, fixes two dead manifest links (`packageRepo`, `docsUrls`), and clears stale references to the long-retired license enforce mode from the Activate-License and Show-Credentials actions (the daemon always boots at the free Creator tier; activating a license lifts the caps). Daemon test suite is at 54 api tests, up from 47. No SDK change.',
|
||||
'',
|
||||
'0.2.0:54 — **Security: settle webhooks are now confirmed against the provider before a license is issued.** Previously the settle handler trusted the webhook body\'s claim alone. BTCPay webhooks are HMAC-signed so a forgery there is infeasible, but **Zaprite webhooks carry no signature** — so a forged `order.change`/`status=PAID` POST containing a buyer-visible Zaprite order id could mint a fully-signed license without any payment (the `externalUniqId` "trust anchor" the code comments described was never actually checked on the inbound path). Fixed in `api/webhook.rs::handle_inner`: on any settle event the daemon now re-fetches the authoritative status from the provider\'s own API (`get_invoice_status`) and requires it to actually be `Settled` before persisting the paid status or taking ANY settle-derived action — license issuance, tier-change application, or subscription renewal (the confirmation gate sits ahead of all three). If the provider\'s API is unreachable the handler acks `200` WITHOUT issuing rather than erroring, so a transient provider outage can\'t turn every in-flight webhook into a retry storm; the existing 60-second reconcile loop re-confirms and issues on its next tick (fail-closed on issuance). This only affects operators who enabled the optional Zaprite provider; BTCPay-only operators were never exposed. No schema change, no SDK change — straight drop-in over :53. **Known follow-up**: the confirmation is a binary settled/not-settled check; a literal paid-amount/currency comparison (to reject a provider-reported underpayment) is not yet wired and is tracked separately. Internally this release also adds the first integration-test seam for the real purchase/settle path (`AppState::provider_override`), bringing the daemon test suite to 47 passing with the prior 3 known-failing payment tests resolved.',
|
||||
'',
|
||||
'0.2.0:53 — **Fix the ambiguous-column bug that broke every paid purchase on :52.** The `:52` merchant-profile model introduced `get_merchant_profile_for_product`, which selects the shared `MERCHANT_PROFILE_COLS` column list (a bare `id, name, …`) while JOINing `products` — but `products` also has an `id`, so SQLite raised `ambiguous column name: id` on every execution. That function runs on every purchase, so **every paid purchase on :52 returned HTTP 500**. Fixed in `db/repo.rs` by replacing the JOIN with an equivalent correlated subquery, keeping `merchant_profiles` the only table in FROM; NULL/missing `merchant_profile_id` behavior is unchanged (no row → caller falls back to the default profile). Also from the same verification pass: added `merchant_profile_provider_resolution_queries_round_trip` covering the previously untested runtime-prepared resolution / CRUD / preference queries, repaired three test call sites for the new `create_invoice` / `create_subscription` params, captured the response body in the `paid_purchase` status assertion, aligned the manifest license to `LicenseRef-Keysat-1.0`, and dropped an unused import. No schema change, no SDK change — straight drop-in over :52.',
|
||||
'',
|
||||
'0.2.0:52 — **Multi-merchant-profile + multi-provider payment model.** Drops the singleton-config-table assumption that one Keysat instance equals one business. Operators on Pro/Patron tier can now run multiple businesses from a single Keysat box: each business is a "merchant profile" with its own brand, post-purchase redirect URL, and a set of payment providers (BTCPay + Zaprite) that settle to that business\'s accounts. Products attach to a merchant profile; the buyer sees the profile\'s brand at checkout and the eventual rail-picker (UI follow-up) routes the buyer\'s payment-method choice to the right provider. **One-way DB migration** — migration 0020 creates `merchant_profiles` + `payment_providers` + `merchant_profile_rail_preferences`, ports the existing singleton `btcpay_config` / `zaprite_config` / `active_payment_provider` setting into the new tables (one auto-created default profile holding everything), then drops the old tables. Migrations 0021 + 0022 add `invoices.payment_provider_id` (so reconciler / tipping / capture know which provider settled each invoice) and a `merchant_profile_id` column on `btcpay_authorize_state` (so BTCPay\'s OAuth CSRF state can round-trip the operator\'s profile pick). **Subscriptions snapshot** both `merchant_profile_id` and `payment_provider_id` at creation, so editing a product\'s profile attachment never redirects existing buyers mid-cycle. **Webhook URLs** are now path-keyed: `/v1/{kind}/webhook/{provider-id}` — each profile\'s provider has its own isolated webhook receiver. Back-compat: the legacy `/v1/{kind}/webhook` URL still routes to the default profile\'s provider so any in-flight deliveries still settle. **Tier-gate**: Creator tier gets 1 profile (the auto-created default); Pro/Patron get unlimited via the new `unlimited_merchant_profiles` entitlement. **POST-MIGRATION MANUAL STEP for the master operator (you)**: after this version installs, your Zaprite webhook is still registered at `https://licensing.keysat.xyz/v1/zaprite/webhook` (the legacy URL). It keeps working via the back-compat fallback, but for proper per-provider isolation, either (a) open the Zaprite sandbox dashboard → Webhooks → edit the URL to include the new provider id shown in the Merchant Profiles UI, or (b) click Disconnect + Reconnect Zaprite in the new Merchant Profiles UI to have Keysat re-register a fresh webhook at the path-keyed URL. **WHAT THIS RELEASE DOES NOT YET INCLUDE** (UI follow-ups): the buy-page rail picker (today the buyer\'s checkout uses the first rail the profile\'s providers serve — fine for single-rail profiles), the product-edit-page merchant-profile picker (new products always go to the default profile until that UI ships), per-profile SMTP override form (the schema fields are in place for the keysat-smtp-emails plan), and rail-preference editing UI (only matters when 2 providers on the same profile both serve the same rail — operators can set them via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` directly). **Entitlement note**: master Keysat\'s Pro and Patron policies need `unlimited_merchant_profiles` added to their entitlement JSON for Pro/Patron customers to actually be able to create multiple profiles — purely a data action on the master keysat.xyz admin UI, no code change.',
|
||||
'',
|
||||
'0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.',
|
||||
'',
|
||||
'0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.',
|
||||
'',
|
||||
'0.2.0:49 — **Zaprite saved-profile capture: full diagnostic logging + reconciler path.** Sandbox testing of `:47` revealed five recurring subscriptions all settled successfully but with NULL `zaprite_payment_profile_id` — even though Zaprite confirmed the saved card on the contact. Two root causes addressed: (1) `capture_zaprite_payment_profile` had six different early-return-Ok branches (no provider, not Zaprite, downcast fail, no contact_id, no profiles array, no matching profile) that ALL silently returned with no logging, so there was no way to know which branch fired. Every branch now emits a `tracing::info!` or `tracing::warn!` explaining what it found, including a sample of the profiles\' `sourceOrder.externalUniqId` values when no match is found (to detect the timing race where Zaprite\'s profile-attach lags the order.paid webhook). (2) The polling reconciler (which catches missed webhook deliveries) called `issue_license_for_invoice` to recover the license + subscription, but never called `on_invoice_settled` — so a recurring sub created via the reconciler path NEVER got its Zaprite profile captured even though the saved profile was sitting on Zaprite\'s contact. Fixed in `reconcile.rs::ensure_license`: now invokes `on_invoice_settled` after license issuance (and on the idempotency early-return, in case a prior license-exists run missed the hook). The hook is itself idempotent and a no-op for BTCPay subs, so this is safe to call from both webhook and reconciler paths. Together these mean: even if your Zaprite webhook never delivers, the reconciler will pick up the slack within ~60 seconds AND capture the saved profile so auto-charge still works on the next renewal cycle.',
|
||||
'',
|
||||
'0.2.0:48 — **Thank-you page copy is now provider-aware.** The `/thank-you` landing page (where buyers wait while their license is signed) hardcoded "Your Bitcoin payment was received" + "Lightning settles in seconds; on-chain typically settles in 10–20 minutes" — true for BTCPay-routed purchases, awkward for Zaprite-routed card payments where the buyer never touched Bitcoin. Fixed in `api/mod.rs::thank_you`: read `SETTING_ACTIVE_PROVIDER`, branch the lede copy on it. For Zaprite: "Your payment was received. Card payments confirm in seconds; Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically settles in 10–20 minutes." For BTCPay (and the unconfigured fallback): unchanged Bitcoin-only copy. Also passed the provider kind into the polling JS so the running-status copy (`waitingCopy()`) makes the same distinction at every elapsed-time threshold (2 min, 10 min, slow-block). When the planned multi-provider work lands, this lookup will switch from the singleton setting to the invoice\'s own `payment_provider_id` so the copy matches the rail that actually settled THIS purchase rather than whatever\'s currently active on the daemon.',
|
||||
'',
|
||||
'0.2.0:47 — **Zaprite recurring purchases now create the contact upfront.** First-time test purchase against a live Zaprite sandbox surfaced the gap: when the order body has `allowSavePaymentProfile: true`, Zaprite\'s API requires an explicit `contactId` and returns `400 contactId is required when allowSavePaymentProfile is true` if you only pass `customerData: { email }`. Their llms.txt docs say contactId is optional in that case; the API itself disagrees, and the API is the source of truth. Fixed in `payment/zaprite/provider.rs`: when about to send `allowSavePaymentProfile: true`, first call a new `client.create_contact(email, name)` helper (`POST /v1/contacts`), then pass the returned id as `contactId` on the subsequent `create_order` call. Three handling paths: (1) recurring + buyer_email present → create contact + save profile, the happy path; (2) recurring + buyer_email MISSING → degrade to one-shot for THIS cycle (buyer gets a license, renewals fall back to manual-pay, warn-logged); (3) non-recurring → unchanged (no contact created, customerData only). Known minor: Zaprite\'s duplicate-email behavior on `POST /v1/contacts` is undocumented, so the same buyer purchasing recurring twice may end up with duplicate contacts in the operator\'s Zaprite dashboard until the multi-provider work introduces a Keysat-side dedup cache.',
|
||||
'',
|
||||
'0.2.0:46 — **Provider create-invoice failures now surface the underlying cause.** When `provider.create_invoice` failed (Zaprite or BTCPay rejection, network error, currency validation), the buy page rendered only "payment provider create-invoice failed: ZapriteProvider.create_invoice" — the outermost `context()` wrapper — and the actual cause (HTTP status + response body from the upstream) was never logged anywhere either. The trait method returned the anyhow error; only the tower trace layer fired, and it only sees the HTTP status code, not the body. Fixed in `api/purchase.rs`: switch user-facing format from `{e}` to `{e:#}` so the full anyhow chain shows up on the buy page, and add an explicit `tracing::error!` before returning so the same chain lands in daemon logs. Operator-visible: failed checkouts now actually tell you what went wrong ("Zaprite create_order returned HTTP 400: missing payment_methods", etc.) without log-spelunking.',
|
||||
'',
|
||||
'0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.',
|
||||
'',
|
||||
'0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.',
|
||||
'',
|
||||
'0.2.0:43 — **BTCPay authorize success page now says "return to Keysat" instead of "return to StartOS".** The success page is the lightweight HTML BTCPay redirects to after the operator clicks Authorize. With the BTCPay connect flow living inside Keysat\'s admin UI (since the :40-era admin-UI redesign), "return to StartOS" was misdirecting the operator — Keysat\'s own tab is what they came from and what they want to return to. One-line copy change in `success_page()` and the GET fallback path in `btcpay_authorize.rs`; no behavior change.',
|
||||
'',
|
||||
'0.2.0:42 — **Revert the implicit Patron→Pro entitlement expansion shipped in :41.** Reasoning on revert: the only license affected by the missing-entitlement bug was the master operator\'s own pre-launch self-license, issued under an earlier entitlement scheme. The Patron policy on the master Keysat now lists the correct entitlements, so any fresh Patron license issued today carries them in the signed payload directly. Making `patron` a magic superset at the resolution layer was paying ongoing complexity tax (entitlement-renames have to update a hardcoded list; the gate behavior diverges from what the policy literally says) for a one-shot migration that won\'t recur. Operators with a stuck old-scheme Patron license should re-issue + run "Activate Keysat license" — the new license overwrites `/data/keysat-license.txt` and the daemon picks up the fresh entitlements without a restart. The :41 BTCPay one-click authorize-flow restoration in the admin UI is unchanged.',
|
||||
'',
|
||||
'0.2.0:41 — **Two fixes: Patron tier now implies the full Pro feature surface, and BTCPay Connect is back to one-click authorize.** Both came from operator-side bugs that the admin-UI redesign exposed.',
|
||||
'',
|
||||
'**Patron implies Pro at the resolution layer.** Previously, every `tier.has(<pro-entitlement>)` check required the Patron POLICY on the master Keysat to redundantly list every Pro entitlement (`unlimited_products`, `unlimited_policies`, `unlimited_codes`, `recurring_billing`, `zaprite_payments`) — if the operator forgot even one slug on the Patron policy, every Patron customer was silently locked out of that feature. The Zaprite gate caught this in the wild: a Patron license without `zaprite_payments` got an "Upgrade to Pro" CTA on the payment-providers page. Fixed at the right layer: `tier::current()` now expands `patron` into the full Pro entitlement set on read, so a Patron policy can list just `patron` and have everything Pro grants flow through automatically. Existing Patron customers get the implied entitlements without re-issuing a license. Recommended cleanup: also list the entitlements explicitly on the Patron policy itself so the buy-page tier card stays informative — but the gate behavior no longer depends on it.',
|
||||
@@ -517,7 +540,7 @@ const ROUTINE_NOTES = [
|
||||
].join('\n\n')
|
||||
|
||||
export const v0_2_0 = VersionInfo.of({
|
||||
version: '0.2.0:41',
|
||||
version: '0.2.0:62',
|
||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||
// SQLite-level migrations live separately under
|
||||
|
||||
Reference in New Issue
Block a user