Compare commits

...

14 Commits

Author SHA1 Message Date
Grant b68bb4b882 Add prepare.sh build bootstrap for clean Debian box
Start9's build-from-source flow needs every host prerequisite installed before
'make' runs. Add a bootstrap mirroring the official 0.4.0.x environment-setup
page: apt prereqs, Node 22, Docker (+binfmt for cross-arch), and start-cli via
the official installer. Rust stays inside the package Dockerfile, not the host.
2026-06-18 14:46:22 -05:00
Grant c739d5c515 Bump to 0.2.0:60: ship Zaprite auto-charge silent-lapse fix
Version bump + changelog for the recurring-renewal money-path fix (treat a non-settled Zaprite charge response as not-success and fall through to manual-pay).
2026-06-18 12:30:43 -05:00
Grant 46972be9db Note merchant_profiles SMTP-override fields are dormant in admin SPA comment 2026-06-18 12:00:12 -05:00
Grant 0a6d73aa29 Mark merchant_profiles SMTP columns dormant; email plan dropped
The keysat-smtp-emails plan is superseded: Keysat will not send buyer email itself (operators own that via their app plus the existing webhooks). The smtp_* columns from migration 0020 are never read to send mail; left in place (a removal migration isn't worth it) and flagged so no send path is built against them.
2026-06-18 11:22:28 -05:00
Grant 241478af95 Fix Zaprite auto-charge silent-lapse on 2xx-with-failure status
charge_order_with_profile errors on non-2xx, but on a 2xx try_auto_charge_zaprite returned Ok(true) regardless of the order status, reading it only for a log line. A 200 carrying a non-settled status (declined/expired/in-flight) suppressed the manual-pay notification and left the worker waiting for an order.paid webhook that never arrives, so the subscription silently lapsed.

Classify the response: success iff status is PAID/COMPLETE/OVERPAID (mirrors get_invoice_status's Settled mapping); anything else logs a WARN and returns Ok(false) so renew_one falls through to manual-pay. Allowlist by design -- Zaprite has no documented terminal-failure string, so unknown/missing statuses route to manual-pay too. Adds a unit test on the new zaprite_charge_settled helper.
2026-06-18 11:22:28 -05:00
Grant 51a88f2a2f Fix admin SPA gold-fill design-contract violations; bump to 0.2.0:59
The featured-pill on-state and the sidebar upgrade CTA filled with gold, which
the brand contract and the admin-UI pill convention forbid (gold is a marketing
accent, never a button fill). The Featured toggle is now navy-filled with a
cream pip; the upgrade CTA is cream-filled with navy text and aligned to the
8px button radius. CSS / inline-style only in the embedded web/index.html — no
schema, no SDK, no behavior change.
2026-06-18 08:02:49 -05:00
Grant 554f3b2da0 Sweep residual v0.1 staleness in API/ARCHITECTURE/README docs 2026-06-17 15:41:17 -05:00
Grant 4755639bdc Keep riscv out of the default make build 2026-06-17 15:25:05 -05:00
Grant eafdc6646e Update docs to match the 0.2.0 daemon (admin-UI actions, runtime image, Zaprite, roles) 2026-06-17 15:25:05 -05:00
Grant 8c5cdb6468 onboarding-harness: combined gate+buyer-pays brief; probe mints .live-env
run-stage2.sh: rewrite AGENT_BRIEF to the four-step operator-order journey
(define a paid product + entitlement, integrate the SDK and verify the gate
is BLOCKED, connect BTCPay regtest and have a buyer pay, then the PURCHASED
license unlocks the gate) and add the sandbox-app section the SDK-gating half
needs. Header comment updated to match.

probe.sh: do what the README/brief already claim it does. In addition to the
de-risk payload dump, create both stores (wallet + no-wallet), generate the
on-chain regtest wallet, mint store-scoped tokens with the five documented
connect permissions, and write .live-env for run-stage2.sh / validate-gate.sh
to source. Previously .live-env had to be hand-built and went stale on down -v.
2026-06-17 12:03:35 -05:00
Grant b6758cf30a Add full Stage 2 teardown + harvest guidance
teardown-stage2.sh stops every Stage 2 run (daemon + docs + sandbox), kills any
orphaned sandbox dev server on :4311 the onboarding-tester left behind, and stops
the regtest BTCPay docker stack + volumes (--keep-btcpay to leave it up). README
documents it as the always-run cleanup step, and adds a harvest note: on a clean
run, check whether the existing public docs already cover the success story before
adding anything.
2026-06-17 10:58:23 -05:00
Grant a507cfa978 Bump version to 0.2.0:58 (agent-payment-connect)
Ships the agent-delegable BTCPay connect gate: a scoped key with the
payment_providers:write a-la-carte scope can connect a non-mainnet BTCPay
provider on a sandbox daemon, fail-closed; master/mainnet/production stay
master-only. Migrations 0024-0025 additive; openapi documents the BTCPay
paths. api suite 65. No SDK change.
2026-06-17 09:51:55 -05:00
Grant c673b10a94 Add Stage 2 onboarding harness (buyer pays on regtest)
Disposable rig that runs the onboarding-tester agent docs-only against the
buyer-pays journey: a sandbox daemon wired to a Dockerized BTCPay regtest stack,
a scoped key with payment_providers:write, and a regtest buyer-pay helper.
Includes the de-risk probe + findings and an end-to-end gate check
(validate-gate.sh, 10/10). The doc-onboarding loop converged completed-clean;
see stage2/STAGE2-RESULT.md. Scratch (.live-env, probe-out/) is gitignored.
2026-06-17 09:32:07 -05:00
Grant 8eb4a97c6f Gate scoped BTCPay connect to sandbox + non-mainnet
Slices 3-4 of agent-payment-connect: a scoped key carrying the a-la-carte
payment_providers:write scope may connect a BTCPay provider, but only on a
sandbox daemon (KEYSAT_SANDBOX_MODE) and only for a non-mainnet
(regtest/testnet/signet) store. Master may connect any network; disconnect and
production/mainnet reconnect stay master-only. A credential that can repoint
settlement is a fund-redirection key, so the gate is deliberately narrow and
fails closed.

- require_provider_connect: outer gate (sandbox flag) at start_connect
- btcpay/network.rs classify_address_network + client::fetch_onchain_network:
  resolve the store network at finish_connect, fail-closed to mainnet on any
  ambiguity (no on-chain method, non-2xx, non-JSON, unknown prefix), before any
  webhook/persist side effect
- initiator carried across the OAuth round-trip via btcpay_authorize_state
  (migration 0025: scoped_initiator + initiator_actor_hash); scoped connects
  are audited
- the GET callback now returns the error's HTTP status (was a misleading 200 on
  a denied connect)
- openapi.rs documents the BTCPay connect/callback/status/disconnect paths and
  the key-creation scopes field

Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate
+ network unit/integration tests.
2026-06-17 09:31:57 -05:00
33 changed files with 1909 additions and 193 deletions
+29 -9
View File
@@ -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` |
---
@@ -173,7 +174,9 @@ curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-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.
---
+5
View File
@@ -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
+37 -59
View File
@@ -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:
+21 -38
View File
@@ -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.
+4 -4
View File
@@ -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`
+13 -10
View File
@@ -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
+10 -1
View File
@@ -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,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;
+85
View File
@@ -227,6 +227,91 @@ 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)]
+100 -12
View File
@@ -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, require_scope}, 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;
@@ -84,7 +89,12 @@ pub async fn start_connect(
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();
// Resolve the target merchant profile (defaulting to the default).
@@ -115,9 +125,17 @@ 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, Some(&profile.id))
.await
.map_err(AppError::Internal)?;
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)?;
// Construct the authorize URL per BTCPay's docs.
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
@@ -226,11 +244,18 @@ pub async fn callback_get(
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to Keysat.",
),
Err(e) => Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
))
.into_response(),
// 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(),
}
}
@@ -302,10 +327,10 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
// 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 recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token)
let auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let profile = match recorded_profile_id.as_deref() {
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!(
@@ -331,6 +356,43 @@ 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);
@@ -387,14 +449,40 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
state.set_payment_provider(provider).await;
}
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(())
}
+38 -2
View File
@@ -400,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", "merchant-onboard", "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"]
} } }
@@ -418,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)" } }
}
}
}
}"##;
+84
View File
@@ -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))
}
+42 -9
View File
@@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
Ok(())
}
/// 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, merchant_profile_id, 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")?;
@@ -109,17 +133,18 @@ pub async fn record_authorize_state(
}
/// Validate that `token` was issued recently and has not been consumed.
/// Consumes (deletes) the token on success so a replay fails, and
/// returns the `merchant_profile_id` recorded at start-connect time so
/// the callback knows which profile to attach the new provider to.
/// 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<Option<String>> {
) -> Result<AuthorizeState> {
use sqlx::Row;
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let row = sqlx::query(
"SELECT state_token, merchant_profile_id 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)
@@ -130,11 +155,19 @@ pub async fn consume_authorize_state(
let Some(row) = row else {
return Err(anyhow!("unknown or expired authorize state token"));
};
let merchant_profile_id: Option<String> = row.try_get("merchant_profile_id").ok().flatten();
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(merchant_profile_id)
Ok(state)
}
+1
View File
@@ -8,4 +8,5 @@
pub mod client;
pub mod config;
pub mod network;
pub mod webhook;
+160
View File
@@ -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());
}
}
+36 -13
View File
@@ -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"
}
};
+10 -5
View File
@@ -27,10 +27,14 @@ use uuid::Uuid;
/// A merchant profile row. Mirrors the `merchant_profiles` table.
///
/// SMTP fields are flattened onto the same struct for simplicity; they
/// land in the same table on the same row. NULL on all six means
/// "inherit StartOS-level SMTP config." See the keysat-smtp-emails
/// plan for how they're consumed.
/// 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,
@@ -42,7 +46,8 @@ pub struct MerchantProfile {
pub post_purchase_redirect_url: Option<String>,
pub is_default: bool,
// SMTP override (all-or-nothing for now; the SMTP plan refines this).
// 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>,
+89 -10
View File
@@ -67,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;
@@ -1349,13 +1349,18 @@ pub async fn capture_zaprite_payment_profile(
/// 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 call succeeded; 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, or active provider
/// isn't Zaprite. Caller proceeds with manual-pay
/// fallback (`subscription.renewal_pending`).
/// - `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
@@ -1400,12 +1405,86 @@ async fn try_auto_charge_zaprite(
.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 = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
"Zaprite auto-charge succeeded; awaiting settle webhook"
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 })));
}
}
+193 -1
View File
@@ -86,6 +86,17 @@ async fn make_pool() -> (SqlitePool, NamedTempFile) {
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
/// products, 5 codes, etc.). Plenty for the small fixtures here.
async fn make_test_state() -> (AppState, NamedTempFile) {
make_test_state_inner(false).await
}
/// Same fixture but with the daemon sandbox flag ON — for the
/// agent-payment-connect outer gate (a scoped `payment_providers:write` key may
/// only start a connect on a sandbox daemon).
async fn make_test_state_sandbox() -> (AppState, NamedTempFile) {
make_test_state_inner(true).await
}
async fn make_test_state_inner(sandbox_mode: bool) -> (AppState, NamedTempFile) {
let (pool, tmp) = make_pool().await;
let keypair = crypto::keys::load_or_generate(&pool)
.await
@@ -103,7 +114,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: Some("Test Operator".into()),
sandbox_mode: false,
sandbox_mode,
};
let state = AppState {
@@ -3607,6 +3618,187 @@ async fn scoped_key_create_rejects_ungrantable_scope() {
assert_eq!(send(&state, req).await.status(), StatusCode::BAD_REQUEST);
}
/// Mint a scoped key of `role` plus à-la-carte `scopes`, returning its token.
async fn mint_scoped_key_with_scopes(state: &AppState, role: &str, scopes: &[&str]) -> String {
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({ "label": format!("{role}+scopes key"), "role": role, "scopes": scopes })),
);
let resp = send(state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "minting {role}+{scopes:?} should succeed");
body_json(resp)
.await
.get("token")
.and_then(|t| t.as_str())
.expect("create returns the raw token once")
.to_string()
}
// ----- agent-payment-connect gate (slices 3-4) -----
// `plans/agent-payment-connect-scope.md`: a scoped `payment_providers:write`
// key may START a BTCPay connect ONLY on a sandbox daemon (outer gate); the
// non-mainnet inner gate is enforced at callback time (covered live in
// tests/btcpay_network_live.rs). These cover the HTTP-level outer gate.
/// OUTER gate, production: a scoped `payment_providers:write` key is 403 on a
/// non-sandbox daemon — even though it holds the scope. Proves §5.1 (a scoped
/// key cannot repoint settlement on a live box, regtest or otherwise).
#[tokio::test]
async fn payment_connect_outer_gate_denies_scoped_on_production() {
let (state, _tmp) = make_test_state().await; // sandbox_mode = false
let token =
mint_scoped_key_with_scopes(&state, "merchant-onboard", &["payment_providers:write"]).await;
let auth = format!("Bearer {token}");
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
assert_eq!(
send(&state, req).await.status(),
StatusCode::FORBIDDEN,
"scoped payment_providers:write key must be 403 on a non-sandbox daemon"
);
}
/// OUTER gate, sandbox: the same key passes on a sandbox daemon, and the
/// connect is recorded as a SCOPED initiator so the callback applies the
/// non-mainnet network gate.
#[tokio::test]
async fn payment_connect_outer_gate_allows_scoped_on_sandbox() {
let (state, _tmp) = make_test_state_sandbox().await; // sandbox_mode = true
let token =
mint_scoped_key_with_scopes(&state, "merchant-onboard", &["payment_providers:write"]).await;
let auth = format!("Bearer {token}");
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "scoped key passes the outer gate on sandbox");
let body = body_json(resp).await;
assert!(
body["authorize_url"].as_str().unwrap_or("").contains("/api-keys/authorize"),
"returns a BTCPay authorize URL; got {body:?}"
);
let (scoped, actor_hash): (i64, Option<String>) = sqlx::query_as(
"SELECT scoped_initiator, initiator_actor_hash FROM btcpay_authorize_state WHERE state_token = ?",
)
.bind(body["state"].as_str().expect("state token echoed"))
.fetch_one(&state.db)
.await
.expect("authorize_state row persisted");
assert_eq!(scoped, 1, "the callback must see this as a scoped initiator");
assert!(
actor_hash.is_some(),
"the scoped initiator's actor hash must be recorded for the callback's audit row"
);
}
/// A scoped key WITHOUT `payment_providers:write` is 403 even on a sandbox
/// daemon — the scope is in no role (not even full-admin), so merchant-onboard
/// can't reach connect. Proves the gate isn't widened by role.
#[tokio::test]
async fn payment_connect_denies_scoped_without_the_scope() {
let (state, _tmp) = make_test_state_sandbox().await; // sandbox ON, so only the missing scope can deny
let auth = format!("Bearer {}", mint_scoped_key(&state, "merchant-onboard").await);
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
assert_eq!(
send(&state, req).await.status(),
StatusCode::FORBIDDEN,
"merchant-onboard without payment_providers:write must be 403 (no role widening)"
);
}
/// The master key may start a connect on ANY daemon (bypasses the sandbox
/// gate). Recorded as a master (non-scoped) initiator → callback applies no
/// network restriction.
#[tokio::test]
async fn payment_connect_allows_master_on_production() {
let (state, _tmp) = make_test_state().await; // sandbox OFF
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request("POST", "/v1/admin/btcpay/connect", &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "master may connect on any daemon");
let body = body_json(resp).await;
let scoped: i64 = sqlx::query_scalar(
"SELECT scoped_initiator FROM btcpay_authorize_state WHERE state_token = ?",
)
.bind(body["state"].as_str().unwrap())
.fetch_one(&state.db)
.await
.expect("authorize_state row persisted");
assert_eq!(scoped, 0, "master connect is not a scoped initiator");
}
/// The initiator + actor hash round-trip through `btcpay_authorize_state`
/// (migration 0025): recorded at start, recovered at callback, single-use.
#[tokio::test]
async fn authorize_state_carries_scoped_initiator() {
let (state, _tmp) = make_test_state().await;
let profile = repo::get_default_merchant_profile(&state.db)
.await
.expect("query default profile")
.expect("a default profile exists post-migration");
keysat::btcpay::config::record_authorize_state(
&state.db,
"tok_scoped",
Some(&profile.id),
true,
Some("deadbeef"),
)
.await
.expect("record scoped");
let s = keysat::btcpay::config::consume_authorize_state(&state.db, "tok_scoped")
.await
.expect("consume scoped");
assert!(s.scoped_initiator, "scoped_initiator must round-trip");
assert_eq!(s.initiator_actor_hash.as_deref(), Some("deadbeef"));
assert_eq!(s.merchant_profile_id.as_deref(), Some(profile.id.as_str()));
// Single-use: a replay of the same token fails.
assert!(
keysat::btcpay::config::consume_authorize_state(&state.db, "tok_scoped")
.await
.is_err(),
"consumed token must not replay"
);
// Master initiator: defaults (not scoped, no hash).
keysat::btcpay::config::record_authorize_state(
&state.db,
"tok_master",
Some(&profile.id),
false,
None,
)
.await
.expect("record master");
let m = keysat::btcpay::config::consume_authorize_state(&state.db, "tok_master")
.await
.expect("consume master");
assert!(!m.scoped_initiator);
assert_eq!(m.initiator_actor_hash, None);
}
/// The GET BTCPay callback must surface a failed/denied connect as a non-2xx
/// status, not a 200 with an HTML error body (the POST callback already does via
/// `?`). An unknown state token fails closed at consume time -> 401. This guards
/// the regression where the deny path (e.g. a scoped key targeting a mainnet
/// store) would otherwise return 200 with no programmatic error signal.
#[tokio::test]
async fn btcpay_callback_get_propagates_error_status() {
let (state, _tmp) = make_test_state_sandbox().await;
let req = build_request(
"GET",
"/v1/btcpay/authorize/callback?state=bogus-token&apiKey=whatever",
&[],
None,
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"a GET callback with an invalid state token must return 401, not a 200 error page"
);
}
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
/// entitlement) with 402. Switching the daemon's self-tier to a
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
@@ -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"
);
}
+11 -11
View File
@@ -415,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,
@@ -534,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>
@@ -5763,7 +5763,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// -------- 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, optional SMTP override) and a set of payment
// 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.
+28 -7
View File
@@ -43,14 +43,35 @@ Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`,
marketing copy), tear down, and re-run on a fresh fixture.
3. Repeat until `completed-clean`.
## Stage 2 (gated, not built yet)
## Stage 2 (buyer pays on regtest) — built, `completed-clean`
The buyer-pays-on-regtest path needs Keysat to ship `payment_providers:write` +
the sandbox-mode daemon flag + the network gate (slices 35, in progress). It
adds a Dockerized BTCPay regtest stack and grants the agent
`merchant-onboard` + `payment_providers:write` so it can connect BTCPay
(regtest) and drive a test buyer payment end to end. Connecting a *mainnet*
wallet stays operator-only by design — that boundary is a feature, not a gap.
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
@@ -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
View File
@@ -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"
+30
View File
@@ -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"
+155
View File
@@ -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"
+56
View File
@@ -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"
+89
View File
@@ -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
+101
View File
@@ -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')."
+8 -2
View File
@@ -39,7 +39,13 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
'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: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.',
'',
@@ -530,7 +536,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:57',
version: '0.2.0:60',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under