Compare commits
16 Commits
3afac078d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b68bb4b882 | |||
| c739d5c515 | |||
| 46972be9db | |||
| 0a6d73aa29 | |||
| 241478af95 | |||
| 51a88f2a2f | |||
| 554f3b2da0 | |||
| 4755639bdc | |||
| eafdc6646e | |||
| 8c5cdb6468 | |||
| b6758cf30a | |||
| a507cfa978 | |||
| c673b10a94 | |||
| 8eb4a97c6f | |||
| be8688de80 | |||
| 7a1c70ab9b |
+29
-9
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
|
||||||
|
|
||||||
Endpoints that touch settings (operator name, payment provider connections,
|
Endpoints that touch settings (operator name, payment provider connections,
|
||||||
@@ -138,7 +139,7 @@ upgrade CTA without parsing message strings.
|
|||||||
| `expired` | Past `expires_at` |
|
| `expired` | Past `expires_at` |
|
||||||
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
|
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
|
||||||
| `product_mismatch` | License is for a different product than the caller asserted |
|
| `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"}'
|
-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`.
|
Scope required: `licenses:write`.
|
||||||
|
|
||||||
@@ -277,10 +280,16 @@ A few patterns that work well in practice.
|
|||||||
|
|
||||||
### Idempotency
|
### Idempotency
|
||||||
|
|
||||||
The daemon's mutation endpoints are idempotent where they can be. Revoke,
|
The daemon's mutation endpoints are idempotent where they can be. Suspend,
|
||||||
suspend, unsuspend, archive, unarchive, subscription cancel — all return
|
unsuspend, archive, unarchive, subscription cancel — all return success on the
|
||||||
success on the second call without changing state. Your agent can safely
|
second call without changing state. Your agent can safely retry on network
|
||||||
retry on network errors.
|
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
|
### Pagination
|
||||||
|
|
||||||
@@ -349,15 +358,26 @@ Some operations are deliberately operator-only and not accessible to any
|
|||||||
scoped key, including `full-admin`:
|
scoped key, including `full-admin`:
|
||||||
|
|
||||||
- Generating / revoking scoped API keys (`/v1/admin/api-keys`)
|
- 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
|
- Setting the operator name
|
||||||
- Activating the self-license (`/v1/admin/self-license`)
|
- Activating the self-license (`/v1/admin/self-license`)
|
||||||
- Resetting the analytics install_uuid
|
- Resetting the analytics install_uuid
|
||||||
- Changing the web UI password (StartOS Action only)
|
- Changing the web UI password (StartOS Action only)
|
||||||
|
|
||||||
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
|
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
|
||||||
that can rotate its own credentials, connect arbitrary payment processors, or
|
that can rotate its own credentials, redirect settled payments, or change the
|
||||||
change the operator identity is no longer bounded by the role it was given.
|
operator identity is no longer bounded by the role it was given.
|
||||||
|
|
||||||
|
**One narrow exception — agent-delegated payment connect.** A key granted the
|
||||||
|
à-la-carte `payment_providers:write` scope (never granted by any role —
|
||||||
|
operators add it explicitly per key) CAN initiate a BTCPay connect, but only
|
||||||
|
fail-closed under two gates: the daemon must be in **sandbox mode** (an outer
|
||||||
|
gate — scoped connect is refused outright on a production daemon, even for
|
||||||
|
regtest), and the target store must be **non-mainnet** (an inner gate enforced
|
||||||
|
after the OAuth round-trip). Disconnecting a provider, and any connect on a
|
||||||
|
production / mainnet daemon, remain master-only. This lets an integrating agent
|
||||||
|
wire up a throwaway sandbox without ever touching a live store's settlement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,9 @@
|
|||||||
#
|
#
|
||||||
# Chain targets when needed: `make clean arm install`.
|
# 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
|
include s9pk.mk
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ comp keys, beta access, or "first N users free" launch promos.
|
|||||||
Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
|
Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
|
||||||
with build context set to the parent directory so the Dockerfile can
|
with build context set to the parent directory so the Dockerfile can
|
||||||
`COPY` from the sibling `licensing-service/` source tree. The Rust binary
|
`COPY` from the sibling `licensing-service/` source tree. The Rust binary
|
||||||
is statically linked against musl (target
|
is statically compiled against musl (target `*-unknown-linux-musl`), and the
|
||||||
`*-unknown-linux-musl`) so the runtime image is a `scratch`-based final
|
runtime stage is `debian:bookworm-slim` with `ca-certificates`, `tini` (init /
|
||||||
stage with no shared-library dependencies. Architectures: `x86_64` and
|
signal handling), and `sqlite3` (an SQL shell for occasional admin tasks)
|
||||||
`aarch64`.
|
installed. Architectures: `x86_64` and `aarch64`.
|
||||||
|
|
||||||
`start-cli s9pk pack` ingests the resulting OCI image, converts it to a
|
`start-cli s9pk pack` ingests the resulting OCI image, converts it to a
|
||||||
squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
|
squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
|
||||||
@@ -105,25 +105,24 @@ mandatory.
|
|||||||
## Installation and First-Run Flow
|
## Installation and First-Run Flow
|
||||||
|
|
||||||
1. Install Keysat via the marketplace (or sideload the `.s9pk`).
|
1. Install Keysat via the marketplace (or sideload the `.s9pk`).
|
||||||
2. Resolve the auto-created **critical task** "Connect BTCPay" by
|
2. Resolve the auto-created **important task** "Connect BTCPay" — open
|
||||||
running the **Connect BTCPay** action. This opens a one-click
|
the embedded admin web UI (**Settings → Payment providers**) and
|
||||||
authorize page on your local BTCPay; after approval, Keysat
|
click **Connect BTCPay**. This opens a one-click authorize page on
|
||||||
auto-detects your store and registers an inbound webhook. No API
|
your local BTCPay; after approval, Keysat auto-detects your store and
|
||||||
keys to copy.
|
registers an inbound webhook. No API keys to copy. The install task
|
||||||
3. Run **Check BTCPay connection** to confirm — the install task clears
|
clears automatically once BTCPay reports connected.
|
||||||
automatically.
|
3. Set your **operator name** (shown on the public homepage and in
|
||||||
4. Set your **operator name** (shown on the public homepage and in
|
|
||||||
buyer-facing receipts).
|
buyer-facing receipts).
|
||||||
5. Create one or more **products** — each represents something you sell.
|
4. Create one or more **products** — each represents something you sell.
|
||||||
6. Create at least one **policy** per product. Multi-tier ladders
|
5. Create at least one **policy** per product. Multi-tier ladders
|
||||||
(Basic / Pro / Max) are first-class: when a product has two or more
|
(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
|
public policies, the buy page renders a tier picker and the buyer
|
||||||
chooses before paying. Policies define duration, grace period, seat
|
chooses before paying. Policies define duration, grace period, seat
|
||||||
cap, entitlements, recurring cadence, trial flag, price overrides,
|
cap, entitlements, recurring cadence, trial flag, price overrides,
|
||||||
marketing bullets, and per-entitlement hide-on-buy-page toggles.
|
marketing bullets, and per-entitlement hide-on-buy-page toggles.
|
||||||
7. Optionally create **discount / referral / free-license codes** (see
|
6. Optionally create **discount / referral / free-license codes** in the
|
||||||
`Create discount code` action).
|
admin web UI.
|
||||||
8. Share the public service URL with buyers.
|
7. Share the public service URL with buyers.
|
||||||
|
|
||||||
## Configuration Management
|
## Configuration Management
|
||||||
|
|
||||||
@@ -145,7 +144,7 @@ interfaces for clarity:
|
|||||||
| Interface | Type | Path prefix | Purpose |
|
| 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. |
|
| `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
|
StartOS terminates TLS at the platform edge. Inside the container every
|
||||||
request arrives as plain HTTP. For browser-facing URLs (e.g., the public
|
request arrives as plain HTTP. For browser-facing URLs (e.g., the public
|
||||||
@@ -153,44 +152,23 @@ purchase page) hardcode `https://`.
|
|||||||
|
|
||||||
## Actions (StartOS UI)
|
## 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 web UI password* — set / recover the admin SPA login password (you
|
||||||
- *Set operator name* — your public-facing brand.
|
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**
|
Everything else — connecting BTCPay (and Zaprite), operator name, products,
|
||||||
- *Connect BTCPay* — one-click authorize against your BTCPay; auto-detects store and registers webhook.
|
policies, discount / referral / free-license codes, licenses, machines,
|
||||||
- *Check BTCPay connection* — confirm BTCPay state; clears the install task on success.
|
outbound webhooks, scoped API keys, and the audit log — lives in the embedded
|
||||||
|
**admin web UI** (Settings tab + the workspace sidebar), not as StartOS
|
||||||
**Credentials**
|
actions.
|
||||||
- *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.
|
|
||||||
|
|
||||||
## Backups and Restore
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## What Is Unchanged from Upstream
|
||||||
|
|
||||||
@@ -257,7 +235,7 @@ service:
|
|||||||
marketingUrl: https://keysat.xyz
|
marketingUrl: https://keysat.xyz
|
||||||
image:
|
image:
|
||||||
source: dockerBuild
|
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]
|
arches: [x86_64, aarch64]
|
||||||
volumes:
|
volumes:
|
||||||
- id: main
|
- id: main
|
||||||
@@ -292,10 +270,10 @@ backups:
|
|||||||
firstRun:
|
firstRun:
|
||||||
tasks:
|
tasks:
|
||||||
- id: btcpay-initial-setup
|
- id: btcpay-initial-setup
|
||||||
severity: critical
|
severity: important
|
||||||
runs: configureBtcpay
|
runs: configureBtcpay
|
||||||
features:
|
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
|
signing: ed25519
|
||||||
offlineVerification: true
|
offlineVerification: true
|
||||||
multiSeat: true
|
multiSeat: true
|
||||||
@@ -315,7 +293,7 @@ features:
|
|||||||
outboundWebhooks: true
|
outboundWebhooks: true
|
||||||
webhookDlq: true # failed deliveries retryable from admin UI
|
webhookDlq: true # failed deliveries retryable from admin UI
|
||||||
auditLog: true
|
auditLog: true
|
||||||
scopedApiKeys: [read-only, license-issuer, support, full-admin]
|
scopedApiKeys: [read-only, license-issuer, support, merchant-onboard, full-admin]
|
||||||
openapiSpec: /v1/openapi.json
|
openapiSpec: /v1/openapi.json
|
||||||
selfLicensingTier: [Creator, Pro, Patron]
|
selfLicensingTier: [Creator, Pro, Patron]
|
||||||
sdks:
|
sdks:
|
||||||
|
|||||||
+21
-38
@@ -56,44 +56,21 @@ See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout.
|
|||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
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):
|
||||||
licensing-service/
|
|
||||||
├── Cargo.toml
|
- `main.rs`, `config.rs`, `error.rs`, `models.rs` — entry point, env-driven config, error → HTTP mapping, shared domain types.
|
||||||
├── LICENSE # source-available; no redistribution
|
- `crypto/` — the LIC1 license-key byte layout and Ed25519 sign/verify (the contract the four SDKs implement).
|
||||||
├── README.md
|
- `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).
|
||||||
├── .env.example # required env vars
|
- `payment/` (`btcpay/`, `zaprite/`) + `merchant_profiles.rs` — the payment-provider abstraction and multi-profile routing.
|
||||||
├── migrations/
|
- `reconcile.rs`, `subscriptions.rs`, `upgrades.rs` — the background worker (invoice reconciliation, recurring renewals, tier upgrades).
|
||||||
│ └── 0001_initial.sql # SQLite schema
|
- `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`.
|
||||||
├── src/
|
- `web/index.html` — the embedded admin SPA.
|
||||||
│ ├── main.rs # entry point: wires everything
|
|
||||||
│ ├── config.rs # env-driven config
|
Deeper docs: [`docs/API.md`](docs/API.md), [`docs/INTEGRATION.md`](docs/INTEGRATION.md), [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||||
│ ├── error.rs # unified error → HTTP mapping
|
|
||||||
│ ├── models.rs # shared domain types
|
|
||||||
│ ├── crypto/
|
|
||||||
│ │ ├── mod.rs # license key format + sign/verify
|
|
||||||
│ │ └── keys.rs # server keypair lifecycle
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── mod.rs # pool + migrations
|
|
||||||
│ │ └── repo.rs # all SQL queries
|
|
||||||
│ ├── btcpay/
|
|
||||||
│ │ ├── client.rs # Greenfield API client
|
|
||||||
│ │ └── webhook.rs # HMAC verification + event parsing
|
|
||||||
│ └── api/
|
|
||||||
│ ├── mod.rs # router + AppState
|
|
||||||
│ ├── products.rs # public product endpoints
|
|
||||||
│ ├── purchase.rs # buy + poll
|
|
||||||
│ ├── validate.rs # the hot path for downstream software
|
|
||||||
│ ├── webhook.rs # BTCPay landing
|
|
||||||
│ └── admin.rs # operator-only actions
|
|
||||||
└── docs/
|
|
||||||
├── API.md # full endpoint reference
|
|
||||||
├── INTEGRATION.md # for developers embedding a client
|
|
||||||
└── ARCHITECTURE.md # deeper design notes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running locally
|
## 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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -109,7 +86,7 @@ On first boot the server generates a fresh Ed25519 keypair and stores it in the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/v1/admin/products \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"slug": "my-app",
|
"slug": "my-app",
|
||||||
@@ -143,7 +120,7 @@ curl -X POST http://localhost:8080/v1/validate \
|
|||||||
|
|
||||||
## Deploying on Start9
|
## 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.
|
- **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.
|
- **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
|
## Status
|
||||||
|
|
||||||
v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator.
|
0.2.0 — shipped and in production. The current feature set:
|
||||||
|
|
||||||
|
- **Four published SDKs** — TypeScript (npm), Rust (crates.io), Python (PyPI), and Go — all wire-compatible against the cross-check fixtures in `tests/crosscheck/`.
|
||||||
|
- **StartOS wrapper included in this repo** under `../startos/`; build the `.s9pk` from the parent directory (no separate wrapper repository).
|
||||||
|
- **Embedded admin SPA** (`web/index.html`) for all day-to-day operations.
|
||||||
|
- **Subscriptions** (recurring auto-renew with trials + grace), **policies / tiers** with per-policy entitlements, **discount / referral / free-license codes**, **outbound webhooks** with a dead-letter queue, and a background **invoice reconciliation** job that recovers dropped payment webhooks.
|
||||||
|
- **Payment providers**: BTCPay Server is required; Zaprite (card / fiat) is optional and gated by the `zaprite_payments` entitlement.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ All endpoints are JSON in / JSON out. Errors return a body of the form:
|
|||||||
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
|
{ "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
|
```json
|
||||||
{
|
{
|
||||||
"service": "keysat",
|
"service": "keysat",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"operator": "Acme Software",
|
"operator": "Acme Software",
|
||||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
|
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
|
||||||
"key_algorithm": "ed25519",
|
"key_algorithm": "ed25519",
|
||||||
@@ -128,7 +128,7 @@ On failure:
|
|||||||
{ "ok": false, "reason": "revoked" }
|
{ "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`
|
### `POST /v1/btcpay/webhook`
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ Landing point for BTCPay Server webhook events. Only BTCPay should call this. We
|
|||||||
|
|
||||||
## Admin endpoints
|
## 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`
|
### `POST /v1/admin/products`
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,18 @@
|
|||||||
|
|
||||||
## Data model
|
## 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.
|
- `products` — what's for sale. Independent pricing per product.
|
||||||
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
|
- `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.
|
- `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
|
## 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).
|
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.
|
- 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.
|
- 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.
|
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.
|
- **Key rotation.** A single static signing key is fine for now. Rotation requires SDK multi-key support and a migration strategy; deferred.
|
||||||
- **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app.
|
|
||||||
- **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured).
|
|
||||||
- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change.
|
|
||||||
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
|
- **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
|
## Notes on Start9 dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,18 @@ curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem
|
|||||||
|
|
||||||
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
|
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
|
## 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
|
```rust
|
||||||
use anyhow::{Context, Result};
|
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;
|
||||||
@@ -227,6 +227,91 @@ pub async fn require_scope(
|
|||||||
Ok(token_hash)
|
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) ----------
|
// ---------- CRUD endpoints (gated on master admin only) ----------
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -22,9 +22,14 @@
|
|||||||
//! callback path uses the CSRF `state` token to tie a callback back to the
|
//! callback path uses the CSRF `state` token to tie a callback back to the
|
||||||
//! issuing operator session.
|
//! 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::client::{self as btcpay_client, BtcpayClient};
|
||||||
use crate::btcpay::config as btcpay_cfg;
|
use crate::btcpay::config as btcpay_cfg;
|
||||||
|
use crate::btcpay::network::BitcoinNetwork;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::payment::btcpay::BtcpayProvider;
|
use crate::payment::btcpay::BtcpayProvider;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -84,7 +89,12 @@ pub async fn start_connect(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Option<Json<StartConnectReq>>,
|
body: Option<Json<StartConnectReq>>,
|
||||||
) -> AppResult<Json<ConnectResp>> {
|
) -> 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();
|
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||||
|
|
||||||
// Resolve the target merchant profile (defaulting to the default).
|
// Resolve the target merchant profile (defaulting to the default).
|
||||||
@@ -115,7 +125,15 @@ pub async fn start_connect(
|
|||||||
rand::thread_rng().fill_bytes(&mut raw);
|
rand::thread_rng().fill_bytes(&mut raw);
|
||||||
let state_token = BASE32_NOPAD.encode(&raw);
|
let state_token = BASE32_NOPAD.encode(&raw);
|
||||||
|
|
||||||
btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id))
|
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
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
|
|
||||||
@@ -226,10 +244,17 @@ pub async fn callback_get(
|
|||||||
Ok(()) => success_page(
|
Ok(()) => success_page(
|
||||||
"BTCPay connected successfully. You can close this tab and return to Keysat.",
|
"BTCPay connected successfully. You can close this tab and return to Keysat.",
|
||||||
),
|
),
|
||||||
Err(e) => Html(format!(
|
// Carry the error's HTTP status onto the HTML page so a denied connect
|
||||||
|
// (e.g. a scoped key targeting a mainnet store -> 400) surfaces as a
|
||||||
|
// non-2xx an agent can detect, not a misleading 200. Matches the POST
|
||||||
|
// callback, which propagates the status via `?`.
|
||||||
|
Err(e) => (
|
||||||
|
e.status_code(),
|
||||||
|
Html(format!(
|
||||||
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
||||||
html_escape::encode_text(&e.to_string())
|
html_escape::encode_text(&e.to_string())
|
||||||
))
|
)),
|
||||||
|
)
|
||||||
.into_response(),
|
.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
|
// Recovers the `merchant_profile_id` recorded when the operator
|
||||||
// kicked off the connect flow. NULL falls back to the default
|
// kicked off the connect flow. NULL falls back to the default
|
||||||
// profile (back-compat for state tokens from pre-0022 runs).
|
// 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
|
.await
|
||||||
.map_err(|_| AppError::Unauthorized)?;
|
.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)
|
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::BadRequest(format!(
|
.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()
|
"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.
|
// Generate a strong webhook secret, then register the webhook on BTCPay.
|
||||||
let mut raw_secret = [0u8; 32];
|
let mut raw_secret = [0u8; 32];
|
||||||
rand::thread_rng().fill_bytes(&mut raw_secret);
|
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;
|
state.set_payment_provider(provider).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let network_str = resolved_network.map(|n| n.as_str());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
provider_id = %provider_id,
|
provider_id = %provider_id,
|
||||||
merchant_profile_id = %profile.id,
|
merchant_profile_id = %profile.id,
|
||||||
store = %store.id,
|
store = %store.id,
|
||||||
store_name = %store.name,
|
store_name = %store.name,
|
||||||
webhook_id = %created_webhook.id,
|
webhook_id = %created_webhook.id,
|
||||||
|
scoped = auth_state.scoped_initiator,
|
||||||
|
network = network_str.unwrap_or("master/any"),
|
||||||
"BTCPay connected via authorize flow"
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"slug": { "type": "string" },
|
"slug": { "type": "string" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"description": { "type": "string" },
|
"description": { "type": "string" },
|
||||||
"price_sats": { "type": "integer", "nullable": true },
|
"price_sats": { "type": "integer", "nullable": true, "description": "Legacy SAT price. Still accepted on create for backward compatibility; new callers should send price_value + price_currency instead. Also returned in responses (derived from price_value when that path is used)." },
|
||||||
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true },
|
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true, "description": "Currency for price_value. Defaults to SAT." },
|
||||||
"price_value": { "type": "integer", "nullable": true },
|
"price_value": { "type": "integer", "nullable": true, "description": "Write field: price in the smallest unit of price_currency (sats for SAT, cents for USD/EUR). Send together with price_currency." },
|
||||||
"active": { "type": "boolean" },
|
"active": { "type": "boolean" },
|
||||||
"entitlements_catalog": {
|
"entitlements_catalog": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -263,7 +263,7 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"/v1/admin/licenses": {
|
"/v1/admin/licenses": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "List licenses",
|
"summary": "List licenses",
|
||||||
"description": "Scope required: `licenses:read`. Filter by status, product_slug, buyer_email, expiring soon, etc. via query params.",
|
"description": "Scope required: `licenses:read`. Requires `product_id=<uuid>` (the product's UUID, not its slug); returns that product's licenses. Use `GET /v1/admin/licenses/search` to look up by buyer_email or invoice id.",
|
||||||
"responses": { "200": { "description": "License list" } }
|
"responses": { "200": { "description": "License list" } }
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
@@ -272,6 +272,13 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"responses": { "200": { "description": "Issued license" } }
|
"responses": { "200": { "description": "Issued license" } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/admin/licenses/search": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Search licenses",
|
||||||
|
"description": "Scope required: `licenses:read`. Look up licenses by `buyer_email`, `nostr_npub`, or `invoice_id` (whichever is supplied). With no filter, returns the 100 most-recent licenses. The `license_key` is never returned here (only on issue / recover).",
|
||||||
|
"responses": { "200": { "description": "Matching licenses" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/admin/licenses/{id}/revoke": {
|
"/v1/admin/licenses/{id}/revoke": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Revoke a license",
|
"summary": "Revoke a license",
|
||||||
@@ -301,11 +308,6 @@ const SPEC_JSON: &str = r##"{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/admin/products": {
|
"/v1/admin/products": {
|
||||||
"get": {
|
|
||||||
"summary": "List products",
|
|
||||||
"description": "Scope required: `products:read`.",
|
|
||||||
"responses": { "200": { "description": "Product list" } }
|
|
||||||
},
|
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Create a product",
|
"summary": "Create a product",
|
||||||
"description": "Scope required: `products:write`.",
|
"description": "Scope required: `products:write`.",
|
||||||
@@ -398,7 +400,8 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
|
"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"]
|
"required": ["label", "role"]
|
||||||
} } }
|
} } }
|
||||||
@@ -416,9 +419,44 @@ const SPEC_JSON: &str = r##"{
|
|||||||
"/v1/admin/tier": {
|
"/v1/admin/tier": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Get this daemon's tier + usage + caps",
|
"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" } }
|
"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)" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"##;
|
}"##;
|
||||||
|
|||||||
@@ -366,3 +366,87 @@ pub async fn list_payment_methods(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the Bitcoin **network** a store settles on, for the scoped
|
||||||
|
/// payment-connect gate (`plans/agent-payment-connect-scope.md` §6.1).
|
||||||
|
///
|
||||||
|
/// Lists the store's payment methods, finds the on-chain BTC method
|
||||||
|
/// (`paymentMethodId` is `BTC-CHAIN` on BTCPay 2.x, `BTC` on 1.x — never
|
||||||
|
/// hardcode), fetches a receive address, and classifies the address prefix.
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// - `Ok(Some(network))` when positively determined;
|
||||||
|
/// - `Ok(None)` when it **cannot** be determined (no on-chain method, no
|
||||||
|
/// address, Lightning-only store, BTCPay not yet synced → `503`, or an
|
||||||
|
/// unrecognized prefix). The caller MUST fail closed (treat `None` as
|
||||||
|
/// mainnet and deny the scoped connect).
|
||||||
|
///
|
||||||
|
/// The address endpoint requires `btcpay.store.canmodifystoresettings`, which
|
||||||
|
/// the daemon's authorize flow already requests (see `REQUESTED_PERMISSIONS`).
|
||||||
|
pub async fn fetch_onchain_network(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
store_id: &str,
|
||||||
|
) -> Result<Option<super::network::BitcoinNetwork>> {
|
||||||
|
// Any failure to enumerate methods → undetermined → caller fails closed.
|
||||||
|
// Swallow the error here (uniform with the non-2xx wallet/address branch
|
||||||
|
// below) and log a body-free reason at warn; detail only at debug so an
|
||||||
|
// upstream error body never lands in normal logs on this sensitive path.
|
||||||
|
let methods = match list_payment_methods(base_url, api_key, store_id).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
store = %store_id,
|
||||||
|
"fetch_onchain_network: could not list payment methods; network undetermined"
|
||||||
|
);
|
||||||
|
tracing::debug!(error = %format!("{e:#}"), "btcpay list-payment-methods error detail");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Find the on-chain BTC method. Lightning ids (`BTC-LN`,
|
||||||
|
// `BTC_LightningLike`, …) are deliberately excluded.
|
||||||
|
let Some(pmid) = methods.iter().find_map(|m| {
|
||||||
|
let id = m.get("paymentMethodId").and_then(|v| v.as_str())?;
|
||||||
|
match id.to_ascii_uppercase().as_str() {
|
||||||
|
"BTC-CHAIN" | "BTC" => Some(id.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
return Ok(None); // no on-chain BTC method → undetermined → fail closed
|
||||||
|
};
|
||||||
|
|
||||||
|
// `pmid` is BTCPay-supplied; percent-encode it as a path segment so a
|
||||||
|
// hostile/buggy server returning an odd id can't corrupt the URL (it would
|
||||||
|
// only ever 4xx → Ok(None) → deny anyway, but keep the request well-formed).
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/stores/{store_id}/payment-methods/{}/wallet/address",
|
||||||
|
base_url.trim_end_matches('/'),
|
||||||
|
urlencoding::encode(&pmid),
|
||||||
|
);
|
||||||
|
let resp = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()?
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("token {api_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("calling BTCPay wallet/address")?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
// 503 (BTCPay not synced / on-chain service down), 404/422 (no wallet),
|
||||||
|
// 403 (insufficient perms) — none let us positively determine the
|
||||||
|
// network, so report undetermined and let the caller fail closed.
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// A 2xx with a non-JSON body (misconfigured BTCPay) is likewise "can't
|
||||||
|
// determine" → Ok(None). Parsing via Ok(None) instead of `?` also keeps any
|
||||||
|
// body snippet reqwest attaches to a parse error out of warn-level logs.
|
||||||
|
let body: serde_json::Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %format!("{e:#}"), "btcpay wallet/address: non-JSON body; network undetermined");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let address = body.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
Ok(super::network::classify_address_network(address))
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
|||||||
Ok(())
|
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`
|
/// Record a new in-flight authorize state token. `merchant_profile_id`
|
||||||
/// (multi-provider model, migration 0022) names which merchant profile
|
/// (multi-provider model, migration 0022) names which merchant profile
|
||||||
/// the resulting provider row should attach to when the callback fires
|
/// the resulting provider row should attach to when the callback fires
|
||||||
/// — None falls back to "the default profile" at consume-time.
|
/// — 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(
|
pub async fn record_authorize_state(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
token: &str,
|
token: &str,
|
||||||
merchant_profile_id: Option<&str>,
|
merchant_profile_id: Option<&str>,
|
||||||
|
scoped_initiator: bool,
|
||||||
|
actor_hash: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
|
"INSERT INTO btcpay_authorize_state \
|
||||||
VALUES (?, ?, ?)",
|
(state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.bind(merchant_profile_id)
|
.bind(merchant_profile_id)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(scoped_initiator as i64)
|
||||||
|
.bind(actor_hash)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.context("recording btcpay authorize state")?;
|
.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.
|
/// Validate that `token` was issued recently and has not been consumed.
|
||||||
/// Consumes (deletes) the token on success so a replay fails, and
|
/// Consumes (deletes) the token on success so a replay fails, and returns the
|
||||||
/// returns the `merchant_profile_id` recorded at start-connect time so
|
/// recorded `AuthorizeState` (profile + initiator) so the callback knows which
|
||||||
/// the callback knows which profile to attach the new provider to.
|
/// profile to attach to and whether to apply the scoped network gate.
|
||||||
pub async fn consume_authorize_state(
|
pub async fn consume_authorize_state(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<AuthorizeState> {
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||||
let row = sqlx::query(
|
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 >= ?",
|
WHERE state_token = ? AND created_at >= ?",
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
@@ -130,11 +155,19 @@ pub async fn consume_authorize_state(
|
|||||||
let Some(row) = row else {
|
let Some(row) = row else {
|
||||||
return Err(anyhow!("unknown or expired authorize state token"));
|
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 = ?")
|
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(merchant_profile_id)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod network;
|
||||||
pub mod webhook;
|
pub mod webhook;
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! Bitcoin network classification from an address string.
|
||||||
|
//!
|
||||||
|
//! Used by the agent-payment-connect gate (`plans/agent-payment-connect-scope.md`
|
||||||
|
//! §6.1): a *scoped* key may connect a BTCPay store only when its target network
|
||||||
|
//! is non-mainnet. Greenfield's `GET /api/v1/server/info` carries no chain-type
|
||||||
|
//! field, so we determine the network from a **network-encoding artifact** — the
|
||||||
|
//! store's on-chain receive address — and classify by its prefix.
|
||||||
|
//!
|
||||||
|
//! Validated against a live regtest BTCPay 2.x: `wallet/address` returns a
|
||||||
|
//! `bcrt1…` address on regtest (see `onboarding-harness/stage2/btcpay-regtest/`).
|
||||||
|
//!
|
||||||
|
//! **Fail-closed:** an unrecognized / empty address yields `None`; the caller
|
||||||
|
//! MUST treat `None` as mainnet (deny the scoped connect). Never assume
|
||||||
|
//! non-mainnet from absence of evidence.
|
||||||
|
|
||||||
|
/// The Bitcoin network a BTCPay store settles on. Only the mainnet-vs-rest
|
||||||
|
/// distinction gates the scoped connect, but the specific non-mainnet variant
|
||||||
|
/// is kept for audit/logging.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BitcoinNetwork {
|
||||||
|
Mainnet,
|
||||||
|
/// testnet3 — shares the `tb1` HRP and `m`/`n`/`2` base58 versions with signet.
|
||||||
|
Testnet,
|
||||||
|
/// Signet — indistinguishable from testnet by address alone (`tb1`), so the
|
||||||
|
/// address classifier never yields this; reserved for a future
|
||||||
|
/// derivation-scheme-based path. Kept distinct because it is a real,
|
||||||
|
/// non-mainnet network the gate must allow.
|
||||||
|
Signet,
|
||||||
|
Regtest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitcoinNetwork {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BitcoinNetwork::Mainnet => "mainnet",
|
||||||
|
BitcoinNetwork::Testnet => "testnet",
|
||||||
|
BitcoinNetwork::Signet => "signet",
|
||||||
|
BitcoinNetwork::Regtest => "regtest",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only question the connect gate actually asks.
|
||||||
|
pub fn is_mainnet(self) -> bool {
|
||||||
|
matches!(self, BitcoinNetwork::Mainnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a Bitcoin address by its network-encoding prefix. Returns `None`
|
||||||
|
/// when the prefix is unrecognized or the string is empty — the caller
|
||||||
|
/// **fails closed** (treats `None` as mainnet).
|
||||||
|
///
|
||||||
|
/// bech32/bech32m HRP: `bcrt1…`=regtest, `tb1…`=testnet/signet, `bc1…`=mainnet.
|
||||||
|
/// Legacy base58: `1`/`3`=mainnet, `m`/`n`/`2`=test/regtest (the `tb1`/base58
|
||||||
|
/// test versions are shared by testnet, signet, and regtest — all non-mainnet,
|
||||||
|
/// which is all the gate needs; only the bech32 `bcrt1` HRP pins regtest
|
||||||
|
/// specifically).
|
||||||
|
pub fn classify_address_network(addr: &str) -> Option<BitcoinNetwork> {
|
||||||
|
let s = addr.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// bech32/bech32m — HRP is case-insensitive. Check `bcrt1` before `bc1`
|
||||||
|
// (it is not a prefix of the others, but order makes the intent explicit).
|
||||||
|
let lower = s.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("bcrt1") {
|
||||||
|
return Some(BitcoinNetwork::Regtest);
|
||||||
|
}
|
||||||
|
if lower.starts_with("tb1") {
|
||||||
|
// testnet and signet share the `tb` HRP and are indistinguishable from
|
||||||
|
// the address alone. Both non-mainnet; report Testnet.
|
||||||
|
return Some(BitcoinNetwork::Testnet);
|
||||||
|
}
|
||||||
|
if lower.starts_with("bc1") {
|
||||||
|
return Some(BitcoinNetwork::Mainnet);
|
||||||
|
}
|
||||||
|
// Legacy base58check — version byte encoded in the leading character.
|
||||||
|
// Only classify when the whole string is a *plausible* base58 address
|
||||||
|
// (correct alphabet + length): otherwise arbitrary text that merely begins
|
||||||
|
// with `n`/`m`/`2` (e.g. "not-an-address") would be mis-read as non-mainnet
|
||||||
|
// and the gate would fail OPEN. Junk falls through to `None` (fail closed).
|
||||||
|
// Case-sensitive, so classify off the original string.
|
||||||
|
if (26..=35).contains(&s.len()) && s.chars().all(is_base58) {
|
||||||
|
return match s.chars().next() {
|
||||||
|
Some('1') | Some('3') => Some(BitcoinNetwork::Mainnet),
|
||||||
|
Some('m') | Some('n') | Some('2') => Some(BitcoinNetwork::Testnet),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base58 alphabet membership (Bitcoin's: omits `0`, `O`, `I`, `l`).
|
||||||
|
fn is_base58(c: char) -> bool {
|
||||||
|
matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bech32_prefixes() {
|
||||||
|
// The exact address the live regtest BTCPay 2.x returned.
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt"),
|
||||||
|
Some(BitcoinNetwork::Regtest)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
|
||||||
|
Some(BitcoinNetwork::Testnet)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
|
||||||
|
Some(BitcoinNetwork::Mainnet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bech32_is_case_insensitive() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("BCRT1QWSH9UA5QEUTSHVRHZ474UDUWQLW8GFUKFPC8VT"),
|
||||||
|
Some(BitcoinNetwork::Regtest)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_address_network("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"),
|
||||||
|
Some(BitcoinNetwork::Mainnet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_base58() {
|
||||||
|
assert_eq!(classify_address_network("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), Some(BitcoinNetwork::Mainnet)); // P2PKH
|
||||||
|
assert_eq!(classify_address_network("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), Some(BitcoinNetwork::Mainnet)); // P2SH
|
||||||
|
assert_eq!(classify_address_network("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"), Some(BitcoinNetwork::Testnet)); // testnet P2PKH
|
||||||
|
assert_eq!(classify_address_network("n2ZNV88uQbede7C5M5jzi6SyG4GVuPpng6"), Some(BitcoinNetwork::Testnet));
|
||||||
|
assert_eq!(classify_address_network("2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"), Some(BitcoinNetwork::Testnet)); // test P2SH
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fail_closed_on_unknown_or_empty() {
|
||||||
|
assert_eq!(classify_address_network(""), None);
|
||||||
|
assert_eq!(classify_address_network(" "), None);
|
||||||
|
assert_eq!(classify_address_network("not-an-address"), None);
|
||||||
|
assert_eq!(classify_address_network("ltc1qxyz"), None); // not bitcoin
|
||||||
|
assert_eq!(classify_address_network("zzz"), None);
|
||||||
|
// The dangerous direction: a base58-length, all-base58 string that does
|
||||||
|
// NOT begin with a version char (1/3/m/n/2) must stay None, never be
|
||||||
|
// mis-read as non-mainnet. (And a real mainnet address always begins
|
||||||
|
// with 1/3/bc1, so it can never fall into the non-mainnet arms.)
|
||||||
|
assert_eq!(classify_address_network("bQ8vZ2mN4pR7sT1uW3xY5zA6dE9fG"), None); // 29 chars, starts 'b'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_mainnet_only_true_for_mainnet() {
|
||||||
|
assert!(BitcoinNetwork::Mainnet.is_mainnet());
|
||||||
|
assert!(!BitcoinNetwork::Testnet.is_mainnet());
|
||||||
|
assert!(!BitcoinNetwork::Signet.is_mainnet());
|
||||||
|
assert!(!BitcoinNetwork::Regtest.is_mainnet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,23 +59,46 @@ pub enum AppError {
|
|||||||
Internal(#[from] anyhow::Error),
|
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 {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, code) = match &self {
|
let status = self.status_code();
|
||||||
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
let code = match &self {
|
||||||
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
AppError::NotFound(_) => "not_found",
|
||||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
AppError::BadRequest(_) => "bad_request",
|
||||||
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
AppError::Unauthorized => "unauthorized",
|
||||||
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
AppError::Forbidden => "forbidden",
|
||||||
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
|
AppError::Conflict(_) => "conflict",
|
||||||
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
|
AppError::LicenseInvalid(_) => "invalid",
|
||||||
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
|
AppError::Upstream(_) => "upstream_error",
|
||||||
AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
|
AppError::BtcpayNotConfigured => "btcpay_not_configured",
|
||||||
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"),
|
AppError::TooManyRequests(_) => "rate_limited",
|
||||||
AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"),
|
AppError::ServiceUnavailable(_) => "service_unavailable",
|
||||||
|
AppError::PaymentRequired { .. } => "tier_cap",
|
||||||
AppError::Database(_) | AppError::Internal(_) => {
|
AppError::Database(_) | AppError::Internal(_) => {
|
||||||
tracing::error!(error = %self, "internal error");
|
tracing::error!(error = %self, "internal error");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
"internal_error"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
/// A merchant profile row. Mirrors the `merchant_profiles` table.
|
/// A merchant profile row. Mirrors the `merchant_profiles` table.
|
||||||
///
|
///
|
||||||
/// SMTP fields are flattened onto the same struct for simplicity; they
|
/// NOTE: the `smtp_*` fields are DORMANT and not consumed by anything.
|
||||||
/// land in the same table on the same row. NULL on all six means
|
/// They were laid down in migration 0020 ahead of the keysat-smtp-emails
|
||||||
/// "inherit StartOS-level SMTP config." See the keysat-smtp-emails
|
/// plan, which was SUPERSEDED 2026-06-18: Keysat will never send buyer
|
||||||
/// plan for how they're consumed.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MerchantProfile {
|
pub struct MerchantProfile {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -42,7 +46,8 @@ pub struct MerchantProfile {
|
|||||||
pub post_purchase_redirect_url: Option<String>,
|
pub post_purchase_redirect_url: Option<String>,
|
||||||
pub is_default: bool,
|
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_host: Option<String>,
|
||||||
pub smtp_port: Option<i64>,
|
pub smtp_port: Option<i64>,
|
||||||
pub smtp_username: Option<String>,
|
pub smtp_username: Option<String>,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ use crate::payment::CreateInvoiceParams;
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
use std::time::Duration as StdDuration;
|
use std::time::Duration as StdDuration;
|
||||||
use uuid::Uuid;
|
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
|
/// worker *after* it has created the order; this turns the order
|
||||||
/// from "buyer must pay" into "auto-charged, will settle via the
|
/// from "buyer must pay" into "auto-charged, will settle via the
|
||||||
/// usual webhook." Returns:
|
/// usual webhook." Returns:
|
||||||
/// - `Ok(true)` — the charge call succeeded; the buyer is not
|
/// - `Ok(true)` — the charge settled (order status PAID/COMPLETE/
|
||||||
/// expected to pay manually. The settle webhook
|
/// OVERPAID); the buyer is not expected to pay
|
||||||
/// will fire on its own and flip the sub to
|
/// manually. The settle webhook will fire on its
|
||||||
/// `active` via `on_invoice_settled`.
|
/// own and flip the sub to `active` via
|
||||||
/// - `Ok(false)` — sub has no saved profile, or active provider
|
/// `on_invoice_settled`.
|
||||||
/// isn't Zaprite. Caller proceeds with manual-pay
|
/// - `Ok(false)` — sub has no saved profile, active provider isn't
|
||||||
/// fallback (`subscription.renewal_pending`).
|
/// 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,
|
/// - `Err(_)` — Zaprite returned an error (declined card,
|
||||||
/// expired profile, network blip). Caller treats
|
/// expired profile, network blip). Caller treats
|
||||||
/// this as a soft failure: log, audit, and ALSO
|
/// this as a soft failure: log, audit, and ALSO
|
||||||
@@ -1400,12 +1405,86 @@ async fn try_auto_charge_zaprite(
|
|||||||
.await
|
.await
|
||||||
.context("Zaprite charge_order_with_profile")?;
|
.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!(
|
tracing::info!(
|
||||||
sub_id = %sub.id,
|
sub_id = %sub.id,
|
||||||
order_id = %provider_invoice_id,
|
order_id = %provider_invoice_id,
|
||||||
profile_id = %profile_id,
|
profile_id = %profile_id,
|
||||||
order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
|
order_status,
|
||||||
"Zaprite auto-charge succeeded; awaiting settle webhook"
|
"Zaprite auto-charge settled; awaiting settle webhook"
|
||||||
);
|
);
|
||||||
Ok(true)
|
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 })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ async fn make_pool() -> (SqlitePool, NamedTempFile) {
|
|||||||
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
|
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
|
||||||
/// products, 5 codes, etc.). Plenty for the small fixtures here.
|
/// products, 5 codes, etc.). Plenty for the small fixtures here.
|
||||||
async fn make_test_state() -> (AppState, NamedTempFile) {
|
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 (pool, tmp) = make_pool().await;
|
||||||
let keypair = crypto::keys::load_or_generate(&pool)
|
let keypair = crypto::keys::load_or_generate(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -103,7 +114,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
|
|||||||
btcpay_webhook_secret: None,
|
btcpay_webhook_secret: None,
|
||||||
public_base_url: "http://keysat.test".to_string(),
|
public_base_url: "http://keysat.test".to_string(),
|
||||||
operator_name: Some("Test Operator".into()),
|
operator_name: Some("Test Operator".into()),
|
||||||
sandbox_mode: false,
|
sandbox_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = AppState {
|
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);
|
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`
|
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
|
||||||
/// entitlement) with 402. Switching the daemon's self-tier to a
|
/// entitlement) with 402. Switching the daemon's self-tier to a
|
||||||
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -415,16 +415,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
border:1px solid var(--border-1);
|
border:1px solid var(--border-1);
|
||||||
}
|
}
|
||||||
.featured-pill-toggle.on {
|
.featured-pill-toggle.on {
|
||||||
background:var(--gold-500); color:var(--navy-950);
|
background:var(--navy-800); color:var(--cream-50);
|
||||||
border-color:var(--gold-500);
|
border-color:var(--navy-800);
|
||||||
box-shadow:0 2px 6px rgba(191,160,104,0.25);
|
box-shadow:0 2px 6px rgba(14,31,51,0.18);
|
||||||
}
|
}
|
||||||
.featured-pill-toggle.on .state {
|
.featured-pill-toggle.on .state {
|
||||||
background:var(--navy-950); color:var(--gold-500);
|
background:var(--cream-50); color:var(--navy-900);
|
||||||
border-color:var(--navy-950);
|
border-color:var(--cream-50);
|
||||||
}
|
}
|
||||||
.featured-pill-toggle.on:hover {
|
.featured-pill-toggle.on:hover {
|
||||||
background:var(--gold-400);
|
background:var(--navy-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tier-card drag affordance — cursor signals draggability on hover,
|
/* 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>
|
<div id="tier-banner-msg" style="margin-bottom:8px;"></div>
|
||||||
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
|
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
|
||||||
display:inline-block; padding:5px 10px;
|
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;
|
font-weight:700; font-size:11px;
|
||||||
border-radius:5px; text-decoration:none;
|
border-radius:8px; text-decoration:none;
|
||||||
transition:background 120ms;
|
transition:background 120ms;
|
||||||
" onmouseover="this.style.background='var(--gold-400)'"
|
" onmouseover="this.style.background='var(--cream-200)'"
|
||||||
onmouseout="this.style.background='var(--gold-500)'"></a>
|
onmouseout="this.style.background='var(--cream-50)'"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer" id="sidebar-footer">
|
<div class="footer" id="sidebar-footer">
|
||||||
<span class="dot warn"></span>
|
<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+) --------
|
// -------- Merchant profiles (multi-provider model, :52+) --------
|
||||||
// Each profile represents one "business" the operator is running on
|
// Each profile represents one "business" the operator is running on
|
||||||
// this Keysat instance. Owns business identity (brand, support contact,
|
// 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.
|
// providers (BTCPay / Zaprite) that legally settle to that business.
|
||||||
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
|
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
|
||||||
// get unlimited.
|
// get unlimited.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Per-run scratch: live daemon DBs, logs, tokens, the symlink to the active run.
|
||||||
|
# Disposable and may contain (worthless, post-teardown) fixture tokens.
|
||||||
|
runs/
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Keysat onboarding harness
|
||||||
|
|
||||||
|
A disposable test rig that runs the global **`onboarding-tester`** agent against
|
||||||
|
Keysat's developer SDK-integration journey, to find every place the *published
|
||||||
|
docs* leave a newcomer stuck — and, on a clean run, to harvest a publishable
|
||||||
|
"all it took was X, Y, Z" walkthrough.
|
||||||
|
|
||||||
|
The premise (from `~/Projects/standards/guides/onboarding-tester.md`): the agent
|
||||||
|
is a fresh adopter who may use **only the published docs corpus**, never Keysat
|
||||||
|
source. The harness builder (you) may read Keysat freely; the agent may not.
|
||||||
|
|
||||||
|
## What a run sets up
|
||||||
|
|
||||||
|
| Piece | What it is | Disposable via |
|
||||||
|
|-------|------------|----------------|
|
||||||
|
| Fixture daemon | a fresh `keysat` release binary on `127.0.0.1:<port>`, throwaway SQLite, fresh issuer keypair | `teardown.sh` |
|
||||||
|
| Provisioning | a **merchant-onboard** scoped key minted with the fixture's master key (the operator's job, not the agent's) | — |
|
||||||
|
| Docs corpus | `keysat-docs/` served over HTTP — the only how-to source the agent may read | `teardown.sh` |
|
||||||
|
| Sandbox | a pristine Next.js/TS proof-of-work (`sandbox-template/`) copied to `/tmp/onboarding-tester/`, with one ungated "Pro export" to gate | `teardown.sh` |
|
||||||
|
|
||||||
|
The fixture's dummy `BTCPAY_URL` is never dialed in this path: **Stage 1 is
|
||||||
|
license issuance + SDK integration, no payments.**
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./run.sh # boot + provision + serve docs + sandbox; writes AGENT_BRIEF.md
|
||||||
|
# → feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||||
|
./teardown.sh runs/<id> # stop daemon + docs server, remove sandbox
|
||||||
|
./teardown.sh runs/<id> --purge # also delete the run dir
|
||||||
|
```
|
||||||
|
|
||||||
|
Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`,
|
||||||
|
`make-sandbox.sh`) can be run on their own; each reads/writes
|
||||||
|
`runs/<id>/state.env` and `runs/current` points at the active run.
|
||||||
|
|
||||||
|
## The loop
|
||||||
|
|
||||||
|
1. `./run.sh`, then run the `onboarding-tester` agent on the brief.
|
||||||
|
2. Read `runs/<id>/reports/friction.md`. If `completed-clean`, harvest the
|
||||||
|
walkthrough into `keysat-docs/agent.html`. Otherwise fix the highest-severity
|
||||||
|
**doc** gaps (additively — document missing API/how-to; don't rewrite
|
||||||
|
marketing copy), tear down, and re-run on a fresh fixture.
|
||||||
|
3. Repeat until `completed-clean`.
|
||||||
|
|
||||||
|
## Stage 2 (buyer pays on regtest) — built, `completed-clean`
|
||||||
|
|
||||||
|
Lives in `stage2/`. Boots a **sandbox** daemon (`KEYSAT_SANDBOX_MODE=1`) wired to
|
||||||
|
a Dockerized BTCPay **regtest** stack and grants the agent `merchant-onboard` +
|
||||||
|
`payment_providers:write` so it connects BTCPay (regtest) and drives a test buyer
|
||||||
|
payment end to end. Connecting a *mainnet* wallet stays operator-only by design —
|
||||||
|
that boundary is a feature, not a gap.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
|
||||||
|
./stage2/btcpay-regtest/probe.sh # mints the BTCPay store token into .live-env (one-time)
|
||||||
|
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
|
||||||
|
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||||
|
./stage2/teardown-stage2.sh # WHEN DONE: stop daemon(s) + docs + sandbox dev server + BTCPay stack
|
||||||
|
```
|
||||||
|
|
||||||
|
- `stage2/btcpay-regtest/` — the BTCPay regtest compose + de-risk probe (`FINDINGS.md`).
|
||||||
|
- `stage2/validate-gate.sh` — end-to-end gate check (deny mainnet/undetermined, allow regtest).
|
||||||
|
- `stage2/buyer-pay.sh` — the test buyer's wallet (pay invoice on regtest + mine).
|
||||||
|
- `stage2/teardown-stage2.sh` — full cleanup: tears down every Stage 2 run, kills any orphaned
|
||||||
|
sandbox dev server (`:4311`), and stops the BTCPay docker stack + volumes (`--keep-btcpay`
|
||||||
|
to leave it up between runs). **Always run this when finished** — the agent can leave a
|
||||||
|
daemon, a docs server, or an `npm run dev` behind.
|
||||||
|
- `stage2/STAGE2-RESULT.md` — convergence + the publishable walkthrough.
|
||||||
|
|
||||||
|
**Harvesting on a clean run:** do NOT reflexively bolt a new success story onto the public
|
||||||
|
HTML. First check whether `keysat-docs/agent.html` (the connect workflow + worked example)
|
||||||
|
and the docs already cover the buyer-pays + SDK-gating case well enough; only propose
|
||||||
|
additions for a genuine gap, with operator approval.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
`cargo`, `node`/`npm`, `python3`, `curl`, `jq`, `openssl`. (Docker is only
|
||||||
|
needed for Stage 2.)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Stage 1 result — developer SDK-integration journey (no payments)
|
||||||
|
|
||||||
|
**Verdict: `completed-clean` on run 3.** A fresh adopter, using only the published
|
||||||
|
docs, can stand up a product, issue a license under a non-master `merchant-onboard`
|
||||||
|
key, integrate the TypeScript SDK into a Next.js app, and gate a feature so a valid
|
||||||
|
license unlocks it and an absent/invalid one blocks it.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
The harness (`./run.sh`) boots a disposable `keysat` fixture (fresh SQLite, fresh
|
||||||
|
issuer keypair), mints a `merchant-onboard` scoped key with the fixture's master
|
||||||
|
key, serves `keysat-docs/` as the published corpus, and materializes a pristine
|
||||||
|
Next.js/TS proof-of-work (`sandbox-template/` → `/tmp/onboarding-tester/`). The
|
||||||
|
global `onboarding-tester` agent then drives the journey **docs-only** — it never
|
||||||
|
reads Keysat source. Corpus declared in-scope: the docs site, the daemon's
|
||||||
|
`/v1/openapi.json`, and the npm `@keysat/licensing-client` README.
|
||||||
|
|
||||||
|
## Convergence
|
||||||
|
|
||||||
|
| Run | Verdict | Findings |
|
||||||
|
|-----|---------|----------|
|
||||||
|
| 1 | completed-with-stumbles (5) + 1 nit | SDK `verify()` shape wrong in integrate.html; product `price_value` vs `price_sats`; licenses filter param; `merchant-onboard` role undocumented; issuer-pubkey response shape; phantom `GET /v1/admin/products`. |
|
||||||
|
| 2 | completed-with-stumbles (1) + 1 nit | "Find a license by email" pointed at the wrong endpoint; server-side key transport unstated. |
|
||||||
|
| 3 | **completed-clean** | none. Walkthrough harvested to `agent.html`. |
|
||||||
|
|
||||||
|
Each finding was verified against Keysat source before the doc was changed (the
|
||||||
|
agent can't read source; the harness builder can).
|
||||||
|
|
||||||
|
## Doc fixes shipped this loop
|
||||||
|
|
||||||
|
**`keysat-docs/` (static site — deploys independently):**
|
||||||
|
- `integrate.html`: rewrote the verify/error examples (TS/Rust/Python) to the real
|
||||||
|
v0.3 SDK — `verify()` throws/returns `Err` and yields `VerifyOk{payload,…}`; no
|
||||||
|
`valid` boolean; entitlements at `payload.entitlements`; errors are `LicensingError`
|
||||||
|
(`.code` in TS, `.kind` in Python; Rust `Error::BadSignature`/`BadFormat`). Replaced the
|
||||||
|
result-fields table; added an offline-expiry note (`isExpiredAt`/`is_expired_at`; TS/Rust
|
||||||
|
`verifyWithTime`) and server-side key-transport guidance.
|
||||||
|
- `agent.html`: added the `merchant-onboard` role row; added "Create a product" and
|
||||||
|
"Add a tier (policy)" workflows with the `price_value`/`price_sats` distinction;
|
||||||
|
fixed the comp-license field name (`buyer_note` → `note`); pointed "Find a license
|
||||||
|
by email" at `/v1/admin/licenses/search`; **added the publishable worked example**
|
||||||
|
(the harvested walkthrough).
|
||||||
|
- `wire-format.html`: corrected the `GET /v1/issuer/public-key` response shape.
|
||||||
|
|
||||||
|
**`licensing-service/src/api/openapi.rs` (served spec — ships with the next daemon
|
||||||
|
release; the local fixture was rebuilt so the agent saw the fixes):**
|
||||||
|
- `GET /v1/admin/licenses` description: requires `product_id=<uuid>`, not a slug.
|
||||||
|
- Removed the phantom `GET /v1/admin/products` (only POST exists; list is the public
|
||||||
|
`GET /v1/products`).
|
||||||
|
- Added the `/v1/admin/licenses/search` path (was referenced but undefined).
|
||||||
|
- Product schema: marked `price_value` as the write field, `price_sats` as derived.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./run.sh # prints the fixture URL, docs URL, merchant key, sandbox path
|
||||||
|
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||||
|
./teardown.sh runs/<id> # leaves nothing running
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-run logs and the three friction reports live under `runs/` (gitignored; the
|
||||||
|
tokens there are worthless after teardown).
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Boot a fresh, disposable Keysat daemon on a throwaway SQLite DB.
|
||||||
|
# Creates a new run dir, writes its state file, points runs/current at it.
|
||||||
|
# Echoes the run id on success.
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||||
|
|
||||||
|
require curl; require openssl; require node
|
||||||
|
|
||||||
|
# Build the daemon if the release binary is missing.
|
||||||
|
if [[ ! -x "$DAEMON_BIN" ]]; then
|
||||||
|
log "release binary missing; building (cargo build --release)…"
|
||||||
|
( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||||
|
RUN_DIR="$RUNS_DIR/$RUN_ID"
|
||||||
|
mkdir -p "$RUN_DIR"
|
||||||
|
STATE="$RUN_DIR/state.env"
|
||||||
|
: > "$STATE"
|
||||||
|
|
||||||
|
PORT="$(free_port)"
|
||||||
|
MASTER="$(openssl rand -hex 32)"
|
||||||
|
DB_DIR="$RUN_DIR/data"
|
||||||
|
mkdir -p "$DB_DIR"
|
||||||
|
|
||||||
|
state_set "$STATE" RUN_ID "$RUN_ID"
|
||||||
|
state_set "$STATE" RUN_DIR "$RUN_DIR"
|
||||||
|
state_set "$STATE" PORT "$PORT"
|
||||||
|
state_set "$STATE" BASE_URL "http://127.0.0.1:$PORT"
|
||||||
|
state_set "$STATE" MASTER_KEY "$MASTER"
|
||||||
|
|
||||||
|
log "booting keysat fixture on 127.0.0.1:$PORT (db: $DB_DIR/keysat.db)"
|
||||||
|
KEYSAT_BIND="127.0.0.1:$PORT" \
|
||||||
|
KEYSAT_DB_PATH="$DB_DIR/keysat.db" \
|
||||||
|
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||||
|
BTCPAY_URL="http://127.0.0.1:1" \
|
||||||
|
KEYSAT_PUBLIC_URL="http://127.0.0.1:$PORT" \
|
||||||
|
KEYSAT_OPERATOR_NAME="Onboarding Fixture" \
|
||||||
|
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
state_set "$STATE" DAEMON_PID "$DAEMON_PID"
|
||||||
|
|
||||||
|
if ! wait_http "http://127.0.0.1:$PORT/healthz" 75; then
|
||||||
|
warn "daemon did not become healthy; last log lines:"
|
||||||
|
tail -20 "$RUN_DIR/daemon.log" >&2 || true
|
||||||
|
kill "$DAEMON_PID" 2>/dev/null || true
|
||||||
|
die "fixture failed to start"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
|
||||||
|
ok "fixture healthy (pid $DAEMON_PID) at http://127.0.0.1:$PORT"
|
||||||
|
echo "$RUN_ID"
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared config + helpers for the Keysat onboarding harness.
|
||||||
|
# Sourced by the stage scripts; not run directly.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HARNESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# onboarding-harness/ -> licensing-service-startos/ -> workspace root
|
||||||
|
WORKSPACE="$(cd "$HARNESS_DIR/../.." && pwd)"
|
||||||
|
DAEMON_DIR="$WORKSPACE/licensing-service-startos/licensing-service"
|
||||||
|
DAEMON_BIN="$DAEMON_DIR/target/release/keysat"
|
||||||
|
DOCS_DIR="$WORKSPACE/keysat-docs"
|
||||||
|
TEMPLATE_DIR="$HARNESS_DIR/sandbox-template"
|
||||||
|
|
||||||
|
# Per-run scratch lives under runs/ (gitignored). The agent's sandbox copy
|
||||||
|
# lives under /tmp/onboarding-tester/ per the onboarding-tester guide.
|
||||||
|
RUNS_DIR="$HARNESS_DIR/runs"
|
||||||
|
SANDBOX_BASE="/tmp/onboarding-tester"
|
||||||
|
|
||||||
|
# The active run's state file is pointed to by runs/current.
|
||||||
|
CURRENT_LINK="$RUNS_DIR/current"
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m[harness]\033[0m %s\n' "$*" >&2; }
|
||||||
|
ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*" >&2; }
|
||||||
|
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# state_set KEY VALUE — append/update a KEY=VALUE line in the run state file.
|
||||||
|
# Not concurrency-safe (uses a fixed temp suffix); the stages call it serially.
|
||||||
|
state_set() {
|
||||||
|
local f="$1" k="$2" v="$3"
|
||||||
|
touch "$f"
|
||||||
|
# strip any existing line for this key, then append
|
||||||
|
grep -v "^${k}=" "$f" > "$f.tmp" 2>/dev/null || true
|
||||||
|
mv "$f.tmp" "$f"
|
||||||
|
printf '%s=%s\n' "$k" "$v" >> "$f"
|
||||||
|
}
|
||||||
|
|
||||||
|
# state_get FILE KEY
|
||||||
|
state_get() { grep "^${2}=" "$1" | head -1 | cut -d= -f2-; }
|
||||||
|
|
||||||
|
# free_port — echo an unused TCP port on 127.0.0.1.
|
||||||
|
free_port() {
|
||||||
|
node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();});'
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_http URL TRIES — poll until URL returns 2xx/3xx, or die.
|
||||||
|
wait_http() {
|
||||||
|
local url="$1" tries="${2:-50}" i
|
||||||
|
for i in $(seq 1 "$tries"); do
|
||||||
|
if curl -fsS -o /dev/null "$url" 2>/dev/null; then return 0; fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Materialize a fresh, pristine proof-of-work app for the agent to integrate
|
||||||
|
# into. Copies sandbox-template/ to /tmp/onboarding-tester/sandbox-<run>/ and
|
||||||
|
# runs `npm install` so the app is known-good before the agent touches it.
|
||||||
|
# The agent mutates ONLY this copy. Usage: make-sandbox.sh [RUN_DIR]
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||||
|
require node; require npm
|
||||||
|
|
||||||
|
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||||
|
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||||
|
STATE="$RUN_DIR/state.env"
|
||||||
|
RUN_ID="$(state_get "$STATE" RUN_ID)"
|
||||||
|
|
||||||
|
mkdir -p "$SANDBOX_BASE"
|
||||||
|
SANDBOX="$SANDBOX_BASE/sandbox-$RUN_ID"
|
||||||
|
rm -rf "$SANDBOX"
|
||||||
|
log "copying pristine proof-of-work to $SANDBOX"
|
||||||
|
# copy template without any stray build artifacts
|
||||||
|
( cd "$TEMPLATE_DIR" && find . -type d \( -name node_modules -o -name .next \) -prune -o -type f -print \
|
||||||
|
| while IFS= read -r f; do mkdir -p "$SANDBOX/$(dirname "$f")"; cp "$f" "$SANDBOX/$f"; done )
|
||||||
|
|
||||||
|
log "installing base app dependencies (npm install)…"
|
||||||
|
( cd "$SANDBOX" && npm install --no-audit --no-fund >"$RUN_DIR/sandbox-npm.log" 2>&1 ) \
|
||||||
|
|| { tail -20 "$RUN_DIR/sandbox-npm.log" >&2; die "sandbox npm install failed"; }
|
||||||
|
|
||||||
|
state_set "$STATE" SANDBOX "$SANDBOX"
|
||||||
|
ok "pristine sandbox ready at $SANDBOX"
|
||||||
|
echo "$SANDBOX"
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Provisioner step (the human operator's job, NOT the agent's): with the
|
||||||
|
# fixture's master key, mint a merchant-onboard scoped key and capture the
|
||||||
|
# issuer public key. Writes both into the run state file.
|
||||||
|
# Usage: provision.sh [RUN_DIR] (defaults to runs/current)
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||||
|
require curl; require jq
|
||||||
|
|
||||||
|
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||||
|
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||||
|
STATE="$RUN_DIR/state.env"
|
||||||
|
BASE_URL="$(state_get "$STATE" BASE_URL)"
|
||||||
|
MASTER="$(state_get "$STATE" MASTER_KEY)"
|
||||||
|
|
||||||
|
log "minting merchant-onboard scoped key via master key"
|
||||||
|
RESP="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" \
|
||||||
|
-H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
|
||||||
|
-d '{"label":"onboarding-agent","role":"merchant-onboard","scopes":[]}')" \
|
||||||
|
|| die "key mint failed"
|
||||||
|
TOKEN="$(echo "$RESP" | jq -r '.token')"
|
||||||
|
[[ "$TOKEN" == ks_* ]] || die "unexpected mint response: $RESP"
|
||||||
|
state_set "$STATE" MERCHANT_KEY "$TOKEN"
|
||||||
|
|
||||||
|
log "fetching issuer public key"
|
||||||
|
PUBKEY_PEM="$(curl -fsS "$BASE_URL/v1/issuer/public-key" | jq -r '.public_key_pem')"
|
||||||
|
[[ "$PUBKEY_PEM" == *"BEGIN PUBLIC KEY"* ]] || die "could not fetch issuer public key"
|
||||||
|
printf '%s' "$PUBKEY_PEM" > "$RUN_DIR/issuer.pub"
|
||||||
|
state_set "$STATE" ISSUER_PUBKEY_FILE "$RUN_DIR/issuer.pub"
|
||||||
|
|
||||||
|
ok "merchant-onboard key minted; issuer pubkey saved to $RUN_DIR/issuer.pub"
|
||||||
|
echo "$TOKEN"
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-shot Stage 1 setup: boot fixture, provision the merchant-onboard key,
|
||||||
|
# serve the docs corpus, materialize a pristine sandbox, then emit the agent
|
||||||
|
# brief (AGENT_BRIEF.md) with the live URLs + credentials interpolated in.
|
||||||
|
#
|
||||||
|
# This script sets the stage; it does NOT run the agent (the orchestrator does
|
||||||
|
# that with the global onboarding-tester agent, feeding it AGENT_BRIEF.md).
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||||
|
|
||||||
|
RUN_ID="$("$HARNESS_DIR/boot-fixture.sh")"
|
||||||
|
RUN_DIR="$RUNS_DIR/$RUN_ID"
|
||||||
|
STATE="$RUN_DIR/state.env"
|
||||||
|
"$HARNESS_DIR/provision.sh" "$RUN_DIR" >/dev/null
|
||||||
|
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
|
||||||
|
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
|
||||||
|
|
||||||
|
BASE_URL="$(state_get "$STATE" BASE_URL)"
|
||||||
|
DOCS_URL="$(state_get "$STATE" DOCS_URL)"
|
||||||
|
MERCHANT_KEY="$(state_get "$STATE" MERCHANT_KEY)"
|
||||||
|
SANDBOX="$(state_get "$STATE" SANDBOX)"
|
||||||
|
mkdir -p "$RUN_DIR/reports"
|
||||||
|
|
||||||
|
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
|
||||||
|
# Onboarding-tester brief — Keysat SDK integration (Stage 1, no payments)
|
||||||
|
|
||||||
|
You are a **fresh adopter**, following your operating guide
|
||||||
|
(\`~/Projects/standards/guides/onboarding-tester.md\`). Reach the goal below
|
||||||
|
using **only the docs corpus**. Never read Keysat's server or SDK source to
|
||||||
|
unblock yourself — if the docs don't get you there, that is a finding.
|
||||||
|
|
||||||
|
## Goal (checkable end-state)
|
||||||
|
A developer with a Next.js/TypeScript app wants to sell it. Using a **scoped,
|
||||||
|
non-master API key**, and the published docs only:
|
||||||
|
|
||||||
|
1. Define the product in Keysat's catalog.
|
||||||
|
2. Add at least one tier/policy with an entitlement.
|
||||||
|
3. Manually issue a license for that product/tier (a comp/dev license — no
|
||||||
|
payment in this path).
|
||||||
|
4. Integrate the TypeScript SDK into the proof-of-work app so the **Pro export**
|
||||||
|
(\`GET /api/export\`) is gated: it returns the CSV only with a valid license.
|
||||||
|
5. Verify the gate both ways: a **valid** license unlocks the export; **no**
|
||||||
|
license and a **tampered/invalid** license are blocked (4xx, not the CSV).
|
||||||
|
|
||||||
|
Success = the gate demonstrably works both ways, reached from the docs alone.
|
||||||
|
|
||||||
|
## Docs corpus (the ONLY how-to sources you may consult)
|
||||||
|
- The Keysat docs site, served at: **$DOCS_URL** (start at \`/integrate.html\`
|
||||||
|
and \`/agent.html\`; the whole site is in-corpus).
|
||||||
|
- The daemon's published OpenAPI spec: **$BASE_URL/v1/openapi.json**
|
||||||
|
(unauthenticated; the docs explicitly point adopters here).
|
||||||
|
- The npm package README for \`@keysat/licensing-client\` (\`npm view\`, or the
|
||||||
|
package page). The SDK's published README is in-corpus.
|
||||||
|
|
||||||
|
**Out of corpus (do not open):** anything under the Keysat source tree
|
||||||
|
(\`$WORKSPACE/licensing-service-startos\`, \`$WORKSPACE/licensing-client-*\`,
|
||||||
|
migrations, tests, this harness). Reading any of it invalidates the run — say so
|
||||||
|
if you do.
|
||||||
|
|
||||||
|
## Your sandbox (mutate ONLY this)
|
||||||
|
\`$SANDBOX\` — a pristine copy of the "Acme Reports" app. Read its own
|
||||||
|
\`README.md\` freely (it's your app). Deps are already installed. Run it with
|
||||||
|
\`npm run dev\` (it serves on http://localhost:4311). Put all scratch under
|
||||||
|
\`/tmp/onboarding-tester/\`.
|
||||||
|
|
||||||
|
## Credentials you were handed (a real adopter would get these from their operator)
|
||||||
|
- Keysat server URL: **$BASE_URL**
|
||||||
|
- Scoped API key (merchant-onboard role): **$MERCHANT_KEY**
|
||||||
|
- (The issuer public key is fetchable per the docs — find how.)
|
||||||
|
|
||||||
|
You were NOT given the master admin key. If a step seems to require it, that is
|
||||||
|
either an intended operator-only boundary (note it) or a doc gap (log it).
|
||||||
|
|
||||||
|
## Output
|
||||||
|
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as
|
||||||
|
your final message, exactly in the format from your guide (Verdict, Corpus &
|
||||||
|
goal, Friction log most-severe-first, Path walked, Confidence). On a
|
||||||
|
\`completed-clean\` verdict only, also emit the publishable walkthrough
|
||||||
|
(secret-free, placeholders for URL/key). Record commands and doc locations as
|
||||||
|
you go; do not work from memory.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "Stage 1 staged. Run id: $RUN_ID"
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
Fixture URL : $BASE_URL
|
||||||
|
Docs corpus : $DOCS_URL
|
||||||
|
Merchant key: $MERCHANT_KEY
|
||||||
|
Sandbox : $SANDBOX
|
||||||
|
Agent brief : $RUN_DIR/AGENT_BRIEF.md
|
||||||
|
Reports dir : $RUN_DIR/reports/
|
||||||
|
|
||||||
|
Tear down with: $HARNESS_DIR/teardown.sh "$RUN_DIR"
|
||||||
|
EOF
|
||||||
|
echo "$RUN_ID"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env*.local
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Acme Reports — proof-of-work app
|
||||||
|
|
||||||
|
A deliberately tiny Next.js (App Router) + TypeScript app. It shows a small
|
||||||
|
analytics table for free and offers a **Pro export** (CSV download) at
|
||||||
|
`GET /api/export`.
|
||||||
|
|
||||||
|
**In its pristine state the Pro export is ungated** — anyone can download it.
|
||||||
|
Your job, as the integrator, is to put it behind a Keysat license: only a
|
||||||
|
holder of a valid license for this product should be able to export.
|
||||||
|
|
||||||
|
This README describes *your own app* — you may read it freely. It tells you
|
||||||
|
nothing about how Keysat works; for that, use only the Keysat docs you were
|
||||||
|
pointed at.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install # already done for you in the sandbox
|
||||||
|
npm run dev # starts on http://localhost:4311
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GET http://localhost:4311/` — the free report view.
|
||||||
|
- `GET http://localhost:4311/api/export` — the Pro export (CSV). Currently free.
|
||||||
|
|
||||||
|
## What "done" looks like
|
||||||
|
|
||||||
|
After integration:
|
||||||
|
|
||||||
|
- `GET /api/export` returns the CSV **only** when a valid license is present.
|
||||||
|
- With **no** license, or a **tampered/invalid** one, `/api/export` is blocked
|
||||||
|
(a 4xx, not the CSV).
|
||||||
|
|
||||||
|
How the app learns the user's license key (env var, file, header) is your
|
||||||
|
call — pick whatever the Keysat docs suggest and note it.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ROWS, toCsv } from "@/lib/reports";
|
||||||
|
|
||||||
|
// The "Pro export" endpoint.
|
||||||
|
//
|
||||||
|
// PRISTINE STATE: this feature is currently FREE — anyone who hits it gets the
|
||||||
|
// CSV. The goal of this proof-of-work is to gate it behind a valid Keysat
|
||||||
|
// license so that only paying customers can export.
|
||||||
|
//
|
||||||
|
// (How you wire that in is up to the integrator following the Keysat docs.)
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const csv = toCsv(ROWS);
|
||||||
|
return new Response(csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv",
|
||||||
|
"Content-Disposition": 'attachment; filename="acme-report.csv"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Acme Reports",
|
||||||
|
description: "A tiny analytics tool with a paid Pro export.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "3rem auto", padding: "0 1rem" }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ROWS } from "@/lib/reports";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Acme Reports</h1>
|
||||||
|
<p>Your signups and revenue by region. Viewing is free.</p>
|
||||||
|
<table cellPadding={6} style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left">Region</th>
|
||||||
|
<th align="right">Signups</th>
|
||||||
|
<th align="right">Revenue (sats)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ROWS.map((r) => (
|
||||||
|
<tr key={r.region}>
|
||||||
|
<td>{r.region}</td>
|
||||||
|
<td align="right">{r.signups}</td>
|
||||||
|
<td align="right">{r.revenueSats.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style={{ marginTop: "2rem" }}>Pro export</h2>
|
||||||
|
<p>
|
||||||
|
Download the full dataset as CSV. This is a paid feature:{" "}
|
||||||
|
<a href="/api/export">/api/export</a>.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// The "data" behind Acme Reports. The free tier lets you view it on screen;
|
||||||
|
// the paid "Pro export" feature lets you download it as CSV. That export is
|
||||||
|
// the feature we want to gate behind a Keysat license.
|
||||||
|
|
||||||
|
export type Row = { region: string; signups: number; revenueSats: number };
|
||||||
|
|
||||||
|
export const ROWS: Row[] = [
|
||||||
|
{ region: "North", signups: 412, revenueSats: 1_240_000 },
|
||||||
|
{ region: "South", signups: 318, revenueSats: 980_500 },
|
||||||
|
{ region: "East", signups: 521, revenueSats: 1_702_300 },
|
||||||
|
{ region: "West", signups: 274, revenueSats: 731_900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function toCsv(rows: Row[]): string {
|
||||||
|
const header = "region,signups,revenue_sats";
|
||||||
|
const body = rows.map((r) => `${r.region},${r.signups},${r.revenueSats}`);
|
||||||
|
return [header, ...body].join("\n") + "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// Keep the proof-of-work app deliberately boring: no experimental flags,
|
||||||
|
// so any onboarding friction is attributable to Keysat, not to Next.js.
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "acme-reports",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Pristine proof-of-work app for the Keysat onboarding harness. A tiny Next.js report tool whose 'Pro export' feature is meant to be gated behind a Keysat license.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 4311",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 4311"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.1.6",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.10.7",
|
||||||
|
"@types/react": "19.0.7",
|
||||||
|
"@types/react-dom": "19.0.3",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Serve the keysat-docs/ site over HTTP as the "published docs corpus" the
|
||||||
|
# agent is allowed to read. Writes the docs URL + server pid into state.
|
||||||
|
# Usage: serve-docs.sh [RUN_DIR]
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||||
|
|
||||||
|
RUN_DIR="${1:-$(readlink "$CURRENT_LINK")}"
|
||||||
|
[[ -d "$RUN_DIR" ]] || die "no run dir (boot a fixture first)"
|
||||||
|
STATE="$RUN_DIR/state.env"
|
||||||
|
[[ -d "$DOCS_DIR" ]] || die "keysat-docs not found at $DOCS_DIR"
|
||||||
|
|
||||||
|
PORT="$(free_port)"
|
||||||
|
log "serving published docs corpus from $DOCS_DIR on 127.0.0.1:$PORT"
|
||||||
|
# --directory avoids a `cd` subshell, so $! is the real python PID (not a
|
||||||
|
# wrapper shell that would orphan the server on teardown). nohup survives the
|
||||||
|
# SIGHUP when this script exits.
|
||||||
|
nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$DOCS_DIR" \
|
||||||
|
>"$RUN_DIR/docs-server.log" 2>&1 &
|
||||||
|
DOCS_PID=$!
|
||||||
|
state_set "$STATE" DOCS_PID "$DOCS_PID"
|
||||||
|
state_set "$STATE" DOCS_PORT "$PORT"
|
||||||
|
state_set "$STATE" DOCS_URL "http://127.0.0.1:$PORT"
|
||||||
|
|
||||||
|
if ! wait_http "http://127.0.0.1:$PORT/" 25; then
|
||||||
|
die "docs server failed to come up"
|
||||||
|
fi
|
||||||
|
ok "docs corpus served at http://127.0.0.1:$PORT (pid $DOCS_PID)"
|
||||||
|
echo "http://127.0.0.1:$PORT"
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Stage 2 result — agent connects BTCPay (regtest) + buyer pays (payments)
|
||||||
|
|
||||||
|
**Verdict: `completed-clean` on run 3 (0 findings).** A fresh adopter, using only the
|
||||||
|
published docs and a **scoped** key (`merchant-onboard` + `payment_providers:write`, no
|
||||||
|
master key), can connect a regtest BTCPay over the API with **no browser step**, stand up
|
||||||
|
a paid product, produce a buyer checkout, and have a **real (regtest) on-chain payment
|
||||||
|
settle into a signed license** that validates offline.
|
||||||
|
|
||||||
|
This is the buyer-pays half of the onboarding harness (Stage 1 = no-payments SDK
|
||||||
|
integration). It is gated on the **agent-payment-connect** daemon feature (slices 3-4):
|
||||||
|
the scoped BTCPay connect is allowed only on a **sandbox** daemon for a **non-mainnet**
|
||||||
|
network. See `plans/agent-payment-connect-scope.md` and `stage2/FINDINGS.md`.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
`stage2/run-stage2.sh` boots a disposable Keysat daemon in **sandbox mode**
|
||||||
|
(`KEYSAT_SANDBOX_MODE=1`) wired to the regtest BTCPay stack (`stage2/btcpay-regtest/`),
|
||||||
|
mints a scoped key carrying `payment_providers:write`, serves `keysat-docs/` as the
|
||||||
|
corpus, and materializes a sandbox app. The daemon binds `0.0.0.0` and registers its
|
||||||
|
settle webhook via `host.docker.internal` so the BTCPay container can reach it. The
|
||||||
|
global `onboarding-tester` agent then drives the journey **docs-only**. The test buyer's
|
||||||
|
wallet is `stage2/buyer-pay.sh` (pays the invoice on regtest + mines a confirmation).
|
||||||
|
|
||||||
|
## Convergence
|
||||||
|
|
||||||
|
| Run | Verdict | Findings |
|
||||||
|
|-----|---------|----------|
|
||||||
|
| 1 | blocked-at-step-1 (docs) | 2 blockers (agent.html#not-exposed said provider-connect is master-only; the connect/status/callback endpoints absent from OpenAPI) + 2 stumbles (headless callback pattern undocumented; `payment_providers:write` scope undocumented) + 1 nit. |
|
||||||
|
| 2 | **completed-clean** | 1 doc nit (install.html BTCPay permission list wrong) + 1 harness-script bug (`buyer-pay.sh` missing `-rpcwallet`). |
|
||||||
|
| 3 | **completed-clean (0)** | none. Walkthrough harvested below. |
|
||||||
|
|
||||||
|
The capability worked end to end from run 1 (the agent connected BTCPay headlessly and got
|
||||||
|
a license); the blockers were purely that the docs *said it was impossible* and didn't
|
||||||
|
document the path.
|
||||||
|
|
||||||
|
## Doc fixes shipped this loop
|
||||||
|
|
||||||
|
**`keysat-docs/` (deploys independently):**
|
||||||
|
- `agent.html`: corrected the `#auth` master-only statement; added an **A-la-carte extra
|
||||||
|
scopes** subsection (`payment_providers:write`); narrowed `#not-exposed` to the accurate
|
||||||
|
gate (scoped connect allowed only sandbox + non-mainnet; disconnect + production/mainnet
|
||||||
|
stay master-only); added the **Connect BTCPay programmatically (sandbox)** workflow
|
||||||
|
(`#connect-btcpay`) with the 3-step API flow.
|
||||||
|
- `install.html`: corrected the BTCPay permission list to the five the daemon actually
|
||||||
|
requests; added an "automating setup?" pointer to the agent path.
|
||||||
|
|
||||||
|
**`licensing-service/src/api/openapi.rs` (served spec; ships next daemon release):**
|
||||||
|
- Added `/v1/admin/btcpay/connect`, `/v1/btcpay/authorize/callback`,
|
||||||
|
`/v1/admin/btcpay/status`, `/v1/admin/btcpay/disconnect`; added the `scopes` field to
|
||||||
|
scoped-key creation; noted the read-only `sandbox` flag on `/v1/admin/tier`.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
|
||||||
|
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
|
||||||
|
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||||
|
./teardown.sh runs/<id> # stops daemon + docs server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishable walkthrough (harvested, run 3)
|
||||||
|
|
||||||
|
All it took, on a sandbox Keysat with a scoped `payment_providers:write` key and a regtest
|
||||||
|
BTCPay store key (no master key, no browser):
|
||||||
|
|
||||||
|
1. **Connect BTCPay** — `POST /v1/admin/btcpay/connect` -> `state`; then
|
||||||
|
`GET /v1/btcpay/authorize/callback?state=<state>&apiKey=<btcpay_store_key>`; confirm with
|
||||||
|
`GET /v1/admin/btcpay/status`.
|
||||||
|
2. **Define a paid product** — `POST /v1/admin/products` + `POST /v1/admin/policies`.
|
||||||
|
3. **Create a checkout** — `POST /v1/purchase` -> `checkout_url` + `amount_sats`.
|
||||||
|
4. **Buyer pays** (regtest on-chain), daemon settles via webhook, `GET /v1/purchase/<id>`
|
||||||
|
returns `status: settled` + a signed `license_key`.
|
||||||
|
5. **Validate** — `POST /v1/validate` -> `ok: true` with the tier's entitlements.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
probe-out/
|
||||||
|
.live-env
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# De-risk result — BTCPay regtest network detection (agent-payment-connect slice 3)
|
||||||
|
|
||||||
|
**Verdict: the spec's primary network-detection assumption (§6.1) is VALIDATED against
|
||||||
|
a live regtest BTCPay 2.x. No blocker; slice 3 needs no extra OAuth permission.**
|
||||||
|
|
||||||
|
Rig: `docker-compose.yml` in this dir — bitcoind(regtest) + NBXplorer + postgres +
|
||||||
|
btcpayserver `2.0.6`. Validated 2026-06-16. Probe: `probe.sh`; raw payloads in
|
||||||
|
`probe-out/`. Bring up `docker compose -p keysat-btcpay up -d`; tear down
|
||||||
|
`docker compose -p keysat-btcpay down -v`.
|
||||||
|
|
||||||
|
## What the gate will actually see
|
||||||
|
|
||||||
|
1. **Payment-method id is `BTC-CHAIN`** on BTCPay 2.x. Posting to the legacy `.../BTC/...`
|
||||||
|
path is normalized to `BTC-CHAIN`. **Do not hardcode** — BTCPay 1.x used `BTC`. Slice 3
|
||||||
|
should read `paymentMethodId` from the list and pick the on-chain BTC method
|
||||||
|
(id ∈ {`BTC-CHAIN`,`BTC`}, not Lightning).
|
||||||
|
|
||||||
|
2. **Primary signal — receive address HRP (spec §6.1 primary), CONFIRMED:**
|
||||||
|
`GET /api/v1/stores/{id}/payment-methods/BTC-CHAIN/wallet/address`
|
||||||
|
→ `{"address":"bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt","keyPath":"0/0","paymentLink":...}`
|
||||||
|
`bcrt1…` HRP ⇒ **regtest** ⇒ non-mainnet ⇒ scoped connect allowed (on a sandbox daemon).
|
||||||
|
Classification table (validated regtest arm; others by HRP spec):
|
||||||
|
`bc1`/base58 `1`,`3` → mainnet (deny scoped) · `tb1` → testnet/signet · `bcrt1` → regtest ·
|
||||||
|
base58 `m`,`n`,`2` → test/regtest.
|
||||||
|
|
||||||
|
3. **Secondary signal — derivation, CONFIRMED but field name differs from the spec.**
|
||||||
|
The spec says `derivationScheme`; on BTCPay 2.x Greenfield it is
|
||||||
|
**`config.accountDerivation`** (and `config.signingKey`, `config.accountKeySettings[].accountKey`),
|
||||||
|
value `tpubDC…` for regtest/testnet (mainnet → `xpub/ypub/zpub`). The BIP-84 account path
|
||||||
|
is `84'/1'/0'` — coin-type `1'` is itself a testnet/regtest marker. **Requires
|
||||||
|
`?includeConfig=true`** — see permission note below.
|
||||||
|
|
||||||
|
## Permission — the daemon already has enough
|
||||||
|
|
||||||
|
- The daemon's BTCPay OAuth (`REQUESTED_PERMISSIONS`, `btcpay_authorize.rs:45`) already
|
||||||
|
requests **`btcpay.store.canmodifystoresettings`** (for webhook registration).
|
||||||
|
- Empirically, with a token holding only `canmodifystoresettings`:
|
||||||
|
`wallet/address` → **HTTP 200**, and `payment-methods?includeConfig=true` → config **visible**.
|
||||||
|
- `wallet/address` specifically needs `canmodifystoresettings` (`canviewstoresettings` →
|
||||||
|
**403**). The `config`/derivation path needs only `canviewstoresettings`.
|
||||||
|
- ⇒ **Slice 3 can use EITHER signal with the key it already obtains at connect. No new
|
||||||
|
OAuth scope.** Recommend the **address-HRP path** (spec's primary; one call; unambiguous).
|
||||||
|
|
||||||
|
## Fail-closed cases (all confirmed → treat as mainnet → master-only)
|
||||||
|
|
||||||
|
- No on-chain wallet configured → `GET payment-methods` returns `[]` (no BTC-CHAIN method).
|
||||||
|
- `wallet/address` on a store with no wallet → **HTTP 503** `"BTC-CHAIN services are not
|
||||||
|
currently available"`. (Same 503 also appears transiently while BTCPay is not yet
|
||||||
|
`synchronized:true` — at operator connect time it will be synced, but treat any non-2xx /
|
||||||
|
missing address / unrecognized HRP as "cannot determine" ⇒ deny scoped, require master.)
|
||||||
|
|
||||||
|
## Implication for the daemon client (slice 3)
|
||||||
|
|
||||||
|
The existing `btcpay/client.rs::list_payment_methods` calls `GET .../payment-methods`
|
||||||
|
**without** `includeConfig`, so today it sees `config:null` (confirmed). To detect network,
|
||||||
|
add a small client fn that GETs `.../payment-methods/{pmid}/wallet/address` and classifies
|
||||||
|
the HRP (preferred), or pass `?includeConfig=true` and read `config.accountDerivation`.
|
||||||
|
Resolve target network **before persisting** the provider (spec §7).
|
||||||
|
|
||||||
|
## Rig gotcha (for whoever rebuilds this)
|
||||||
|
|
||||||
|
NBXplorer defaults to cookie auth; with separate datadir volumes BTCPay can't read the
|
||||||
|
cookie → `401` → BTCPay never reaches `synchronized:true` → on-chain `BTC-CHAIN` service
|
||||||
|
stays unavailable (`503`). Fix used here: `NBXPLORER_NOAUTH=1` (fine for a throwaway
|
||||||
|
regtest box). A production-faithful harness would instead share NBXplorer's datadir volume
|
||||||
|
into BTCPay so the cookie is shared.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Throwaway BTCPay Server regtest stack — de-risk rig for agent-payment-connect
|
||||||
|
# network detection (spec §6.1). NOT a production deployment, NOT yet wired into
|
||||||
|
# the Stage 2 harness. Bring up: docker compose -p keysat-btcpay up -d
|
||||||
|
# Tear down (incl. volumes): docker compose -p keysat-btcpay down -v
|
||||||
|
#
|
||||||
|
# Ports published to the host:
|
||||||
|
# BTCPay UI/Greenfield API → http://127.0.0.1:49392
|
||||||
|
# bitcoind regtest RPC → 127.0.0.1:43782 (user/pass keysat/keysat)
|
||||||
|
services:
|
||||||
|
bitcoind:
|
||||||
|
image: btcpayserver/bitcoin:28.1
|
||||||
|
environment:
|
||||||
|
BITCOIN_NETWORK: regtest
|
||||||
|
BITCOIN_EXTRA_ARGS: |
|
||||||
|
rpcuser=keysat
|
||||||
|
rpcpassword=keysat
|
||||||
|
rpcbind=0.0.0.0:43782
|
||||||
|
rpcallowip=0.0.0.0/0
|
||||||
|
port=39388
|
||||||
|
whitelist=0.0.0.0/0
|
||||||
|
zmqpubrawblock=tcp://0.0.0.0:28332
|
||||||
|
zmqpubrawtx=tcp://0.0.0.0:28333
|
||||||
|
fallbackfee=0.0002
|
||||||
|
txindex=1
|
||||||
|
expose:
|
||||||
|
- "43782"
|
||||||
|
- "39388"
|
||||||
|
- "28332"
|
||||||
|
- "28333"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:43782:43782"
|
||||||
|
volumes:
|
||||||
|
- bitcoin_datadir:/data
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:13.13
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
volumes:
|
||||||
|
- postgres_datadir:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
nbxplorer:
|
||||||
|
image: nicolasdorier/nbxplorer:2.5.22
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NBXPLORER_NETWORK: regtest
|
||||||
|
NBXPLORER_NOAUTH: "1"
|
||||||
|
NBXPLORER_BIND: 0.0.0.0:32838
|
||||||
|
NBXPLORER_TRIMEVENTS: "10000"
|
||||||
|
NBXPLORER_SIGNALFILESDIR: /datadir
|
||||||
|
NBXPLORER_CHAINS: "btc"
|
||||||
|
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
|
||||||
|
NBXPLORER_BTCRPCUSER: keysat
|
||||||
|
NBXPLORER_BTCRPCPASSWORD: keysat
|
||||||
|
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
|
||||||
|
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer
|
||||||
|
depends_on:
|
||||||
|
- bitcoind
|
||||||
|
- postgres
|
||||||
|
volumes:
|
||||||
|
- nbxplorer_datadir:/datadir
|
||||||
|
|
||||||
|
btcpayserver:
|
||||||
|
image: btcpayserver/btcpayserver:2.0.6
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=20;Database=btcpayserver
|
||||||
|
BTCPAY_NETWORK: regtest
|
||||||
|
BTCPAY_BIND: 0.0.0.0:49392
|
||||||
|
BTCPAY_ROOTPATH: /
|
||||||
|
BTCPAY_PROTOCOL: http
|
||||||
|
BTCPAY_CHAINS: "btc"
|
||||||
|
BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/
|
||||||
|
BTCPAY_DEBUGLOG: btcpay.log
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:49392:49392"
|
||||||
|
depends_on:
|
||||||
|
- nbxplorer
|
||||||
|
- postgres
|
||||||
|
volumes:
|
||||||
|
- btcpay_datadir:/datadir
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bitcoin_datadir:
|
||||||
|
postgres_datadir:
|
||||||
|
nbxplorer_datadir:
|
||||||
|
btcpay_datadir:
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# De-risk probe + .live-env minter for the Stage 2 / combined onboarding harness.
|
||||||
|
# Run once after `docker compose -p keysat-btcpay up -d`.
|
||||||
|
#
|
||||||
|
# Two jobs:
|
||||||
|
# A. Mint .live-env — create the two stores the harness needs (one with an
|
||||||
|
# on-chain regtest wallet, one without) plus store-scoped BTCPay API tokens
|
||||||
|
# carrying the five permissions the Connect-BTCPay flow documents
|
||||||
|
# (install.html#connect-btcpay), and write them to .live-env for
|
||||||
|
# run-stage2.sh / validate-gate.sh to source.
|
||||||
|
# B. De-risk (spec §6.1) — dump the exact Greenfield responses the slice-3
|
||||||
|
# network gate consults (payment-methods, wallet/address) into probe-out/
|
||||||
|
# and classify the receive-address HRP.
|
||||||
|
#
|
||||||
|
# Idempotency: assumes a FRESH instance (compose `up -d` after `down -v`).
|
||||||
|
# Re-running against a live instance creates duplicate stores — tear down first.
|
||||||
|
# Read-only against Keysat; only mutates the throwaway BTCPay instance.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
BASE="${BTCPAY_BASE:-http://127.0.0.1:49392}"
|
||||||
|
ADMIN_EMAIL="admin@keysat.local"
|
||||||
|
ADMIN_PW="keysatregtest1!"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OUT_DIR="$HERE/probe-out"
|
||||||
|
LIVE_ENV="$HERE/.live-env"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
# Permissions the documented Connect-BTCPay flow grants (install.html#connect-btcpay).
|
||||||
|
STORE_PERMS='canviewstoresettings canmodifystoresettings canviewinvoices cancreateinvoice canmodifyinvoices'
|
||||||
|
BTND=keysat-btcpay-bitcoind-1
|
||||||
|
|
||||||
|
hr(){ printf '\n\033[1;36m=== %s ===\033[0m\n' "$*"; }
|
||||||
|
jqp(){ jq . 2>/dev/null || cat; }
|
||||||
|
AUTH=(-u "$ADMIN_EMAIL:$ADMIN_PW")
|
||||||
|
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
|
||||||
|
|
||||||
|
create_store(){ # NAME -> store id
|
||||||
|
curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/stores" \
|
||||||
|
-H 'Content-Type: application/json' -d "{\"name\":\"$1\"}" | jq -r '.id'
|
||||||
|
}
|
||||||
|
store_token(){ # STORE_ID -> store-scoped API key with the 5 documented perms
|
||||||
|
local sid="$1" perms="" p
|
||||||
|
for p in $STORE_PERMS; do perms="$perms\"btcpay.store.$p:$sid\","; done
|
||||||
|
curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/api-keys" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"label\":\"keysat-$sid\",\"permissions\":[${perms%,}]}" | jq -r '.apiKey'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 0. wait for BTCPay --------------------------------------------------------
|
||||||
|
hr "0. waiting for BTCPay health at $BASE"
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
curl -fsS "$BASE/api/v1/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
[[ $i == 120 ]] && { echo "BTCPay never became healthy"; exit 1; }
|
||||||
|
done
|
||||||
|
curl -fsS "$BASE/api/v1/health" | jqp
|
||||||
|
|
||||||
|
# --- 1. create first admin (unauthenticated, only works on a fresh instance) ---
|
||||||
|
hr "1. create first admin (idempotent: 'already exists' is fine)"
|
||||||
|
curl -sS -X POST "$BASE/api/v1/users" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PW\",\"isAdministrator\":true}" | jqp
|
||||||
|
|
||||||
|
# --- 2. admin user API key (KEYSAT_LIVE_BTCPAY_KEY; broad, for ad-hoc admin use) -
|
||||||
|
hr "2. mint admin user API key"
|
||||||
|
ADMIN_KEY="$(curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/api-keys" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"label":"keysat-admin","permissions":["btcpay.server.canmodifyserversettings","btcpay.store.canmodifystoresettings","btcpay.store.canmodifyinvoices"]}' \
|
||||||
|
| jq -r '.apiKey')"
|
||||||
|
echo "ADMIN_KEY=${ADMIN_KEY:0:8}…"
|
||||||
|
|
||||||
|
# --- 3. regtest store WITH an on-chain wallet ----------------------------------
|
||||||
|
hr "3. create regtest store (with on-chain wallet)"
|
||||||
|
STORE_REGTEST="$(create_store 'Keysat Regtest Co')"
|
||||||
|
echo "STORE_REGTEST=$STORE_REGTEST"
|
||||||
|
[[ -z "$STORE_REGTEST" || "$STORE_REGTEST" == null ]] && { echo "no regtest store id"; exit 1; }
|
||||||
|
|
||||||
|
gen_body='{"savePrivateKeys":false,"importKeysToRPC":false,"wordList":"English","wordCount":12,"scriptPubKeyType":"Segwit"}'
|
||||||
|
PMID=""
|
||||||
|
for cand in BTC-CHAIN BTC; do
|
||||||
|
hr "3b. generate wallet on pmid=$cand"
|
||||||
|
code="$(curl -sS -o "$OUT_DIR/gen-$cand.json" -w '%{http_code}' "${AUTH[@]}" \
|
||||||
|
-X POST "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods/$cand/wallet/generate" \
|
||||||
|
-H 'Content-Type: application/json' -d "$gen_body")"
|
||||||
|
echo "HTTP $code"; cat "$OUT_DIR/gen-$cand.json" | jqp
|
||||||
|
[[ "$code" == 2* ]] && { PMID="$cand"; break; }
|
||||||
|
done
|
||||||
|
[[ -z "$PMID" ]] && { echo "!! wallet generate failed for both pmid forms"; exit 1; }
|
||||||
|
|
||||||
|
# --- 4. mine regtest blocks so the wallet has a usable address -----------------
|
||||||
|
hr "4. mine regtest blocks"
|
||||||
|
ADDR_FOR_MINE="$(cli getnewaddress 2>/dev/null || true)"
|
||||||
|
echo "miner address: ${ADDR_FOR_MINE:-<none>}"
|
||||||
|
[[ -n "$ADDR_FOR_MINE" ]] && { cli generatetoaddress 101 "$ADDR_FOR_MINE" >/dev/null 2>&1 \
|
||||||
|
&& echo "mined 101 blocks" || echo "mine failed (non-fatal for detection probe)"; }
|
||||||
|
|
||||||
|
# --- 5. no-wallet store (fail-closed arm of the gate) --------------------------
|
||||||
|
hr "5. create no-wallet store"
|
||||||
|
STORE_NOWALLET="$(create_store 'Keysat NoWallet Co')"
|
||||||
|
echo "STORE_NOWALLET=$STORE_NOWALLET"
|
||||||
|
[[ -z "$STORE_NOWALLET" || "$STORE_NOWALLET" == null ]] && { echo "no nowallet store id"; exit 1; }
|
||||||
|
|
||||||
|
# --- 6. store-scoped tokens (what the agent/harness hand Keysat at connect) -----
|
||||||
|
hr "6. mint store-scoped tokens"
|
||||||
|
GATE_TOK_REGTEST="$(store_token "$STORE_REGTEST")"
|
||||||
|
GATE_TOK_NOWALLET="$(store_token "$STORE_NOWALLET")"
|
||||||
|
echo "GATE_TOK_REGTEST=${GATE_TOK_REGTEST:0:8}… GATE_TOK_NOWALLET=${GATE_TOK_NOWALLET:0:8}…"
|
||||||
|
[[ "$GATE_TOK_REGTEST" == null || -z "$GATE_TOK_REGTEST" ]] && { echo "regtest token mint failed"; exit 1; }
|
||||||
|
|
||||||
|
# --- 7. THE PAYLOADS the slice-3 gate consults --------------------------------
|
||||||
|
hr "7a. GET payment-methods (does it expose derivationScheme? what pmid?)"
|
||||||
|
curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods?includeConfig=true" \
|
||||||
|
| tee "$OUT_DIR/payment-methods.json" | jqp
|
||||||
|
|
||||||
|
hr "7b. GET wallet/address (THE network artifact — expect bcrt1…)"
|
||||||
|
ADDR_JSON="$(curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods/${PMID:-BTC-CHAIN}/wallet/address")"
|
||||||
|
echo "$ADDR_JSON" | tee "$OUT_DIR/wallet-address.json" | jqp
|
||||||
|
ADDR="$(echo "$ADDR_JSON" | jq -r '.address // empty')"
|
||||||
|
|
||||||
|
# --- 8. classify --------------------------------------------------------------
|
||||||
|
hr "8. network classification"
|
||||||
|
echo "pmid used : ${PMID:-BTC-CHAIN}"
|
||||||
|
echo "receive address: ${ADDR:-<none>}"
|
||||||
|
case "$ADDR" in
|
||||||
|
bcrt1*) echo "=> prefix bcrt1 => REGTEST ✅ (non-mainnet → scoped connect allowed)";;
|
||||||
|
tb1*) echo "=> prefix tb1 => TESTNET/SIGNET (non-mainnet)";;
|
||||||
|
bc1*) echo "=> prefix bc1 => MAINNET ❌";;
|
||||||
|
[mn2]*) echo "=> legacy base58 m/n/2 => TEST/REGTEST (non-mainnet)";;
|
||||||
|
[13]*) echo "=> legacy base58 1/3 => MAINNET ❌";;
|
||||||
|
"") echo "=> NO ADDRESS (Lightning-only / unconfigured) => FAIL-CLOSED → mainnet → master-only";;
|
||||||
|
*) echo "=> UNRECOGNIZED prefix => FAIL-CLOSED → mainnet → master-only";;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- 9. write .live-env -------------------------------------------------------
|
||||||
|
hr "9. write .live-env"
|
||||||
|
cat > "$LIVE_ENV" <<EOF
|
||||||
|
export KEYSAT_LIVE_BTCPAY_URL=$BASE
|
||||||
|
export KEYSAT_LIVE_BTCPAY_KEY=$ADMIN_KEY
|
||||||
|
export KEYSAT_LIVE_BTCPAY_STORE_REGTEST=$STORE_REGTEST
|
||||||
|
export KEYSAT_LIVE_BTCPAY_STORE_NOWALLET=$STORE_NOWALLET
|
||||||
|
export GATE_TOK_REGTEST=$GATE_TOK_REGTEST
|
||||||
|
export GATE_TOK_NOWALLET=$GATE_TOK_NOWALLET
|
||||||
|
EOF
|
||||||
|
echo "wrote $LIVE_ENV"
|
||||||
|
|
||||||
|
hr "done — raw payloads under $OUT_DIR/, credentials in $LIVE_ENV"
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The "test buyer's wallet": pay a BTCPay invoice on regtest by sending to its
|
||||||
|
# on-chain address from the regtest bitcoind and mining a confirmation. Used by
|
||||||
|
# the Stage 2 harness to drive settlement (BTCPay → webhook → Keysat issues the
|
||||||
|
# license) once the merchant journey has produced a checkout invoice.
|
||||||
|
#
|
||||||
|
# Usage: buyer-pay.sh <btcpay_base_url> <store_api_key> <store_id> <invoice_id>
|
||||||
|
# Prints the funding txid on success.
|
||||||
|
set -euo pipefail
|
||||||
|
BASE="${1:?btcpay base url}"; KEY="${2:?store api key}"; STORE="${3:?store id}"; INV="${4:?invoice id}"
|
||||||
|
BTND=keysat-btcpay-bitcoind-1
|
||||||
|
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
|
||||||
|
# Wallet RPCs must name the wallet explicitly: NBXplorer loads its own wallet, so
|
||||||
|
# bitcoind has >1 loaded and a bare wallet call errors "Wallet file not specified".
|
||||||
|
wcli(){ cli -rpcwallet=miner "$@"; }
|
||||||
|
|
||||||
|
# Pull the invoice's on-chain payment address + BTC amount from BTCPay.
|
||||||
|
PM="$(curl -fsS -H "Authorization: token $KEY" \
|
||||||
|
"$BASE/api/v1/stores/$STORE/invoices/$INV/payment-methods")"
|
||||||
|
ADDR="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].destination // empty')"
|
||||||
|
AMT="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].amount // empty')"
|
||||||
|
[[ -n "$ADDR" && -n "$AMT" ]] || { echo "no on-chain payment method on invoice $INV" >&2; echo "$PM" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Ensure the miner wallet has spendable coins, then pay + confirm.
|
||||||
|
cli -named createwallet wallet_name=miner load_on_startup=true >/dev/null 2>&1 || cli loadwallet miner >/dev/null 2>&1 || true
|
||||||
|
MINE_ADDR="$(wcli getnewaddress)"
|
||||||
|
cli generatetoaddress 101 "$MINE_ADDR" >/dev/null # generatetoaddress is node-level (no wallet needed)
|
||||||
|
TXID="$(wcli sendtoaddress "$ADDR" "$AMT")"
|
||||||
|
cli generatetoaddress 1 "$MINE_ADDR" >/dev/null # 1 conf (BTCPay HighSpeed settles at 0-conf seen / 1-conf)
|
||||||
|
echo "$TXID"
|
||||||
Executable
+155
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stage 2 setup: a sandbox Keysat daemon wired to the regtest BTCPay stack, a
|
||||||
|
# scoped key that can BOTH onboard a catalog AND connect a payment provider
|
||||||
|
# (merchant-onboard + payment_providers:write), the docs corpus, and a sandbox
|
||||||
|
# app — then the agent brief for the COMBINED journey: gate a paid product
|
||||||
|
# (define product + paid tier, integrate the SDK, prove the export is BLOCKED),
|
||||||
|
# then prove it end to end (connect BTCPay regtest, a buyer pays, and the
|
||||||
|
# PURCHASED license UNLOCKS the gated export).
|
||||||
|
#
|
||||||
|
# Networking: the daemon binds 0.0.0.0 and registers its BTCPay webhook via
|
||||||
|
# host.docker.internal so the BTCPay *container* can reach it on settle; the
|
||||||
|
# agent/harness reach the daemon on 127.0.0.1. Sandbox mode + a non-mainnet
|
||||||
|
# (regtest) store are what let the scoped key connect BTCPay at all.
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib.sh"
|
||||||
|
require curl; require jq; require openssl; require node
|
||||||
|
STAGE2_DIR="$HARNESS_DIR/stage2"
|
||||||
|
BTCPAY_URL="$(grep -h KEYSAT_LIVE_BTCPAY_URL "$STAGE2_DIR/btcpay-regtest/.live-env" 2>/dev/null | cut -d= -f2-)"
|
||||||
|
BTCPAY_URL="${BTCPAY_URL:-http://127.0.0.1:49392}"
|
||||||
|
|
||||||
|
curl -fsS "$BTCPAY_URL/api/v1/health" >/dev/null 2>&1 \
|
||||||
|
|| die "regtest BTCPay not reachable at $BTCPAY_URL — run: (cd $STAGE2_DIR/btcpay-regtest && docker compose -p keysat-btcpay up -d)"
|
||||||
|
|
||||||
|
[[ -x "$DAEMON_BIN" ]] || { log "building daemon (cargo build --release)…"; ( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"; }
|
||||||
|
|
||||||
|
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-stage2-$$"
|
||||||
|
RUN_DIR="$RUNS_DIR/$RUN_ID"; mkdir -p "$RUN_DIR/data" "$RUN_DIR/reports"
|
||||||
|
STATE="$RUN_DIR/state.env"; : > "$STATE"
|
||||||
|
PORT="$(free_port)"; MASTER="$(openssl rand -hex 32)"
|
||||||
|
BASE_URL="http://127.0.0.1:$PORT" # agent/harness-facing
|
||||||
|
PUBLIC_URL="http://host.docker.internal:$PORT" # BTCPay-container-facing (webhooks)
|
||||||
|
|
||||||
|
state_set "$STATE" RUN_ID "$RUN_ID"; state_set "$STATE" RUN_DIR "$RUN_DIR"
|
||||||
|
state_set "$STATE" PORT "$PORT"; state_set "$STATE" BASE_URL "$BASE_URL"
|
||||||
|
state_set "$STATE" MASTER_KEY "$MASTER"; state_set "$STATE" BTCPAY_URL "$BTCPAY_URL"
|
||||||
|
|
||||||
|
log "booting sandbox daemon on 0.0.0.0:$PORT (btcpay → $BTCPAY_URL)"
|
||||||
|
KEYSAT_BIND="0.0.0.0:$PORT" \
|
||||||
|
KEYSAT_DB_PATH="$RUN_DIR/data/keysat.db" \
|
||||||
|
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||||
|
KEYSAT_SANDBOX_MODE=1 \
|
||||||
|
BTCPAY_URL="$BTCPAY_URL" \
|
||||||
|
KEYSAT_PUBLIC_URL="$PUBLIC_URL" \
|
||||||
|
KEYSAT_OPERATOR_NAME="Stage 2 Sandbox" \
|
||||||
|
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
|
||||||
|
state_set "$STATE" DAEMON_PID "$!"
|
||||||
|
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
|
||||||
|
wait_http "$BASE_URL/healthz" 75 || { tail -20 "$RUN_DIR/daemon.log" >&2; die "daemon failed to start"; }
|
||||||
|
|
||||||
|
# Confirm the sandbox flag is actually on (the whole gate depends on it).
|
||||||
|
[[ "$(curl -fsS -H "Authorization: Bearer $MASTER" "$BASE_URL/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] \
|
||||||
|
|| die "daemon did not report sandbox mode"
|
||||||
|
|
||||||
|
log "minting scoped key: merchant-onboard + payment_providers:write"
|
||||||
|
SK="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" -H "Authorization: Bearer $MASTER" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"label":"stage2-agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' \
|
||||||
|
| jq -r '.token')"
|
||||||
|
[[ "$SK" == ks_* ]] || die "scoped key mint failed"
|
||||||
|
state_set "$STATE" MERCHANT_KEY "$SK"
|
||||||
|
|
||||||
|
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
|
||||||
|
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
|
||||||
|
DOCS_URL="$(state_get "$STATE" DOCS_URL)"; SANDBOX="$(state_get "$STATE" SANDBOX)"
|
||||||
|
|
||||||
|
# Two BTCPay store contexts the test buyer/agent can use (regtest store has an
|
||||||
|
# on-chain wallet; created during de-risk). The agent connects via the scoped
|
||||||
|
# key; the BTCPay credential it needs is provided as the "operator's BTCPay".
|
||||||
|
[[ -f "$STAGE2_DIR/btcpay-regtest/.live-env" ]] \
|
||||||
|
|| die ".live-env missing — run stage2/btcpay-regtest/probe.sh first to mint the BTCPay store token (GATE_TOK_REGTEST)"
|
||||||
|
source "$STAGE2_DIR/btcpay-regtest/.live-env"
|
||||||
|
|
||||||
|
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
|
||||||
|
# Onboarding-tester brief — Keysat combined journey (gate a paid product, then prove it with a real buyer payment)
|
||||||
|
|
||||||
|
You are a **fresh adopter**, following \`~/Projects/standards/guides/onboarding-tester.md\`.
|
||||||
|
Reach the goal using **only the docs corpus**. Never read Keysat's server or SDK source to
|
||||||
|
unblock yourself — if the docs don't get you there, that is a finding.
|
||||||
|
|
||||||
|
## Goal (checkable end-state)
|
||||||
|
You are an operator selling a Next.js/TypeScript app. Do the **whole flow in the order an
|
||||||
|
operator actually works** — gate the paid feature first, then prove it end to end with a
|
||||||
|
real buyer payment. Use a **scoped, non-master** API key (it carries
|
||||||
|
\`payment_providers:write\`), a **sandbox** Keysat instance, and the published docs only:
|
||||||
|
|
||||||
|
1. **Define the product + a paid tier that grants an entitlement.** Register the product in
|
||||||
|
Keysat's catalog and add a **paid** policy/tier whose purchase grants a named
|
||||||
|
**entitlement** (the thing your gate will check for). Note the entitlement key.
|
||||||
|
2. **Integrate the SDK and gate the Pro export — verify the BLOCKED path FIRST.** Wire
|
||||||
|
\`@keysat/licensing-client\` into your app so \`GET /api/export\` returns the CSV **only**
|
||||||
|
when the caller holds a valid license carrying that entitlement. Then prove it is shut:
|
||||||
|
with **no** license and with a **tampered/invalid** license, \`/api/export\` returns a
|
||||||
|
**4xx, not the CSV**. (At this point no real license exists yet — that's expected.)
|
||||||
|
3. **Connect BTCPay (regtest) and drive a real buyer payment → license issued.** Connect
|
||||||
|
the regtest BTCPay to Keysat over the API (no master key, no browser — you hold a BTCPay
|
||||||
|
credential the way an operator delegating setup would hand you one). Produce a **buyer
|
||||||
|
checkout** for the paid product, then have the buyer pay it. The settled payment must
|
||||||
|
issue a **real, signed license** carrying the entitlement from step 1. (The harness will
|
||||||
|
pay the regtest invoice for you if the docs leave that last on-chain step to plumbing —
|
||||||
|
note where, but the *checkout* itself must come from the docs.)
|
||||||
|
4. **Paste the PURCHASED license into the app → verify the UNLOCKED path.** Feed that
|
||||||
|
purchased license to your app and confirm \`GET /api/export\` now returns the **CSV**.
|
||||||
|
This is the step that ties the two halves together: the license a *buyer's payment*
|
||||||
|
produced unlocks the feature your *gate* protects.
|
||||||
|
|
||||||
|
Success = the same gate that was demonstrably shut in step 2 is opened in step 4 by a
|
||||||
|
license that a real (regtest) buyer payment produced in step 3 — reached from the docs
|
||||||
|
alone, under a scoped key, with BTCPay connected by you.
|
||||||
|
|
||||||
|
## Docs corpus (the ONLY how-to sources you may consult)
|
||||||
|
- Keysat docs site: **$DOCS_URL** — start at \`/integrate.html\` (SDK + gating) and
|
||||||
|
\`/agent.html\` (scoped-key + connect-BTCPay workflow); the whole site is in-corpus.
|
||||||
|
- Daemon OpenAPI: **$BASE_URL/v1/openapi.json** (unauthenticated; the docs point here).
|
||||||
|
- The npm package README for \`@keysat/licensing-client\` is in-corpus (\`npm view\` / the
|
||||||
|
package page).
|
||||||
|
|
||||||
|
## Your sandbox app (mutate ONLY this)
|
||||||
|
\`$SANDBOX\` — a pristine copy of the "Acme Reports" app whose **Pro export**
|
||||||
|
(\`GET /api/export\`) is currently ungated. Read its own \`README.md\` freely (it's your
|
||||||
|
app; it tells you nothing about Keysat). Deps are installed. Run it with \`npm run dev\`
|
||||||
|
(serves on http://localhost:4311). How the app learns the license key (env var, file,
|
||||||
|
header) is your call — pick what the docs suggest and note it. Put scratch under
|
||||||
|
\`/tmp/onboarding-tester/\`.
|
||||||
|
|
||||||
|
## Credentials you were handed (an operator delegating setup would hand you these)
|
||||||
|
- Keysat server: **$BASE_URL**
|
||||||
|
- Scoped API key (merchant-onboard + payment_providers:write): **$SK**
|
||||||
|
- Regtest BTCPay server: **${KEYSAT_LIVE_BTCPAY_URL:-$BTCPAY_URL}**, store
|
||||||
|
**${KEYSAT_LIVE_BTCPAY_STORE_REGTEST:-<regtest store id>}**, BTCPay token
|
||||||
|
**${GATE_TOK_REGTEST:-<btcpay store token>}** (your "operator's BTCPay" access).
|
||||||
|
- You were NOT given the master Keysat admin key. If a step seems to need it, that is
|
||||||
|
either an intended operator-only boundary (note it) or a doc gap (log it).
|
||||||
|
|
||||||
|
## Out of corpus (do not open)
|
||||||
|
Anything under the Keysat source tree (\`$WORKSPACE/licensing-service-startos\`,
|
||||||
|
\`$WORKSPACE/licensing-client-*\`), migrations, tests, or this harness. Reading any of it
|
||||||
|
invalidates the run — say so if you do.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as your final
|
||||||
|
message, in your guide's format. Most-severe-first. On \`completed-clean\`, also emit the
|
||||||
|
publishable "all the agent had to do was X, Y, Z" walkthrough (secret-free).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "Stage 2 staged. Run id: $RUN_ID"
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
Daemon (agent) : $BASE_URL (sandbox, btcpay → $BTCPAY_URL)
|
||||||
|
Docs corpus : $DOCS_URL
|
||||||
|
Scoped key : $SK
|
||||||
|
Sandbox app : $SANDBOX
|
||||||
|
Agent brief : $RUN_DIR/AGENT_BRIEF.md
|
||||||
|
Buyer-pay helper: $STAGE2_DIR/buyer-pay.sh
|
||||||
|
Tear down : $HARNESS_DIR/teardown.sh "$RUN_DIR"
|
||||||
|
EOF
|
||||||
|
echo "$RUN_ID"
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Full Stage 2 teardown — run when the onboarding test is done so nothing keeps
|
||||||
|
# running. Stops, for each Stage 2 run: the ephemeral daemon + docs server +
|
||||||
|
# sandbox copy (via the shared teardown.sh); then kills any sandbox dev server
|
||||||
|
# the onboarding-tester left behind; then stops the shared regtest BTCPay docker
|
||||||
|
# stack (containers + volumes).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./teardown-stage2.sh # tear down ALL Stage 2 runs + dev servers + BTCPay stack
|
||||||
|
# ./teardown-stage2.sh --keep-btcpay # same, but leave the BTCPay stack up (iterating)
|
||||||
|
# ./teardown-stage2.sh runs/<id> # one specific run dir (path relative to onboarding-harness/)
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$HERE/../lib.sh"
|
||||||
|
|
||||||
|
KEEP_BTCPAY=0; ONE_RUN=""
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
--keep-btcpay) KEEP_BTCPAY=1 ;;
|
||||||
|
*) ONE_RUN="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 1. Per-run teardown (daemon + docs server + sandbox copy + freed ports).
|
||||||
|
if [[ -n "$ONE_RUN" ]]; then
|
||||||
|
"$HARNESS_DIR/teardown.sh" "$ONE_RUN" || true
|
||||||
|
else
|
||||||
|
shopt -s nullglob
|
||||||
|
any=0
|
||||||
|
for d in "$RUNS_DIR"/*stage2*/; do
|
||||||
|
[[ -f "${d}state.env" ]] || continue
|
||||||
|
"$HARNESS_DIR/teardown.sh" "${d%/}" || true
|
||||||
|
any=1
|
||||||
|
done
|
||||||
|
[[ "$any" == 0 ]] && warn "no Stage 2 run dirs found under $RUNS_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Kill any sandbox dev server the agent left running. The proof-of-work app
|
||||||
|
# serves on :4311 (npm run dev); the onboarding-tester may start it and not
|
||||||
|
# stop it.
|
||||||
|
for pid in $(lsof -ti tcp:4311 -sTCP:LISTEN 2>/dev/null || true); do
|
||||||
|
kill "$pid" 2>/dev/null && log "stopped orphaned sandbox dev server (pid $pid on :4311)" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Stop the shared regtest BTCPay stack (containers + volumes) unless told to keep it.
|
||||||
|
if [[ "$KEEP_BTCPAY" == 1 ]]; then
|
||||||
|
ok "left BTCPay regtest stack running (--keep-btcpay)"
|
||||||
|
elif docker ps -a --filter "name=keysat-btcpay" --format '{{.Names}}' 2>/dev/null | grep -q .; then
|
||||||
|
( cd "$HERE/btcpay-regtest" && docker compose -p keysat-btcpay down -v ) >/dev/null 2>&1 \
|
||||||
|
&& ok "stopped BTCPay regtest stack (containers + volumes removed)" \
|
||||||
|
|| warn "could not fully stop BTCPay — check: docker ps -a --filter name=keysat-btcpay"
|
||||||
|
else
|
||||||
|
ok "BTCPay regtest stack already stopped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Stage 2 teardown complete"
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end validation of the agent-payment-connect gate against the LIVE
|
||||||
|
# regtest BTCPay (the spec's hard requirement). Boots a throwaway Keysat daemon
|
||||||
|
# in sandbox mode pointed at the regtest BTCPay stack, mints a scoped
|
||||||
|
# `payment_providers:write` key, and drives the full OAuth round-trip for two
|
||||||
|
# stores:
|
||||||
|
# - no-wallet store → network undetermined → FAIL CLOSED → connect DENIED (400)
|
||||||
|
# - regtest store → bcrt1 address → non-mainnet → connect ALLOWED (persisted)
|
||||||
|
#
|
||||||
|
# Requires the regtest stack up (docker compose -p keysat-btcpay up -d) and
|
||||||
|
# .live-env populated (GATE_TOK_REGTEST / GATE_TOK_NOWALLET — single-store BTCPay
|
||||||
|
# tokens). Reads the daemon release binary built by `cargo build --release`.
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$HERE/btcpay-regtest/.live-env"
|
||||||
|
BIN="$HERE/../../licensing-service/target/release/keysat"
|
||||||
|
[[ -x "$BIN" ]] || { echo "FAIL: release binary missing ($BIN) — run cargo build --release"; exit 1; }
|
||||||
|
|
||||||
|
PORT=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();})')
|
||||||
|
MASTER=$(openssl rand -hex 32)
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
BASE="http://127.0.0.1:$PORT"
|
||||||
|
pass=0; fail=0
|
||||||
|
ok(){ echo " ✅ $*"; pass=$((pass+1)); }
|
||||||
|
no(){ echo " ❌ $*"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
echo "== booting sandbox daemon on $BASE (btcpay → $KEYSAT_LIVE_BTCPAY_URL) =="
|
||||||
|
KEYSAT_BIND="127.0.0.1:$PORT" \
|
||||||
|
KEYSAT_DB_PATH="$TMP/keysat.db" \
|
||||||
|
KEYSAT_ADMIN_API_KEY="$MASTER" \
|
||||||
|
KEYSAT_SANDBOX_MODE=1 \
|
||||||
|
BTCPAY_URL="$KEYSAT_LIVE_BTCPAY_URL" \
|
||||||
|
KEYSAT_PUBLIC_URL="$BASE" \
|
||||||
|
KEYSAT_OPERATOR_NAME="Stage2 Gate Validation" \
|
||||||
|
nohup "$BIN" >"$TMP/daemon.log" 2>&1 &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
trap 'kill $DAEMON_PID 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||||
|
for i in $(seq 1 75); do curl -fsS "$BASE/healthz" >/dev/null 2>&1 && break; sleep 0.2; [[ $i == 75 ]] && { echo "FAIL: daemon never healthy"; tail -20 "$TMP/daemon.log"; exit 1; }; done
|
||||||
|
|
||||||
|
M=(-H "Authorization: Bearer $MASTER")
|
||||||
|
|
||||||
|
echo "== 1. sandbox flag surfaced read-only in /v1/admin/tier =="
|
||||||
|
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] && ok "tier.sandbox == true" || no "sandbox flag not surfaced"
|
||||||
|
|
||||||
|
echo "== 2. mint scoped merchant-onboard + payment_providers:write key =="
|
||||||
|
SK="$(curl -sS "${M[@]}" -X POST "$BASE/v1/admin/api-keys" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"label":"agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' | jq -r '.token')"
|
||||||
|
[[ "$SK" == ks_* ]] && ok "scoped key minted" || { no "mint failed"; }
|
||||||
|
S=(-H "Authorization: Bearer $SK")
|
||||||
|
|
||||||
|
# drive a connect: returns HTTP status of the callback. $1=btcpay token
|
||||||
|
drive_connect(){
|
||||||
|
local tok="$1"
|
||||||
|
local st; st="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
|
||||||
|
[[ -n "$st" && "$st" != null ]] || { echo "000"; return; }
|
||||||
|
curl -sS -o /tmp/gate-cb.out -w '%{http_code}' -X POST "$BASE/v1/btcpay/authorize/callback?state=$st" \
|
||||||
|
--data-urlencode "apiKey=$tok"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== 3. DENY: scoped connect to a no-wallet store (undetermined → fail-closed) =="
|
||||||
|
code="$(drive_connect "$GATE_TOK_NOWALLET")"
|
||||||
|
if [[ "$code" == 400 ]]; then
|
||||||
|
ok "callback rejected with HTTP 400"
|
||||||
|
grep -qi "non-mainnet" /tmp/gate-cb.out && ok "rejection cites the non-mainnet restriction" || no "rejection message unexpected: $(cat /tmp/gate-cb.out | head -c200)"
|
||||||
|
else
|
||||||
|
no "expected 400, got $code ($(cat /tmp/gate-cb.out | head -c200))"
|
||||||
|
fi
|
||||||
|
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status" | jq -r '.connected')" == "false" ]] && ok "no provider persisted on deny" || no "a provider was persisted despite deny!"
|
||||||
|
# The GET callback form (what the agent docs show) must ALSO deny with a 4xx,
|
||||||
|
# not a 200 error page (regression guard for the GET-handler status fix).
|
||||||
|
gst="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
|
||||||
|
gcode="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/v1/btcpay/authorize/callback?state=$gst&apiKey=$GATE_TOK_NOWALLET")"
|
||||||
|
[[ "$gcode" == 4* ]] && ok "GET callback form denies with HTTP $gcode (not a 200 error page)" || no "GET callback returned $gcode (expected 4xx)"
|
||||||
|
|
||||||
|
echo "== 4. ALLOW: scoped connect to the regtest store (bcrt1 → non-mainnet) =="
|
||||||
|
code="$(drive_connect "$GATE_TOK_REGTEST")"
|
||||||
|
if [[ "$code" == 200 ]]; then ok "callback succeeded with HTTP 200"; else no "expected 200, got $code ($(cat /tmp/gate-cb.out | head -c300))"; fi
|
||||||
|
ST_JSON="$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status")"
|
||||||
|
[[ "$(echo "$ST_JSON" | jq -r '.connected')" == "true" ]] && ok "provider persisted" || no "provider not persisted on allow"
|
||||||
|
[[ "$(echo "$ST_JSON" | jq -r '.store_id')" == "$KEYSAT_LIVE_BTCPAY_STORE_REGTEST" ]] && ok "persisted store is the regtest store" || no "wrong store persisted: $(echo "$ST_JSON" | jq -c '.store_id')"
|
||||||
|
|
||||||
|
echo "== 5. scoped connect is audited with the resolved network =="
|
||||||
|
AUD="$(curl -sS "${M[@]}" "$BASE/v1/admin/audit?action=payment_provider.connect_scoped" | jq -c '.entries[0] // empty')"
|
||||||
|
echo " audit: $AUD"
|
||||||
|
echo "$AUD" | grep -qi "regtest" && ok "audit row records network=regtest" || no "audit row missing/!regtest"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== RESULT: $pass passed, $fail failed ===="
|
||||||
|
[[ $fail == 0 ]] || exit 1
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tear down a run: stop the daemon + docs server, remove the agent's sandbox
|
||||||
|
# copy. Keeps the run dir (logs + reports) unless --purge is given.
|
||||||
|
# Usage: teardown.sh [RUN_DIR] [--purge]
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"
|
||||||
|
|
||||||
|
PURGE=0; RUN_DIR=""
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
--purge) PURGE=1 ;;
|
||||||
|
*) RUN_DIR="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
RUN_DIR="${RUN_DIR:-$(readlink "$CURRENT_LINK" 2>/dev/null || true)}"
|
||||||
|
[[ -n "$RUN_DIR" && -d "$RUN_DIR" ]] || { warn "no run dir to tear down"; exit 0; }
|
||||||
|
STATE="$RUN_DIR/state.env"
|
||||||
|
|
||||||
|
for key in DAEMON_PID DOCS_PID; do
|
||||||
|
pid="$(state_get "$STATE" "$key" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
log "stopped $key ($pid)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Belt-and-suspenders: free the recorded ports in case a PID drifted.
|
||||||
|
for portkey in PORT DOCS_PORT; do
|
||||||
|
port="$(state_get "$STATE" "$portkey" 2>/dev/null || true)"
|
||||||
|
[[ -z "$port" ]] && continue
|
||||||
|
for lpid in $(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true); do
|
||||||
|
kill "$lpid" 2>/dev/null && log "freed port $port (pid $lpid)" || true
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
SANDBOX="$(state_get "$STATE" SANDBOX 2>/dev/null || true)"
|
||||||
|
if [[ -n "$SANDBOX" && -d "$SANDBOX" ]]; then rm -rf "$SANDBOX"; log "removed sandbox $SANDBOX"; fi
|
||||||
|
|
||||||
|
if [[ "$PURGE" == 1 ]]; then
|
||||||
|
rm -rf "$RUN_DIR"; log "purged run dir $RUN_DIR"
|
||||||
|
[[ "$(readlink "$CURRENT_LINK" 2>/dev/null)" == "$RUN_DIR" ]] && rm -f "$CURRENT_LINK"
|
||||||
|
fi
|
||||||
|
ok "teardown complete"
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# prepare.sh — bootstrap a clean Debian/Ubuntu box to build the Keysat s9pk.
|
||||||
|
#
|
||||||
|
# Start9's build-from-source flow clones this repo onto a fresh box, then runs a
|
||||||
|
# bootstrap script followed by `make`. This installs every HOST prerequisite that
|
||||||
|
# `make` needs (npm → wrapper bundle; start-cli s9pk pack → Docker image build).
|
||||||
|
# It mirrors the official StartOS 0.4.0.x environment-setup page:
|
||||||
|
# https://docs.start9.com/packaging/0.4.0.x/environment-setup.html
|
||||||
|
#
|
||||||
|
# Note: `prepare.sh` is a 0.3.5.x community-submission convention; the 0.4.x docs
|
||||||
|
# don't mention it, so the 0.4.x submission flow may not invoke it. This file is
|
||||||
|
# still the runnable, single-source record of what a clean build box needs.
|
||||||
|
#
|
||||||
|
# The Rust daemon is NOT built on the host — it compiles inside this package's
|
||||||
|
# Dockerfile (FROM rust:1.88-slim-bookworm), so no rustup/cargo is installed here.
|
||||||
|
#
|
||||||
|
# Idempotent: re-running skips tools already present. Targets apt-based distros.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Use sudo only when not already root (Start9's build box may run either way).
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_MAJOR=22
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
# --- apt prerequisites -------------------------------------------------------
|
||||||
|
# build-essential → make/gcc; squashfs-tools(-ng) → start-cli s9pk packing;
|
||||||
|
# jq → used by s9pk.mk's build summary; git → the s9pk embeds the commit hash.
|
||||||
|
log "Installing apt prerequisites (make, jq, git, squashfs, curl)"
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
squashfs-tools \
|
||||||
|
squashfs-tools-ng
|
||||||
|
|
||||||
|
# --- Node.js 22 --------------------------------------------------------------
|
||||||
|
# The wrapper (@start9labs/start-sdk + @vercel/ncc bundle) needs Node 22. We
|
||||||
|
# install it system-wide via NodeSource so it's on PATH for the non-interactive
|
||||||
|
# `make` that follows (the docs' nvm method would need a shell rc sourced first).
|
||||||
|
if command -v node >/dev/null 2>&1 && node -v | grep -q "^v${NODE_MAJOR}\."; then
|
||||||
|
log "Node.js $(node -v) already present — skipping"
|
||||||
|
else
|
||||||
|
log "Installing Node.js ${NODE_MAJOR} (NodeSource)"
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | $SUDO -E bash -
|
||||||
|
$SUDO apt-get install -y nodejs
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Docker (+ buildx) -------------------------------------------------------
|
||||||
|
# start-cli s9pk pack builds the daemon image from the Dockerfile via Docker
|
||||||
|
# buildx. get.docker.com is Docker's official installer and bundles the buildx
|
||||||
|
# plugin.
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
log "Docker $(docker --version | awk '{print $3}' | tr -d ,) already present — skipping"
|
||||||
|
else
|
||||||
|
log "Installing Docker (official get.docker.com installer)"
|
||||||
|
curl -fsSL https://get.docker.com | $SUDO sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cross-architecture builds (`make universal` / `make arm` on an x86 host) need
|
||||||
|
# QEMU binfmt handlers registered. Best-effort: requires the Docker daemon to be
|
||||||
|
# running. Harmless to skip if you only build the host's native arch (`make x86`).
|
||||||
|
if $SUDO docker info >/dev/null 2>&1; then
|
||||||
|
log "Registering QEMU binfmt handlers for cross-arch builds (best-effort)"
|
||||||
|
$SUDO docker run --privileged --rm tonistiigi/binfmt --install all ||
|
||||||
|
echo " (binfmt registration skipped — only native-arch builds will work)"
|
||||||
|
else
|
||||||
|
echo " (Docker daemon not reachable yet — skipping binfmt setup; start Docker"
|
||||||
|
echo " and re-run this script if you need cross-arch/universal builds.)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- start-cli (StartOS 0.4.x SDK) -------------------------------------------
|
||||||
|
# Official installer: fetches the latest prebuilt binary into ~/.local/bin.
|
||||||
|
# For a reproducible build, pin a release instead, e.g.:
|
||||||
|
# curl -fsSLo ~/.local/bin/start-cli \
|
||||||
|
# https://github.com/Start9Labs/start-os/releases/download/<tag>/start-cli_x86_64-linux
|
||||||
|
# chmod +x ~/.local/bin/start-cli
|
||||||
|
if command -v start-cli >/dev/null 2>&1; then
|
||||||
|
log "start-cli $(start-cli --version 2>/dev/null | awk '{print $2}') already present — skipping"
|
||||||
|
else
|
||||||
|
log "Installing start-cli (StartOS 0.4.x SDK)"
|
||||||
|
curl -fsSL https://start9.com/start-cli/install.sh | sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The installer drops start-cli in ~/.local/bin and appends it to your shell rc.
|
||||||
|
# Persist it to .profile for future shells (only if not already recorded, so
|
||||||
|
# re-runs don't pile up duplicates), and export it for the rest of THIS session.
|
||||||
|
if ! grep -qsF '.local/bin' "${HOME}/.profile"; then
|
||||||
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >>"${HOME}/.profile"
|
||||||
|
fi
|
||||||
|
export PATH="${HOME}/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
log "Done. Initialise your signing key with 'start-cli init', then run 'make' (or 'make x86')."
|
||||||
@@ -39,7 +39,13 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
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.',
|
'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')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:57',
|
version: '0.2.0:60',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user