Add approved spec: agent-delegable payment-provider connect
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# Plan: agent-delegable payment-provider connect (without making it a fund-redirection key)
|
||||
|
||||
Status: **APPROVED — agreed shape, ready to build. No urgency** (doc-harness Path 1, no
|
||||
payments, ships first; this work feeds Path 2, the full regtest buyer-pays walkthrough).
|
||||
Author: this session, 2026-06-16. Sign-off: other dev, 2026-06-16.
|
||||
Related: `merchant-onboard` role shipped in `0.2.0:57` (`src/api/api_keys.rs`); multi-provider
|
||||
model (`plans/multi-provider-payment-model.md`).
|
||||
|
||||
## 1. Problem / motivation
|
||||
|
||||
Make Keysat **fully agent-compatible**: an operator hands an agent a scoped credential and
|
||||
says *"add Keysat licensing to this software and connect a Bitcoin wallet on BTCPay,"* and
|
||||
the agent does the whole thing over the API. Two use cases:
|
||||
|
||||
1. **Production delegation** — a real operator delegates first-time setup (catalog + connect
|
||||
their BTCPay) to an agent.
|
||||
2. **Doc-onboarding test harness** — a fresh AI agent integrates Keysat from the docs alone
|
||||
against a disposable server, published as marketing ("all the agent had to do was X, Y,
|
||||
Z"). For dev/testing the agent uses **regtest** (private Bitcoin network — mine blocks on
|
||||
demand, no faucets, no real money).
|
||||
|
||||
`merchant-onboard` (shipped `:57`) covers catalog + manual license issuance end-to-end but
|
||||
**cannot connect a payment provider** — that stays master-key only. So the agent story stops
|
||||
one step short of "accept buyer payments."
|
||||
|
||||
## 2. The core risk (why connect is the crown jewel) — ratified
|
||||
|
||||
Creating products/policies/licenses is *"what you sell."* Connecting — or **re**-connecting —
|
||||
a payment provider is **"where the money goes":** a credential that can do it can repoint
|
||||
settlement at an **attacker-controlled wallet/store**, after which every buyer payment lands
|
||||
there. Direct **theft-of-funds**, strictly worse than any catalog write. The value of a
|
||||
scoped key is **bounded blast radius**; folding connect into `merchant-onboard` would make it
|
||||
"near-total financial control with a friendly name."
|
||||
|
||||
**Binding decision (both devs): do NOT fold connect into `merchant-onboard`.** A key that can
|
||||
repoint settlement is a fund-redirection key; bounding that blast radius is the whole point.
|
||||
|
||||
## 3. Current state (verified against code, 2026-06-16)
|
||||
|
||||
- All provider-mutating endpoints are **master-key only** (`require_admin`):
|
||||
- BTCPay connect — `api/btcpay_authorize.rs:87` (OAuth-style: returns an `authorize_url`; a
|
||||
human approves on BTCPay, then the callback persists store id / API key / webhook secret).
|
||||
- BTCPay disconnect — `btcpay_authorize.rs:426`.
|
||||
- Zaprite connect — `api/zaprite_authorize.rs:56` (API-key paste; **no** OAuth step).
|
||||
- Zaprite disconnect — `zaprite_authorize.rs:207`.
|
||||
- Only `payment_providers:read` exists as a scope; there is **no** `payment_providers:write`
|
||||
today. `merchant-onboard` grants all `:read` + `products:write` + `policies:write` +
|
||||
`licenses:write`. Master-only ops sit behind `require_admin`, never consulting `Role::grants`.
|
||||
- Asymmetry: **BTCPay is self-hosted, can run regtest/testnet** (the safe case). **Zaprite is
|
||||
a hosted mainnet SaaS** — no regtest equivalent assumed, so scoped Zaprite-connect stays
|
||||
master-only and the demo uses BTCPay (the motivating case anyway).
|
||||
|
||||
## 4. Options (B rejected; built shape is C + D, gated by E)
|
||||
|
||||
| # | Option | Verdict |
|
||||
|---|--------|---------|
|
||||
| A | Keep connect master-only (status quo) | Safe, not fully agent-compatible |
|
||||
| B | Fold connect into `merchant-onboard` | **Rejected (binding)** — fund-redirection key |
|
||||
| C | À-la-carte scope `payment_providers:write`, never in any default role | **Chosen** packaging |
|
||||
| D | Network gate: scoped connect only regtest/testnet/signet; mainnet → master | **Chosen** (inner defense) |
|
||||
| E | Sandbox-mode daemon flag for disposable instances | **Chosen as the OUTER gate** (see §5) |
|
||||
|
||||
## 5. Agreed design — gate order matters
|
||||
|
||||
A *scoped* `payment_providers:write` connect is permitted **iff ALL** hold, checked in this
|
||||
order:
|
||||
|
||||
1. **OUTER — sandbox-mode daemon flag is ON.** On a production (non-sandbox) daemon, scoped
|
||||
payment-connect is **disabled entirely — even regtest.** *(Refinement, dev:* a scoped key
|
||||
connecting a regtest provider on a production box would knock out the live store's real
|
||||
payments — denial-of-revenue, not theft, but still bad. So the flag, not the network, is
|
||||
the first gate.*)*
|
||||
2. **INNER — target network is non-mainnet** (regtest/testnet/signet). Defense-in-depth so
|
||||
that even on a sandbox daemon a scoped key can't wire up mainnet. **Fail closed:** if the
|
||||
network can't be positively determined as non-mainnet, treat it as mainnet → deny.
|
||||
3. **BTCPay OAuth human-approve** still happens (someone approves on BTCPay's side).
|
||||
|
||||
Master key bypasses 1–2 and may connect any network (still subject to BTCPay's own OAuth).
|
||||
|
||||
**The sandbox flag is daemon-level config (env / launch), read at boot — NEVER settable via
|
||||
any API, scoped or otherwise.** *(Refinement, dev:* otherwise a scoped key flips sandbox on,
|
||||
then connects. Keep it strictly out-of-band.*)*
|
||||
|
||||
Net: full agent-compatibility for dev/testing on a sandbox+regtest instance (zero master-key
|
||||
steps — the doc-harness Path 2), and production stays locked: no scoped key can connect,
|
||||
reconnect, or disconnect a provider on a live box.
|
||||
|
||||
## 6. Resolved decisions (the dev's open-question answers)
|
||||
|
||||
1. **BTCPay network detection — VERIFIED.** Greenfield's `GET /api/v1/server/info`
|
||||
(`ServerInfoData`) has **no** network/chainType field (only `syncStatus[].chainHeight`
|
||||
etc.). Determine the network from a **network-encoding artifact**, not a field:
|
||||
- Primary: fetch a store on-chain receive address —
|
||||
`GET /api/v1/stores/{storeId}/payment-methods/{pmid}/wallet/address` →
|
||||
`OnChainWalletAddressData.address` — and classify by prefix: bech32 HRP `bc1…`=mainnet,
|
||||
`tb1…`=testnet/signet, `bcrt1…`=regtest (legacy base58 `1`/`3`=mainnet vs `m`/`n`/`2`=
|
||||
test/regtest).
|
||||
- Secondary: the on-chain payment method's `derivationScheme` (NBXplorer format) — xpub/
|
||||
ypub/zpub=mainnet, tpub/upub/vpub=testnet. (Fetching config needs
|
||||
`btcpay.store.canmodifystoresettings`.)
|
||||
- **Fail closed:** Lightning-only store / no address / unrecognized prefix → treat as
|
||||
mainnet → require master.
|
||||
2. **Zaprite** — no non-mainnet mode assumed → scoped Zaprite-connect stays master-only;
|
||||
demo uses BTCPay. (Agreed.)
|
||||
3. **Reconnect / rotate of an already-connected mainnet provider — always master-only.**
|
||||
(Strong yes — that's the exact attack.) Sandbox/regtest reconnect follows the §5 scoped
|
||||
rules; mainnet reconnect is never scoped.
|
||||
4. **Packaging — à-la-carte `payment_providers:write`** (composes with `merchant-onboard`,
|
||||
avoids role sprawl), **not** a new fixed role. Implementation note: "composes with" means
|
||||
moving from one-role-per-key to **role + optional extra scopes per key** (or full per-key
|
||||
scope sets) — a schema addition (`scoped_api_keys` gains a scopes column, migration
|
||||
`0024`). Acceptable given no urgency; flagged so it's not a surprise at build time.
|
||||
|
||||
## 7. Design sketch (build)
|
||||
|
||||
- **Scope string**: add `"payment_providers:write"`. Granted per-key (à-la-carte, §6.4), never
|
||||
via a `:write` suffix match, never in `merchant-onboard`'s grant set.
|
||||
- **Sandbox flag**: e.g. `KEYSAT_SANDBOX_MODE=1`, read at boot into `AppState`; surfaced
|
||||
read-only in `/v1/admin/tier`-style status + a prominent "SANDBOX" banner in the admin UI so
|
||||
a sandbox instance is never mistaken for production. **No setter endpoint of any kind.**
|
||||
- **Gate helper**: replace `require_admin` on the connect handlers with
|
||||
`require_provider_connect(state, headers, target_network)`:
|
||||
- master → allow any network;
|
||||
- scoped w/ `payment_providers:write` → allow **iff** `state.sandbox_mode` **and**
|
||||
`target_network != mainnet`;
|
||||
- else 403.
|
||||
Resolve `target_network` (§6.1) **before** persisting anything.
|
||||
- **Reconnect/disconnect**: mainnet reconnect always master-only (§6.3); disconnect on a
|
||||
production daemon stays master-only (denial-of-revenue, consistent with §5.1). In sandbox,
|
||||
both follow the scoped rule.
|
||||
- **Audit**: every scoped connect writes an audit row with actor hash + resolved network +
|
||||
provider.
|
||||
|
||||
## 8. Migration / back-compat
|
||||
|
||||
- New scope string + gate helper + sandbox flag. Migration `0024` only for §6.4 (per-key extra
|
||||
scopes column on `scoped_api_keys`).
|
||||
- Master-key automation unaffected; existing `merchant-onboard` keys unaffected (capability
|
||||
unchanged); existing keys default to no extra scopes.
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- Scoped `payment_providers:write` key on a **sandbox** daemon: connect regtest BTCPay →
|
||||
allowed; connect mainnet BTCPay → 403; Lightning-only / unknown network → 403 (fail-closed).
|
||||
- Same key on a **production** (non-sandbox) daemon: connect regtest → **403** (outer gate);
|
||||
proves §5.1.
|
||||
- Sandbox flag is not flippable via API (assert no endpoint mutates it).
|
||||
- Master key: connect any network → allowed.
|
||||
- `merchant-onboard` without the extra scope → 403 on connect everywhere (proves no role widen).
|
||||
- Mainnet reconnect/rotate → master-only even in sandbox.
|
||||
|
||||
## 10. Status
|
||||
|
||||
Shape **approved by both devs**: C (à-la-carte `payment_providers:write`) + D (network gate,
|
||||
fail-closed) gated by E (sandbox flag as the outer gate, daemon-level only), BTCPay OAuth
|
||||
preserved, B explicitly rejected. Build when it suits — Path 1 ships first.
|
||||
Reference in New Issue
Block a user