diff --git a/AGENTS.md b/AGENTS.md index fb3a011..39b4efa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ its guide** — see the index below. ## Subsystem guides (read before editing the area) - Before editing the daemon source, read `docs/guides/daemon-architecture.md`. -- Before editing payment / provider / merchant-profile code or migrations 0020–0022, read `docs/guides/payments.md`. +- Before editing payment / provider / merchant-profile code, the scoped-connect gate, or migrations 0020–0022 + 0024–0025, read `docs/guides/payments.md`. - Before touching self-license or tier-gating code, read `docs/guides/licensing-tiers.md`. - Before changing the LIC1 wire format, crypto, or crosscheck fixtures, read `docs/guides/crypto-wire-format.md`. - Before building, bumping the version, or editing the StartOS wrapper, read `docs/guides/startos-packaging.md`. @@ -109,59 +109,40 @@ Operator-specific memories at `~/.claude/projects/-Users-macpro-Projects-keysat/ policy entitlements (admin UI), then re-issue the master self-license so it takes effect. -## Current state (2026-06-16) +## Current state (2026-06-17) -- **Live (registry/canonical)**: `registry.keysat.xyz` + `files.keysat.xyz/keysat.s9pk` - publish **`0.2.0:57`** — universal multi-arch (x86_64 + aarch64), GitHub release - `v0.2.0-57`. Migrations 0020–0023; four SDKs published; `keysat.xyz` + - `docs.keysat.xyz` deployed. - `:57` shipped the **`merchant-onboard`** scoped role (catalog + license self-serve, no - master key; `src/api/api_keys.rs`) — see git log for detail. -- **Live box `immense-voyage.local`**: `:57` was deployed this session via `make install` - (`start-cli package install` returned clean; StartOS applies the swap async — **not - independently confirmed**; verify the StartOS UI shows `0.2.0:57`). `publish.sh` now runs - `make install` as step 5, so future ships auto-deploy (best-effort, non-fatal). +- **Live / canonical**: **`0.2.0:58`** published — registry + `files.keysat.xyz/keysat.s9pk`, + GitHub `v0.2.0-58`, universal multi-arch (x86_64 + aarch64). Live box + `immense-voyage.local` **confirmed on `:58`** (operator-verified in the StartOS UI). All + three public sites deployed (`keysat.xyz`, `docs.keysat.xyz`, `registry.keysat.xyz`). + Migrations through 0025; four SDKs published. -- **Onboarding doc-harness — Stage 1 (no payments): `completed-clean` this session, committed + pushed.** - `licensing-service-startos/onboarding-harness/` runs the global `onboarding-tester` agent - docs-only against the SDK-integration journey (loop converged 5→1→0 stumbles over 3 runs). - Doc fixes shipped to `keysat-docs` (integrate/agent/wire-format) + the served `openapi.rs` - spec; the publishable walkthrough is harvested to `agent.html` #worked-example. The - `openapi.rs` fixes reach the live spec only on the **next daemon release**; keysat-docs - deploys independently. Full detail: `onboarding-harness/STAGE1-RESULT.md`. **Stage 2 (regtest - buyer-pays) is gated on agent-payment-connect slices 3–5 below.** +- **Shipped in `:58` — agent-payment-connect complete (slices 1–5).** A scoped key with the + à-la-carte `payment_providers:write` scope connects a BTCPay provider over the API, but + ONLY on a sandbox daemon (`KEYSAT_SANDBOX_MODE`) for a non-mainnet store; + master/mainnet/production + disconnect stay master-only. The gate fails closed: the store's + network is resolved from its on-chain receive address at callback, anything not provably + non-mainnet is denied. Migrations 0024–0025. Three reviewer passes; live gate + `validate-gate.sh` 10/10. Detail: `docs/guides/payments.md`, `plans/agent-payment-connect-scope.md`. -- **In progress — agent-payment-connect (phase 2)**. Approved spec: - `plans/agent-payment-connect-scope.md`. Lets a scoped key connect a BTCPay provider, but - ONLY on a sandbox daemon and ONLY for a non-mainnet network — never folded into a role - (a key that can repoint settlement is a fund-redirection key). - - **Foundation committed this session (`3afac07`, origin+gitea; NOT version-bumped)** — - slices 1–2 of 5: `Config.sandbox_mode` (env `KEYSAT_SANDBOX_MODE`, never API-settable; - surfaced in `/v1/admin/tier`); migration 0024 `scoped_api_keys.extra_scopes`; per-key - à-la-carte scopes (`GRANTABLE_EXTRA_SCOPES=["payment_providers:write"]`, granted via - `extra_scopes`, in NO role — `grants()` carves it out of full-admin's wildcard; - fail-closed parsing). Reviewer pass clean after fixing the full-admin-wildcard P1. - - **Pending — slices 3–5**: `require_provider_connect` gate (master→any; scoped+ - `payment_providers:write`→only if `sandbox_mode` AND non-mainnet); BTCPay OAuth wiring - (record `scoped_initiator` in `btcpay_authorize_state` at `start_connect`, network-check - at `finish_connect`, migration 0025); Zaprite stays master-only. **The BTCPay on-chain - address network detection MUST be validated against a live regtest box** before - shipping (classify address prefix `bc1`/`tb1`/`bcrt1`, fail-closed to mainnet; the - payment-method id is `BTC-CHAIN` vs `BTC` by version). +- **Onboarding doc-harness — BOTH stages `completed-clean`.** Stage 1 (SDK integration, no + payments) prior session; **Stage 2 (regtest buyer-pays) this session, converged run 1→3.** + Rig + publishable walkthrough: `onboarding-harness/stage2/STAGE2-RESULT.md`. Doc fixes live + on `keysat-docs` (agent.html/install.html); the served `openapi.rs` BTCPay paths reach the + live spec as of `:58`. The two stages have only been validated **separately**, not as one run. -- **Work queue (next, in order)**: - 1. Build gate slices 3–5 (above) — validate the BTCPay address fetch on regtest. - 2. Confirm `:57` is live on `immense-voyage.local` (StartOS UI). - 3. Operator data action (master key): grant `unlimited_merchant_profiles` to Pro/Patron. - 4. 3 multi-profile UIs + split `audit:read` (ROADMAP / Open TODOs). +- **Next (priority order)**: + 1. Operator data action (needs the master key): grant `unlimited_merchant_profiles` to + Pro/Patron on the live master (confirmed-absent details in Open TODOs). + 2. 3 multi-profile UIs + split `audit:read` (ROADMAP / Open TODOs). + 3. Optional: a single combined SDK-integration + buyer-pays onboarding-tester run. -- **P2/P3 debt (unchanged)**: `set_product_entitlements_catalog` missing `rows_affected` - guard; no rate-limit on purchase/redeem (spoofable XFF); `422`/`415` plain-text not JSON; - `slug` unvalidated; `GET /v1/admin/products` 405 vs OpenAPI; dep advisories (`sqlx`→≥0.8.1, - `rustls-webpki`→≥0.103.12); no CI / fmt-clippy unenforced; field-naming drift; outbound - webhook SSRF; design-contract conformance (see ROADMAP). +- **P2/P3 debt (unchanged, see ROADMAP)**: `set_product_entitlements_catalog` missing + `rows_affected` guard; no rate-limit on purchase/redeem (spoofable XFF); `422`/`415` + plain-text not JSON; `slug` unvalidated; dep advisories (`sqlx`→≥0.8.1, + `rustls-webpki`→≥0.103.12); no CI / fmt-clippy unenforced; outbound webhook SSRF; + design-contract conformance. -- **Tests/build**: `cargo check` + `npm run check` clean (1 intentional deprecation - warning); full suite green — lib unit **13**, api **59**, subscriptions 7, upgrades 9, - worker 3, crosscheck 4, migrations 9 (through 0024). No new clippy warnings. FK - enforcement confirmed — sqlx pool sets `foreign_keys(true)` per connection. +- **Tests/build**: full suite green — lib **18**, api **65**, subscriptions 7, upgrades 9, + worker 3, crosscheck 4, migrations 9 (through **0025**); `cargo check` + `npm run check` + clean (1 intentional deprecation warning); new code clippy-clean. diff --git a/docs/guides/payments.md b/docs/guides/payments.md index c878b53..9ded0d7 100644 --- a/docs/guides/payments.md +++ b/docs/guides/payments.md @@ -108,6 +108,33 @@ response carries no parseable positive amount. Regression tests in `tests/api.rs - Zaprite is optional, gated by the `zaprite_payments` entitlement; recurring auto-charge works via `charge_order_with_profile`. +### Scoped (agent) BTCPay connect — the sandbox gate + +Shipped `:58` (spec: `plans/agent-payment-connect-scope.md`). Provider connect is +master-only EXCEPT: a scoped key carrying the à-la-carte `payment_providers:write` +scope (in NO role, granted per-key via `extra_scopes`) may connect a BTCPay provider +**iff the daemon is in sandbox mode AND the target store is non-mainnet**. Disconnect, +mainnet, and any production daemon stay master-only — a key that can repoint settlement +is a fund-redirection key. Two gates, both fail-closed: + +- **Outer (`api_keys.rs::require_provider_connect`)**: replaces `require_admin` on + `start_connect`. Master → any. Scoped+`payment_providers:write` → only if + `Config.sandbox_mode` (env `KEYSAT_SANDBOX_MODE`, boot-only, never API-settable). +- **Inner (`btcpay_authorize.rs::finish_connect`)**: the initiator (master vs scoped) is + carried across the OAuth round-trip in `btcpay_authorize_state` (migration 0025: + `scoped_initiator` + `initiator_actor_hash`). For a scoped initiator, the store network + is resolved from its on-chain receive address (`btcpay/network.rs::classify_address_network` + via `client::fetch_onchain_network`) and a mainnet/undetermined result is refused **before** + any webhook/persist. Fail-closed: no on-chain wallet, unreachable BTCPay, non-JSON, or + unknown prefix all → mainnet → deny. Scoped connects write an audit row. + +Validated on a live regtest BTCPay (`onboarding-harness/stage2/`): the on-chain pmid is +`BTC-CHAIN` (BTCPay 2.x; legacy `BTC` is normalized to it — don't hardcode), the +`wallet/address` endpoint needs `btcpay.store.canmodifystoresettings` (which the daemon's +OAuth already requests), and a regtest address is `bcrt1…`. Gotcha: both callback forms +(GET + POST) must return the error's real HTTP status on a denied connect — the GET handler +uses `AppError::status_code()` so a refusal is a 4xx, not a misleading 200. + ## Migrations & gotchas - Migrations 0020–0022 are **one-way**. 0020 ports the singleton