Add Stage 2 onboarding harness (buyer pays on regtest)
Disposable rig that runs the onboarding-tester agent docs-only against the buyer-pays journey: a sandbox daemon wired to a Dockerized BTCPay regtest stack, a scoped key with payment_providers:write, and a regtest buyer-pay helper. Includes the de-risk probe + findings and an end-to-end gate check (validate-gate.sh, 10/10). The doc-onboarding loop converged completed-clean; see stage2/STAGE2-RESULT.md. Scratch (.live-env, probe-out/) is gitignored.
This commit is contained in:
@@ -43,14 +43,24 @@ Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`,
|
|||||||
marketing copy), tear down, and re-run on a fresh fixture.
|
marketing copy), tear down, and re-run on a fresh fixture.
|
||||||
3. Repeat until `completed-clean`.
|
3. Repeat until `completed-clean`.
|
||||||
|
|
||||||
## Stage 2 (gated, not built yet)
|
## Stage 2 (buyer pays on regtest) — built, `completed-clean`
|
||||||
|
|
||||||
The buyer-pays-on-regtest path needs Keysat to ship `payment_providers:write` +
|
Lives in `stage2/`. Boots a **sandbox** daemon (`KEYSAT_SANDBOX_MODE=1`) wired to
|
||||||
the sandbox-mode daemon flag + the network gate (slices 3–5, in progress). It
|
a Dockerized BTCPay **regtest** stack and grants the agent `merchant-onboard` +
|
||||||
adds a Dockerized BTCPay regtest stack and grants the agent
|
`payment_providers:write` so it connects BTCPay (regtest) and drives a test buyer
|
||||||
`merchant-onboard` + `payment_providers:write` so it can connect BTCPay
|
payment end to end. Connecting a *mainnet* wallet stays operator-only by design —
|
||||||
(regtest) and drive a test buyer payment end to end. Connecting a *mainnet*
|
that boundary is a feature, not a gap.
|
||||||
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/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
|
||||||
|
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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/STAGE2-RESULT.md` — convergence + the publishable walkthrough.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Stage 2 result — agent connects BTCPay (regtest) + buyer pays (payments)
|
||||||
|
|
||||||
|
**Verdict: `completed-clean` on run 3 (0 findings).** A fresh adopter, using only the
|
||||||
|
published docs and a **scoped** key (`merchant-onboard` + `payment_providers:write`, no
|
||||||
|
master key), can connect a regtest BTCPay over the API with **no browser step**, stand up
|
||||||
|
a paid product, produce a buyer checkout, and have a **real (regtest) on-chain payment
|
||||||
|
settle into a signed license** that validates offline.
|
||||||
|
|
||||||
|
This is the buyer-pays half of the onboarding harness (Stage 1 = no-payments SDK
|
||||||
|
integration). It is gated on the **agent-payment-connect** daemon feature (slices 3-4):
|
||||||
|
the scoped BTCPay connect is allowed only on a **sandbox** daemon for a **non-mainnet**
|
||||||
|
network. See `plans/agent-payment-connect-scope.md` and `stage2/FINDINGS.md`.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
`stage2/run-stage2.sh` boots a disposable Keysat daemon in **sandbox mode**
|
||||||
|
(`KEYSAT_SANDBOX_MODE=1`) wired to the regtest BTCPay stack (`stage2/btcpay-regtest/`),
|
||||||
|
mints a scoped key carrying `payment_providers:write`, serves `keysat-docs/` as the
|
||||||
|
corpus, and materializes a sandbox app. The daemon binds `0.0.0.0` and registers its
|
||||||
|
settle webhook via `host.docker.internal` so the BTCPay container can reach it. The
|
||||||
|
global `onboarding-tester` agent then drives the journey **docs-only**. The test buyer's
|
||||||
|
wallet is `stage2/buyer-pay.sh` (pays the invoice on regtest + mines a confirmation).
|
||||||
|
|
||||||
|
## Convergence
|
||||||
|
|
||||||
|
| Run | Verdict | Findings |
|
||||||
|
|-----|---------|----------|
|
||||||
|
| 1 | blocked-at-step-1 (docs) | 2 blockers (agent.html#not-exposed said provider-connect is master-only; the connect/status/callback endpoints absent from OpenAPI) + 2 stumbles (headless callback pattern undocumented; `payment_providers:write` scope undocumented) + 1 nit. |
|
||||||
|
| 2 | **completed-clean** | 1 doc nit (install.html BTCPay permission list wrong) + 1 harness-script bug (`buyer-pay.sh` missing `-rpcwallet`). |
|
||||||
|
| 3 | **completed-clean (0)** | none. Walkthrough harvested below. |
|
||||||
|
|
||||||
|
The capability worked end to end from run 1 (the agent connected BTCPay headlessly and got
|
||||||
|
a license); the blockers were purely that the docs *said it was impossible* and didn't
|
||||||
|
document the path.
|
||||||
|
|
||||||
|
## Doc fixes shipped this loop
|
||||||
|
|
||||||
|
**`keysat-docs/` (deploys independently):**
|
||||||
|
- `agent.html`: corrected the `#auth` master-only statement; added an **A-la-carte extra
|
||||||
|
scopes** subsection (`payment_providers:write`); narrowed `#not-exposed` to the accurate
|
||||||
|
gate (scoped connect allowed only sandbox + non-mainnet; disconnect + production/mainnet
|
||||||
|
stay master-only); added the **Connect BTCPay programmatically (sandbox)** workflow
|
||||||
|
(`#connect-btcpay`) with the 3-step API flow.
|
||||||
|
- `install.html`: corrected the BTCPay permission list to the five the daemon actually
|
||||||
|
requests; added an "automating setup?" pointer to the agent path.
|
||||||
|
|
||||||
|
**`licensing-service/src/api/openapi.rs` (served spec; ships next daemon release):**
|
||||||
|
- Added `/v1/admin/btcpay/connect`, `/v1/btcpay/authorize/callback`,
|
||||||
|
`/v1/admin/btcpay/status`, `/v1/admin/btcpay/disconnect`; added the `scopes` field to
|
||||||
|
scoped-key creation; noted the read-only `sandbox` flag on `/v1/admin/tier`.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time
|
||||||
|
./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key
|
||||||
|
# feed runs/<id>/AGENT_BRIEF.md to the onboarding-tester agent
|
||||||
|
./teardown.sh runs/<id> # stops daemon + docs server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishable walkthrough (harvested, run 3)
|
||||||
|
|
||||||
|
All it took, on a sandbox Keysat with a scoped `payment_providers:write` key and a regtest
|
||||||
|
BTCPay store key (no master key, no browser):
|
||||||
|
|
||||||
|
1. **Connect BTCPay** — `POST /v1/admin/btcpay/connect` -> `state`; then
|
||||||
|
`GET /v1/btcpay/authorize/callback?state=<state>&apiKey=<btcpay_store_key>`; confirm with
|
||||||
|
`GET /v1/admin/btcpay/status`.
|
||||||
|
2. **Define a paid product** — `POST /v1/admin/products` + `POST /v1/admin/policies`.
|
||||||
|
3. **Create a checkout** — `POST /v1/purchase` -> `checkout_url` + `amount_sats`.
|
||||||
|
4. **Buyer pays** (regtest on-chain), daemon settles via webhook, `GET /v1/purchase/<id>`
|
||||||
|
returns `status: settled` + a signed `license_key`.
|
||||||
|
5. **Validate** — `POST /v1/validate` -> `ok: true` with the tier's entitlements.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
probe-out/
|
||||||
|
.live-env
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# De-risk result — BTCPay regtest network detection (agent-payment-connect slice 3)
|
||||||
|
|
||||||
|
**Verdict: the spec's primary network-detection assumption (§6.1) is VALIDATED against
|
||||||
|
a live regtest BTCPay 2.x. No blocker; slice 3 needs no extra OAuth permission.**
|
||||||
|
|
||||||
|
Rig: `docker-compose.yml` in this dir — bitcoind(regtest) + NBXplorer + postgres +
|
||||||
|
btcpayserver `2.0.6`. Validated 2026-06-16. Probe: `probe.sh`; raw payloads in
|
||||||
|
`probe-out/`. Bring up `docker compose -p keysat-btcpay up -d`; tear down
|
||||||
|
`docker compose -p keysat-btcpay down -v`.
|
||||||
|
|
||||||
|
## What the gate will actually see
|
||||||
|
|
||||||
|
1. **Payment-method id is `BTC-CHAIN`** on BTCPay 2.x. Posting to the legacy `.../BTC/...`
|
||||||
|
path is normalized to `BTC-CHAIN`. **Do not hardcode** — BTCPay 1.x used `BTC`. Slice 3
|
||||||
|
should read `paymentMethodId` from the list and pick the on-chain BTC method
|
||||||
|
(id ∈ {`BTC-CHAIN`,`BTC`}, not Lightning).
|
||||||
|
|
||||||
|
2. **Primary signal — receive address HRP (spec §6.1 primary), CONFIRMED:**
|
||||||
|
`GET /api/v1/stores/{id}/payment-methods/BTC-CHAIN/wallet/address`
|
||||||
|
→ `{"address":"bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt","keyPath":"0/0","paymentLink":...}`
|
||||||
|
`bcrt1…` HRP ⇒ **regtest** ⇒ non-mainnet ⇒ scoped connect allowed (on a sandbox daemon).
|
||||||
|
Classification table (validated regtest arm; others by HRP spec):
|
||||||
|
`bc1`/base58 `1`,`3` → mainnet (deny scoped) · `tb1` → testnet/signet · `bcrt1` → regtest ·
|
||||||
|
base58 `m`,`n`,`2` → test/regtest.
|
||||||
|
|
||||||
|
3. **Secondary signal — derivation, CONFIRMED but field name differs from the spec.**
|
||||||
|
The spec says `derivationScheme`; on BTCPay 2.x Greenfield it is
|
||||||
|
**`config.accountDerivation`** (and `config.signingKey`, `config.accountKeySettings[].accountKey`),
|
||||||
|
value `tpubDC…` for regtest/testnet (mainnet → `xpub/ypub/zpub`). The BIP-84 account path
|
||||||
|
is `84'/1'/0'` — coin-type `1'` is itself a testnet/regtest marker. **Requires
|
||||||
|
`?includeConfig=true`** — see permission note below.
|
||||||
|
|
||||||
|
## Permission — the daemon already has enough
|
||||||
|
|
||||||
|
- The daemon's BTCPay OAuth (`REQUESTED_PERMISSIONS`, `btcpay_authorize.rs:45`) already
|
||||||
|
requests **`btcpay.store.canmodifystoresettings`** (for webhook registration).
|
||||||
|
- Empirically, with a token holding only `canmodifystoresettings`:
|
||||||
|
`wallet/address` → **HTTP 200**, and `payment-methods?includeConfig=true` → config **visible**.
|
||||||
|
- `wallet/address` specifically needs `canmodifystoresettings` (`canviewstoresettings` →
|
||||||
|
**403**). The `config`/derivation path needs only `canviewstoresettings`.
|
||||||
|
- ⇒ **Slice 3 can use EITHER signal with the key it already obtains at connect. No new
|
||||||
|
OAuth scope.** Recommend the **address-HRP path** (spec's primary; one call; unambiguous).
|
||||||
|
|
||||||
|
## Fail-closed cases (all confirmed → treat as mainnet → master-only)
|
||||||
|
|
||||||
|
- No on-chain wallet configured → `GET payment-methods` returns `[]` (no BTC-CHAIN method).
|
||||||
|
- `wallet/address` on a store with no wallet → **HTTP 503** `"BTC-CHAIN services are not
|
||||||
|
currently available"`. (Same 503 also appears transiently while BTCPay is not yet
|
||||||
|
`synchronized:true` — at operator connect time it will be synced, but treat any non-2xx /
|
||||||
|
missing address / unrecognized HRP as "cannot determine" ⇒ deny scoped, require master.)
|
||||||
|
|
||||||
|
## Implication for the daemon client (slice 3)
|
||||||
|
|
||||||
|
The existing `btcpay/client.rs::list_payment_methods` calls `GET .../payment-methods`
|
||||||
|
**without** `includeConfig`, so today it sees `config:null` (confirmed). To detect network,
|
||||||
|
add a small client fn that GETs `.../payment-methods/{pmid}/wallet/address` and classifies
|
||||||
|
the HRP (preferred), or pass `?includeConfig=true` and read `config.accountDerivation`.
|
||||||
|
Resolve target network **before persisting** the provider (spec §7).
|
||||||
|
|
||||||
|
## Rig gotcha (for whoever rebuilds this)
|
||||||
|
|
||||||
|
NBXplorer defaults to cookie auth; with separate datadir volumes BTCPay can't read the
|
||||||
|
cookie → `401` → BTCPay never reaches `synchronized:true` → on-chain `BTC-CHAIN` service
|
||||||
|
stays unavailable (`503`). Fix used here: `NBXPLORER_NOAUTH=1` (fine for a throwaway
|
||||||
|
regtest box). A production-faithful harness would instead share NBXplorer's datadir volume
|
||||||
|
into BTCPay so the cookie is shared.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Throwaway BTCPay Server regtest stack — de-risk rig for agent-payment-connect
|
||||||
|
# network detection (spec §6.1). NOT a production deployment, NOT yet wired into
|
||||||
|
# the Stage 2 harness. Bring up: docker compose -p keysat-btcpay up -d
|
||||||
|
# Tear down (incl. volumes): docker compose -p keysat-btcpay down -v
|
||||||
|
#
|
||||||
|
# Ports published to the host:
|
||||||
|
# BTCPay UI/Greenfield API → http://127.0.0.1:49392
|
||||||
|
# bitcoind regtest RPC → 127.0.0.1:43782 (user/pass keysat/keysat)
|
||||||
|
services:
|
||||||
|
bitcoind:
|
||||||
|
image: btcpayserver/bitcoin:28.1
|
||||||
|
environment:
|
||||||
|
BITCOIN_NETWORK: regtest
|
||||||
|
BITCOIN_EXTRA_ARGS: |
|
||||||
|
rpcuser=keysat
|
||||||
|
rpcpassword=keysat
|
||||||
|
rpcbind=0.0.0.0:43782
|
||||||
|
rpcallowip=0.0.0.0/0
|
||||||
|
port=39388
|
||||||
|
whitelist=0.0.0.0/0
|
||||||
|
zmqpubrawblock=tcp://0.0.0.0:28332
|
||||||
|
zmqpubrawtx=tcp://0.0.0.0:28333
|
||||||
|
fallbackfee=0.0002
|
||||||
|
txindex=1
|
||||||
|
expose:
|
||||||
|
- "43782"
|
||||||
|
- "39388"
|
||||||
|
- "28332"
|
||||||
|
- "28333"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:43782:43782"
|
||||||
|
volumes:
|
||||||
|
- bitcoin_datadir:/data
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:13.13
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
volumes:
|
||||||
|
- postgres_datadir:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
nbxplorer:
|
||||||
|
image: nicolasdorier/nbxplorer:2.5.22
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NBXPLORER_NETWORK: regtest
|
||||||
|
NBXPLORER_NOAUTH: "1"
|
||||||
|
NBXPLORER_BIND: 0.0.0.0:32838
|
||||||
|
NBXPLORER_TRIMEVENTS: "10000"
|
||||||
|
NBXPLORER_SIGNALFILESDIR: /datadir
|
||||||
|
NBXPLORER_CHAINS: "btc"
|
||||||
|
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
|
||||||
|
NBXPLORER_BTCRPCUSER: keysat
|
||||||
|
NBXPLORER_BTCRPCPASSWORD: keysat
|
||||||
|
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
|
||||||
|
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer
|
||||||
|
depends_on:
|
||||||
|
- bitcoind
|
||||||
|
- postgres
|
||||||
|
volumes:
|
||||||
|
- nbxplorer_datadir:/datadir
|
||||||
|
|
||||||
|
btcpayserver:
|
||||||
|
image: btcpayserver/btcpayserver:2.0.6
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=20;Database=btcpayserver
|
||||||
|
BTCPAY_NETWORK: regtest
|
||||||
|
BTCPAY_BIND: 0.0.0.0:49392
|
||||||
|
BTCPAY_ROOTPATH: /
|
||||||
|
BTCPAY_PROTOCOL: http
|
||||||
|
BTCPAY_CHAINS: "btc"
|
||||||
|
BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/
|
||||||
|
BTCPAY_DEBUGLOG: btcpay.log
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:49392:49392"
|
||||||
|
depends_on:
|
||||||
|
- nbxplorer
|
||||||
|
- postgres
|
||||||
|
volumes:
|
||||||
|
- btcpay_datadir:/datadir
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bitcoin_datadir:
|
||||||
|
postgres_datadir:
|
||||||
|
nbxplorer_datadir:
|
||||||
|
btcpay_datadir:
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# De-risk probe for agent-payment-connect network detection (spec §6.1).
|
||||||
|
# Stands up a store + on-chain regtest wallet on the local BTCPay regtest stack,
|
||||||
|
# then dumps the exact Greenfield responses the slice-3 gate would consult:
|
||||||
|
# - GET /api/v1/stores/{id}/payment-methods (paymentMethodId form? derivationScheme exposed?)
|
||||||
|
# - GET /api/v1/stores/{id}/payment-methods/{pmid}/wallet/address (bcrt1… prefix?)
|
||||||
|
# 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!"
|
||||||
|
OUT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/probe-out"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
hr(){ printf '\n\033[1;36m=== %s ===\033[0m\n' "$*"; }
|
||||||
|
jqp(){ jq . 2>/dev/null || cat; }
|
||||||
|
|
||||||
|
# --- 0. wait for BTCPay --------------------------------------------------------
|
||||||
|
hr "0. waiting for BTCPay health at $BASE"
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
if curl -fsS "$BASE/api/v1/health" >/dev/null 2>&1; then break; fi
|
||||||
|
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
|
||||||
|
|
||||||
|
# Basic-auth header for subsequent Greenfield calls.
|
||||||
|
AUTH=(-u "$ADMIN_EMAIL:$ADMIN_PW")
|
||||||
|
|
||||||
|
# --- 2. create a store ---------------------------------------------------------
|
||||||
|
hr "2. create store"
|
||||||
|
STORE_JSON="$(curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/stores" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"name":"Keysat Regtest Co"}')"
|
||||||
|
echo "$STORE_JSON" | jqp
|
||||||
|
STORE_ID="$(echo "$STORE_JSON" | jq -r '.id')"
|
||||||
|
echo "STORE_ID=$STORE_ID"
|
||||||
|
[[ -z "$STORE_ID" || "$STORE_ID" == null ]] && { echo "no store id"; exit 1; }
|
||||||
|
|
||||||
|
# --- 3. generate an on-chain wallet; try BTC-CHAIN then BTC --------------------
|
||||||
|
gen_body='{"savePrivateKeys":false,"importKeysToRPC":false,"wordList":"English","wordCount":12,"scriptPubKeyType":"Segwit"}'
|
||||||
|
PMID=""
|
||||||
|
for cand in BTC-CHAIN BTC; do
|
||||||
|
hr "3. 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_ID/payment-methods/$cand/wallet/generate" \
|
||||||
|
-H 'Content-Type: application/json' -d "$gen_body")"
|
||||||
|
echo "HTTP $code"; cat "$OUT_DIR/gen-$cand.json" | jqp
|
||||||
|
if [[ "$code" == 2* ]]; then PMID="$cand"; break; fi
|
||||||
|
done
|
||||||
|
[[ -z "$PMID" ]] && echo "!! wallet generate failed for both pmid forms (see above)"
|
||||||
|
|
||||||
|
# --- 4. mine some regtest blocks so the wallet has a usable address ------------
|
||||||
|
hr "4. mine regtest blocks"
|
||||||
|
ADDR_FOR_MINE="$(docker exec keysat-btcpay-bitcoind-1 bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 getnewaddress 2>/dev/null || true)"
|
||||||
|
echo "miner address: ${ADDR_FOR_MINE:-<none>}"
|
||||||
|
if [[ -n "$ADDR_FOR_MINE" ]]; then
|
||||||
|
docker exec keysat-btcpay-bitcoind-1 bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 generatetoaddress 101 "$ADDR_FOR_MINE" >/dev/null 2>&1 \
|
||||||
|
&& echo "mined 101 blocks" || echo "mine failed (non-fatal for detection probe)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. THE PAYLOADS the slice-3 gate consults --------------------------------
|
||||||
|
hr "5a. GET payment-methods (does it expose derivationScheme? what pmid?)"
|
||||||
|
curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_ID/payment-methods?includeConfig=true" \
|
||||||
|
| tee "$OUT_DIR/payment-methods.json" | jqp
|
||||||
|
|
||||||
|
hr "5b. GET wallet/address (THE network artifact — expect bcrt1…)"
|
||||||
|
ADDR_JSON="$(curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_ID/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')"
|
||||||
|
|
||||||
|
# --- 6. classify --------------------------------------------------------------
|
||||||
|
hr "6. 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
|
||||||
|
|
||||||
|
hr "done — raw payloads under $OUT_DIR/"
|
||||||
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
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/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 buyer-pays journey.
|
||||||
|
#
|
||||||
|
# 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 Stage 2 (agent connects BTCPay regtest + buyer pays)
|
||||||
|
|
||||||
|
You are a **fresh adopter**, following \`~/Projects/standards/guides/onboarding-tester.md\`.
|
||||||
|
Reach the goal using **only the docs corpus**. Never read Keysat source to unblock
|
||||||
|
yourself — a gap in the docs is a finding.
|
||||||
|
|
||||||
|
## Goal (checkable end-state)
|
||||||
|
Acting for a merchant on a **sandbox** Keysat instance, using a **scoped, non-master**
|
||||||
|
API key (it carries \`payment_providers:write\`), and the published docs only:
|
||||||
|
|
||||||
|
1. **Connect a BTCPay payment provider** (this box's regtest BTCPay) to Keysat over the
|
||||||
|
API — no master key, no human clicking in a browser. (You hold a BTCPay credential for
|
||||||
|
the regtest server, the way an operator delegating setup would hand one to you.)
|
||||||
|
2. Create a product with a **paid** policy/tier.
|
||||||
|
3. Produce a **buyer checkout** for that product (a purchase invoice).
|
||||||
|
4. Confirm that paying the invoice issues a license (the harness will pay it on regtest if
|
||||||
|
you cannot from the docs alone — note where the docs leave that to plumbing).
|
||||||
|
|
||||||
|
Success = a paid product whose purchase, once settled, yields a valid license — reached
|
||||||
|
from the docs alone, under a scoped key, with BTCPay connected by you.
|
||||||
|
|
||||||
|
## Docs corpus (the ONLY how-to sources)
|
||||||
|
- Keysat docs site: **$DOCS_URL** (start at \`/agent.html\`, \`/integrate.html\`).
|
||||||
|
- Daemon OpenAPI: **$BASE_URL/v1/openapi.json**.
|
||||||
|
|
||||||
|
## Credentials you were handed
|
||||||
|
- 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, migrations, tests, or this harness.
|
||||||
|
|
||||||
|
## 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
+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
|
||||||
Reference in New Issue
Block a user