Compare commits

...

36 Commits

Author SHA1 Message Date
Grant cc08230f72 Release 0.2.0:62 2026-06-20 06:07:03 -05:00
Grant 1faab61098 Escape single quotes on the buyer-facing buy page
buy_page.rs kept a private html_escape that omitted the `'` escape the
canonical api::mod.rs impl has, so operator/product/discount-code text
rendered into HTML attributes was under-escaped. Drop the fork, reuse the
canonical escaper, and add a unit test covering the single quote.
2026-06-19 23:15:09 -05:00
Grant fd71b19f86 Release 0.2.0:61 2026-06-19 11:55:26 -05:00
Grant 97bf9cc843 Re-verify self-license on tier refresh 2026-06-19 11:48:25 -05:00
Grant 9f08a72619 Harden self-license tier refresh 2026-06-19 09:23:00 -05:00
Grant b68bb4b882 Add prepare.sh build bootstrap for clean Debian box
Start9's build-from-source flow needs every host prerequisite installed before
'make' runs. Add a bootstrap mirroring the official 0.4.0.x environment-setup
page: apt prereqs, Node 22, Docker (+binfmt for cross-arch), and start-cli via
the official installer. Rust stays inside the package Dockerfile, not the host.
2026-06-18 14:46:22 -05:00
Grant c739d5c515 Bump to 0.2.0:60: ship Zaprite auto-charge silent-lapse fix
Version bump + changelog for the recurring-renewal money-path fix (treat a non-settled Zaprite charge response as not-success and fall through to manual-pay).
2026-06-18 12:30:43 -05:00
Grant 46972be9db Note merchant_profiles SMTP-override fields are dormant in admin SPA comment 2026-06-18 12:00:12 -05:00
Grant 0a6d73aa29 Mark merchant_profiles SMTP columns dormant; email plan dropped
The keysat-smtp-emails plan is superseded: Keysat will not send buyer email itself (operators own that via their app plus the existing webhooks). The smtp_* columns from migration 0020 are never read to send mail; left in place (a removal migration isn't worth it) and flagged so no send path is built against them.
2026-06-18 11:22:28 -05:00
Grant 241478af95 Fix Zaprite auto-charge silent-lapse on 2xx-with-failure status
charge_order_with_profile errors on non-2xx, but on a 2xx try_auto_charge_zaprite returned Ok(true) regardless of the order status, reading it only for a log line. A 200 carrying a non-settled status (declined/expired/in-flight) suppressed the manual-pay notification and left the worker waiting for an order.paid webhook that never arrives, so the subscription silently lapsed.

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

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

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

Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate
+ network unit/integration tests.
2026-06-17 09:31:57 -05:00
Grant be8688de80 Fix OpenAPI spec inaccuracies found by the onboarding test
- GET /v1/admin/licenses requires product_id (uuid), not a slug
- add the /v1/admin/licenses/search path (was referenced, never defined)
- drop the phantom GET /v1/admin/products (only POST exists; list is
  the public GET /v1/products)
- clarify product price_value (write field) vs legacy price_sats
2026-06-16 22:48:09 -05:00
Grant 7a1c70ab9b Add onboarding doc-test harness
Disposable rig that runs the global onboarding-tester agent against the
developer SDK-integration journey: boots a fresh keysat fixture, mints a
merchant-onboard scoped key, serves keysat-docs as the published corpus,
scaffolds a pristine Next.js/TS proof-of-work, and has the agent gate it
docs-only. Stage 1 (no payments) reached completed-clean over three runs;
see onboarding-harness/STAGE1-RESULT.md. Stage 2 (regtest buyer-pays) is
gated on the agent-payment-connect scope work.
2026-06-16 22:48:09 -05:00
Grant 3afac078d4 Add sandbox flag + per-key à-la-carte scopes (payment-connect foundation)
Foundation for agent-delegable payment-provider connect
(plans/agent-payment-connect-scope.md, slices 1-2 of 5). Not yet wired to any
connect endpoint — the gate (require_provider_connect + BTCPay non-mainnet
network check) is a follow-up.

- Config.sandbox_mode from KEYSAT_SANDBOX_MODE (daemon-level, never settable
  via any API); surfaced read-only in /v1/admin/tier as "sandbox".
- Migration 0024: additive scoped_api_keys.extra_scopes column (JSON array).
- Per-key à-la-carte scopes: require_scope grants via role OR a key's
  extra_scopes; GRANTABLE_EXTRA_SCOPES allowlist (payment_providers:write
  only), validated on create and echoed in create/list responses.
- payment_providers:write is in NO role: grants() carves the à-la-carte set
  out of full-admin's wildcard, so even a scoped full-admin key can't reach
  it through its role — only a per-key grant does. extra_scopes parsing
  fails closed (NULL/malformed -> no grant).
- Tests: invariant (no role grants the à-la-carte set), fail-closed parsing,
  create/list round-trip, reject ungrantable scope. Suite green: lib 13, api 59.
2026-06-16 21:16:20 -05:00
Grant 069cf1eb40 Bump version to 0.2.0:57 (merchant-onboard scoped-key role) 2026-06-16 19:17:02 -05:00
Grant d5885d1d97 Add merchant-onboard scoped-key role for self-serve onboarding
New scoped API-key role granting read + products:write + policies:write +
licenses:write — the least-privilege credential for end-to-end catalog
setup and license issuance (create product, define policies/tiers, issue
licenses against them) without holding the master key.

The catalog write scopes already existed and were enforced on the
endpoints; only the role->scope expansion was missing. So this is a new
Role variant, not a scope-model change. grants() matches scope strings
explicitly (never by :write suffix) so the role can't widen into
settings / payment / merchant-profile / webhook writes, and every
master-only operation stays behind require_admin and so is structurally
unreachable. Existing tier caps still bound it (Creator: 5 products /
5 policies per product).

Migration 0023 rebuilds scoped_api_keys to widen the role CHECK (SQLite
can't alter a CHECK in place); the table has no FKs, so it's a plain
copy/drop/rename. Test covers the full onboard chain under the key's own
credential plus denial of master-only gates and support-only writes.
2026-06-16 18:55:18 -05:00
Grant 6b02992013 Cut 0.2.0:56 — product→merchant-profile write path 2026-06-16 14:10:34 -05:00
Grant d2846ac6ae Fix stale scoped-API-key panel note in api_keys.rs doc comment 2026-06-16 13:05:26 -05:00
Grant b088bfc062 Wire product→merchant-profile write path
Multi-profile resolution shipped in :52 but nothing wrote
products.merchant_profile_id, so it was non-functional end to end.

Add merchant_profile_id to the Product model + all four product
SELECTs, a set_product_merchant_profile writer (validates the target
profile exists, returning 404 instead of a raw FK-violation 500), and
thread an optional field through CreateProductReq (post-write) and
UpdateProductReq (double-Option; Some(None) clears to default). The
admin SPA product form shows a profile picker only when >1 profile
exists. Mirrors the entitlements-catalog post-write pattern.

Tests: repo round-trip (attach/resolve/clear/bad-id) + HTTP handler
arms. api suite 54→56, full suite green.
2026-06-15 21:38:24 -05:00
Grant 5cf56007f0 Genericize revoke-reason example (drop refund framing) 2026-06-13 06:58:15 -05:00
Grant 5fc2c4516f Bump to 0.2.0:55 — scoped API keys, settle-amount tripwire, universal multi-arch 2026-06-13 06:43:43 -05:00
Grant ca32309ad9 Add StartOS instructions.md; fix manifest links; clear retired-enforce-mode drift
- instructions.md: new, required for Start9 community-registry submission
- manifest: fix dead packageRepo and docsUrls links
- versions/v0.2.0.ts: drop stale 'NOT YET WIRED' header
- actions: remove retired enforce-mode references; showLicenseStatus no longer
  reads a nonexistent 'mode' field; relabel the Creator (free) tier
2026-06-13 06:40:11 -05:00
Keysat 0508690d5a Wire scoped API keys and add advisory settle-amount tripwire
Scoped API keys (P1): migrate 58 admin endpoints from require_admin to
require_scope so ks_ keys with Read-only/License-issuer/Support/Full-admin roles
work as intended. 12 sensitive endpoints stay master-key-only (issuer key,
provider connect/disconnect, web password, api-key CRUD, db-info, operator-name,
per-license tier change). require_scope is re-exported from api::admin so both
auth gates import from one place. Adds role-boundary tests.

Settle-amount tripwire (P1): get_invoice_status now returns
ProviderInvoiceSnapshot { status, amount }. On a confirmed settle,
audit_settle_amount (shared by the webhook and reconcile issue paths) compares
the provider-reported sat amount against the invoice's amount_sats and, on drift,
logs a warning + writes an invoice.amount_mismatch audit row, then issues anyway.
Advisory by design: a hard gate would fight an operator's BTCPay payment
tolerance, and Settled already implies paid-in-full. SAT-only — skips non-SAT
settles (fiat subscription renewals) and unparseable amounts.
2026-06-13 00:10:45 -05:00
Grant 495fbbf351 Bump to 0.2.0:54 — ship the webhook settle-confirmation fix 2026-06-12 22:37:17 -05:00
Grant 783372c03b Confirm settle with provider API before issuing; add test-injection seam
The settle-webhook honored payment on the webhook body's claim alone.
Zaprite webhooks carry no signature, so a forged order.change/status=PAID
POST with a buyer-visible order id minted a signed license without payment.

handle_inner now re-fetches provider.get_invoice_status and requires Settled
before persisting "settled" or taking any settle-derived action (issuance,
tier-change, subscription renewal — the guard precedes all of them). On a
provider-API error it acks 200 without issuing, so a transient outage can't
trigger a webhook retry storm; the reconcile loop re-confirms and issues later.

Adds the always-compiled AppState::provider_override seam (None in prod),
honored by provider_from_row at every resolution site, so integration tests
drive the real resolver with a MockPaymentProvider. Greens the two
paid_purchase_* tests, deletes the dead payment_provider_preference_round_trip,
and adds forged-settle + provider-unreachable regression tests. api 47/47.

Not addressed: a literal paid-amount/currency check (needs a trait change).
2026-06-12 22:36:42 -05:00
Grant 8c4baccf6b Bump to 0.2.0:53 — ship the ambiguous-column purchase fix 2026-06-12 20:48:54 -05:00
Grant 31f4670efa Fix ambiguous-column bug in merchant-profile resolution
get_merchant_profile_for_product selected the bare MERCHANT_PROFILE_COLS list
while JOINing products (which also has an id), so SQLite raised "ambiguous
column name: id" on every execution. The function runs on every purchase, so
every paid purchase on 0.2.0:52 returned HTTP 500. Replace the JOIN with an
equivalent correlated subquery, keeping merchant_profiles the only table in
FROM; behavior on NULL/missing merchant_profile_id is unchanged (no row, caller
falls back to the default profile).

Also from the verification pass:
- Add merchant_profile_provider_resolution_queries_round_trip, exercising the
  previously untested runtime-prepared resolution / CRUD / preference queries.
- Repair three test call sites for the new create_invoice / create_subscription
  params; capture the response body in the paid_purchase status assertion.
- Align manifest license to LicenseRef-Keysat-1.0; drop an unused import.
2026-06-12 19:39:33 -05:00
Grant b17565bdcb Add registry icon asset 2026-06-12 17:58:27 -05:00
87 changed files with 4605 additions and 612 deletions
+30 -10
View File
@@ -57,6 +57,7 @@ API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars>
| `read-only` | List / get every resource. Mutate nothing. |
| `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. |
| `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. |
| `merchant-onboard` | All `read-only` scopes + `products:write` + `policies:write` + `licenses:write` — the least-privilege credential for standing up a fresh catalog (create products, define policies/tiers, issue licenses against them) via the API. Deliberately excludes the support writes (subscriptions / machines) and every master-only gate. Tier caps still bound it. |
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
Endpoints that touch settings (operator name, payment provider connections,
@@ -138,7 +139,7 @@ upgrade CTA without parsing message strings.
| `expired` | Past `expires_at` |
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
| `product_mismatch` | License is for a different product than the caller asserted |
| `machine_cap_exceeded` | Activating this fingerprint would exceed `max_machines` |
| `too_many_machines` | Activating this fingerprint would exceed `max_machines` |
---
@@ -170,10 +171,12 @@ Scope required: `licenses:write` (any role except `read-only`).
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{"reason":"refund issued"}'
-d '{"reason":"customer request"}'
```
Idempotent. The next online validate from the buyer's app returns `reason: revoked`.
The next online validate from the buyer's app returns `reason: revoked`. Not
idempotent — a second revoke of the same license returns `404 not_found` (treat
as success-equivalent on retry; see Idempotency below).
Scope required: `licenses:write`.
@@ -277,10 +280,16 @@ A few patterns that work well in practice.
### Idempotency
The daemon's mutation endpoints are idempotent where they can be. Revoke,
suspend, unsuspend, archive, unarchive, subscription cancel — all return
success on the second call without changing state. Your agent can safely
retry on network errors.
The daemon's mutation endpoints are idempotent where they can be. Suspend,
unsuspend, archive, unarchive, subscription cancel — all return success on the
second call without changing state. Your agent can safely retry on network
errors.
One exception: **revoke is not idempotent** — revoking an already-revoked
license returns `404 not_found` (the row no longer matches the
`status != 'revoked'` update guard). When retrying a revoke after an ambiguous
network failure, treat a `404` as success-equivalent: the license is already
revoked.
### Pagination
@@ -349,15 +358,26 @@ Some operations are deliberately operator-only and not accessible to any
scoped key, including `full-admin`:
- Generating / revoking scoped API keys (`/v1/admin/api-keys`)
- Connecting / disconnecting payment providers
- Disconnecting a payment provider, and connecting *any* provider on a
production daemon
- Setting the operator name
- Activating the self-license (`/v1/admin/self-license`)
- Resetting the analytics install_uuid
- Changing the web UI password (StartOS Action only)
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
that can rotate its own credentials, connect arbitrary payment processors, or
change the operator identity is no longer bounded by the role it was given.
that can rotate its own credentials, redirect settled payments, or change the
operator identity is no longer bounded by the role it was given.
**One narrow exception — agent-delegated payment connect.** A key granted the
à-la-carte `payment_providers:write` scope (never granted by any role —
operators add it explicitly per key) CAN initiate a BTCPay connect, but only
fail-closed under two gates: the daemon must be in **sandbox mode** (an outer
gate — scoped connect is refused outright on a production daemon, even for
regtest), and the target store must be **non-mainnet** (an inner gate enforced
after the OAuth round-trip). Disconnecting a provider, and any connect on a
production / mainnet daemon, remain master-only. This lets an integrating agent
wire up a throwaway sandbox without ever touching a live store's settlement.
---
+5
View File
@@ -15,4 +15,9 @@
#
# Chain targets when needed: `make clean arm install`.
# Only x86_64 and aarch64 are supported and declared in the manifest. The shared
# s9pk.mk defaults ARCHES to include riscv; override it here so a bare `make`
# (which builds every arch in ARCHES) does not attempt an unverified riscv build.
ARCHES := x86 arm
include s9pk.mk
+37 -59
View File
@@ -80,10 +80,10 @@ comp keys, beta access, or "first N users free" launch promos.
Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
with build context set to the parent directory so the Dockerfile can
`COPY` from the sibling `licensing-service/` source tree. The Rust binary
is statically linked against musl (target
`*-unknown-linux-musl`) so the runtime image is a `scratch`-based final
stage with no shared-library dependencies. Architectures: `x86_64` and
`aarch64`.
is statically compiled against musl (target `*-unknown-linux-musl`), and the
runtime stage is `debian:bookworm-slim` with `ca-certificates`, `tini` (init /
signal handling), and `sqlite3` (an SQL shell for occasional admin tasks)
installed. Architectures: `x86_64` and `aarch64`.
`start-cli s9pk pack` ingests the resulting OCI image, converts it to a
squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
@@ -105,25 +105,24 @@ mandatory.
## Installation and First-Run Flow
1. Install Keysat via the marketplace (or sideload the `.s9pk`).
2. Resolve the auto-created **critical task** "Connect BTCPay" by
running the **Connect BTCPay** action. This opens a one-click
authorize page on your local BTCPay; after approval, Keysat
auto-detects your store and registers an inbound webhook. No API
keys to copy.
3. Run **Check BTCPay connection** to confirm — the install task clears
automatically.
4. Set your **operator name** (shown on the public homepage and in
2. Resolve the auto-created **important task** "Connect BTCPay" — open
the embedded admin web UI (**Settings → Payment providers**) and
click **Connect BTCPay**. This opens a one-click authorize page on
your local BTCPay; after approval, Keysat auto-detects your store and
registers an inbound webhook. No API keys to copy. The install task
clears automatically once BTCPay reports connected.
3. Set your **operator name** (shown on the public homepage and in
buyer-facing receipts).
5. Create one or more **products** — each represents something you sell.
6. Create at least one **policy** per product. Multi-tier ladders
4. Create one or more **products** — each represents something you sell.
5. Create at least one **policy** per product. Multi-tier ladders
(Basic / Pro / Max) are first-class: when a product has two or more
public policies, the buy page renders a tier picker and the buyer
chooses before paying. Policies define duration, grace period, seat
cap, entitlements, recurring cadence, trial flag, price overrides,
marketing bullets, and per-entitlement hide-on-buy-page toggles.
7. Optionally create **discount / referral / free-license codes** (see
`Create discount code` action).
8. Share the public service URL with buyers.
6. Optionally create **discount / referral / free-license codes** in the
admin web UI.
7. Share the public service URL with buyers.
## Configuration Management
@@ -145,7 +144,7 @@ interfaces for clarity:
| Interface | Type | Path prefix | Purpose |
|-----------|------|-------------|------------------------------------------------------------------------------|
| `api` | api | `/` | Public REST API for buyers (purchase, redeem) and licensed apps (validate, machine activation). Bake the URL into your software builds as the licensing endpoint. |
| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically during Connect BTCPay; not for human use. |
| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically when you connect BTCPay in the admin web UI; not for human use. |
StartOS terminates TLS at the platform edge. Inside the container every
request arrives as plain HTTP. For browser-facing URLs (e.g., the public
@@ -153,44 +152,23 @@ purchase page) hardcode `https://`.
## Actions (StartOS UI)
Grouped as displayed in the dashboard.
The StartOS Actions tab is intentionally minimal — only the four operations
that must happen outside the embedded admin web UI are registered as actions:
**General**
- *Set operator name* — your public-facing brand.
- *Set web UI password* — set / recover the admin SPA login password (you
can't reset it from inside the UI if you're locked out).
- *Show credentials* — reveal the admin API key on first install, before
you've logged into the admin UI.
- *Activate Keysat license* — first-install bootstrap for paid self-hosting
tiers, and recovery if `/data/keysat-license.txt` is lost.
- *Show license status* — sanity-check the self-license state without
logging into the admin UI.
**BTCPay**
- *Connect BTCPay* — one-click authorize against your BTCPay; auto-detects store and registers webhook.
- *Check BTCPay connection* — confirm BTCPay state; clears the install task on success.
**Credentials**
- *Show admin credentials* — admin API key for direct `/v1/admin/*` access.
**Products + Policies**
- *Create product* — declare something to sell.
- *Create policy* — license template for a product (duration, grace, seat cap, entitlements, trial flag, price override).
**Discount codes**
- *Create discount code* — percent-off / fixed-sats-off / free-license.
- *List discount codes* — usage stats.
- *Disable / enable discount code*.
**Licenses**
- *Issue license manually* — comp / press / grandfathered keys.
- *Search licenses* — by email or BTCPay invoice id.
- *Suspend license* — reversible lockout.
- *Unsuspend license*.
- *Revoke license* — terminal kill.
**Machines**
- *List machines* — installs bound to a license.
- *Deactivate machine* — free a seat.
**Webhooks (outbound)**
- *Register webhook endpoint* — POST signed events to your URL.
- *List webhook endpoints*.
**Diagnostics**
- *View audit log* — admin mutation history, filterable.
Everything else — connecting BTCPay (and Zaprite), operator name, products,
policies, discount / referral / free-license codes, licenses, machines,
outbound webhooks, scoped API keys, and the audit log — lives in the embedded
**admin web UI** (Settings tab + the workspace sidebar), not as StartOS
actions.
## Backups and Restore
@@ -228,7 +206,7 @@ Known current limitations:
- **No bulk / volume licensing UI.** "Buy 10 keys at once with discount" is not built into the buy page. Operators can issue N comp licenses via the admin API in a loop.
- **Webhook delivery retries are bounded.** A subscriber down past the 10-attempt retry window lands in the dead-letter queue (visible in admin UI → Webhooks → Failed). BTCPay invoice reconciliation runs as a background poll so dropped *payment* webhooks are recovered.
- **Hardware fingerprinting is client-supplied.** Keysat does not derive fingerprints itself; the buyer-side SDK passes whatever the integrator chose. The fingerprint is bound on first activate and enforced thereafter.
- **Card payments not shipped.** The Zaprite payment provider is in design for v0.3 — operators on Pro / Patron will get a card-payment option alongside BTCPay. Until then, payments are BTC / Lightning only.
- **Card payments via Zaprite are gated.** Zaprite ships as an optional second payment provider (card / fiat alongside BTCPay) but is gated by the `zaprite_payments` entitlement — operators on the tiers that grant it can connect Zaprite in the admin web UI. BTCPay remains the required provider; without the entitlement, payments are BTC / Lightning only.
## What Is Unchanged from Upstream
@@ -257,7 +235,7 @@ service:
marketingUrl: https://keysat.xyz
image:
source: dockerBuild
baseImage: scratch (musl-static Rust binary)
baseImage: debian:bookworm-slim (musl-compiled Rust binary; bundles ca-certificates, tini, sqlite3)
arches: [x86_64, aarch64]
volumes:
- id: main
@@ -292,10 +270,10 @@ backups:
firstRun:
tasks:
- id: btcpay-initial-setup
severity: critical
severity: important
runs: configureBtcpay
features:
paymentRail: btcpay-server # zaprite planned for v0.3 (card payments)
paymentProviders: [btcpay-server, zaprite] # btcpay required; zaprite optional, gated by the zaprite_payments entitlement
signing: ed25519
offlineVerification: true
multiSeat: true
@@ -315,7 +293,7 @@ features:
outboundWebhooks: true
webhookDlq: true # failed deliveries retryable from admin UI
auditLog: true
scopedApiKeys: [read-only, license-issuer, support, full-admin]
scopedApiKeys: [read-only, license-issuer, support, merchant-onboard, full-admin]
openapiSpec: /v1/openapi.json
selfLicensingTier: [Creator, Pro, Patron]
sdks:
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+87
View File
@@ -0,0 +1,87 @@
# Keysat Licensing — Instructions
Keysat is a Bitcoin-native, self-hosted licensing service for software
creators. You run your own instance, hold your own signing key, and issue
Ed25519-signed license keys that your software verifies offline. There is no
central authority and no shared database.
## Before you start
- **BTCPay Server is required.** Install and start BTCPay Server first — Keysat
uses it to take Bitcoin/Lightning payments and confirm settlement. StartOS
lists this dependency before it lets you install Keysat.
- **A clearnet domain is recommended if you sell to the public**, so buyers
anywhere can reach your checkout. LAN/Tor-only works for testing.
- **Zaprite is optional** (adds card payments). You connect it later from inside
the admin web UI; nothing to do up front.
## First-time setup
1. **Get your admin API key.** Open the **Actions** tab and run
**Show admin API key**. Copy it — you sign into the admin web UI with it the
first time.
2. **Open the admin dashboard.** Click **Launch UI** on the **Admin Web UI**
interface and paste the admin API key to sign in.
3. **(Recommended) Set a real password.** Run the **Set web UI password** action
(Actions tab, minimum 12 characters). After this the login page shows a
password field; the admin API key keeps working for automation.
4. **Connect your payment provider.** In the admin web UI's Settings, use the
one-click **Connect BTCPay** flow to authorize Keysat against your BTCPay
Server. (Optionally connect Zaprite here too.)
5. **Set your operator name** in the admin web UI — it appears on buyer-facing
checkout and receipts.
6. **Create what you sell.** Use **Create product** for each item, and
optionally **Create policy** to set per-product defaults (duration, grace
period, entitlements, seat cap, trial flag). A policy slugged `default` is the
one the public purchase flow uses.
Activation is optional. Keysat runs out of the box at the free **Creator** tier
(up to 5 products, 5 policies per product, and 10 active discount codes).
Activating a license lifts those caps and unlocks recurring billing and Zaprite
(card) payments. To activate, get a key at
[registry.keysat.xyz](https://registry.keysat.xyz), run the **Activate Keysat
license** action, and confirm with **Show Keysat license status**.
## Selling licenses
Share your **Licensing API** URL with buyers and bake it into your software as
the validation endpoint. Buyers call `POST /v1/purchase`, pay via BTCPay, and
Keysat issues a signed license key. Your software validates keys against
`POST /v1/validate` — including revocation checks, which return
`ok: false` with `reason: "revoked"`.
The same admin web UI covers manual license issuance (comps, press, trials),
suspension/unsuspension, revocation, machine management, discount codes,
outbound webhooks, and the audit log.
## Interfaces and exposure
- **Licensing API** (`/`) — public-facing. This is the URL you share with
customers and bake into your builds.
- **Admin Web UI** (`/admin`) — your dashboard. Restrict this interface to LAN or
Tor only; the public internet does not need to reach it.
- **BTCPay webhook endpoint** (`/btcpay`) — registered with BTCPay automatically
during the Connect BTCPay flow. Not for human use.
## Backups and uninstalling
Your data volume holds the SQLite database — which contains your server signing
key and every license record — and StartOS backs it up automatically. Your
self-license at `/data/keysat-license.txt` is included in the backup and
survives upgrades and reinstalls.
**Uninstalling deletes your signing key and all license records.** Once it is
gone, previously issued license keys no longer validate against this server. Back
up first if you plan to reinstall.
## Recovery
- **Locked out of the admin UI?** Run **Set web UI password** to set a new one,
or **Show admin API key** to sign in with the key.
- **Lost your Keysat license?** Re-run **Activate Keysat license** with your key.
## More
Full developer and integration documentation lives in the upstream repository
(`README.md` and `KEYSAT_INTEGRATION.md`) and at
[keysat.xyz](https://keysat.xyz).
+21 -38
View File
@@ -56,44 +56,21 @@ See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout.
## Project layout
```
licensing-service/
├── Cargo.toml
├── LICENSE # source-available; no redistribution
├── README.md
├── .env.example # required env vars
├── migrations/
│ └── 0001_initial.sql # SQLite schema
├── src/
│ ├── main.rs # entry point: wires everything
│ ├── config.rs # env-driven config
│ ├── error.rs # unified error → HTTP mapping
│ ├── models.rs # shared domain types
│ ├── crypto/
│ │ ├── mod.rs # license key format + sign/verify
│ │ └── keys.rs # server keypair lifecycle
│ ├── db/
│ │ ├── mod.rs # pool + migrations
│ │ └── repo.rs # all SQL queries
│ ├── btcpay/
│ │ ├── client.rs # Greenfield API client
│ │ └── webhook.rs # HMAC verification + event parsing
│ └── api/
│ ├── mod.rs # router + AppState
│ ├── products.rs # public product endpoints
│ ├── purchase.rs # buy + poll
│ ├── validate.rs # the hot path for downstream software
│ ├── webhook.rs # BTCPay landing
│ └── admin.rs # operator-only actions
└── docs/
├── API.md # full endpoint reference
├── INTEGRATION.md # for developers embedding a client
└── ARCHITECTURE.md # deeper design notes
```
The daemon source lives under `src/`, organized by subsystem (browse it for the current layout — the tree below has grown well past the v0.1 snapshot):
- `main.rs`, `config.rs`, `error.rs`, `models.rs` — entry point, env-driven config, error → HTTP mapping, shared domain types.
- `crypto/` — the LIC1 license-key byte layout and Ed25519 sign/verify (the contract the four SDKs implement).
- `db/` — SQLite pool, migrations, and `repo.rs` (all SQL). `migrations/` holds the numbered, additive schema (0001 through the latest; the schema has grown substantially since 0001).
- `payment/` (`btcpay/`, `zaprite/`) + `merchant_profiles.rs` — the payment-provider abstraction and multi-profile routing.
- `reconcile.rs`, `subscriptions.rs`, `upgrades.rs` — the background worker (invoice reconciliation, recurring renewals, tier upgrades).
- `api/` — the ~30 route modules: public (`products`, `purchase`, `validate`, `redeem`) and admin (`admin*`, scoped API keys, webhooks, etc.), plus the router and `AppState` in `api/mod.rs`.
- `web/index.html` — the embedded admin SPA.
Deeper docs: [`docs/API.md`](docs/API.md), [`docs/INTEGRATION.md`](docs/INTEGRATION.md), [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Running locally
Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted).
Prerequisites: Rust 1.88+ (the build toolchain; the crate's Cargo.toml still declares MSRV 1.75, but the dependency tree now requires a newer compiler), a BTCPay Server instance you can point at (local or hosted).
```bash
cp .env.example .env
@@ -109,7 +86,7 @@ On first boot the server generates a fresh Ed25519 keypair and stores it in the
```bash
curl -X POST http://localhost:8080/v1/admin/products \
-H "Authorization: Bearer $LICENSING_ADMIN_API_KEY" \
-H "Authorization: Bearer $KEYSAT_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "my-app",
@@ -143,7 +120,7 @@ curl -X POST http://localhost:8080/v1/validate \
## Deploying on Start9
This repository ships the service only. To package as an `.s9pk` for the 0.4.0.x platform you'll need a separate wrapper repository following [docs.start9.com/packaging/0.4.0.x](https://docs.start9.com/packaging/0.4.0.x/). The service is designed to slot in cleanly:
The StartOS wrapper lives in **this same repository** under `../startos/` (this `licensing-service/` directory is the daemon source it bundles). Build the `.s9pk` for the 0.4.0.x platform from the parent directory — see the build/release guide and `../Makefile`. The service is designed to slot in cleanly:
- **Declares a dependency** on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a `.startos` hostname and supply the env vars from the wrapper's action handlers.
- **Persists to `/data`**, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups.
@@ -170,4 +147,10 @@ Commercial redistribution / resale rights: contact licensing@keysat.xyz.
## Status
v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator.
0.2.0 — shipped and in production. The current feature set:
- **Four published SDKs** — TypeScript (npm), Rust (crates.io), Python (PyPI), and Go — all wire-compatible against the cross-check fixtures in `tests/crosscheck/`.
- **StartOS wrapper included in this repo** under `../startos/`; build the `.s9pk` from the parent directory (no separate wrapper repository).
- **Embedded admin SPA** (`web/index.html`) for all day-to-day operations.
- **Subscriptions** (recurring auto-renew with trials + grace), **policies / tiers** with per-policy entitlements, **discount / referral / free-license codes**, **outbound webhooks** with a dead-letter queue, and a background **invoice reconciliation** job that recovers dropped payment webhooks.
- **Payment providers**: BTCPay Server is required; Zaprite (card / fiat) is optional and gated by the `zaprite_payments` entitlement.
+4 -4
View File
@@ -6,7 +6,7 @@ All endpoints are JSON in / JSON out. Errors return a body of the form:
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
```
Admin endpoints require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`.
---
@@ -19,7 +19,7 @@ Service metadata including the Ed25519 public key. Useful for SDKs to fetch the
```json
{
"service": "keysat",
"version": "0.1.0",
"version": "0.2.0",
"operator": "Acme Software",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
"key_algorithm": "ed25519",
@@ -128,7 +128,7 @@ On failure:
{ "ok": false, "reason": "revoked" }
```
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `product_mismatch`, `fingerprint_mismatch`.
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`, `expired`, `product_mismatch`, `fingerprint_mismatch`, `too_many_machines` (multi-seat cap reached).
### `POST /v1/btcpay/webhook`
@@ -138,7 +138,7 @@ Landing point for BTCPay Server webhook events. Only BTCPay should call this. We
## Admin endpoints
All of these require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
All of these require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`.
### `POST /v1/admin/products`
+13 -10
View File
@@ -12,13 +12,18 @@
## Data model
See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables:
The schema lives in [`migrations/`](../migrations/) as numbered, additive
migrations (0001 through the latest — it has grown substantially past the
original five-table v0.1 schema, adding discount codes, tiered pricing,
multi-currency, subscriptions, tier upgrades, per-product entitlement catalogs,
scoped API keys, merchant profiles, and more). The core tables established in
[`0001_initial.sql`](../migrations/0001_initial.sql):
- `products` — what's for sale. Independent pricing per product.
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns.
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns. Later migrations add `expires_at`, entitlements, trial flag, and tier columns.
- `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us.
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot, never rotated in v0.1 (rotation is a planned feature).
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot.
## License key format
@@ -28,7 +33,7 @@ LIC1 - <base32(74-byte payload)> - <base32(64-byte signature)>
The payload is a fixed binary layout, not JSON, to keep keys short. Details in [`src/crypto/mod.rs`](../src/crypto/mod.rs).
Why base32 Crockford-style (no padding)?
Why base32 (RFC 4648, no padding)?
- Uppercase only, unambiguous chars, easy to read aloud or type from a screen.
- Slightly longer than base64 but less error-prone for humans copying keys.
@@ -58,14 +63,12 @@ Who might attack this?
5. **Chargeback / dispute** (applicable to non-Bitcoin rails, but worth noting). Bitcoin payments are irreversible, so the normal fraud model that motivates software DRM mostly doesn't apply here. Most revocations will be: key leaked publicly, legitimate business decision, mistaken issuance.
## What's deliberately NOT in v0.1
## Deliberately out of scope
- **Key rotation.** A single static signing key is fine for first launch. Rotation requires SDK multi-key support and a migration strategy; deferred.
- **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app.
- **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured).
- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change.
- **Key rotation.** A single static signing key is fine for now. Rotation requires SDK multi-key support and a migration strategy; deferred.
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
- **Admin UI.** Everything is API-driven. Wrap it in whatever UI you like — or just use `curl`.
(Trial/time-limited policies, multi-currency pricing, the optional Zaprite card rail, and the embedded admin UI all shipped after v0.1 and are no longer on this list.)
## Notes on Start9 dependencies
+10 -1
View File
@@ -25,9 +25,18 @@ curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
> **Official SDKs exist — use them first.** Four wire-compatible client SDKs
> are published: TypeScript (`@keysat/licensing-client` on npm), Rust
> (`keysat-licensing-client` on crates.io), Python (`keysat-licensing-client`
> on PyPI), and Go (`github.com/keysat-xyz/keysat-client-go`). Install commands
> are in the main README. The by-hand reference implementations below are a
> fallback for languages without an SDK, or for understanding exactly what the
> SDKs do under the hood.
## Reference integration in Rust
This is what a Start9 package written in Rust might look like. No SDK crate yet — that's planned; here's what you'd write by hand:
This is what a Start9 package written in Rust might look like if you verify by
hand instead of using the Rust SDK:
```rust
use anyhow::{Context, Result};
@@ -0,0 +1,39 @@
-- Migration 0023: add the 'merchant-onboard' scoped-API-key role.
--
-- 0016 created scoped_api_keys with a CHECK that pins `role` to the four
-- roles known then (read-only | license-issuer | support | full-admin).
-- SQLite can't ALTER or DROP a CHECK constraint in place, so adding a
-- fifth role means rebuilding the table with a widened CHECK.
--
-- scoped_api_keys has no foreign keys (inbound or outbound), so this is
-- the simple copy -> drop -> rename rebuild, without any of the FK
-- juggling that 0009 needed. sqlx-migrate wraps each file in a
-- transaction; we don't BEGIN here.
--
-- Idempotent: re-running produces the same end state. Existing rows (any
-- role, active or revoked) are preserved verbatim. The leading DROP IF
-- EXISTS clears a stray _new table from any partially-applied prior run
-- before we rebuild.
DROP TABLE IF EXISTS scoped_api_keys_new;
CREATE TABLE scoped_api_keys_new (
id TEXT PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
CHECK (role IN ('read-only', 'license-issuer', 'support', 'merchant-onboard', 'full-admin'))
);
INSERT INTO scoped_api_keys_new
SELECT id, label, token_hash, role, created_at, last_used_at, revoked_at
FROM scoped_api_keys;
DROP TABLE scoped_api_keys;
ALTER TABLE scoped_api_keys_new RENAME TO scoped_api_keys;
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_token ON scoped_api_keys(token_hash);
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_active ON scoped_api_keys(revoked_at);
@@ -0,0 +1,13 @@
-- Migration 0024: per-key à-la-carte scopes on scoped API keys.
--
-- Roles (read-only | license-issuer | support | merchant-onboard | full-admin)
-- expand to a fixed scope set. Some capabilities are too sensitive to bake into
-- any role but still need to be grantable to a SPECIFIC key. The first is
-- `payment_providers:write` — agent-delegated payment-provider connect, itself
-- gated further by the daemon sandbox flag + a non-mainnet network check (see
-- plans/agent-payment-connect-scope.md).
--
-- `extra_scopes` holds a JSON array of additional scope strings granted to THIS
-- key on top of its role. NULL / absent = role scopes only (every existing key),
-- so this is a pure additive column — no table rebuild.
ALTER TABLE scoped_api_keys ADD COLUMN extra_scopes TEXT;
@@ -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;
+62 -15
View File
@@ -16,6 +16,13 @@ use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
// The scoped-API-key gate lives in `api_keys` (next to the Role/scope logic),
// but endpoint modules import both auth gates from here so there's one obvious
// place to reach for when wiring an admin route. `require_admin` = master key
// only; `require_scope` = master key OR a scoped key whose role grants the
// named scope.
pub use crate::api::api_keys::require_scope;
/// Guards every admin handler: pulls the bearer token out of the header and
/// compares constant-time against the configured admin key. Returns the
/// SHA-256 hex of the token on success so handlers can write an audit row
@@ -100,6 +107,11 @@ pub struct CreateProductReq {
/// policies can carry any entitlement string.
#[serde(default)]
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
/// Merchant profile to attach the product to (migration 0020).
/// Omit / null to resolve to the default profile. Only meaningful
/// when the operator runs more than one profile.
#[serde(default)]
pub merchant_profile_id: Option<String>,
}
/// Currencies the admin endpoints accept. Whitelist enforced here so
@@ -169,7 +181,7 @@ pub async fn create_product(
headers: HeaderMap,
Json(req): Json<CreateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "products:write").await?;
let (ip, ua) = request_context(&headers);
// Tier-cap gate: Creator caps at 5 products. 402 if over.
crate::api::tier::enforce_product_cap(&state).await?;
@@ -205,6 +217,17 @@ pub async fn create_product(
} else {
product
};
// Attach to a merchant profile if the operator picked one (same
// post-write pattern as the entitlements catalog). Omitted = NULL =
// resolves to the default profile. A bad profile id 404s here AFTER
// the row exists, leaving it with a NULL profile — benign (resolves
// to default; reattach or delete). The admin UI only offers existing
// profiles, so this is an API-direct edge only.
let product = if let Some(profile_id) = req.merchant_profile_id.as_deref() {
repo::set_product_merchant_profile(&state.db, &product.id, Some(profile_id)).await?
} else {
product
};
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
@@ -260,7 +283,7 @@ pub async fn delete_product(
Path(id): Path<String>,
Query(opts): Query<DeleteOpts>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "products:write").await?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_id(&state.db, &id)
@@ -430,6 +453,20 @@ pub struct UpdateProductReq {
/// string until the catalog is set again.
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
/// Reassign the product's merchant profile (migration 0020).
/// `Some(Some(id))` attaches, `Some(None)` clears it back to
/// default-resolution, omit / absent leaves it unchanged.
#[serde(default, deserialize_with = "deser_double_option_profile", skip_serializing_if = "Option::is_none")]
pub merchant_profile_id: Option<Option<String>>,
}
/// Serde adapter for the nullable merchant-profile patch — same
/// "omitted vs null vs value" three-way distinction as the catalog.
fn deser_double_option_profile<'de, D>(de: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(de).map(Some)
}
/// Serde adapter — distinguishes "field omitted" (None) from
@@ -451,7 +488,7 @@ pub async fn update_product(
Path(id): Path<String>,
Json(req): Json<UpdateProductReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "products:write").await?;
let (ip, ua) = request_context(&headers);
// Resolve the pricing patch into (currency, value, sats) tuple
@@ -526,6 +563,16 @@ pub async fn update_product(
}
None => updated,
};
// Merchant-profile reassignment, same three-way patch as the
// catalog: Some(Some) attaches, Some(None) clears to default, None
// leaves it untouched.
let updated = match &req.merchant_profile_id {
Some(Some(profile_id)) => {
repo::set_product_merchant_profile(&state.db, &id, Some(profile_id.as_str())).await?
}
Some(None) => repo::set_product_merchant_profile(&state.db, &id, None).await?,
None => updated,
};
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
@@ -551,7 +598,7 @@ pub async fn set_product_active(
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "products:write").await?;
let (ip, ua) = request_context(&headers);
repo::set_product_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
@@ -581,7 +628,7 @@ pub async fn list_licenses(
headers: HeaderMap,
Query(q): Query<ListLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "licenses:read").await?;
let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?;
Ok(Json(json!({ "licenses": licenses })))
}
@@ -605,7 +652,7 @@ pub async fn search_licenses(
headers: HeaderMap,
Query(q): Query<SearchLicensesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "licenses:read").await?;
let licenses = repo::search_licenses(
&state.db,
q.buyer_email.as_deref(),
@@ -685,7 +732,7 @@ pub async fn revenue_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "licenses:read").await?;
let total: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(amount_sats), 0) FROM invoices WHERE status = 'settled'",
)
@@ -730,7 +777,7 @@ pub async fn license_counts(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "licenses:read").await?;
let by_product: Vec<(String, i64)> = sqlx::query_as(
"SELECT product_id, COUNT(*) FROM licenses GROUP BY product_id",
)
@@ -762,7 +809,7 @@ pub async fn licenses_summary(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "licenses:read").await?;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await?;
@@ -845,7 +892,7 @@ pub async fn issue_license(
headers: HeaderMap,
Json(req): Json<IssueLicenseReq>,
) -> AppResult<Json<IssueLicenseResp>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
@@ -997,7 +1044,7 @@ pub async fn revoke_license(
Path(license_id): Path<String>,
Json(req): Json<RevokeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin revoke".to_string()
@@ -1040,7 +1087,7 @@ pub async fn suspend_license(
Path(license_id): Path<String>,
Json(req): Json<SuspendReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin suspend".to_string()
@@ -1074,7 +1121,7 @@ pub async fn unsuspend_license(
headers: HeaderMap,
Path(license_id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "licenses:write").await?;
let (ip, ua) = request_context(&headers);
repo::unsuspend_license(&state.db, &license_id).await?;
let _ = repo::insert_audit(
@@ -1116,7 +1163,7 @@ pub async fn list_audit(
headers: HeaderMap,
Query(q): Query<ListAuditQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "audit:read").await?;
let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?;
Ok(Json(json!({ "entries": rows })))
}
@@ -1164,7 +1211,7 @@ pub async fn get_operator_name(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "settings:read").await?;
let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?;
let effective = stored
.clone()
+272 -27
View File
@@ -5,20 +5,29 @@
//! script a credential that does only what it needs to. Operator-friendly
//! flow:
//!
//! 1. Operator generates a new key in Settings → API keys, picks a role
//! from a fixed list (Read-only / License issuer / Support / Full admin).
//! 2. UI returns the raw token ONCE. The token never appears in any
//! response afterward — only its sha256 hash is stored.
//! 3. Agent uses `Authorization: Bearer <token>` like the master key.
//! Endpoints that have been scope-wired check the agent's role
//! grants the required scope; if not, 403.
//! 4. Operator can revoke any key from the same UI; revoked tokens
//! stop working immediately.
//! 1. Operator mints a new key via the Settings → "Scoped API keys" panel
//! in the admin SPA (or directly via `POST /v1/admin/api-keys`), picking a
//! role from a fixed list (Read-only / License issuer / Support /
//! Merchant onboard / Full admin).
//! 2. The create response returns the raw token ONCE. The token never
//! appears in any response afterward — only its sha256 hash is stored.
//! 3. Agent uses `Authorization: Bearer <token>` like the master key. Each
//! scope-gated endpoint checks the agent's role grants the required
//! scope; if not, 403.
//! 4. Operator can revoke any key (`DELETE /v1/admin/api-keys/:id`); revoked
//! tokens stop working immediately.
//!
//! The master `admin_api_key` always works on every endpoint. Scoped keys
//! work only on endpoints that have been migrated to call `require_scope`
//! instead of `require_admin`. Endpoints not yet migrated reject scoped
//! keys with 403 — secure-by-default.
//! The master `admin_api_key` always works on every endpoint. Scoped keys are
//! honored across the catalog/license/support surface: every read endpoint
//! (`<resource>:read`), license writes (`licenses:write`), and the support
//! writes (`subscriptions:write`, `machines:write`). A deliberate set of
//! sensitive endpoints stays master-key-only — even a `full-admin` scoped key
//! gets 403 on them: rotating the issuer signing key, connecting/disconnecting
//! payment providers, setting the web-admin password, managing API keys
//! themselves, changing server settings or license tiers, and DB
//! introspection. When adding a new admin route, gate it with
//! `require_scope(state, headers, "<resource>:<read|write>")` unless it belongs
//! in that master-only set, in which case use `require_admin`.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
@@ -53,6 +62,16 @@ pub enum Role {
/// Right shape for a customer-support agent that resolves common
/// requests without touching catalog or settings.
Support,
/// Read-only + catalog *and* license writes: create/edit products,
/// define policies/tiers, and issue licenses against them. The
/// least-privilege credential for end-to-end self-serve onboarding —
/// a merchant (or an integrating agent) standing up a fresh catalog
/// via the API without the master key. Deliberately excludes the
/// support writes (subs/machines) and every master-only gate
/// (settings, tiers, payment connect, key mgmt, signing-key, db).
/// Tier caps still bound it: a Creator-tier box stays at 5 products /
/// 5 policies-per-product regardless of credential.
MerchantOnboard,
/// Every scope. Equivalent to the master `admin_api_key` for endpoints
/// that use `require_scope`; still rejected by endpoints that gate on
/// settings-write or tier-write where the master key is required.
@@ -65,6 +84,7 @@ impl Role {
Role::ReadOnly => "read-only",
Role::LicenseIssuer => "license-issuer",
Role::Support => "support",
Role::MerchantOnboard => "merchant-onboard",
Role::FullAdmin => "full-admin",
}
}
@@ -73,6 +93,7 @@ impl Role {
"read-only" => Some(Role::ReadOnly),
"license-issuer" => Some(Role::LicenseIssuer),
"support" => Some(Role::Support),
"merchant-onboard" => Some(Role::MerchantOnboard),
"full-admin" => Some(Role::FullAdmin),
_ => None,
}
@@ -81,7 +102,13 @@ impl Role {
/// `<resource>:<read|write>`, e.g. `licenses:write`.
pub fn grants(self, scope: &str) -> bool {
match self {
Role::FullAdmin => true,
// Every scope EXCEPT the à-la-carte-only ones (e.g.
// `payment_providers:write`). Those are never role-grantable — only
// a per-key `extra_scopes` entry grants them — so even a full-admin
// *scoped* key can't reach payment-connect through its role. (The
// master key still passes `require_scope` ahead of this, via the
// early constant-time compare, and may do anything.)
Role::FullAdmin => !GRANTABLE_EXTRA_SCOPES.contains(&scope),
Role::ReadOnly => scope.ends_with(":read"),
Role::LicenseIssuer => {
scope.ends_with(":read")
@@ -96,9 +123,37 @@ impl Role {
| "machines:write"
)
}
// Catalog + license writes only. Match scopes EXPLICITLY (never
// by `:write` suffix) so this role can never widen into
// settings:write / merchant_profiles:write / payment / webhooks
// / rates — all of which would otherwise share the suffix. Adding
// a write scope here is a deliberate per-string decision.
Role::MerchantOnboard => {
scope.ends_with(":read")
|| matches!(
scope,
"products:write" | "policies:write" | "licenses:write"
)
}
}
}
}
/// Scopes an operator may grant à-la-carte on a key (on top of its role), via
/// the `scopes` field on create. Deliberately tiny: only sensitive
/// capabilities that don't belong in any role. `payment_providers:write` is the
/// first — it is further gated at the endpoint (daemon sandbox mode + a
/// non-mainnet network check). See `plans/agent-payment-connect-scope.md`.
pub const GRANTABLE_EXTRA_SCOPES: &[&str] = &["payment_providers:write"];
/// Parse a key's `extra_scopes` JSON array and test membership. Tolerant of
/// NULL / malformed JSON (treated as "no extra scopes") so a bad row can never
/// widen access — it only ever fails closed.
fn extra_scopes_contains(json: Option<&str>, scope: &str) -> bool {
json.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.map(|v| v.iter().any(|s| s == scope))
.unwrap_or(false)
}
/// Verify the request carries a credential that grants the named scope.
/// Order of acceptance:
@@ -138,14 +193,14 @@ pub async fn require_scope(
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
"SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?",
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) = match row {
let (key_id, role_str, revoked_at, extra_scopes_json) = match row {
Some(r) => r,
None => return Err(AppError::Forbidden),
};
@@ -153,7 +208,11 @@ pub async fn require_scope(
return Err(AppError::Forbidden);
}
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
if !role.grants(scope) {
// A key grants a scope via its role OR via an à-la-carte `extra_scopes`
// entry (e.g. `payment_providers:write`, which is in no role).
let granted =
role.grants(scope) || extra_scopes_contains(extra_scopes_json.as_deref(), scope);
if !granted {
return Err(AppError::Forbidden);
}
@@ -168,12 +227,101 @@ pub async fn require_scope(
Ok(token_hash)
}
/// Who initiated a payment-provider connect — determines the network gate at
/// callback time (`btcpay_authorize::finish_connect`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectInitiator {
/// The master `admin_api_key`. May connect any network.
Master,
/// A scoped key carrying `payment_providers:write` on a sandbox daemon.
/// Restricted to non-mainnet stores (enforced after the OAuth round-trip,
/// once the store + network are known).
Scoped,
}
/// Gate for **starting** a BTCPay provider connect — the fund-redirection-
/// sensitive operation. Stricter than `require_scope`: a scoped key reaches it
/// ONLY with the à-la-carte `payment_providers:write` scope AND only on a
/// **sandbox daemon** (the OUTER gate — on a production box scoped connect is
/// disabled entirely, even for regtest, since a scoped key re-pointing
/// settlement on a live box is denial-of-revenue). The INNER gate (target
/// network must be non-mainnet) is enforced separately at callback time, once
/// the store is known. See `plans/agent-payment-connect-scope.md` §5.
///
/// Returns `(actor_hash, initiator)`. The caller records `initiator` in the
/// authorize-state row so the callback can apply the network gate. Master keys
/// bypass both gates (still subject to BTCPay's own OAuth approval).
pub async fn require_provider_connect(
state: &AppState,
headers: &HeaderMap,
) -> AppResult<(String, ConnectInitiator)> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
// Master admin key — full bypass, may connect any network.
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
return Ok((hex::encode(hasher.finalize()), ConnectInitiator::Master));
}
// Scoped key — must carry `payment_providers:write` (never role-granted;
// only via à-la-carte `extra_scopes`) AND the daemon must be in sandbox mode.
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?",
)
.bind(&token_hash)
.fetch_optional(&state.db)
.await?;
let (key_id, role_str, revoked_at, extra_scopes_json) = row.ok_or(AppError::Forbidden)?;
if revoked_at.is_some() {
return Err(AppError::Forbidden);
}
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
let has_scope = role.grants("payment_providers:write")
|| extra_scopes_contains(extra_scopes_json.as_deref(), "payment_providers:write");
if !has_scope {
return Err(AppError::Forbidden);
}
// OUTER gate: scoped connect is permitted only on a sandbox daemon.
if !state.config.sandbox_mode {
return Err(AppError::Forbidden);
}
let now = Utc::now().to_rfc3339();
let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?")
.bind(&now)
.bind(&key_id)
.execute(&state.db)
.await;
Ok((token_hash, ConnectInitiator::Scoped))
}
// ---------- CRUD endpoints (gated on master admin only) ----------
#[derive(Debug, Deserialize)]
pub struct CreateApiKeyReq {
pub label: String,
pub role: String,
/// Optional à-la-carte scopes granted on top of the role. Each must be in
/// `GRANTABLE_EXTRA_SCOPES`. Omitted / empty = role scopes only.
#[serde(default)]
pub scopes: Vec<String>,
}
#[derive(Debug, Serialize)]
@@ -181,6 +329,8 @@ pub struct CreateApiKeyResp {
pub id: String,
pub label: String,
pub role: String,
/// À-la-carte scopes granted on top of the role (echoed back).
pub scopes: Vec<String>,
pub created_at: String,
/// The raw token. Returned ONCE on create and never again — operator
/// must copy it now or generate a new key.
@@ -204,10 +354,36 @@ pub async fn create(
}
let role = Role::parse(req.role.trim()).ok_or_else(|| {
AppError::BadRequest(
"role must be one of: read-only, license-issuer, support, full-admin".into(),
"role must be one of: read-only, license-issuer, support, merchant-onboard, full-admin"
.into(),
)
})?;
// Validate à-la-carte extra scopes (granted on top of the role). Only the
// capabilities in GRANTABLE_EXTRA_SCOPES may be granted this way; anything
// else is rejected so a typo can't silently grant nothing (or something).
let mut extra_scopes: Vec<String> = req
.scopes
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
extra_scopes.sort();
extra_scopes.dedup();
for s in &extra_scopes {
if !GRANTABLE_EXTRA_SCOPES.contains(&s.as_str()) {
return Err(AppError::BadRequest(format!(
"scope '{s}' is not grantable on a key; allowed à-la-carte scopes: {}",
GRANTABLE_EXTRA_SCOPES.join(", ")
)));
}
}
let extra_scopes_json = if extra_scopes.is_empty() {
None
} else {
Some(serde_json::to_string(&extra_scopes).expect("Vec<String> serializes"))
};
// 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars.
// Prefix `ks_` so it's recognizable in logs as a Keysat-style token.
use rand::RngCore;
@@ -225,14 +401,15 @@ pub async fn create(
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)",
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at, extra_scopes)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(label)
.bind(&token_hash)
.bind(role.as_str())
.bind(&now)
.bind(&extra_scopes_json)
.execute(&state.db)
.await?;
@@ -245,7 +422,7 @@ pub async fn create(
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "label": label, "role": role.as_str() }),
&json!({ "label": label, "role": role.as_str(), "scopes": extra_scopes.clone() }),
)
.await;
@@ -253,6 +430,7 @@ pub async fn create(
id,
label: label.to_string(),
role: role.as_str().to_string(),
scopes: extra_scopes,
created_at: now,
token,
}))
@@ -263,6 +441,8 @@ pub struct ApiKeyListEntry {
pub id: String,
pub label: String,
pub role: String,
/// À-la-carte scopes granted on top of the role (empty for most keys).
pub scopes: Vec<String>,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
@@ -275,23 +455,35 @@ pub async fn list(
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows: Vec<(String, String, String, String, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT id, label, role, created_at, last_used_at, revoked_at
let rows: Vec<(
String,
String,
String,
Option<String>,
String,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT id, label, role, extra_scopes, created_at, last_used_at, revoked_at
FROM scoped_api_keys ORDER BY created_at DESC",
)
.fetch_all(&state.db)
.await?;
let out: Vec<ApiKeyListEntry> = rows
.into_iter()
.map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
.map(
|(id, label, role, extra_scopes, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
id,
label,
role,
scopes: extra_scopes
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default(),
created_at,
last_used_at,
revoked_at,
})
},
)
.collect();
Ok(Json(json!({ "api_keys": out })))
}
@@ -340,3 +532,56 @@ pub async fn revoke(
.await;
Ok(Json(json!({ "ok": true, "revoked_at": now })))
}
#[cfg(test)]
mod tests {
use super::*;
/// The invariant: à-la-carte-only scopes (e.g. `payment_providers:write`)
/// are NEVER grantable by any role — not even `full-admin`. Only a per-key
/// `extra_scopes` entry grants them. Guards the P1 regression where
/// `FullAdmin => true` would let a scoped full-admin key reach
/// payment-connect through its role.
#[test]
fn no_role_grants_alacarte_only_scopes() {
let roles = [
Role::ReadOnly,
Role::LicenseIssuer,
Role::Support,
Role::MerchantOnboard,
Role::FullAdmin,
];
for role in roles {
for scope in GRANTABLE_EXTRA_SCOPES {
assert!(
!role.grants(scope),
"role {} must NOT grant à-la-carte-only scope {scope}",
role.as_str()
);
}
}
}
/// Full-admin still grants every *role* scope — the fix only carves out the
/// à-la-carte-only set, nothing else.
#[test]
fn full_admin_still_grants_ordinary_scopes() {
assert!(Role::FullAdmin.grants("products:write"));
assert!(Role::FullAdmin.grants("policies:write"));
assert!(Role::FullAdmin.grants("settings:read"));
assert!(Role::FullAdmin.grants("payment_providers:read"));
}
/// `extra_scopes` parsing fails closed: NULL / malformed / wrong-shape JSON
/// grants nothing and never errors open.
#[test]
fn extra_scopes_contains_fails_closed() {
let json = r#"["payment_providers:write"]"#;
assert!(extra_scopes_contains(Some(json), "payment_providers:write"));
assert!(!extra_scopes_contains(Some(json), "products:write"));
assert!(!extra_scopes_contains(None, "payment_providers:write")); // NULL
assert!(!extra_scopes_contains(Some("not json"), "payment_providers:write")); // malformed
assert!(!extra_scopes_contains(Some("{}"), "payment_providers:write")); // wrong shape
assert!(!extra_scopes_contains(Some("[]"), "payment_providers:write")); // empty
}
}
+97 -9
View File
@@ -22,9 +22,14 @@
//! callback path uses the CSRF `state` token to tie a callback back to the
//! issuing operator session.
use crate::api::{admin::require_admin, AppState};
use crate::api::{
admin::{require_admin, require_scope},
api_keys::{require_provider_connect, ConnectInitiator},
AppState,
};
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
use crate::btcpay::config as btcpay_cfg;
use crate::btcpay::network::BitcoinNetwork;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
@@ -84,7 +89,12 @@ pub async fn start_connect(
headers: HeaderMap,
body: Option<Json<StartConnectReq>>,
) -> AppResult<Json<ConnectResp>> {
require_admin(&state, &headers)?;
// Master key → connect any network. Scoped key with `payment_providers:write`
// → permitted ONLY on a sandbox daemon (outer gate); the non-mainnet inner
// gate is enforced at callback time once the store is known. See
// `plans/agent-payment-connect-scope.md` §5.
let (actor_hash, initiator) = require_provider_connect(&state, &headers).await?;
let scoped_initiator = matches!(initiator, ConnectInitiator::Scoped);
let req = body.map(|Json(b)| b).unwrap_or_default();
// Resolve the target merchant profile (defaulting to the default).
@@ -115,7 +125,15 @@ pub async fn start_connect(
rand::thread_rng().fill_bytes(&mut raw);
let state_token = BASE32_NOPAD.encode(&raw);
btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id))
btcpay_cfg::record_authorize_state(
&state.db,
&state_token,
Some(&profile.id),
scoped_initiator,
// Only stored for scoped connects (the callback's audit row). Master
// connects are covered by the StartOS action audit trail.
scoped_initiator.then_some(actor_hash.as_str()),
)
.await
.map_err(AppError::Internal)?;
@@ -226,10 +244,17 @@ pub async fn callback_get(
Ok(()) => success_page(
"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_escape::encode_text(&e.to_string())
))
)),
)
.into_response(),
}
}
@@ -243,7 +268,7 @@ pub async fn payment_methods(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "payment_providers:read").await?;
let default = crate::merchant_profiles::require_default(&state.db).await?;
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
.await?;
@@ -271,7 +296,7 @@ pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "payment_providers:read").await?;
let default = crate::merchant_profiles::get_default(&state.db).await?;
let row = match &default {
Some(profile) => {
@@ -302,10 +327,10 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
// Recovers the `merchant_profile_id` recorded when the operator
// kicked off the connect flow. NULL falls back to the default
// profile (back-compat for state tokens from pre-0022 runs).
let recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token)
let auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let profile = match recorded_profile_id.as_deref() {
let profile = match auth_state.merchant_profile_id.as_deref() {
Some(id) => crate::merchant_profiles::get(&state.db, id)
.await?
.ok_or_else(|| AppError::BadRequest(format!(
@@ -331,6 +356,43 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
))?;
// INNER gate (scoped initiators only): the target store must settle on a
// non-mainnet network. This is the first point in the flow where we know
// the store, so detection happens here — BEFORE registering any webhook or
// persisting the provider. Fail closed: if the network can't be positively
// determined as non-mainnet, treat it as mainnet and refuse. Master
// initiators skip this entirely (they may connect any network).
let resolved_network = if auth_state.scoped_initiator {
let network = match btcpay_client::fetch_onchain_network(base_url, api_key, &store.id).await {
Ok(Some(net)) => net,
Ok(None) => {
tracing::warn!(
store = %store.id,
"scoped BTCPay connect: on-chain network undetermined → fail-closed to mainnet (deny)"
);
BitcoinNetwork::Mainnet
}
Err(e) => {
tracing::warn!(
store = %store.id, error = %format!("{e:#}"),
"scoped BTCPay connect: network detection errored → fail-closed to mainnet (deny)"
);
BitcoinNetwork::Mainnet
}
};
if network.is_mainnet() {
return Err(AppError::BadRequest(format!(
"Scoped payment-provider connect is restricted to non-mainnet \
(regtest/testnet/signet) BTCPay stores; the selected store resolved \
to '{}'. Use the master admin key to connect a mainnet store.",
network.as_str()
)));
}
Some(network)
} else {
None
};
// Generate a strong webhook secret, then register the webhook on BTCPay.
let mut raw_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_secret);
@@ -387,14 +449,40 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
state.set_payment_provider(provider).await;
}
let network_str = resolved_network.map(|n| n.as_str());
tracing::info!(
provider_id = %provider_id,
merchant_profile_id = %profile.id,
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
scoped = auth_state.scoped_initiator,
network = network_str.unwrap_or("master/any"),
"BTCPay connected via authorize flow"
);
// Audit every scoped connect (spec §7) — attributes the fund-redirection-
// sensitive op to the initiating credential + the resolved network. Master
// connects are already covered by the StartOS action audit trail.
if auth_state.scoped_initiator {
let _ = crate::db::repo::insert_audit(
&state.db,
"scoped_api_key",
auth_state.initiator_actor_hash.as_deref(),
"payment_provider.connect_scoped",
Some("payment_provider"),
Some(&provider_id),
None,
None,
&json!({
"kind": "btcpay",
"store_id": store.id,
"merchant_profile_id": profile.id,
"network": network_str,
}),
)
.await;
}
Ok(())
}
+3 -7
View File
@@ -16,6 +16,9 @@
//! buyer-facing surface — easy to deploy, no asset hosting required.
use crate::api::AppState;
// Reuse the canonical HTML escaper (escapes `'` as well as `&<>"`) instead of a
// private copy, so the buyer-facing page can't fall behind on attribute escaping.
use crate::api::html_escape;
use crate::db::repo;
use axum::{
extract::{Path, Query, State},
@@ -1533,13 +1536,6 @@ code{{background:#eee;padding:0.1em 0.4em;border-radius:4px;font-family:ui-monos
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_thousands(n: i64) -> String {
// Renders 50000 as "50,000" — visible price legibility for sat amounts.
let s = n.to_string();
+4 -4
View File
@@ -19,7 +19,7 @@
use crate::analytics::{
self, SETTING_COLLECTOR_URL, SETTING_ENABLED, SETTING_INSTALL_UUID,
};
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
@@ -31,7 +31,7 @@ pub async fn get(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "community:read").await?;
let enabled = analytics::is_enabled(&state).await;
let collector_url = repo::settings_get(&state.db, SETTING_COLLECTOR_URL).await?;
let install_uuid = repo::settings_get(&state.db, SETTING_INSTALL_UUID).await?;
@@ -84,7 +84,7 @@ pub async fn set(
headers: HeaderMap,
Json(req): Json<SetReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "community:write").await?;
let (ip, ua) = request_context(&headers);
// Validate URL shape if one was supplied. We don't try to reach
@@ -154,7 +154,7 @@ pub async fn reset(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "community:write").await?;
let (ip, ua) = request_context(&headers);
repo::settings_set(&state.db, SETTING_INSTALL_UUID, None).await?;
let _ = repo::insert_audit(
+7 -7
View File
@@ -4,7 +4,7 @@
//! The public purchase flow consumes codes via the `code` field on
//! `POST /v1/purchase`; that path is handled in `crate::api::purchase`.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
@@ -67,7 +67,7 @@ pub async fn create(
headers: HeaderMap,
Json(req): Json<CreateDiscountCodeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
let (ip, ua) = request_context(&headers);
// Tier-cap gate: Creator caps at 5 active discount codes.
@@ -200,7 +200,7 @@ pub async fn list(
headers: HeaderMap,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "discount_codes:read").await?;
let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?;
Ok(Json(json!({ "codes": codes })))
}
@@ -210,7 +210,7 @@ pub async fn get_one(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "discount_codes:read").await?;
let code = repo::get_discount_code_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?;
@@ -271,7 +271,7 @@ pub async fn update(
Path(id): Path<String>,
Json(req): Json<UpdateDiscountCodeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
let (ip, ua) = request_context(&headers);
// Resolve policy_slugs → policy ids using the code's EXISTING product
@@ -360,7 +360,7 @@ pub async fn set_active(
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
let (ip, ua) = request_context(&headers);
repo::set_discount_code_active(&state.db, &id, req.active).await?;
let action = if req.active {
@@ -392,7 +392,7 @@ pub async fn delete(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?;
let (ip, ua) = request_context(&headers);
// Look up the code so we can audit-log meaningful detail.
+3 -3
View File
@@ -19,7 +19,7 @@
//! Admin endpoints let operators look at who's using what and force-kick a
//! machine off a license.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::crypto;
use crate::db::repo;
@@ -261,7 +261,7 @@ pub async fn admin_list(
headers: HeaderMap,
Query(q): Query<AdminListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "machines:read").await?;
// Resolve product_slug → product_id if the caller passed the slug
// form. Either works; product_id takes precedence on conflict.
@@ -299,7 +299,7 @@ pub async fn admin_deactivate(
Path(id): Path<String>,
Json(req): Json<AdminDeactivateReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "machines:write").await?;
let (ip, ua) = request_context(&headers);
let reason = if req.reason.is_empty() {
"admin deactivate".to_string()
@@ -4,7 +4,7 @@
//! `crate::merchant_profiles` and the rail-preference repo helpers.
//! Consumed by the new Merchant Profiles section of the admin UI.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::merchant_profiles::{
@@ -77,7 +77,7 @@ pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "merchant_profiles:read").await?;
let profiles = merchant_profiles::list(&state.db).await?;
let mut out: Vec<Value> = Vec::with_capacity(profiles.len());
for p in &profiles {
@@ -94,7 +94,7 @@ pub async fn get(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "merchant_profiles:read").await?;
let profile = merchant_profiles::get(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("merchant profile {id}")))?;
@@ -125,7 +125,7 @@ pub async fn create(
headers: HeaderMap,
Json(req): Json<NewMerchantProfile>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
let created = merchant_profiles::create(&state, req).await?;
let _ = crate::db::repo::insert_audit(
@@ -150,7 +150,7 @@ pub async fn update(
Path(id): Path<String>,
Json(patch): Json<MerchantProfileUpdate>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
let updated = merchant_profiles::update(&state.db, &id, patch).await?;
let _ = crate::db::repo::insert_audit(
@@ -175,7 +175,7 @@ pub async fn delete(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
merchant_profiles::delete(&state.db, &id).await?;
let _ = crate::db::repo::insert_audit(
@@ -200,7 +200,7 @@ pub async fn set_default(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
merchant_profiles::set_default(&state.db, &id).await?;
let _ = crate::db::repo::insert_audit(
@@ -237,7 +237,7 @@ pub async fn set_rail_preference(
Path((profile_id, rail)): Path<(String, String)>,
Json(req): Json<SetRailPreferenceReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
// Validate the rail name.
@@ -311,7 +311,7 @@ pub async fn clear_rail_preference(
headers: HeaderMap,
Path((profile_id, rail)): Path<(String, String)>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| {
+45 -10
View File
@@ -108,6 +108,15 @@ pub struct AppState {
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
/// write lock when the operator runs Connect / Disconnect.
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
/// Test-only injection seam. When `Some`, the merchant-profile
/// resolver (`resolve_provider_for_profile_rail`, `payment_provider_by_id`)
/// returns THIS provider instead of constructing a real BTCPay/Zaprite
/// client from the DB row via `payment::build_provider`. The DB still
/// drives profile/rail/row resolution, so that logic is exercised for
/// real — only the network-talking impl is swapped. Always `None` in
/// production (`main.rs`); set by integration tests so they can drive
/// the real purchase/settle path with a `MockPaymentProvider`.
pub provider_override: Option<Arc<dyn crate::payment::PaymentProvider>>,
pub config: Arc<Config>,
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
/// operator activates a fresh license via the admin endpoint.
@@ -199,7 +208,20 @@ impl AppState {
.ok_or_else(|| {
AppError::NotFound(format!("payment provider {provider_id}"))
})?;
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
self.provider_from_row(&row)
}
/// Instantiate a `PaymentProvider` from a resolved DB row, honoring the
/// test-only `provider_override` seam. In production `provider_override`
/// is always `None`, so this just delegates to `payment::build_provider`.
fn provider_from_row(
&self,
row: &crate::db::repo::PaymentProviderRow,
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
if let Some(p) = &self.provider_override {
return Ok(p.clone());
}
crate::payment::build_provider(row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)
}
@@ -241,9 +263,7 @@ impl AppState {
pref.payment_provider_id
))
})?;
let provider =
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)?;
let provider = self.provider_from_row(&row)?;
return Ok((row, provider));
}
@@ -271,9 +291,7 @@ impl AppState {
))),
[only] => {
let row = (*only).clone();
let provider =
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)?;
let provider = self.provider_from_row(&row)?;
Ok((row, provider))
}
[first, ..] => {
@@ -287,9 +305,7 @@ impl AppState {
this warning."
);
let row = (*first).clone();
let provider =
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)?;
let provider = self.provider_from_row(&row)?;
Ok((row, provider))
}
}
@@ -1177,3 +1193,22 @@ async fn pubkey(
"public_key_pem": state.keypair.public_key_pem,
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// The canonical escaper must cover the single quote — operator/product/
/// discount-code text renders into HTML attributes (incl. single-quoted),
/// so omitting `'` is an injection hole. Guards against re-forking a copy
/// that drops it (the bug that lived in `buy_page.rs`).
#[test]
fn html_escape_covers_single_quote_and_friends() {
assert_eq!(html_escape("'"), "&#39;");
assert_eq!(
html_escape(r#"<a href='x' title="y">&</a>"#),
"&lt;a href=&#39;x&#39; title=&quot;y&quot;&gt;&amp;&lt;/a&gt;"
);
assert_eq!(html_escape("plain"), "plain");
}
}
+50 -12
View File
@@ -50,7 +50,7 @@ const SPEC_JSON: &str = r##"{
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, or full-admin."
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, merchant-onboard, or full-admin."
}
},
"schemas": {
@@ -86,9 +86,9 @@ const SPEC_JSON: &str = r##"{
"slug": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" },
"price_sats": { "type": "integer", "nullable": true },
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true },
"price_value": { "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, "description": "Currency for price_value. Defaults to SAT." },
"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" },
"entitlements_catalog": {
"type": "array",
@@ -263,7 +263,7 @@ const SPEC_JSON: &str = r##"{
"/v1/admin/licenses": {
"get": {
"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" } }
},
"post": {
@@ -272,6 +272,13 @@ const SPEC_JSON: &str = r##"{
"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": {
"post": {
"summary": "Revoke a license",
@@ -301,11 +308,6 @@ const SPEC_JSON: &str = r##"{
}
},
"/v1/admin/products": {
"get": {
"summary": "List products",
"description": "Scope required: `products:read`.",
"responses": { "200": { "description": "Product list" } }
},
"post": {
"summary": "Create a product",
"description": "Scope required: `products:write`.",
@@ -398,7 +400,8 @@ const SPEC_JSON: &str = r##"{
"type": "object",
"properties": {
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "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"]
} } }
@@ -416,9 +419,44 @@ const SPEC_JSON: &str = r##"{
"/v1/admin/tier": {
"get": {
"summary": "Get this daemon's tier + usage + caps",
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.",
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier. Includes a read-only `sandbox` boolean (true when KEYSAT_SANDBOX_MODE is set).",
"responses": { "200": { "description": "Tier info" } }
}
},
"/v1/admin/btcpay/connect": {
"post": {
"summary": "Start a BTCPay provider connect",
"description": "Returns a one-time `state` token and the BTCPay authorize URL; complete the connect at /v1/btcpay/authorize/callback. The master key may connect any network. A scoped key needs the `payment_providers:write` extra scope AND a sandbox daemon (KEYSAT_SANDBOX_MODE); the target store must resolve to a non-mainnet network or the callback refuses. Optional JSON body: { merchant_profile_id }.",
"responses": {
"200": { "description": "{ authorize_url, state, merchant_profile_id }" },
"403": { "description": "Scoped key without payment_providers:write, or not a sandbox daemon" },
"409": { "description": "Profile already has a BTCPay provider; disconnect first" }
}
}
},
"/v1/btcpay/authorize/callback": {
"get": {
"summary": "Complete a BTCPay connect",
"description": "BTCPay redirects here after the operator approves in a browser, or an agent calls it directly with a pre-issued store API key. Query params: `state` (from /connect) and `apiKey` (a BTCPay store key with the same store-settings + invoice permissions the browser flow grants). Keysat resolves the store's network and, for a scoped initiator, refuses anything not provably non-mainnet (fail-closed). No auth header; the single-use `state` token is the tie. A refusal returns a 4xx on both the GET and POST forms.",
"responses": {
"200": { "description": "Connected (HTML confirmation page)" },
"400": { "description": "Scoped connect to a mainnet/undetermined store; nothing persisted" }
}
}
},
"/v1/admin/btcpay/status": {
"get": {
"summary": "BTCPay connection status (default profile)",
"description": "Requires payment_providers:read. Returns { connected, store_id, base_url, webhook_id, ... }.",
"responses": { "200": { "description": "Connection status" } }
}
},
"/v1/admin/btcpay/disconnect": {
"post": {
"summary": "Disconnect a BTCPay provider",
"description": "Master admin key required, on any daemon. Best-effort revokes the webhook + key on BTCPay, then clears the local provider row.",
"responses": { "200": { "description": "Disconnected (or no-op)" } }
}
}
}
}"##;
@@ -16,7 +16,7 @@
//! should use the new `/v1/admin/merchant-profiles` endpoints to see
//! all providers across all profiles.
use crate::api::admin::require_admin;
use crate::api::admin::require_scope;
use crate::api::AppState;
use crate::error::AppResult;
use axum::{extract::State, http::HeaderMap, Json};
@@ -31,7 +31,7 @@ pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "payment_providers:read").await?;
let default = crate::merchant_profiles::get_default(&state.db).await?;
let providers = match &default {
Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?,
+10 -10
View File
@@ -9,7 +9,7 @@
//! product when a customer buys it through the normal purchase flow — so most
//! products should have at least one policy slugged `default`.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
@@ -158,7 +158,7 @@ pub async fn create(
headers: HeaderMap,
Json(req): Json<CreatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
let product = repo::get_product_by_slug(&state.db, &req.product_slug)
.await?
@@ -289,7 +289,7 @@ pub async fn list(
headers: HeaderMap,
Query(q): Query<ListPoliciesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "policies:read").await?;
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
@@ -314,7 +314,7 @@ pub async fn set_active(
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
repo::set_policy_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
@@ -351,7 +351,7 @@ pub async fn set_archived(
Path(id): Path<String>,
Json(req): Json<SetArchivedReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
repo::set_policy_archived(&state.db, &id, req.archived).await?;
let _ = repo::insert_audit(
@@ -389,7 +389,7 @@ pub async fn delete(
Path(id): Path<String>,
Query(opts): Query<PolicyDeleteOpts>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
let policy = repo::get_policy_by_id(&state.db, &id)
@@ -606,7 +606,7 @@ pub async fn update(
Path(id): Path<String>,
Json(req): Json<UpdatePolicyReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
if let Some(d) = req.duration_seconds {
@@ -739,7 +739,7 @@ pub async fn set_public(
Path(id): Path<String>,
Json(req): Json<SetPublicReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
repo::set_policy_public(&state.db, &id, req.public).await?;
let _ = repo::insert_audit(
@@ -933,7 +933,7 @@ pub async fn set_tip(
Path(id): Path<String>,
Json(req): Json<SetTipReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "policies:write").await?;
let (ip, ua) = request_context(&headers);
if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 {
return Err(AppError::BadRequest(
@@ -992,7 +992,7 @@ pub async fn list_tips(
headers: HeaderMap,
Query(q): Query<ListTipsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "policies:read").await?;
let entries = repo::list_tip_attempts(
&state.db,
q.license_id.as_deref(),
+3 -3
View File
@@ -12,7 +12,7 @@
//! outage to confirm the
//! chain works end-to-end.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::rates;
@@ -24,7 +24,7 @@ pub async fn get(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "rates:read").await?;
let snapshot = state.rates.snapshot().await;
let rates_json: Vec<Value> = snapshot
.into_iter()
@@ -52,7 +52,7 @@ pub async fn refresh(
headers: HeaderMap,
Json(req): Json<RefreshReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "rates:write").await?;
let (ip, ua) = request_context(&headers);
let currency = req.currency.to_uppercase();
+1 -1
View File
@@ -24,7 +24,7 @@ use crate::error::{AppError, AppResult};
use axum::{
extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response},
response::{Html, IntoResponse},
Json,
};
use chrono::DateTime;
+3 -3
View File
@@ -26,7 +26,7 @@
//! convention (Stripe, Zaprite, etc.) and avoids a UX where the
//! buyer cancels mid-month and immediately loses what they paid for.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use axum::{
@@ -58,7 +58,7 @@ pub async fn admin_list(
headers: HeaderMap,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "subscriptions:read").await?;
if let Some(s) = q.status.as_deref() {
if !["active", "past_due", "cancelled", "lapsed"].contains(&s) {
return Err(AppError::BadRequest(format!(
@@ -115,7 +115,7 @@ pub async fn admin_cancel(
Path(id): Path<String>,
body: Option<Json<CancelReq>>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "subscriptions:write").await?;
let (ip, ua) = request_context(&headers);
let reason = body.and_then(|Json(b)| b.reason).filter(|s| !s.trim().is_empty());
+5 -1
View File
@@ -124,7 +124,7 @@ pub async fn admin_status(
axum::extract::State(state): axum::extract::State<AppState>,
headers: axum::http::HeaderMap,
) -> AppResult<axum::Json<serde_json::Value>> {
crate::api::admin::require_admin(&state, &headers)?;
crate::api::admin::require_scope(&state, &headers, "tier:read").await?;
let tier = current(&state).await;
let product_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&state.db)
@@ -162,6 +162,10 @@ pub async fn admin_status(
Ok(axum::Json(serde_json::json!({
"tier": tier.label,
"tier_name": tier.display_name,
// Daemon-level sandbox flag (env KEYSAT_SANDBOX_MODE, read-only here —
// never settable via any API). The admin SPA renders a "SANDBOX"
// banner on it; it also gates scoped payment-provider connect.
"sandbox": state.config.sandbox_mode,
"entitlements": tier.entitlements,
"usage": {
"products": product_count,
+118
View File
@@ -117,6 +117,59 @@ async fn handle_inner(
"webhook event applied"
);
// Anti-forgery: never settle on the webhook body's claim alone. Re-fetch
// the authoritative status from the provider's own API and require it to
// actually be Settled before we mark the invoice paid or take ANY
// settle-derived action. This guard runs ahead of every downstream effect
// — status persistence, tier-change application, subscription renewal, and
// license issuance — so confirming once here gates all of them.
// This is load-bearing for providers without webhook signatures: Zaprite
// webhooks carry no HMAC, so a forged `order.change`/`status=PAID` POST
// with a buyer-visible order id would otherwise mint a free license. The
// re-fetch also defeats replay of a stale settled body against an invoice
// that has since expired/refunded (the provider reports the current state,
// not the replayed one). BTCPay is HMAC-verified upstream and is settled
// already, so this is cheap belt-and-suspenders there. On a provider
// error we fail closed — the reconcile loop re-confirms on its next tick.
// `Some` once a settle is confirmed: the provider-reported amount, fed to
// the advisory tripwire below (after the local invoice is loaded). `None`
// for non-settle events and when the provider reports no parseable amount.
let confirmed_amount = if new_status == "settled" {
match provider.get_invoice_status(&provider_invoice_id).await {
Ok(snapshot)
if snapshot.status == crate::payment::ProviderInvoiceStatus::Settled =>
{
snapshot.amount
}
Ok(snapshot) => {
tracing::warn!(
provider = provider.kind().as_str(),
provider_invoice_id = %provider_invoice_id,
provider_status = ?snapshot.status,
"settle webhook NOT confirmed by provider API; refusing to settle/issue"
);
return Ok(StatusCode::OK);
}
Err(e) => {
// Ack 200 rather than erroring: a non-2xx makes BTCPay/Zaprite
// re-deliver aggressively, so a transient provider-API outage
// would turn every in-flight webhook into a retry storm. We
// simply don't issue now — the reconcile loop re-fetches the
// status on its next tick and issues then, so issuance is still
// "fail closed" without depending on this delivery.
tracing::warn!(
provider = provider.kind().as_str(),
provider_invoice_id = %provider_invoice_id,
error = format!("{e:#}"),
"could not reach provider to confirm settle; not issuing now, deferring to reconciler"
);
return Ok(StatusCode::OK);
}
}
} else {
None
};
// Persist status.
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
@@ -155,6 +208,12 @@ async fn handle_inner(
return Ok(StatusCode::OK);
};
// Advisory settle-amount tripwire. The Settled gate above already ensures
// the provider considers this paid in full, so this never blocks issuance
// — it logs + audits if the provider's recorded amount/currency ever
// drifts from what we charged. See docs/guides/payments.md.
audit_settle_amount(&state, &invoice, confirmed_amount.as_ref()).await;
// Tier-change branch: this settled invoice may be a tier upgrade
// (recorded by POST /v1/upgrade or the future admin-change-tier
// endpoint) rather than a fresh purchase or a subscription
@@ -196,6 +255,65 @@ async fn handle_inner(
Ok(StatusCode::OK)
}
/// Advisory settle-amount tripwire, shared by the webhook handler and the
/// reconcile loop. The Settled gate at both call sites already guarantees the
/// provider considers the invoice paid in full (BTCPay won't settle an unpaid
/// invoice; Zaprite maps `UNDERPAID` → `Pending`), so this NEVER blocks
/// issuance. It exists to surface drift: if the provider's recorded amount or
/// currency ever differs from what we charged — a charge-vs-record bug on our
/// side, or a currency-confusion bug — we log a warning and write an
/// `invoice.amount_mismatch` audit row, then let issuance proceed.
///
/// `confirmed` is `None` ("no opinion") when the provider response carried no
/// parseable amount; in that case the tripwire is skipped. Every invoice we
/// create is SAT-denominated (`purchase.rs` passes `Money::sats`), so the
/// expected value is `invoice.amount_sats` in `SAT`.
pub(crate) async fn audit_settle_amount(
state: &AppState,
invoice: &crate::models::Invoice,
confirmed: Option<&crate::payment::Money>,
) {
let Some(paid) = confirmed else { return };
// The comparison basis is `invoice.amount_sats` (SAT), which equals what we
// told the provider to charge ONLY for SAT-denominated orders — one-shot
// purchases and SAT subscriptions (`purchase.rs` / `upgrades` pass
// `Money::sats`). Fiat-priced subscription RENEWALS (`subscriptions.rs`)
// create the order in the listed fiat currency, where `amount_sats` is not
// the charged amount, so there's no clean SAT comparison — skip those (the
// `Settled` gate already guarantees paid-in-full). A non-SAT provider
// amount therefore means "no comparable basis", not a mismatch.
if paid.currency != "SAT" {
return;
}
if paid.amount == invoice.amount_sats {
return;
}
tracing::warn!(
invoice_id = %invoice.id,
provider_invoice_id = %invoice.btcpay_invoice_id,
expected_amount_sats = invoice.amount_sats,
provider_amount_sats = paid.amount,
"settled invoice amount does NOT match the recorded charge; issuing \
anyway (advisory) — investigate provider config or a charge-vs-record bug"
);
let _ = repo::insert_audit(
&state.db,
"system",
None,
"invoice.amount_mismatch",
Some("invoice"),
Some(&invoice.id),
None,
None,
&serde_json::json!({
"provider_invoice_id": invoice.btcpay_invoice_id,
"expected_amount_sats": invoice.amount_sats,
"provider_amount_sats": paid.amount,
}),
)
.await;
}
/// Shared issuance path — used by both the webhook handler and the reconcile
/// loop. Pulls the invoice's associated policy (if the product has a default
/// one) and materializes a license row with the right expiry / entitlements.
@@ -14,7 +14,7 @@
//! that was down for >6h during a license-issuance burst would
//! silently lose those events forever.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::db::repo::{self, DeliveryStatusFilter};
use crate::error::{AppError, AppResult};
@@ -46,7 +46,7 @@ pub async fn list(
headers: HeaderMap,
Query(q): Query<ListDeliveriesQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "webhooks:read").await?;
let status = match q.status.as_deref() {
Some(s) => DeliveryStatusFilter::parse(s).ok_or_else(|| {
AppError::BadRequest(format!(
@@ -80,7 +80,7 @@ pub async fn retry(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
let (ip, ua) = request_context(&headers);
let delivery = repo::requeue_delivery(&state.db, &id)
.await?
@@ -9,7 +9,7 @@
//! they've stored it somewhere safe, later reads return the secret masked.
//! (If they lose it, they can rotate by deleting + recreating the endpoint.)
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_scope};
use crate::api::AppState;
use crate::db::repo;
use crate::error::AppResult;
@@ -48,7 +48,7 @@ pub async fn create(
headers: HeaderMap,
Json(req): Json<CreateEndpointReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
let (ip, ua) = request_context(&headers);
let secret = req.secret.unwrap_or_else(generate_secret);
let ep = repo::create_webhook_endpoint(
@@ -96,7 +96,7 @@ pub async fn list(
headers: HeaderMap,
Query(q): Query<ListEndpointsQuery>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "webhooks:read").await?;
let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?;
Ok(Json(json!({ "endpoints": rows })))
}
@@ -112,7 +112,7 @@ pub async fn set_active(
Path(id): Path<String>,
Json(req): Json<SetActiveReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
let (ip, ua) = request_context(&headers);
repo::set_webhook_active(&state.db, &id, req.active).await?;
let _ = repo::insert_audit(
@@ -135,7 +135,7 @@ pub async fn delete(
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let actor_hash = require_scope(&state, &headers, "webhooks:write").await?;
let (ip, ua) = request_context(&headers);
repo::delete_webhook_endpoint(&state.db, &id).await?;
let _ = repo::insert_audit(
@@ -14,7 +14,7 @@
//! Old "active provider" semantics are gone — profiles attach to
//! products explicitly.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::{request_context, require_admin, require_scope};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::payment::zaprite::{ZapriteClient, ZapriteProvider};
@@ -276,7 +276,7 @@ pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
require_scope(&state, &headers, "payment_providers:read").await?;
let default = crate::merchant_profiles::get_default(&state.db).await?;
let connected_row = match &default {
Some(profile) => {
+84
View File
@@ -366,3 +366,87 @@ pub async fn list_payment_methods(
.cloned()
.unwrap_or_default())
}
/// Resolve the Bitcoin **network** a store settles on, for the scoped
/// payment-connect gate (`plans/agent-payment-connect-scope.md` §6.1).
///
/// Lists the store's payment methods, finds the on-chain BTC method
/// (`paymentMethodId` is `BTC-CHAIN` on BTCPay 2.x, `BTC` on 1.x — never
/// hardcode), fetches a receive address, and classifies the address prefix.
///
/// Returns:
/// - `Ok(Some(network))` when positively determined;
/// - `Ok(None)` when it **cannot** be determined (no on-chain method, no
/// address, Lightning-only store, BTCPay not yet synced → `503`, or an
/// unrecognized prefix). The caller MUST fail closed (treat `None` as
/// mainnet and deny the scoped connect).
///
/// The address endpoint requires `btcpay.store.canmodifystoresettings`, which
/// the daemon's authorize flow already requests (see `REQUESTED_PERMISSIONS`).
pub async fn fetch_onchain_network(
base_url: &str,
api_key: &str,
store_id: &str,
) -> Result<Option<super::network::BitcoinNetwork>> {
// Any failure to enumerate methods → undetermined → caller fails closed.
// Swallow the error here (uniform with the non-2xx wallet/address branch
// below) and log a body-free reason at warn; detail only at debug so an
// upstream error body never lands in normal logs on this sensitive path.
let methods = match list_payment_methods(base_url, api_key, store_id).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(
store = %store_id,
"fetch_onchain_network: could not list payment methods; network undetermined"
);
tracing::debug!(error = %format!("{e:#}"), "btcpay list-payment-methods error detail");
return Ok(None);
}
};
// Find the on-chain BTC method. Lightning ids (`BTC-LN`,
// `BTC_LightningLike`, …) are deliberately excluded.
let Some(pmid) = methods.iter().find_map(|m| {
let id = m.get("paymentMethodId").and_then(|v| v.as_str())?;
match id.to_ascii_uppercase().as_str() {
"BTC-CHAIN" | "BTC" => Some(id.to_string()),
_ => None,
}
}) else {
return Ok(None); // no on-chain BTC method → undetermined → fail closed
};
// `pmid` is BTCPay-supplied; percent-encode it as a path segment so a
// hostile/buggy server returning an odd id can't corrupt the URL (it would
// only ever 4xx → Ok(None) → deny anyway, but keep the request well-formed).
let url = format!(
"{}/api/v1/stores/{store_id}/payment-methods/{}/wallet/address",
base_url.trim_end_matches('/'),
urlencoding::encode(&pmid),
);
let resp = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?
.get(&url)
.header("Authorization", format!("token {api_key}"))
.send()
.await
.context("calling BTCPay wallet/address")?;
if !resp.status().is_success() {
// 503 (BTCPay not synced / on-chain service down), 404/422 (no wallet),
// 403 (insufficient perms) — none let us positively determine the
// network, so report undetermined and let the caller fail closed.
return Ok(None);
}
// A 2xx with a non-JSON body (misconfigured BTCPay) is likewise "can't
// determine" → Ok(None). Parsing via Ok(None) instead of `?` also keeps any
// body snippet reqwest attaches to a parse error out of warn-level logs.
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "btcpay wallet/address: non-JSON body; network undetermined");
return Ok(None);
}
};
let address = body.get("address").and_then(|v| v.as_str()).unwrap_or("");
Ok(super::network::classify_address_network(address))
}
+42 -9
View File
@@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
Ok(())
}
/// An in-flight authorize round-trip, recovered at callback time. `Default`
/// (no profile, `scoped_initiator = false`) is the back-compat reading of a
/// pre-0025 / NULL row: "master connect to the default profile" — the only
/// kind that existed before scoped connect.
#[derive(Debug, Clone, Default)]
pub struct AuthorizeState {
/// Merchant profile the resulting provider row attaches to (migration
/// 0022). None → "the default profile".
pub merchant_profile_id: Option<String>,
/// True when a *scoped* key (not the master key) started the connect
/// (migration 0025). The callback applies the non-mainnet network gate
/// only for scoped initiators.
pub scoped_initiator: bool,
/// sha256 of the initiating credential — for the callback's audit row.
pub initiator_actor_hash: Option<String>,
}
/// Record a new in-flight authorize state token. `merchant_profile_id`
/// (multi-provider model, migration 0022) names which merchant profile
/// the resulting provider row should attach to when the callback fires
/// — None falls back to "the default profile" at consume-time.
/// `scoped_initiator` / `actor_hash` (migration 0025) carry who started the
/// connect so the callback can apply the network gate + attribute the audit.
pub async fn record_authorize_state(
pool: &SqlitePool,
token: &str,
merchant_profile_id: Option<&str>,
scoped_initiator: bool,
actor_hash: Option<&str>,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
VALUES (?, ?, ?)",
"INSERT INTO btcpay_authorize_state \
(state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(token)
.bind(merchant_profile_id)
.bind(&now)
.bind(scoped_initiator as i64)
.bind(actor_hash)
.execute(pool)
.await
.context("recording btcpay authorize state")?;
@@ -109,17 +133,18 @@ pub async fn record_authorize_state(
}
/// Validate that `token` was issued recently and has not been consumed.
/// Consumes (deletes) the token on success so a replay fails, and
/// returns the `merchant_profile_id` recorded at start-connect time so
/// the callback knows which profile to attach the new provider to.
/// Consumes (deletes) the token on success so a replay fails, and returns the
/// recorded `AuthorizeState` (profile + initiator) so the callback knows which
/// profile to attach to and whether to apply the scoped network gate.
pub async fn consume_authorize_state(
pool: &SqlitePool,
token: &str,
) -> Result<Option<String>> {
) -> Result<AuthorizeState> {
use sqlx::Row;
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let row = sqlx::query(
"SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \
"SELECT merchant_profile_id, scoped_initiator, initiator_actor_hash \
FROM btcpay_authorize_state \
WHERE state_token = ? AND created_at >= ?",
)
.bind(token)
@@ -130,11 +155,19 @@ pub async fn consume_authorize_state(
let Some(row) = row else {
return Err(anyhow!("unknown or expired authorize state token"));
};
let merchant_profile_id: Option<String> = row.try_get("merchant_profile_id").ok().flatten();
let state = AuthorizeState {
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
// Tolerant read: a NULL/absent column reads as 0 (master) — fail toward
// the *less*-restrictive master path is acceptable here because the
// column only exists to ADD the scoped restriction; a pre-0025 token
// could only ever have been a master connect.
scoped_initiator: row.try_get::<i64, _>("scoped_initiator").unwrap_or(0) != 0,
initiator_actor_hash: row.try_get("initiator_actor_hash").ok().flatten(),
};
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
.bind(token)
.execute(pool)
.await?;
Ok(merchant_profile_id)
Ok(state)
}
+1
View File
@@ -8,4 +8,5 @@
pub mod client;
pub mod config;
pub mod network;
pub mod webhook;
+160
View File
@@ -0,0 +1,160 @@
//! Bitcoin network classification from an address string.
//!
//! Used by the agent-payment-connect gate (`plans/agent-payment-connect-scope.md`
//! §6.1): a *scoped* key may connect a BTCPay store only when its target network
//! is non-mainnet. Greenfield's `GET /api/v1/server/info` carries no chain-type
//! field, so we determine the network from a **network-encoding artifact** — the
//! store's on-chain receive address — and classify by its prefix.
//!
//! Validated against a live regtest BTCPay 2.x: `wallet/address` returns a
//! `bcrt1…` address on regtest (see `onboarding-harness/stage2/btcpay-regtest/`).
//!
//! **Fail-closed:** an unrecognized / empty address yields `None`; the caller
//! MUST treat `None` as mainnet (deny the scoped connect). Never assume
//! non-mainnet from absence of evidence.
/// The Bitcoin network a BTCPay store settles on. Only the mainnet-vs-rest
/// distinction gates the scoped connect, but the specific non-mainnet variant
/// is kept for audit/logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BitcoinNetwork {
Mainnet,
/// testnet3 — shares the `tb1` HRP and `m`/`n`/`2` base58 versions with signet.
Testnet,
/// Signet — indistinguishable from testnet by address alone (`tb1`), so the
/// address classifier never yields this; reserved for a future
/// derivation-scheme-based path. Kept distinct because it is a real,
/// non-mainnet network the gate must allow.
Signet,
Regtest,
}
impl BitcoinNetwork {
pub fn as_str(self) -> &'static str {
match self {
BitcoinNetwork::Mainnet => "mainnet",
BitcoinNetwork::Testnet => "testnet",
BitcoinNetwork::Signet => "signet",
BitcoinNetwork::Regtest => "regtest",
}
}
/// The only question the connect gate actually asks.
pub fn is_mainnet(self) -> bool {
matches!(self, BitcoinNetwork::Mainnet)
}
}
/// Classify a Bitcoin address by its network-encoding prefix. Returns `None`
/// when the prefix is unrecognized or the string is empty — the caller
/// **fails closed** (treats `None` as mainnet).
///
/// bech32/bech32m HRP: `bcrt1…`=regtest, `tb1…`=testnet/signet, `bc1…`=mainnet.
/// Legacy base58: `1`/`3`=mainnet, `m`/`n`/`2`=test/regtest (the `tb1`/base58
/// test versions are shared by testnet, signet, and regtest — all non-mainnet,
/// which is all the gate needs; only the bech32 `bcrt1` HRP pins regtest
/// specifically).
pub fn classify_address_network(addr: &str) -> Option<BitcoinNetwork> {
let s = addr.trim();
if s.is_empty() {
return None;
}
// bech32/bech32m — HRP is case-insensitive. Check `bcrt1` before `bc1`
// (it is not a prefix of the others, but order makes the intent explicit).
let lower = s.to_ascii_lowercase();
if lower.starts_with("bcrt1") {
return Some(BitcoinNetwork::Regtest);
}
if lower.starts_with("tb1") {
// testnet and signet share the `tb` HRP and are indistinguishable from
// the address alone. Both non-mainnet; report Testnet.
return Some(BitcoinNetwork::Testnet);
}
if lower.starts_with("bc1") {
return Some(BitcoinNetwork::Mainnet);
}
// Legacy base58check — version byte encoded in the leading character.
// Only classify when the whole string is a *plausible* base58 address
// (correct alphabet + length): otherwise arbitrary text that merely begins
// with `n`/`m`/`2` (e.g. "not-an-address") would be mis-read as non-mainnet
// and the gate would fail OPEN. Junk falls through to `None` (fail closed).
// Case-sensitive, so classify off the original string.
if (26..=35).contains(&s.len()) && s.chars().all(is_base58) {
return match s.chars().next() {
Some('1') | Some('3') => Some(BitcoinNetwork::Mainnet),
Some('m') | Some('n') | Some('2') => Some(BitcoinNetwork::Testnet),
_ => None,
};
}
None
}
/// Base58 alphabet membership (Bitcoin's: omits `0`, `O`, `I`, `l`).
fn is_base58(c: char) -> bool {
matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bech32_prefixes() {
// The exact address the live regtest BTCPay 2.x returned.
assert_eq!(
classify_address_network("bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt"),
Some(BitcoinNetwork::Regtest)
);
assert_eq!(
classify_address_network("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
Some(BitcoinNetwork::Testnet)
);
assert_eq!(
classify_address_network("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
Some(BitcoinNetwork::Mainnet)
);
}
#[test]
fn bech32_is_case_insensitive() {
assert_eq!(
classify_address_network("BCRT1QWSH9UA5QEUTSHVRHZ474UDUWQLW8GFUKFPC8VT"),
Some(BitcoinNetwork::Regtest)
);
assert_eq!(
classify_address_network("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"),
Some(BitcoinNetwork::Mainnet)
);
}
#[test]
fn legacy_base58() {
assert_eq!(classify_address_network("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), Some(BitcoinNetwork::Mainnet)); // P2PKH
assert_eq!(classify_address_network("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), Some(BitcoinNetwork::Mainnet)); // P2SH
assert_eq!(classify_address_network("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"), Some(BitcoinNetwork::Testnet)); // testnet P2PKH
assert_eq!(classify_address_network("n2ZNV88uQbede7C5M5jzi6SyG4GVuPpng6"), Some(BitcoinNetwork::Testnet));
assert_eq!(classify_address_network("2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"), Some(BitcoinNetwork::Testnet)); // test P2SH
}
#[test]
fn fail_closed_on_unknown_or_empty() {
assert_eq!(classify_address_network(""), None);
assert_eq!(classify_address_network(" "), None);
assert_eq!(classify_address_network("not-an-address"), None);
assert_eq!(classify_address_network("ltc1qxyz"), None); // not bitcoin
assert_eq!(classify_address_network("zzz"), None);
// The dangerous direction: a base58-length, all-base58 string that does
// NOT begin with a version char (1/3/m/n/2) must stay None, never be
// mis-read as non-mainnet. (And a real mainnet address always begins
// with 1/3/bc1, so it can never fall into the non-mainnet arms.)
assert_eq!(classify_address_network("bQ8vZ2mN4pR7sT1uW3xY5zA6dE9fG"), None); // 29 chars, starts 'b'
}
#[test]
fn is_mainnet_only_true_for_mainnet() {
assert!(BitcoinNetwork::Mainnet.is_mainnet());
assert!(!BitcoinNetwork::Testnet.is_mainnet());
assert!(!BitcoinNetwork::Signet.is_mainnet());
assert!(!BitcoinNetwork::Regtest.is_mainnet());
}
}
+14
View File
@@ -61,6 +61,16 @@ pub struct Config {
/// Optional human-readable operator name shown in `/` index responses.
pub operator_name: Option<String>,
/// When true, this daemon is a disposable dev / sandbox instance. It is
/// the OUTER gate for agent-delegated payment-provider connect: only on a
/// sandbox daemon may a scoped `payment_providers:write` key connect a
/// provider (and then only a non-mainnet one — see the network gate). On a
/// production daemon (false) scoped payment-connect is refused outright, so
/// a scoped key can never disrupt a live store's payments. Daemon-level
/// only (env `KEYSAT_SANDBOX_MODE`) and **never settable via any API** —
/// otherwise a scoped key could flip it on, then connect.
pub sandbox_mode: bool,
}
impl Config {
@@ -102,6 +112,9 @@ impl Config {
let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET");
let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?;
let operator_name = env_with_fallback("KEYSAT_OPERATOR_NAME", "LICENSING_OPERATOR_NAME");
let sandbox_mode = optional_nonempty("KEYSAT_SANDBOX_MODE")
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false);
Ok(Self {
bind,
@@ -115,6 +128,7 @@ impl Config {
btcpay_webhook_secret,
public_base_url: public_base_url.trim_end_matches('/').to_string(),
operator_name,
sandbox_mode,
})
}
}
+55 -7
View File
@@ -14,10 +14,10 @@ use uuid::Uuid;
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
let q = if only_active {
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
FROM products WHERE active = 1 ORDER BY name"
} else {
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
FROM products ORDER BY name"
};
let rows = sqlx::query(q).fetch_all(pool).await?;
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
let row = sqlx::query(
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
FROM products WHERE slug = ?",
)
.bind(slug)
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
let row = sqlx::query(
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, merchant_profile_id, created_at, updated_at
FROM products WHERE id = ?",
)
.bind(id)
@@ -301,6 +301,41 @@ pub async fn set_product_entitlements_catalog(
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
}
/// Attach a product to a merchant profile (migration 0020). Pass
/// `Some(profile_id)` to set it, `None` to clear it (the product then
/// resolves to the default profile). The target profile is validated to
/// exist first so a bad id returns a clean 404 rather than surfacing as
/// a raw foreign-key-violation 500.
pub async fn set_product_merchant_profile(
pool: &SqlitePool,
product_id: &str,
merchant_profile_id: Option<&str>,
) -> AppResult<Product> {
if let Some(profile_id) = merchant_profile_id {
if get_merchant_profile_by_id(pool, profile_id).await?.is_none() {
return Err(AppError::NotFound(format!(
"merchant profile {profile_id}"
)));
}
}
let now = Utc::now().to_rfc3339();
let rows = sqlx::query(
"UPDATE products SET merchant_profile_id = ?, updated_at = ? WHERE id = ?",
)
.bind(merchant_profile_id)
.bind(&now)
.bind(product_id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("product {product_id}")));
}
get_product_by_id(pool, product_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
}
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
let metadata_json: String = row.try_get("metadata_json")?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
@@ -326,6 +361,13 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
.flatten()
.and_then(|s| serde_json::from_str::<Vec<crate::models::EntitlementDef>>(&s).ok())
.filter(|v| !v.is_empty());
// merchant_profile_id lands in migration 0020. NULL = resolves to
// the default profile (back-compat); try_get is tolerant of older
// rows / SELECTs that predate the column.
let merchant_profile_id: Option<String> = row
.try_get::<Option<String>, _>("merchant_profile_id")
.ok()
.flatten();
Ok(Product {
id: row.try_get("id")?,
slug: row.try_get("slug")?,
@@ -337,6 +379,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
active: active_int != 0,
metadata,
entitlements_catalog,
merchant_profile_id,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
@@ -3003,10 +3046,15 @@ pub async fn get_merchant_profile_for_product(
pool: &SqlitePool,
product_id: &str,
) -> AppResult<Option<crate::merchant_profiles::MerchantProfile>> {
// Subquery rather than a JOIN: `MERCHANT_PROFILE_COLS` is a bare
// column list (`id, name, …`) shared with the non-JOIN profile
// queries, and `products` also has an `id`, so a JOIN here makes the
// SELECT list's `id` ambiguous. The subquery keeps `merchant_profiles`
// the only table in FROM. A product with a NULL `merchant_profile_id`
// yields no match (subquery → NULL), so callers fall back to default.
let row = sqlx::query(&format!(
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles mp \
JOIN products p ON p.merchant_profile_id = mp.id \
WHERE p.id = ? LIMIT 1"
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles \
WHERE id = (SELECT merchant_profile_id FROM products WHERE id = ?) LIMIT 1"
))
.bind(product_id)
.fetch_optional(pool)
+36 -13
View File
@@ -59,23 +59,46 @@ pub enum AppError {
Internal(#[from] anyhow::Error),
}
impl AppError {
/// HTTP status this error maps to. Exposed so handlers that render a
/// non-JSON body (e.g. the BTCPay callback's HTML page) still return the
/// correct status instead of a misleading 200 on a denied request.
pub fn status_code(&self) -> StatusCode {
match self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Forbidden => StatusCode::FORBIDDEN,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::LicenseInvalid(_) => StatusCode::OK,
AppError::Upstream(_) => StatusCode::BAD_GATEWAY,
AppError::BtcpayNotConfigured => StatusCode::SERVICE_UNAVAILABLE,
AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
AppError::PaymentRequired { .. } => StatusCode::PAYMENT_REQUIRED,
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
AppError::LicenseInvalid(_) => (StatusCode::OK, "invalid"),
AppError::Upstream(_) => (StatusCode::BAD_GATEWAY, "upstream_error"),
AppError::BtcpayNotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "btcpay_not_configured"),
AppError::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
AppError::ServiceUnavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable"),
AppError::PaymentRequired { .. } => (StatusCode::PAYMENT_REQUIRED, "tier_cap"),
let status = self.status_code();
let code = match &self {
AppError::NotFound(_) => "not_found",
AppError::BadRequest(_) => "bad_request",
AppError::Unauthorized => "unauthorized",
AppError::Forbidden => "forbidden",
AppError::Conflict(_) => "conflict",
AppError::LicenseInvalid(_) => "invalid",
AppError::Upstream(_) => "upstream_error",
AppError::BtcpayNotConfigured => "btcpay_not_configured",
AppError::TooManyRequests(_) => "rate_limited",
AppError::ServiceUnavailable(_) => "service_unavailable",
AppError::PaymentRequired { .. } => "tier_cap",
AppError::Database(_) | AppError::Internal(_) => {
tracing::error!(error = %self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
"internal_error"
}
};
+158 -36
View File
@@ -201,43 +201,34 @@ fn log_licensed(tier: &Tier) {
/// Live-refresh the daemon's self-tier from the local `licenses` row.
///
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
/// extracts entitlements from the SIGNED PAYLOAD. Those entitlements
/// are immutable for the life of that key — the operator can't ever
/// downgrade themselves by editing the DB row, because the daemon
/// trusts the signature, not the DB.
///
/// In practice that means tier upgrades / downgrades / revocations
/// applied via admin (or eventually, via an upstream master) don't
/// propagate to a running daemon — even though the daemon is online
/// and the data is right there in its own DB. This function is the
/// fix: re-read the licenses row by license_id and use the LIVE
/// entitlements + revocation status. The on-disk signed key is kept
/// as proof-of-authenticity (signature still verifies) but the live
/// DB row is the source of tier truth.
/// `check_at_boot` verifies the on-disk LIC1 key against the embedded
/// trust root and reads its entitlements from the signed payload. That
/// signed set is the ceiling. This function lets issuer-applied changes
/// reach a running daemon without a restart — revocations, suspensions,
/// downgrades, and the key's own expiry — by re-verifying the on-disk
/// key and re-reading the `licenses` row by license_id. The signed key
/// stays authoritative: the DB row may *narrow* the tier but never
/// *widen* it beyond what the signature grants (see
/// `clamp_to_signed_ceiling`).
///
/// Behavior:
/// - If the on-disk tier is `Unlicensed`, do nothing — there's no
/// license_id to look up.
/// - If the licenses row is missing in the DB (legitimate for a
/// daemon that's never been online to sync, e.g.), keep the
/// signed-payload tier as last-known.
/// - If the row is revoked, demote to `Unlicensed { reason: "revoked" }`.
/// - Otherwise, replace the entitlements vec with whatever the DB
/// row currently says.
/// - On-disk tier is `Unlicensed` → no-op (no license_id to look up).
/// - Signed key no longer verifies (expired, tampered, corrupt) → demote
/// to `Unlicensed`.
/// - `licenses` row missing → keep the signed-payload tier as last-known
/// (legitimate for a daemon that's never synced its row).
/// - Row revoked or suspended → demote to `Unlicensed`.
/// - Otherwise → keep the signed product/expiry, with entitlements taken
/// from the DB row clamped to the signed ceiling.
///
/// Run from main.rs at boot (after `check_at_boot`) and on a 1-hour
/// interval thereafter. Also surfaced as an admin "Refresh
/// self-license tier" action for operators who want to trigger
/// immediately after a change instead of waiting for the next tick.
/// interval thereafter. Also surfaced as an admin "Refresh self-license
/// tier" action for an immediate pass instead of waiting for the tick.
///
/// Non-master operators in v0.3+ can extend this to call
/// `https://licensing.keysat.xyz/v1/validate` instead of (or in
/// addition to) the local DB. For v0.2.x, local-DB-only — which is
/// the right thing for the master Keysat (which is selling its own
/// licenses) and a no-op-but-safe for downstream operators (their
/// own DB row hasn't been mutated, so live read returns the same
/// thing as the boot-time signed-payload extraction).
/// Non-master operators in v0.3+ can extend this to consult
/// `https://licensing.keysat.xyz/v1/validate` in addition to the local
/// DB. For v0.2.x it is local-DB-only; an honest downstream operator's
/// DB row matches its signed key, so the clamp is a no-op there.
pub async fn refresh_self_tier_from_db(
pool: &sqlx::SqlitePool,
current: &Tier,
@@ -247,6 +238,43 @@ pub async fn refresh_self_tier_from_db(
Tier::Unlicensed { .. } => return current.clone(),
};
// Re-read and re-verify the on-disk/env self-license key on every
// pass. This is what makes the key's own EXPIRY (and any tampering or
// corruption) take effect on a *running* daemon, not just at the next
// restart — mirroring how the licenses we issue are re-checked on
// every `/v1/validate`. Done before the DB lookup so an expired key
// demotes even when the daemon has no synced `licenses` row. The
// verified entitlements double as the ceiling the DB row is clamped
// to below.
let signed_ceiling = match read_license_string() {
Some(key) => match verify_license(&key) {
Ok(tier) => Some(entitlements_of(&tier)),
// Present but no longer verifies — expired, tampered, or
// corrupt. Demote to Creator (free), same as revoked/suspended.
// A read racing a concurrent `activate` file-write could trip
// this transiently; it self-heals on the next pass.
Err(e) => {
tracing::warn!(
license_id = %license_id,
"self-tier refresh: self-license no longer verifies ({e:#}); demoting to Creator (free) tier"
);
return Tier::Unlicensed {
reason: format!("self-license re-verification failed: {e:#}"),
};
}
},
// No key on disk or in env though we booted Licensed — the source
// was removed. Keep last-known entitlements as the ceiling (offline
// grace), but log it.
None => {
tracing::warn!(
license_id = %license_id,
"self-tier refresh: self-license source missing; keeping last-known entitlements"
);
None
}
};
let row = match crate::db::repo::get_license_by_id(pool, &license_id).await {
Ok(Some(row)) => row,
Ok(None) => {
@@ -281,10 +309,16 @@ pub async fn refresh_self_tier_from_db(
};
}
// Pull the LIVE entitlements from the DB. These can differ from
// the signed payload's entitlements (which were baked at signing
// time) if an admin has done a Change Tier on this license.
let entitlements = row.entitlements.clone();
// Clamp the live DB row to the signed ceiling derived above: the row
// may narrow the tier (an issuer-applied downgrade) but must never
// widen it beyond what the signature authorizes. If the key source
// was missing, fall back to the in-effect entitlements — themselves
// already clamped on a prior pass — so a DB edit still can't widen.
let ceiling = match &signed_ceiling {
Some(c) => c.clone(),
None => entitlements_of(current),
};
let entitlements = clamp_to_signed_ceiling(row.entitlements.clone(), &ceiling);
// Same product / license / expiry — only the entitlement set is
// live. Cheap rebuild.
@@ -308,3 +342,91 @@ pub async fn refresh_self_tier_from_db(
current.clone()
}
}
/// Entitlements a tier carries; `Unlicensed` carries none.
fn entitlements_of(tier: &Tier) -> Vec<String> {
match tier {
Tier::Licensed { entitlements, .. } => entitlements.clone(),
Tier::Unlicensed { .. } => Vec::new(),
}
}
/// Restrict a DB-sourced entitlement set to the signed ceiling.
///
/// The signed self-license key bounds what the tier may grant. The
/// local `licenses` row may *narrow* the tier — an issuer-applied
/// downgrade — but anything in it that the signature does not grant is
/// dropped, so the row can never *widen* the tier past the ceiling.
/// Kept standalone so the invariant is unit-testable without the
/// offline signing key needed to mint a verifiable self-license.
fn clamp_to_signed_ceiling(db_entitlements: Vec<String>, signed: &[String]) -> Vec<String> {
db_entitlements
.into_iter()
.filter(|e| signed.iter().any(|s| s == e))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn db_row_cannot_widen_beyond_signed_ceiling() {
// Signed key grants only the free tier; a tampered DB row
// claiming top-tier entitlements is stripped to the signed set.
let signed = v(&["creator_only"]);
let tampered = v(&[
"unlimited_products",
"unlimited_policies",
"recurring_billing",
"zaprite_payments",
"patron",
"creator_only",
]);
assert_eq!(
clamp_to_signed_ceiling(tampered, &signed),
v(&["creator_only"])
);
}
#[test]
fn db_row_may_narrow_below_signed_ceiling() {
// Signed key grants a broad set; an issuer-applied downgrade to
// a smaller set in the DB row is honored (narrowing is allowed).
let signed = v(&["unlimited_products", "recurring_billing", "zaprite_payments"]);
let downgraded = v(&["unlimited_products"]);
assert_eq!(
clamp_to_signed_ceiling(downgraded, &signed),
v(&["unlimited_products"])
);
}
#[test]
fn matching_entitlements_pass_through_unchanged() {
let signed = v(&["unlimited_products", "recurring_billing"]);
let db = v(&["unlimited_products", "recurring_billing"]);
assert_eq!(clamp_to_signed_ceiling(db.clone(), &signed), db);
}
#[test]
fn empty_signed_ceiling_strips_everything() {
let db = v(&["unlimited_products", "patron"]);
assert!(clamp_to_signed_ceiling(db, &[]).is_empty());
}
#[test]
fn partial_downgrade_keeps_the_still_granted_entitlements() {
// Multi-entitlement signed key; the DB row drops one of them
// (an issuer-applied partial downgrade) and keeps the rest.
let signed = v(&["unlimited_products", "recurring_billing", "zaprite_payments"]);
let db = v(&["unlimited_products", "zaprite_payments"]);
assert_eq!(
clamp_to_signed_ceiling(db, &signed),
v(&["unlimited_products", "zaprite_payments"])
);
}
}
+1
View File
@@ -128,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(tokio::sync::RwLock::new(provider)),
provider_override: None,
config: Arc::new(cfg.clone()),
self_tier,
rates: keysat::rates::RateCache::new(),
+10 -5
View File
@@ -27,10 +27,14 @@ use uuid::Uuid;
/// A merchant profile row. Mirrors the `merchant_profiles` table.
///
/// SMTP fields are flattened onto the same struct for simplicity; they
/// land in the same table on the same row. NULL on all six means
/// "inherit StartOS-level SMTP config." See the keysat-smtp-emails
/// plan for how they're consumed.
/// NOTE: the `smtp_*` fields are DORMANT and not consumed by anything.
/// They were laid down in migration 0020 ahead of the keysat-smtp-emails
/// plan, which was SUPERSEDED 2026-06-18: Keysat will never send buyer
/// email itself (operators own that via their own app + the existing
/// webhooks). The columns are left in place because a removal migration
/// isn't worth it — do not build a send path against them. See
/// `plans/keysat-smtp-emails.md` (superseded banner) and the
/// "Operability & alerts" ROADMAP item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MerchantProfile {
pub id: String,
@@ -42,7 +46,8 @@ pub struct MerchantProfile {
pub post_purchase_redirect_url: Option<String>,
pub is_default: bool,
// SMTP override (all-or-nothing for now; the SMTP plan refines this).
// Dormant SMTP-override columns (see struct doc) — stored/returned
// but never read to send mail; no send path exists or is planned.
pub smtp_host: Option<String>,
pub smtp_port: Option<i64>,
pub smtp_username: Option<String>,
+6
View File
@@ -34,6 +34,12 @@ pub struct Product {
/// behavior); operators can opt-in by adding rows.
#[serde(default)]
pub entitlements_catalog: Option<Vec<EntitlementDef>>,
/// Merchant profile this product belongs to (migration 0020). None
/// resolves to the default profile (back-compat for rows created
/// before the operator ran more than one profile). Set via the admin
/// product form when >1 profile exists.
#[serde(default)]
pub merchant_profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
+33 -8
View File
@@ -8,7 +8,7 @@
use super::{
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
ProviderInvoiceSnapshot, ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use crate::btcpay::client::BtcpayClient;
use crate::btcpay::webhook::{verify_signature, WebhookEvent as BtcpayWebhookEvent};
@@ -155,17 +155,13 @@ impl PaymentProvider for BtcpayProvider {
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
) -> Result<ProviderInvoiceSnapshot> {
let raw = self
.client
.get_invoice(provider_invoice_id)
.await
.context("BTCPay get-invoice")?;
let status = raw
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("Pending");
Ok(match status {
let status = match raw.get("status").and_then(|v| v.as_str()).unwrap_or("Pending") {
"Settled" | "Complete" => ProviderInvoiceStatus::Settled,
"Expired" => ProviderInvoiceStatus::Expired,
"Invalid" => ProviderInvoiceStatus::Invalid,
@@ -173,7 +169,36 @@ impl PaymentProvider for BtcpayProvider {
// reports it via metadata we'd handle here. For now it falls
// through to Pending.
_ => ProviderInvoiceStatus::Pending,
})
};
// The amount the invoice is denominated for, for the advisory
// settle-amount tripwire (see docs/guides/payments.md). We price
// BTCPay invoices in "BTC" with a decimal amount = sats / 1e8 (see
// btcpay/client.rs::create_invoice), so convert that back to sats —
// f64 is exact for sat-magnitude integers and mirrors the inverse
// conversion already used in the client. Any other currency
// shouldn't occur in our flow; pass it through verbatim so the
// tripwire downstream flags the unexpected currency. Absent or
// unparseable amount → None ("no opinion"; tripwire skips it).
let amount = match (
raw.get("currency").and_then(|v| v.as_str()),
raw.get("amount").and_then(|v| v.as_str()),
) {
(Some("BTC"), Some(amt)) => amt
.parse::<f64>()
.ok()
.map(|btc| (btc * 100_000_000.0).round() as i64)
// Guard against garbage from the provider (negative/zero/NaN
// → 0): a real invoice amount is positive. Non-positive → None
// ("no opinion"), so the advisory tripwire skips it.
.filter(|&sats| sats > 0)
.map(Money::sats),
(Some(cur), Some(amt)) => amt.parse::<i64>().ok().map(|v| Money {
currency: cur.to_string(),
amount: v,
}),
_ => None,
};
Ok(ProviderInvoiceSnapshot { status, amount })
}
fn validate_webhook(
+20 -2
View File
@@ -18,7 +18,8 @@
//!
//! - `kind()` — provider identity, for logs / audit / admin UI
//! - `create_invoice` — make a hosted-checkout session, return a URL
//! - `get_invoice_status` — for the reconcile loop (webhook misses)
//! - `get_invoice_status` — authoritative status + amount, for the reconcile
//! loop (webhook misses) and the webhook settle-confirmation gate
//! - `validate_webhook` — provider-specific signature scheme + parse
//! - `pay_lightning_invoice` — for the tip-recipient flow; default impl
//! returns a "not supported" error so providers without a Lightning
@@ -280,6 +281,23 @@ pub enum ProviderInvoiceStatus {
Invalid,
}
/// The provider's current view of an invoice: its `status` plus the amount
/// the provider has the invoice denominated for. Returned by
/// `PaymentProvider::get_invoice_status`.
///
/// `amount` is the price the provider has on record for the invoice (what we
/// asked it to charge), normalized to `SAT` when the provider used a Bitcoin
/// unit. It is `None` when the response carried no parseable amount/currency.
/// `status` is the load-bearing settle gate; `amount` feeds only the
/// **advisory** settle-amount tripwire in `api::webhook` / `reconcile` —
/// callers treat `None` as "no opinion" and MUST NOT gate issuance on it.
/// See docs/guides/payments.md.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderInvoiceSnapshot {
pub status: ProviderInvoiceStatus,
pub amount: Option<Money>,
}
/// Parsed webhook event. Only the kinds Keysat actually acts on are
/// modeled; everything else falls into `Other` and is ignored.
#[derive(Debug, Clone)]
@@ -355,7 +373,7 @@ pub trait PaymentProvider: Send + Sync + Any {
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus>;
) -> Result<ProviderInvoiceSnapshot>;
/// Verify and parse a webhook delivery. Implementations are
/// responsible for reading whatever signature header their provider
@@ -7,7 +7,7 @@
use crate::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
ProviderInvoiceSnapshot, ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use anyhow::{anyhow, Context, Result};
use axum::http::HeaderMap;
@@ -175,7 +175,7 @@ impl PaymentProvider for ZapriteProvider {
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
) -> Result<ProviderInvoiceSnapshot> {
let order = self
.client
.get_order(provider_invoice_id)
@@ -198,7 +198,7 @@ impl PaymentProvider for ZapriteProvider {
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
Ok(match status_str {
let status = match status_str {
"PAID" | "COMPLETE" | "OVERPAID" => ProviderInvoiceStatus::Settled,
"PENDING" | "PROCESSING" | "UNDERPAID" => ProviderInvoiceStatus::Pending,
// Zaprite doesn't have explicit Expired/Refunded states
@@ -207,7 +207,30 @@ impl PaymentProvider for ZapriteProvider {
// doesn't change. Fall-through covers any future
// additions defensively.
_ => ProviderInvoiceStatus::Invalid,
})
};
// The amount the order is denominated for, for the advisory
// settle-amount tripwire (see docs/guides/payments.md). We create
// Zaprite orders priced in "BTC" with the amount already in sats
// (see create_invoice above), so a Bitcoin currency maps straight
// to sats. Zaprite's order schema isn't fully documented, so this
// is best-effort: an absent/unparseable amount yields None and the
// tripwire is skipped. A non-Bitcoin currency is passed through so
// the tripwire can flag the unexpected currency.
let amount = match (
order.get("currency").and_then(|v| v.as_str()),
order.get("amount").and_then(|v| v.as_i64()),
) {
// Zaprite spells Bitcoin as "BTC" with the amount already in sats
// (see create_invoice above); "SAT" is accepted defensively. Both
// map to our canonical sat unit. Non-positive → None (skip).
(Some("BTC") | Some("SAT"), Some(sats)) if sats > 0 => Some(Money::sats(sats)),
(Some(cur), Some(v)) if v > 0 => Some(Money {
currency: cur.to_string(),
amount: v,
}),
_ => None,
};
Ok(ProviderInvoiceSnapshot { status, amount })
}
/// Validate an incoming webhook delivery from Zaprite.
+12 -2
View File
@@ -83,9 +83,9 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
},
};
match provider.get_invoice_status(&inv.btcpay_invoice_id).await {
Ok(status) => {
Ok(snapshot) => {
use crate::payment::ProviderInvoiceStatus::*;
let new_status = match status {
let new_status = match snapshot.status {
Settled => "settled",
Expired => "expired",
Invalid => "invalid",
@@ -124,6 +124,16 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
}
if new_status == "settled" {
// Same advisory amount tripwire the webhook path applies
// (see crate::api::webhook::audit_settle_amount). Never
// blocks issuance — logs + audits any amount/currency
// drift from what we charged.
crate::api::webhook::audit_settle_amount(
state,
&inv,
snapshot.amount.as_ref(),
)
.await;
if let Err(e) = ensure_license(state, &inv).await {
tracing::warn!(
error = %e,
+89 -10
View File
@@ -67,7 +67,7 @@ use crate::payment::CreateInvoiceParams;
use anyhow::{anyhow, Context, Result};
use chrono::{Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{json, Value};
use sqlx::{Row, SqlitePool};
use std::time::Duration as StdDuration;
use uuid::Uuid;
@@ -1349,13 +1349,18 @@ pub async fn capture_zaprite_payment_profile(
/// worker *after* it has created the order; this turns the order
/// from "buyer must pay" into "auto-charged, will settle via the
/// usual webhook." Returns:
/// - `Ok(true)` — the charge call succeeded; the buyer is not
/// expected to pay manually. The settle webhook
/// will fire on its own and flip the sub to
/// `active` via `on_invoice_settled`.
/// - `Ok(false)` — sub has no saved profile, or active provider
/// isn't Zaprite. Caller proceeds with manual-pay
/// fallback (`subscription.renewal_pending`).
/// - `Ok(true)` — the charge settled (order status PAID/COMPLETE/
/// OVERPAID); the buyer is not expected to pay
/// manually. The settle webhook will fire on its
/// own and flip the sub to `active` via
/// `on_invoice_settled`.
/// - `Ok(false)` — sub has no saved profile, active provider isn't
/// Zaprite, OR Zaprite accepted the request (HTTP
/// 2xx) but the order did NOT reach a settled status
/// (declined/expired/in-flight/unknown). In every
/// `Ok(false)` case the caller proceeds with the
/// manual-pay fallback (`subscription.renewal_pending`)
/// so the buyer keeps a path to recover the cycle.
/// - `Err(_)` — Zaprite returned an error (declined card,
/// expired profile, network blip). Caller treats
/// this as a soft failure: log, audit, and ALSO
@@ -1400,12 +1405,86 @@ async fn try_auto_charge_zaprite(
.await
.context("Zaprite charge_order_with_profile")?;
// A 2xx from `/v1/orders/charge` only means Zaprite ACCEPTED the
// request — the order's `status` says whether the money actually
// moved. A charge that came back declined/expired/in-flight (or any
// status we don't positively recognize as settled) leaves no settle
// webhook to wait for, so returning Ok(true) here would silently
// lapse the sub: we'd suppress the manual-pay notification and wait
// forever for an `order.paid` that never arrives. Fail safe — only
// suppress manual-pay when the order is in a recognized settled
// state; otherwise fall through (Ok(false)) so the buyer still gets
// a pay link and can recover the cycle.
let order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?");
if !zaprite_charge_settled(&resp) {
tracing::warn!(
sub_id = %sub.id,
order_id = %provider_invoice_id,
profile_id = %profile_id,
order_status,
"Zaprite auto-charge accepted (HTTP 2xx) but order is not settled; \
falling back to manual-pay renewal"
);
return Ok(false);
}
tracing::info!(
sub_id = %sub.id,
order_id = %provider_invoice_id,
profile_id = %profile_id,
order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
"Zaprite auto-charge succeeded; awaiting settle webhook"
order_status,
"Zaprite auto-charge settled; awaiting settle webhook"
);
Ok(true)
}
/// Does a Zaprite `/v1/orders/charge` response (HTTP 2xx already
/// confirmed by the client) indicate the charge actually settled?
///
/// Mirrors the PAID/COMPLETE/OVERPAID → `Settled` mapping in
/// `ZapriteProvider::get_invoice_status`. Deliberately an **allowlist**,
/// not a failure blocklist: Zaprite's confirmed order-status enum is
/// PENDING|PROCESSING|PAID|COMPLETE|OVERPAID|UNDERPAID with no documented
/// terminal-failure string, so any unrecognized or missing status must be
/// treated as "not settled" and routed to manual-pay rather than
/// optimistically assumed paid.
fn zaprite_charge_settled(resp: &Value) -> bool {
matches!(
resp.get("status").and_then(|v| v.as_str()),
Some("PAID") | Some("COMPLETE") | Some("OVERPAID")
)
}
#[cfg(test)]
mod tests {
use super::zaprite_charge_settled;
use serde_json::json;
#[test]
fn charge_settled_only_for_recognized_paid_statuses() {
// Settled states → suppress manual-pay (Ok(true) upstream).
for s in ["PAID", "COMPLETE", "OVERPAID"] {
assert!(
zaprite_charge_settled(&json!({ "status": s })),
"{s} should count as settled"
);
}
// The silent-lapse guard: a 2xx carrying any non-settled status
// must NOT be treated as success. In-flight, underpaid,
// terminal-failure, and unknown statuses all fall through to the
// manual-pay path.
for s in [
"PENDING", "PROCESSING", "UNDERPAID", "FAILED", "DECLINED", "EXPIRED",
"CANCELED", "REFUNDED", "",
] {
assert!(
!zaprite_charge_settled(&json!({ "status": s })),
"{s} must NOT count as settled"
);
}
// Malformed / absent / non-string status fields fall through too.
assert!(!zaprite_charge_settled(&json!({})));
assert!(!zaprite_charge_settled(&json!({ "status": null })));
assert!(!zaprite_charge_settled(&json!({ "status": 200 })));
}
}
File diff suppressed because it is too large Load Diff
@@ -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"
);
}
+9 -4
View File
@@ -20,8 +20,8 @@ use keysat::api::AppState;
use keysat::config::Config;
use keysat::license_self::Tier;
use keysat::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
ProviderKind, ProviderWebhookEvent,
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceSnapshot,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use keysat::subscriptions;
use serde_json::{json, Value};
@@ -77,6 +77,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: None,
sandbox_mode: false,
};
let mock = Arc::new(MockProvider::new());
let state = AppState {
@@ -85,6 +86,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
payment: Arc::new(RwLock::new(Some(
mock.clone() as Arc<dyn PaymentProvider>,
))),
provider_override: None,
config: Arc::new(cfg),
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
reason: "test".into(),
@@ -132,8 +134,11 @@ impl PaymentProvider for MockProvider {
checkout_url: format!("http://mock.test/checkout/{n}"),
})
}
async fn get_invoice_status(&self, _id: &str) -> Result<ProviderInvoiceStatus> {
Ok(ProviderInvoiceStatus::Pending)
async fn get_invoice_status(&self, _id: &str) -> Result<ProviderInvoiceSnapshot> {
Ok(ProviderInvoiceSnapshot {
status: ProviderInvoiceStatus::Pending,
amount: None,
})
}
fn validate_webhook(&self, _h: &HeaderMap, _b: &[u8]) -> Result<ProviderWebhookEvent> {
anyhow::bail!("not exercised by renewal-worker tests")
+9 -4
View File
@@ -61,11 +61,13 @@ async fn make_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: None,
sandbox_mode: false,
};
let state = AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(RwLock::new(None)),
provider_override: None,
config: Arc::new(cfg),
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
reason: "test".into(),
@@ -733,8 +735,8 @@ async fn apply_tier_change_mutates_license_and_subscription() {
#[tokio::test]
async fn renewal_worker_applies_pending_tier_change_before_billing() {
use keysat::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
ProviderKind, ProviderWebhookEvent,
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceSnapshot,
ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent,
};
use std::any::Any;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -764,8 +766,11 @@ async fn renewal_worker_applies_pending_tier_change_before_billing() {
checkout_url: format!("http://cap/{n}"),
})
}
async fn get_invoice_status(&self, _id: &str) -> anyhow::Result<ProviderInvoiceStatus> {
Ok(ProviderInvoiceStatus::Pending)
async fn get_invoice_status(&self, _id: &str) -> anyhow::Result<ProviderInvoiceSnapshot> {
Ok(ProviderInvoiceSnapshot {
status: ProviderInvoiceStatus::Pending,
amount: None,
})
}
fn validate_webhook(
&self,
+2
View File
@@ -60,11 +60,13 @@ async fn make_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: None,
sandbox_mode: false,
};
let state = AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(RwLock::new(None)),
provider_override: None,
config: Arc::new(cfg),
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
reason: "test".into(),
+56 -16
View File
@@ -415,16 +415,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
border:1px solid var(--border-1);
}
.featured-pill-toggle.on {
background:var(--gold-500); color:var(--navy-950);
border-color:var(--gold-500);
box-shadow:0 2px 6px rgba(191,160,104,0.25);
background:var(--navy-800); color:var(--cream-50);
border-color:var(--navy-800);
box-shadow:0 2px 6px rgba(14,31,51,0.18);
}
.featured-pill-toggle.on .state {
background:var(--navy-950); color:var(--gold-500);
border-color:var(--navy-950);
background:var(--cream-50); color:var(--navy-900);
border-color:var(--cream-50);
}
.featured-pill-toggle.on:hover {
background:var(--gold-400);
background:var(--navy-900);
}
/* Tier-card drag affordance — cursor signals draggability on hover,
@@ -534,12 +534,12 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
<div id="tier-banner-msg" style="margin-bottom:8px;"></div>
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
display:inline-block; padding:5px 10px;
background:var(--gold-500); color:var(--navy-950);
background:var(--cream-50); color:var(--navy-900);
font-weight:700; font-size:11px;
border-radius:5px; text-decoration:none;
border-radius:8px; text-decoration:none;
transition:background 120ms;
" onmouseover="this.style.background='var(--gold-400)'"
onmouseout="this.style.background='var(--gold-500)'"></a>
" onmouseover="this.style.background='var(--cream-200)'"
onmouseout="this.style.background='var(--cream-50)'"></a>
</div>
<div class="footer" id="sidebar-footer">
<span class="dot warn"></span>
@@ -1574,11 +1574,32 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
// -------- Products --------
// Merchant-profile picker for the product create/edit forms. Returns
// null when the operator runs 0 or 1 profile — there's nothing to
// choose, and the product resolves to the default profile. With >1
// profile it returns { element, value() }; `selectedId` pre-selects a
// profile, falling back to the default when null.
function profileSelectField(profiles, selectedId) {
if (!profiles || profiles.length <= 1) return null
const sel = el('select', { class: 'input', name: 'p_merchant_profile' },
profiles.map((pr) => el('option', { value: pr.id },
pr.name + (pr.is_default ? ' (default)' : ''))))
const fallback = (profiles.find((pr) => pr.is_default) || profiles[0]).id
sel.value = selectedId || fallback
const element = el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:0 0 4px' }, 'Merchant profile'),
sel,
el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
'Which business this product sells under — sets the payment provider and branding buyers see at checkout.'),
])
return { element, value: () => sel.value }
}
// Edit-product modal. Opens when the operator clicks Edit on a product
// row. Mutable: name, description, price (currency + value). Slug is
// intentionally not editable (it's part of the public buy URL —
// changing it would break bookmarks).
function openEditProduct(p) {
// row. Mutable: name, description, price (currency + value), merchant
// profile. Slug is intentionally not editable (it's part of the public
// buy URL — changing it would break bookmarks).
function openEditProduct(p, profiles) {
const overlay = el('div', {
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
'display:flex; align-items:center; justify-content:center; padding:20px;',
@@ -1586,6 +1607,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
const editCatalog = catalogEditor(p.entitlements_catalog || null)
const editProfile = profileSelectField(profiles, p.merchant_profile_id || null)
// Currency-aware price inputs. For SAT-currency products, show
// the integer sat amount. For USD/EUR, render the cents value
@@ -1643,6 +1665,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
editCatalog.element,
]),
editProfile && editProfile.element,
status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn primary', onclick: async function () {
@@ -1663,6 +1686,9 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// the server treats null as "set to NULL", absent as
// "leave alone".
body.entitlements_catalog = editCatalog.read()
// Only present when >1 profile; always a concrete id when
// shown, so this is a Some(Some(id)) reassignment server-side.
if (editProfile) body.merchant_profile_id = editProfile.value()
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
overlay.remove()
routes.products()
@@ -1691,6 +1717,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const gfBanner = grandfatherBanner(tierStatus, 'products', 'products')
if (gfBanner) target.appendChild(gfBanner)
// Merchant profiles drive the optional profile picker on the create
// + edit forms (rendered only when >1 profile exists). Non-fatal on
// error: an empty list just hides the picker, and products resolve
// to the default profile.
let profiles = []
try {
const pj = await api('/v1/admin/merchant-profiles')
profiles = (pj && pj.profiles) || []
} catch (e) { profiles = [] }
// Create form. Currency picker swaps the price-input units in
// place: SAT → integer sats, USD/EUR → dollar/euro amount which
// we convert to cents on the way out (the backend stores
@@ -1721,6 +1757,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
})
const createCatalog = catalogEditor(null)
const createProfile = profileSelectField(profiles, null)
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new product'),
el('div', { class: 'body' }, [
@@ -1750,6 +1787,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
createCatalog.element,
]),
createProfile && createProfile.element,
// Pre-check warning when the operator is at cap-1 (or already
// over) for products. Renders inline above the submit so they
// know what to expect before clicking.
@@ -1781,6 +1819,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
metadata: {},
}
if (catalog) body.entitlements_catalog = catalog
if (createProfile) body.merchant_profile_id = createProfile.value()
await api('/v1/admin/products', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.products, 600)
@@ -1838,7 +1877,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('td', null, el('div', { class: 'actions-row' }, [
el('button', {
class: 'btn sm secondary',
onclick: function () { openEditProduct(p) },
onclick: function () { openEditProduct(p, profiles) },
}, 'Edit'),
el('button', {
class: 'btn sm danger',
@@ -5724,7 +5763,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// -------- Merchant profiles (multi-provider model, :52+) --------
// Each profile represents one "business" the operator is running on
// this Keysat instance. Owns business identity (brand, support contact,
// post-purchase redirect, optional SMTP override) and a set of payment
// post-purchase redirect; SMTP-override cols are dormant/unused) and a set of payment
// providers (BTCPay / Zaprite) that legally settle to that business.
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
// get unlimited.
@@ -6579,6 +6618,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('option', { value: 'read-only' }, 'Read-only — list everything; mutate nothing'),
el('option', { value: 'license-issuer' }, 'License issuer — read + issue / revoke / change-tier licenses'),
el('option', { value: 'support' }, 'Support — license issuer + cancel subs + deactivate machines'),
el('option', { value: 'merchant-onboard' }, 'Merchant onboard — read + create products / policies + issue licenses (self-serve catalog setup)'),
el('option', { value: 'full-admin' }, 'Full admin — every scope (use sparingly)'),
])
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
+3
View File
@@ -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/
+79
View File
@@ -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.)
+62
View File
@@ -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).
+52
View File
@@ -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"
+57
View File
@@ -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"; }
+28
View File
@@ -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"
+31
View File
@@ -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"
+94
View File
@@ -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"]
}
+28
View File
@@ -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
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# De-risk probe + .live-env minter for the Stage 2 / combined onboarding harness.
# Run once after `docker compose -p keysat-btcpay up -d`.
#
# Two jobs:
# A. Mint .live-env — create the two stores the harness needs (one with an
# on-chain regtest wallet, one without) plus store-scoped BTCPay API tokens
# carrying the five permissions the Connect-BTCPay flow documents
# (install.html#connect-btcpay), and write them to .live-env for
# run-stage2.sh / validate-gate.sh to source.
# B. De-risk (spec §6.1) — dump the exact Greenfield responses the slice-3
# network gate consults (payment-methods, wallet/address) into probe-out/
# and classify the receive-address HRP.
#
# Idempotency: assumes a FRESH instance (compose `up -d` after `down -v`).
# Re-running against a live instance creates duplicate stores — tear down first.
# Read-only against Keysat; only mutates the throwaway BTCPay instance.
set -uo pipefail
BASE="${BTCPAY_BASE:-http://127.0.0.1:49392}"
ADMIN_EMAIL="admin@keysat.local"
ADMIN_PW="keysatregtest1!"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$HERE/probe-out"
LIVE_ENV="$HERE/.live-env"
mkdir -p "$OUT_DIR"
# Permissions the documented Connect-BTCPay flow grants (install.html#connect-btcpay).
STORE_PERMS='canviewstoresettings canmodifystoresettings canviewinvoices cancreateinvoice canmodifyinvoices'
BTND=keysat-btcpay-bitcoind-1
hr(){ printf '\n\033[1;36m=== %s ===\033[0m\n' "$*"; }
jqp(){ jq . 2>/dev/null || cat; }
AUTH=(-u "$ADMIN_EMAIL:$ADMIN_PW")
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
create_store(){ # NAME -> store id
curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/stores" \
-H 'Content-Type: application/json' -d "{\"name\":\"$1\"}" | jq -r '.id'
}
store_token(){ # STORE_ID -> store-scoped API key with the 5 documented perms
local sid="$1" perms="" p
for p in $STORE_PERMS; do perms="$perms\"btcpay.store.$p:$sid\","; done
curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/api-keys" \
-H 'Content-Type: application/json' \
-d "{\"label\":\"keysat-$sid\",\"permissions\":[${perms%,}]}" | jq -r '.apiKey'
}
# --- 0. wait for BTCPay --------------------------------------------------------
hr "0. waiting for BTCPay health at $BASE"
for i in $(seq 1 120); do
curl -fsS "$BASE/api/v1/health" >/dev/null 2>&1 && break
sleep 2
[[ $i == 120 ]] && { echo "BTCPay never became healthy"; exit 1; }
done
curl -fsS "$BASE/api/v1/health" | jqp
# --- 1. create first admin (unauthenticated, only works on a fresh instance) ---
hr "1. create first admin (idempotent: 'already exists' is fine)"
curl -sS -X POST "$BASE/api/v1/users" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PW\",\"isAdministrator\":true}" | jqp
# --- 2. admin user API key (KEYSAT_LIVE_BTCPAY_KEY; broad, for ad-hoc admin use) -
hr "2. mint admin user API key"
ADMIN_KEY="$(curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/api-keys" \
-H 'Content-Type: application/json' \
-d '{"label":"keysat-admin","permissions":["btcpay.server.canmodifyserversettings","btcpay.store.canmodifystoresettings","btcpay.store.canmodifyinvoices"]}' \
| jq -r '.apiKey')"
echo "ADMIN_KEY=${ADMIN_KEY:0:8}"
# --- 3. regtest store WITH an on-chain wallet ----------------------------------
hr "3. create regtest store (with on-chain wallet)"
STORE_REGTEST="$(create_store 'Keysat Regtest Co')"
echo "STORE_REGTEST=$STORE_REGTEST"
[[ -z "$STORE_REGTEST" || "$STORE_REGTEST" == null ]] && { echo "no regtest store id"; exit 1; }
gen_body='{"savePrivateKeys":false,"importKeysToRPC":false,"wordList":"English","wordCount":12,"scriptPubKeyType":"Segwit"}'
PMID=""
for cand in BTC-CHAIN BTC; do
hr "3b. generate wallet on pmid=$cand"
code="$(curl -sS -o "$OUT_DIR/gen-$cand.json" -w '%{http_code}' "${AUTH[@]}" \
-X POST "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods/$cand/wallet/generate" \
-H 'Content-Type: application/json' -d "$gen_body")"
echo "HTTP $code"; cat "$OUT_DIR/gen-$cand.json" | jqp
[[ "$code" == 2* ]] && { PMID="$cand"; break; }
done
[[ -z "$PMID" ]] && { echo "!! wallet generate failed for both pmid forms"; exit 1; }
# --- 4. mine regtest blocks so the wallet has a usable address -----------------
hr "4. mine regtest blocks"
ADDR_FOR_MINE="$(cli getnewaddress 2>/dev/null || true)"
echo "miner address: ${ADDR_FOR_MINE:-<none>}"
[[ -n "$ADDR_FOR_MINE" ]] && { cli generatetoaddress 101 "$ADDR_FOR_MINE" >/dev/null 2>&1 \
&& echo "mined 101 blocks" || echo "mine failed (non-fatal for detection probe)"; }
# --- 5. no-wallet store (fail-closed arm of the gate) --------------------------
hr "5. create no-wallet store"
STORE_NOWALLET="$(create_store 'Keysat NoWallet Co')"
echo "STORE_NOWALLET=$STORE_NOWALLET"
[[ -z "$STORE_NOWALLET" || "$STORE_NOWALLET" == null ]] && { echo "no nowallet store id"; exit 1; }
# --- 6. store-scoped tokens (what the agent/harness hand Keysat at connect) -----
hr "6. mint store-scoped tokens"
GATE_TOK_REGTEST="$(store_token "$STORE_REGTEST")"
GATE_TOK_NOWALLET="$(store_token "$STORE_NOWALLET")"
echo "GATE_TOK_REGTEST=${GATE_TOK_REGTEST:0:8}… GATE_TOK_NOWALLET=${GATE_TOK_NOWALLET:0:8}"
[[ "$GATE_TOK_REGTEST" == null || -z "$GATE_TOK_REGTEST" ]] && { echo "regtest token mint failed"; exit 1; }
# --- 7. THE PAYLOADS the slice-3 gate consults --------------------------------
hr "7a. GET payment-methods (does it expose derivationScheme? what pmid?)"
curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods?includeConfig=true" \
| tee "$OUT_DIR/payment-methods.json" | jqp
hr "7b. GET wallet/address (THE network artifact — expect bcrt1…)"
ADDR_JSON="$(curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_REGTEST/payment-methods/${PMID:-BTC-CHAIN}/wallet/address")"
echo "$ADDR_JSON" | tee "$OUT_DIR/wallet-address.json" | jqp
ADDR="$(echo "$ADDR_JSON" | jq -r '.address // empty')"
# --- 8. classify --------------------------------------------------------------
hr "8. network classification"
echo "pmid used : ${PMID:-BTC-CHAIN}"
echo "receive address: ${ADDR:-<none>}"
case "$ADDR" in
bcrt1*) echo "=> prefix bcrt1 => REGTEST ✅ (non-mainnet → scoped connect allowed)";;
tb1*) echo "=> prefix tb1 => TESTNET/SIGNET (non-mainnet)";;
bc1*) echo "=> prefix bc1 => MAINNET ❌";;
[mn2]*) echo "=> legacy base58 m/n/2 => TEST/REGTEST (non-mainnet)";;
[13]*) echo "=> legacy base58 1/3 => MAINNET ❌";;
"") echo "=> NO ADDRESS (Lightning-only / unconfigured) => FAIL-CLOSED → mainnet → master-only";;
*) echo "=> UNRECOGNIZED prefix => FAIL-CLOSED → mainnet → master-only";;
esac
# --- 9. write .live-env -------------------------------------------------------
hr "9. write .live-env"
cat > "$LIVE_ENV" <<EOF
export KEYSAT_LIVE_BTCPAY_URL=$BASE
export KEYSAT_LIVE_BTCPAY_KEY=$ADMIN_KEY
export KEYSAT_LIVE_BTCPAY_STORE_REGTEST=$STORE_REGTEST
export KEYSAT_LIVE_BTCPAY_STORE_NOWALLET=$STORE_NOWALLET
export GATE_TOK_REGTEST=$GATE_TOK_REGTEST
export GATE_TOK_NOWALLET=$GATE_TOK_NOWALLET
EOF
echo "wrote $LIVE_ENV"
hr "done — raw payloads under $OUT_DIR/, credentials in $LIVE_ENV"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# The "test buyer's wallet": pay a BTCPay invoice on regtest by sending to its
# on-chain address from the regtest bitcoind and mining a confirmation. Used by
# the Stage 2 harness to drive settlement (BTCPay → webhook → Keysat issues the
# license) once the merchant journey has produced a checkout invoice.
#
# Usage: buyer-pay.sh <btcpay_base_url> <store_api_key> <store_id> <invoice_id>
# Prints the funding txid on success.
set -euo pipefail
BASE="${1:?btcpay base url}"; KEY="${2:?store api key}"; STORE="${3:?store id}"; INV="${4:?invoice id}"
BTND=keysat-btcpay-bitcoind-1
cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; }
# Wallet RPCs must name the wallet explicitly: NBXplorer loads its own wallet, so
# bitcoind has >1 loaded and a bare wallet call errors "Wallet file not specified".
wcli(){ cli -rpcwallet=miner "$@"; }
# Pull the invoice's on-chain payment address + BTC amount from BTCPay.
PM="$(curl -fsS -H "Authorization: token $KEY" \
"$BASE/api/v1/stores/$STORE/invoices/$INV/payment-methods")"
ADDR="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].destination // empty')"
AMT="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].amount // empty')"
[[ -n "$ADDR" && -n "$AMT" ]] || { echo "no on-chain payment method on invoice $INV" >&2; echo "$PM" >&2; exit 1; }
# Ensure the miner wallet has spendable coins, then pay + confirm.
cli -named createwallet wallet_name=miner load_on_startup=true >/dev/null 2>&1 || cli loadwallet miner >/dev/null 2>&1 || true
MINE_ADDR="$(wcli getnewaddress)"
cli generatetoaddress 101 "$MINE_ADDR" >/dev/null # generatetoaddress is node-level (no wallet needed)
TXID="$(wcli sendtoaddress "$ADDR" "$AMT")"
cli generatetoaddress 1 "$MINE_ADDR" >/dev/null # 1 conf (BTCPay HighSpeed settles at 0-conf seen / 1-conf)
echo "$TXID"
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Stage 2 setup: a sandbox Keysat daemon wired to the regtest BTCPay stack, a
# scoped key that can BOTH onboard a catalog AND connect a payment provider
# (merchant-onboard + payment_providers:write), the docs corpus, and a sandbox
# app — then the agent brief for the COMBINED journey: gate a paid product
# (define product + paid tier, integrate the SDK, prove the export is BLOCKED),
# then prove it end to end (connect BTCPay regtest, a buyer pays, and the
# PURCHASED license UNLOCKS the gated export).
#
# Networking: the daemon binds 0.0.0.0 and registers its BTCPay webhook via
# host.docker.internal so the BTCPay *container* can reach it on settle; the
# agent/harness reach the daemon on 127.0.0.1. Sandbox mode + a non-mainnet
# (regtest) store are what let the scoped key connect BTCPay at all.
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib.sh"
require curl; require jq; require openssl; require node
STAGE2_DIR="$HARNESS_DIR/stage2"
BTCPAY_URL="$(grep -h KEYSAT_LIVE_BTCPAY_URL "$STAGE2_DIR/btcpay-regtest/.live-env" 2>/dev/null | cut -d= -f2-)"
BTCPAY_URL="${BTCPAY_URL:-http://127.0.0.1:49392}"
curl -fsS "$BTCPAY_URL/api/v1/health" >/dev/null 2>&1 \
|| die "regtest BTCPay not reachable at $BTCPAY_URL — run: (cd $STAGE2_DIR/btcpay-regtest && docker compose -p keysat-btcpay up -d)"
[[ -x "$DAEMON_BIN" ]] || { log "building daemon (cargo build --release)…"; ( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"; }
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-stage2-$$"
RUN_DIR="$RUNS_DIR/$RUN_ID"; mkdir -p "$RUN_DIR/data" "$RUN_DIR/reports"
STATE="$RUN_DIR/state.env"; : > "$STATE"
PORT="$(free_port)"; MASTER="$(openssl rand -hex 32)"
BASE_URL="http://127.0.0.1:$PORT" # agent/harness-facing
PUBLIC_URL="http://host.docker.internal:$PORT" # BTCPay-container-facing (webhooks)
state_set "$STATE" RUN_ID "$RUN_ID"; state_set "$STATE" RUN_DIR "$RUN_DIR"
state_set "$STATE" PORT "$PORT"; state_set "$STATE" BASE_URL "$BASE_URL"
state_set "$STATE" MASTER_KEY "$MASTER"; state_set "$STATE" BTCPAY_URL "$BTCPAY_URL"
log "booting sandbox daemon on 0.0.0.0:$PORT (btcpay → $BTCPAY_URL)"
KEYSAT_BIND="0.0.0.0:$PORT" \
KEYSAT_DB_PATH="$RUN_DIR/data/keysat.db" \
KEYSAT_ADMIN_API_KEY="$MASTER" \
KEYSAT_SANDBOX_MODE=1 \
BTCPAY_URL="$BTCPAY_URL" \
KEYSAT_PUBLIC_URL="$PUBLIC_URL" \
KEYSAT_OPERATOR_NAME="Stage 2 Sandbox" \
nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 &
state_set "$STATE" DAEMON_PID "$!"
ln -sfn "$RUN_DIR" "$CURRENT_LINK"
wait_http "$BASE_URL/healthz" 75 || { tail -20 "$RUN_DIR/daemon.log" >&2; die "daemon failed to start"; }
# Confirm the sandbox flag is actually on (the whole gate depends on it).
[[ "$(curl -fsS -H "Authorization: Bearer $MASTER" "$BASE_URL/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] \
|| die "daemon did not report sandbox mode"
log "minting scoped key: merchant-onboard + payment_providers:write"
SK="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" -H "Authorization: Bearer $MASTER" \
-H 'Content-Type: application/json' \
-d '{"label":"stage2-agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' \
| jq -r '.token')"
[[ "$SK" == ks_* ]] || die "scoped key mint failed"
state_set "$STATE" MERCHANT_KEY "$SK"
"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null
"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null
DOCS_URL="$(state_get "$STATE" DOCS_URL)"; SANDBOX="$(state_get "$STATE" SANDBOX)"
# Two BTCPay store contexts the test buyer/agent can use (regtest store has an
# on-chain wallet; created during de-risk). The agent connects via the scoped
# key; the BTCPay credential it needs is provided as the "operator's BTCPay".
[[ -f "$STAGE2_DIR/btcpay-regtest/.live-env" ]] \
|| die ".live-env missing — run stage2/btcpay-regtest/probe.sh first to mint the BTCPay store token (GATE_TOK_REGTEST)"
source "$STAGE2_DIR/btcpay-regtest/.live-env"
cat > "$RUN_DIR/AGENT_BRIEF.md" <<EOF
# Onboarding-tester brief — Keysat combined journey (gate a paid product, then prove it with a real buyer payment)
You are a **fresh adopter**, following \`~/Projects/standards/guides/onboarding-tester.md\`.
Reach the goal using **only the docs corpus**. Never read Keysat's server or SDK source to
unblock yourself — if the docs don't get you there, that is a finding.
## Goal (checkable end-state)
You are an operator selling a Next.js/TypeScript app. Do the **whole flow in the order an
operator actually works** — gate the paid feature first, then prove it end to end with a
real buyer payment. Use a **scoped, non-master** API key (it carries
\`payment_providers:write\`), a **sandbox** Keysat instance, and the published docs only:
1. **Define the product + a paid tier that grants an entitlement.** Register the product in
Keysat's catalog and add a **paid** policy/tier whose purchase grants a named
**entitlement** (the thing your gate will check for). Note the entitlement key.
2. **Integrate the SDK and gate the Pro export — verify the BLOCKED path FIRST.** Wire
\`@keysat/licensing-client\` into your app so \`GET /api/export\` returns the CSV **only**
when the caller holds a valid license carrying that entitlement. Then prove it is shut:
with **no** license and with a **tampered/invalid** license, \`/api/export\` returns a
**4xx, not the CSV**. (At this point no real license exists yet — that's expected.)
3. **Connect BTCPay (regtest) and drive a real buyer payment → license issued.** Connect
the regtest BTCPay to Keysat over the API (no master key, no browser — you hold a BTCPay
credential the way an operator delegating setup would hand you one). Produce a **buyer
checkout** for the paid product, then have the buyer pay it. The settled payment must
issue a **real, signed license** carrying the entitlement from step 1. (The harness will
pay the regtest invoice for you if the docs leave that last on-chain step to plumbing —
note where, but the *checkout* itself must come from the docs.)
4. **Paste the PURCHASED license into the app → verify the UNLOCKED path.** Feed that
purchased license to your app and confirm \`GET /api/export\` now returns the **CSV**.
This is the step that ties the two halves together: the license a *buyer's payment*
produced unlocks the feature your *gate* protects.
Success = the same gate that was demonstrably shut in step 2 is opened in step 4 by a
license that a real (regtest) buyer payment produced in step 3 — reached from the docs
alone, under a scoped key, with BTCPay connected by you.
## Docs corpus (the ONLY how-to sources you may consult)
- Keysat docs site: **$DOCS_URL** — start at \`/integrate.html\` (SDK + gating) and
\`/agent.html\` (scoped-key + connect-BTCPay workflow); the whole site is in-corpus.
- Daemon OpenAPI: **$BASE_URL/v1/openapi.json** (unauthenticated; the docs point here).
- The npm package README for \`@keysat/licensing-client\` is in-corpus (\`npm view\` / the
package page).
## Your sandbox app (mutate ONLY this)
\`$SANDBOX\` — a pristine copy of the "Acme Reports" app whose **Pro export**
(\`GET /api/export\`) is currently ungated. Read its own \`README.md\` freely (it's your
app; it tells you nothing about Keysat). Deps are installed. Run it with \`npm run dev\`
(serves on http://localhost:4311). How the app learns the license key (env var, file,
header) is your call — pick what the docs suggest and note it. Put scratch under
\`/tmp/onboarding-tester/\`.
## Credentials you were handed (an operator delegating setup would hand you these)
- Keysat server: **$BASE_URL**
- Scoped API key (merchant-onboard + payment_providers:write): **$SK**
- Regtest BTCPay server: **${KEYSAT_LIVE_BTCPAY_URL:-$BTCPAY_URL}**, store
**${KEYSAT_LIVE_BTCPAY_STORE_REGTEST:-<regtest store id>}**, BTCPay token
**${GATE_TOK_REGTEST:-<btcpay store token>}** (your "operator's BTCPay" access).
- You were NOT given the master Keysat admin key. If a step seems to need it, that is
either an intended operator-only boundary (note it) or a doc gap (log it).
## Out of corpus (do not open)
Anything under the Keysat source tree (\`$WORKSPACE/licensing-service-startos\`,
\`$WORKSPACE/licensing-client-*\`), migrations, tests, or this harness. Reading any of it
invalidates the run — say so if you do.
## Output
Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as your final
message, in your guide's format. Most-severe-first. On \`completed-clean\`, also emit the
publishable "all the agent had to do was X, Y, Z" walkthrough (secret-free).
EOF
ok "Stage 2 staged. Run id: $RUN_ID"
cat >&2 <<EOF
Daemon (agent) : $BASE_URL (sandbox, btcpay → $BTCPAY_URL)
Docs corpus : $DOCS_URL
Scoped key : $SK
Sandbox app : $SANDBOX
Agent brief : $RUN_DIR/AGENT_BRIEF.md
Buyer-pay helper: $STAGE2_DIR/buyer-pay.sh
Tear down : $HARNESS_DIR/teardown.sh "$RUN_DIR"
EOF
echo "$RUN_ID"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Full Stage 2 teardown — run when the onboarding test is done so nothing keeps
# running. Stops, for each Stage 2 run: the ephemeral daemon + docs server +
# sandbox copy (via the shared teardown.sh); then kills any sandbox dev server
# the onboarding-tester left behind; then stops the shared regtest BTCPay docker
# stack (containers + volumes).
#
# Usage:
# ./teardown-stage2.sh # tear down ALL Stage 2 runs + dev servers + BTCPay stack
# ./teardown-stage2.sh --keep-btcpay # same, but leave the BTCPay stack up (iterating)
# ./teardown-stage2.sh runs/<id> # one specific run dir (path relative to onboarding-harness/)
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/../lib.sh"
KEEP_BTCPAY=0; ONE_RUN=""
for a in "$@"; do
case "$a" in
--keep-btcpay) KEEP_BTCPAY=1 ;;
*) ONE_RUN="$a" ;;
esac
done
# 1. Per-run teardown (daemon + docs server + sandbox copy + freed ports).
if [[ -n "$ONE_RUN" ]]; then
"$HARNESS_DIR/teardown.sh" "$ONE_RUN" || true
else
shopt -s nullglob
any=0
for d in "$RUNS_DIR"/*stage2*/; do
[[ -f "${d}state.env" ]] || continue
"$HARNESS_DIR/teardown.sh" "${d%/}" || true
any=1
done
[[ "$any" == 0 ]] && warn "no Stage 2 run dirs found under $RUNS_DIR"
fi
# 2. Kill any sandbox dev server the agent left running. The proof-of-work app
# serves on :4311 (npm run dev); the onboarding-tester may start it and not
# stop it.
for pid in $(lsof -ti tcp:4311 -sTCP:LISTEN 2>/dev/null || true); do
kill "$pid" 2>/dev/null && log "stopped orphaned sandbox dev server (pid $pid on :4311)" || true
done
# 3. Stop the shared regtest BTCPay stack (containers + volumes) unless told to keep it.
if [[ "$KEEP_BTCPAY" == 1 ]]; then
ok "left BTCPay regtest stack running (--keep-btcpay)"
elif docker ps -a --filter "name=keysat-btcpay" --format '{{.Names}}' 2>/dev/null | grep -q .; then
( cd "$HERE/btcpay-regtest" && docker compose -p keysat-btcpay down -v ) >/dev/null 2>&1 \
&& ok "stopped BTCPay regtest stack (containers + volumes removed)" \
|| warn "could not fully stop BTCPay — check: docker ps -a --filter name=keysat-btcpay"
else
ok "BTCPay regtest stack already stopped"
fi
ok "Stage 2 teardown complete"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# End-to-end validation of the agent-payment-connect gate against the LIVE
# regtest BTCPay (the spec's hard requirement). Boots a throwaway Keysat daemon
# in sandbox mode pointed at the regtest BTCPay stack, mints a scoped
# `payment_providers:write` key, and drives the full OAuth round-trip for two
# stores:
# - no-wallet store → network undetermined → FAIL CLOSED → connect DENIED (400)
# - regtest store → bcrt1 address → non-mainnet → connect ALLOWED (persisted)
#
# Requires the regtest stack up (docker compose -p keysat-btcpay up -d) and
# .live-env populated (GATE_TOK_REGTEST / GATE_TOK_NOWALLET — single-store BTCPay
# tokens). Reads the daemon release binary built by `cargo build --release`.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/btcpay-regtest/.live-env"
BIN="$HERE/../../licensing-service/target/release/keysat"
[[ -x "$BIN" ]] || { echo "FAIL: release binary missing ($BIN) — run cargo build --release"; exit 1; }
PORT=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();})')
MASTER=$(openssl rand -hex 32)
TMP=$(mktemp -d)
BASE="http://127.0.0.1:$PORT"
pass=0; fail=0
ok(){ echo "$*"; pass=$((pass+1)); }
no(){ echo "$*"; fail=$((fail+1)); }
echo "== booting sandbox daemon on $BASE (btcpay → $KEYSAT_LIVE_BTCPAY_URL) =="
KEYSAT_BIND="127.0.0.1:$PORT" \
KEYSAT_DB_PATH="$TMP/keysat.db" \
KEYSAT_ADMIN_API_KEY="$MASTER" \
KEYSAT_SANDBOX_MODE=1 \
BTCPAY_URL="$KEYSAT_LIVE_BTCPAY_URL" \
KEYSAT_PUBLIC_URL="$BASE" \
KEYSAT_OPERATOR_NAME="Stage2 Gate Validation" \
nohup "$BIN" >"$TMP/daemon.log" 2>&1 &
DAEMON_PID=$!
trap 'kill $DAEMON_PID 2>/dev/null; rm -rf "$TMP"' EXIT
for i in $(seq 1 75); do curl -fsS "$BASE/healthz" >/dev/null 2>&1 && break; sleep 0.2; [[ $i == 75 ]] && { echo "FAIL: daemon never healthy"; tail -20 "$TMP/daemon.log"; exit 1; }; done
M=(-H "Authorization: Bearer $MASTER")
echo "== 1. sandbox flag surfaced read-only in /v1/admin/tier =="
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] && ok "tier.sandbox == true" || no "sandbox flag not surfaced"
echo "== 2. mint scoped merchant-onboard + payment_providers:write key =="
SK="$(curl -sS "${M[@]}" -X POST "$BASE/v1/admin/api-keys" -H 'Content-Type: application/json' \
-d '{"label":"agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' | jq -r '.token')"
[[ "$SK" == ks_* ]] && ok "scoped key minted" || { no "mint failed"; }
S=(-H "Authorization: Bearer $SK")
# drive a connect: returns HTTP status of the callback. $1=btcpay token
drive_connect(){
local tok="$1"
local st; st="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
[[ -n "$st" && "$st" != null ]] || { echo "000"; return; }
curl -sS -o /tmp/gate-cb.out -w '%{http_code}' -X POST "$BASE/v1/btcpay/authorize/callback?state=$st" \
--data-urlencode "apiKey=$tok"
}
echo "== 3. DENY: scoped connect to a no-wallet store (undetermined → fail-closed) =="
code="$(drive_connect "$GATE_TOK_NOWALLET")"
if [[ "$code" == 400 ]]; then
ok "callback rejected with HTTP 400"
grep -qi "non-mainnet" /tmp/gate-cb.out && ok "rejection cites the non-mainnet restriction" || no "rejection message unexpected: $(cat /tmp/gate-cb.out | head -c200)"
else
no "expected 400, got $code ($(cat /tmp/gate-cb.out | head -c200))"
fi
[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status" | jq -r '.connected')" == "false" ]] && ok "no provider persisted on deny" || no "a provider was persisted despite deny!"
# The GET callback form (what the agent docs show) must ALSO deny with a 4xx,
# not a 200 error page (regression guard for the GET-handler status fix).
gst="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')"
gcode="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/v1/btcpay/authorize/callback?state=$gst&apiKey=$GATE_TOK_NOWALLET")"
[[ "$gcode" == 4* ]] && ok "GET callback form denies with HTTP $gcode (not a 200 error page)" || no "GET callback returned $gcode (expected 4xx)"
echo "== 4. ALLOW: scoped connect to the regtest store (bcrt1 → non-mainnet) =="
code="$(drive_connect "$GATE_TOK_REGTEST")"
if [[ "$code" == 200 ]]; then ok "callback succeeded with HTTP 200"; else no "expected 200, got $code ($(cat /tmp/gate-cb.out | head -c300))"; fi
ST_JSON="$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status")"
[[ "$(echo "$ST_JSON" | jq -r '.connected')" == "true" ]] && ok "provider persisted" || no "provider not persisted on allow"
[[ "$(echo "$ST_JSON" | jq -r '.store_id')" == "$KEYSAT_LIVE_BTCPAY_STORE_REGTEST" ]] && ok "persisted store is the regtest store" || no "wrong store persisted: $(echo "$ST_JSON" | jq -c '.store_id')"
echo "== 5. scoped connect is audited with the resolved network =="
AUD="$(curl -sS "${M[@]}" "$BASE/v1/admin/audit?action=payment_provider.connect_scoped" | jq -c '.entries[0] // empty')"
echo " audit: $AUD"
echo "$AUD" | grep -qi "regtest" && ok "audit row records network=regtest" || no "audit row missing/!regtest"
echo
echo "==== RESULT: $pass passed, $fail failed ===="
[[ $fail == 0 ]] || exit 1
+42
View File
@@ -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
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# prepare.sh — bootstrap a clean Debian/Ubuntu box to build the Keysat s9pk.
#
# Start9's build-from-source flow clones this repo onto a fresh box, then runs a
# bootstrap script followed by `make`. This installs every HOST prerequisite that
# `make` needs (npm → wrapper bundle; start-cli s9pk pack → Docker image build).
# It mirrors the official StartOS 0.4.0.x environment-setup page:
# https://docs.start9.com/packaging/0.4.0.x/environment-setup.html
#
# Note: `prepare.sh` is a 0.3.5.x community-submission convention; the 0.4.x docs
# don't mention it, so the 0.4.x submission flow may not invoke it. This file is
# still the runnable, single-source record of what a clean build box needs.
#
# The Rust daemon is NOT built on the host — it compiles inside this package's
# Dockerfile (FROM rust:1.88-slim-bookworm), so no rustup/cargo is installed here.
#
# Idempotent: re-running skips tools already present. Targets apt-based distros.
set -euo pipefail
# Use sudo only when not already root (Start9's build box may run either way).
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
NODE_MAJOR=22
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
# --- apt prerequisites -------------------------------------------------------
# build-essential → make/gcc; squashfs-tools(-ng) → start-cli s9pk packing;
# jq → used by s9pk.mk's build summary; git → the s9pk embeds the commit hash.
log "Installing apt prerequisites (make, jq, git, squashfs, curl)"
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
git \
jq \
squashfs-tools \
squashfs-tools-ng
# --- Node.js 22 --------------------------------------------------------------
# The wrapper (@start9labs/start-sdk + @vercel/ncc bundle) needs Node 22. We
# install it system-wide via NodeSource so it's on PATH for the non-interactive
# `make` that follows (the docs' nvm method would need a shell rc sourced first).
if command -v node >/dev/null 2>&1 && node -v | grep -q "^v${NODE_MAJOR}\."; then
log "Node.js $(node -v) already present — skipping"
else
log "Installing Node.js ${NODE_MAJOR} (NodeSource)"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | $SUDO -E bash -
$SUDO apt-get install -y nodejs
fi
# --- Docker (+ buildx) -------------------------------------------------------
# start-cli s9pk pack builds the daemon image from the Dockerfile via Docker
# buildx. get.docker.com is Docker's official installer and bundles the buildx
# plugin.
if command -v docker >/dev/null 2>&1; then
log "Docker $(docker --version | awk '{print $3}' | tr -d ,) already present — skipping"
else
log "Installing Docker (official get.docker.com installer)"
curl -fsSL https://get.docker.com | $SUDO sh
fi
# Cross-architecture builds (`make universal` / `make arm` on an x86 host) need
# QEMU binfmt handlers registered. Best-effort: requires the Docker daemon to be
# running. Harmless to skip if you only build the host's native arch (`make x86`).
if $SUDO docker info >/dev/null 2>&1; then
log "Registering QEMU binfmt handlers for cross-arch builds (best-effort)"
$SUDO docker run --privileged --rm tonistiigi/binfmt --install all ||
echo " (binfmt registration skipped — only native-arch builds will work)"
else
echo " (Docker daemon not reachable yet — skipping binfmt setup; start Docker"
echo " and re-run this script if you need cross-arch/universal builds.)"
fi
# --- start-cli (StartOS 0.4.x SDK) -------------------------------------------
# Official installer: fetches the latest prebuilt binary into ~/.local/bin.
# For a reproducible build, pin a release instead, e.g.:
# curl -fsSLo ~/.local/bin/start-cli \
# https://github.com/Start9Labs/start-os/releases/download/<tag>/start-cli_x86_64-linux
# chmod +x ~/.local/bin/start-cli
if command -v start-cli >/dev/null 2>&1; then
log "start-cli $(start-cli --version 2>/dev/null | awk '{print $2}') already present — skipping"
else
log "Installing start-cli (StartOS 0.4.x SDK)"
curl -fsSL https://start9.com/start-cli/install.sh | sh
fi
# The installer drops start-cli in ~/.local/bin and appends it to your shell rc.
# Persist it to .profile for future shells (only if not already recorded, so
# re-runs don't pile up duplicates), and export it for the rest of THIS session.
if ! grep -qsF '.local/bin' "${HOME}/.profile"; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >>"${HOME}/.profile"
fi
export PATH="${HOME}/.local/bin:${PATH}"
log "Done. Initialise your signing key with 'start-cli init', then run 'make' (or 'make x86')."
+14 -19
View File
@@ -6,12 +6,10 @@
// writes it to /data/keysat-license.txt, and swaps its runtime tier
// to Licensed without a restart.
//
// In permissive builds (the default for local `make x86`) the daemon
// will start regardless and this action just records the tier. In
// enforce builds (compiled with KEYSAT_LICENSE_ENFORCE=1, used for
// the marketplace .s9pk) the daemon refuses to start without a valid
// license, and this action is the bootstrap path: install Keysat,
// run this action with your activation key, then start the service.
// The daemon always boots regardless of license state (enforce mode was
// retired — see license_self.rs::check_at_boot). With no valid self-license
// it runs at the free Creator tier with Creator caps; this action records
// the license and lifts those caps without a restart.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
@@ -36,9 +34,9 @@ export const activateLicense = sdk.Action.withInput(
async () => ({
name: 'Activate Keysat license',
description:
'Activate this Keysat install. Required for marketplace builds; ' +
'optional but recommended for source-built dev installs (signals support, ' +
'and lets the admin UI show your tier).',
'Activate this Keysat install. Optional — Keysat runs at the free ' +
'Creator tier without it. Activating lifts the Creator caps, unlocks ' +
'recurring billing + Zaprite payments, and shows your tier in the admin UI.',
warning: null,
allowedStatuses: 'only-running',
group: 'License',
@@ -80,7 +78,6 @@ export const activateLicense = sdk.Action.withInput(
product_id?: string
expires_at?: number
entitlements?: string[]
mode: string
}
message: string
}
@@ -132,7 +129,6 @@ export const showLicenseStatus = sdk.Action.withoutInput(
expires_at?: number
entitlements?: string[]
reason?: string
mode: string
}
if (j.tier === 'licensed') {
@@ -146,20 +142,19 @@ export const showLicenseStatus = sdk.Action.withoutInput(
message:
`License id: ${j.license_id}\n` +
`Expires: ${exp}\n` +
`Entitlements: ${ents}\n` +
`Build mode: ${j.mode}`,
`Entitlements: ${ents}`,
result: null,
}
} else {
return {
version: '1',
title: 'Unlicensed',
title: 'Creator (free tier)',
message:
`Reason: ${j.reason || 'no license configured'}\n` +
`Build mode: ${j.mode}\n\n` +
(j.mode === 'enforce'
? 'This is a marketplace build that requires a valid license to run. Use the "Activate Keysat license" action to bootstrap.'
: 'This is a permissive (dev) build. The daemon will keep running. Activate a license to see your tier reflected here.'),
`This install is running at the free Creator tier.\n` +
`Reason: ${j.reason || 'no license configured'}\n\n` +
`Creator caps: 5 products, 5 policies per product, 10 active ` +
`discount codes. Activating a license lifts these caps and unlocks ` +
`recurring billing + Zaprite payments (the "Activate Keysat license" action).`,
result: null,
}
}
+9 -6
View File
@@ -1,8 +1,9 @@
// Action: reveal the auto-generated admin API key.
//
// The operator rarely needs this — every other action in StartOS already
// carries the key for them — but it's useful if they want to script against
// the admin HTTP API directly.
// The operator needs this on first install to sign into the admin web UI
// (until they set a web UI password); afterward it's mainly for scripting
// the admin HTTP API directly, since every other StartOS action already
// carries the key for them.
//
// The BTCPay webhook secret used to live in the StartOS store; it now lives
// inside the daemon's own SQLite database, generated automatically during
@@ -35,9 +36,11 @@ export const showCredentials = sdk.Action.withoutInput(
version: '1',
title: 'Admin API key',
message:
`Used as 'Authorization: Bearer <key>' against /v1/admin/*. All ` +
`StartOS actions already supply this for you — only export it if ` +
`you intend to script against the admin API from outside the box.`,
`This is your admin API key — the 'Authorization: Bearer <key>' ` +
`credential for /v1/admin/*. Use it to sign into the admin web UI on ` +
`first install (until you set a web UI password). Every StartOS action ` +
`already supplies it for you, so you only need to export it to script ` +
`the admin API yourself.`,
result: {
type: 'single',
value: storeData.admin_api_key,
+5 -3
View File
@@ -14,14 +14,16 @@ import { short, long } from './i18n'
export const manifest = setupManifest({
id: 'keysat',
title: 'Keysat Licensing',
license: 'LicenseRef-Proprietary',
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
license: 'LicenseRef-Keysat-1.0',
// packageRepo (the s9pk wrapper source) and upstreamRepo (the daemon source)
// are the same URL: the StartOS wrapper and the Rust daemon share one monorepo.
packageRepo: 'https://github.com/keysat-xyz/keysat',
upstreamRepo: 'https://github.com/keysat-xyz/keysat',
marketingUrl: 'https://keysat.xyz',
donationUrl: null,
docsUrls: [
'https://github.com/keysat-xyz/keysat/blob/main/README.md',
'https://github.com/keysat-xyz/keysat/blob/main/docs/INTEGRATION.md',
'https://github.com/keysat-xyz/keysat/blob/main/KEYSAT_INTEGRATION.md',
],
description: { short, long },
// A single data volume holds the SQLite database (which in turn holds the
+26 -25
View File
@@ -1,27 +1,8 @@
// Draft of the v0.2.0 milestone version entry.
//
// NOT YET WIRED INTO `versions/index.ts` — this file sits ready to
// use when we cut v0.2.0:0 from the alpha-iteration line. To
// activate:
// 1. In `versions/index.ts`:
// import { v0_2_0 } from './v0.2.0'
// export const versions = VersionGraph.of({
// current: v0_2_0,
// other: [v0_1_0], // ← so installs on 0.1.0:N can upgrade
// })
// 2. Build the .s9pk (`make x86`).
// 3. Publish via `~/.keysat/publish.sh` (the version-changed gate
// will fire because `0.2.0:0` differs from the recorded
// `0.1.0:N`).
//
// Why this draft exists separately:
// - The cut is an irreversible release decision for already-installed
// operators (downgrade paths exist in StartOS but they're sticky).
// - Wiring it in changes how StartOS computes the upgrade dialog
// shown to operators on registry refresh — best to QA the
// release-notes content in this file before flipping the switch.
// - Lets us write the v0.2.0 release notes carefully and then ship
// them all at once, rather than amending mid-build.
// The v0.2.0 milestone version entry — the current, active version on
// the v0.2 line. Wired into `versions/index.ts` as `current: v0_2_0`,
// with `v0_1_0` in `other` so installs on 0.1.0:N can upgrade. Routine
// wrapper updates bump the downstream revision here (`0.2.0:N`) before
// each build/publish; see startos-packaging.md.
//
// Version-string format reminder: ExVer is `<upstream>:<downstream>`.
// The `<upstream>` bump from 0.1.0 → 0.2.0 marks the milestone; the
@@ -58,6 +39,26 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
"0.2.0:62 — **Escape single quotes on the buyer-facing buy page.** The public buy page (`/buy/:slug`) carried its own HTML escaper that omitted the single-quote (`'`) escape the canonical escaper applies, so operator-controlled text rendered into HTML attributes (product name, description, discount code, operator name) was under-escaped. Replaced the forked escaper with the canonical implementation (which escapes `'` as `&#39;` alongside `&<>\"`) and added a unit test covering the single quote. No schema change, no SDK change — straight drop-in over :61.",
'',
"0.2.0:61 — **Security hardening for self-license tier enforcement.** The daemon now re-verifies its own self-license against the signed key on every hourly tier refresh, not only at boot. Issuer-applied tier changes — downgrade, suspension, revocation, and the license's own expiry — now take effect on a running daemon within the hour instead of waiting for the next restart. Master and honest downstream instances behave exactly as before. No schema change, no SDK change — straight drop-in over :60.",
'',
'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:55 — **Scoped API keys, an advisory settle-amount tripwire, and multi-arch packaging.** Three things land over :54, with no schema migration (highest is still 0022) — straight drop-in. **(1) Scoped admin API keys.** 58 admin endpoints move from the blanket `require_admin` gate to role-scoped `require_scope` checks, so an operator can mint reduced-privilege keys (for example, read-only access to dashboards and licenses) instead of handing out the master key; 12 sensitive endpoints stay master-only (issuer key, provider connect/disconnect, set-password, API-key CRUD, db-info, operator-name, per-license tier change). The master admin key keeps full access, so existing automation is unaffected. **(2) Advisory settle-amount tripwire** — the follow-up flagged in :54. On settle, `audit_settle_amount` (shared by the webhook and reconcile issue paths) compares the provider-reported paid amount against what was invoiced; on drift it WARN-logs and writes an `invoice.amount_mismatch` audit row, then issues anyway. It is an advisory signal, not a payment gate (a hard gate would fight BTCPay payment tolerance). SAT-denominated invoices only; fiat-subscription renewals and amount-less snapshots are skipped so there are no false positives. **(3) StartOS packaging and multi-arch.** The package now ships as a single universal s9pk built for both `x86_64` and `aarch64` (previously x86-only), so it installs on ARM StartOS hardware. Adds the required `instructions.md`, fixes two dead manifest links (`packageRepo`, `docsUrls`), and clears stale references to the long-retired license enforce mode from the Activate-License and Show-Credentials actions (the daemon always boots at the free Creator tier; activating a license lifts the caps). Daemon test suite is at 54 api tests, up from 47. No SDK change.',
'',
'0.2.0:54 — **Security: settle webhooks are now confirmed against the provider before a license is issued.** Previously the settle handler trusted the webhook body\'s claim alone. BTCPay webhooks are HMAC-signed so a forgery there is infeasible, but **Zaprite webhooks carry no signature** — so a forged `order.change`/`status=PAID` POST containing a buyer-visible Zaprite order id could mint a fully-signed license without any payment (the `externalUniqId` "trust anchor" the code comments described was never actually checked on the inbound path). Fixed in `api/webhook.rs::handle_inner`: on any settle event the daemon now re-fetches the authoritative status from the provider\'s own API (`get_invoice_status`) and requires it to actually be `Settled` before persisting the paid status or taking ANY settle-derived action — license issuance, tier-change application, or subscription renewal (the confirmation gate sits ahead of all three). If the provider\'s API is unreachable the handler acks `200` WITHOUT issuing rather than erroring, so a transient provider outage can\'t turn every in-flight webhook into a retry storm; the existing 60-second reconcile loop re-confirms and issues on its next tick (fail-closed on issuance). This only affects operators who enabled the optional Zaprite provider; BTCPay-only operators were never exposed. No schema change, no SDK change — straight drop-in over :53. **Known follow-up**: the confirmation is a binary settled/not-settled check; a literal paid-amount/currency comparison (to reject a provider-reported underpayment) is not yet wired and is tracked separately. Internally this release also adds the first integration-test seam for the real purchase/settle path (`AppState::provider_override`), bringing the daemon test suite to 47 passing with the prior 3 known-failing payment tests resolved.',
'',
'0.2.0:53 — **Fix the ambiguous-column bug that broke every paid purchase on :52.** The `:52` merchant-profile model introduced `get_merchant_profile_for_product`, which selects the shared `MERCHANT_PROFILE_COLS` column list (a bare `id, name, …`) while JOINing `products` — but `products` also has an `id`, so SQLite raised `ambiguous column name: id` on every execution. That function runs on every purchase, so **every paid purchase on :52 returned HTTP 500**. Fixed in `db/repo.rs` by replacing the JOIN with an equivalent correlated subquery, keeping `merchant_profiles` the only table in FROM; NULL/missing `merchant_profile_id` behavior is unchanged (no row → caller falls back to the default profile). Also from the same verification pass: added `merchant_profile_provider_resolution_queries_round_trip` covering the previously untested runtime-prepared resolution / CRUD / preference queries, repaired three test call sites for the new `create_invoice` / `create_subscription` params, captured the response body in the `paid_purchase` status assertion, aligned the manifest license to `LicenseRef-Keysat-1.0`, and dropped an unused import. No schema change, no SDK change — straight drop-in over :52.',
'',
'0.2.0:52 — **Multi-merchant-profile + multi-provider payment model.** Drops the singleton-config-table assumption that one Keysat instance equals one business. Operators on Pro/Patron tier can now run multiple businesses from a single Keysat box: each business is a "merchant profile" with its own brand, post-purchase redirect URL, and a set of payment providers (BTCPay + Zaprite) that settle to that business\'s accounts. Products attach to a merchant profile; the buyer sees the profile\'s brand at checkout and the eventual rail-picker (UI follow-up) routes the buyer\'s payment-method choice to the right provider. **One-way DB migration** — migration 0020 creates `merchant_profiles` + `payment_providers` + `merchant_profile_rail_preferences`, ports the existing singleton `btcpay_config` / `zaprite_config` / `active_payment_provider` setting into the new tables (one auto-created default profile holding everything), then drops the old tables. Migrations 0021 + 0022 add `invoices.payment_provider_id` (so reconciler / tipping / capture know which provider settled each invoice) and a `merchant_profile_id` column on `btcpay_authorize_state` (so BTCPay\'s OAuth CSRF state can round-trip the operator\'s profile pick). **Subscriptions snapshot** both `merchant_profile_id` and `payment_provider_id` at creation, so editing a product\'s profile attachment never redirects existing buyers mid-cycle. **Webhook URLs** are now path-keyed: `/v1/{kind}/webhook/{provider-id}` — each profile\'s provider has its own isolated webhook receiver. Back-compat: the legacy `/v1/{kind}/webhook` URL still routes to the default profile\'s provider so any in-flight deliveries still settle. **Tier-gate**: Creator tier gets 1 profile (the auto-created default); Pro/Patron get unlimited via the new `unlimited_merchant_profiles` entitlement. **POST-MIGRATION MANUAL STEP for the master operator (you)**: after this version installs, your Zaprite webhook is still registered at `https://licensing.keysat.xyz/v1/zaprite/webhook` (the legacy URL). It keeps working via the back-compat fallback, but for proper per-provider isolation, either (a) open the Zaprite sandbox dashboard → Webhooks → edit the URL to include the new provider id shown in the Merchant Profiles UI, or (b) click Disconnect + Reconnect Zaprite in the new Merchant Profiles UI to have Keysat re-register a fresh webhook at the path-keyed URL. **WHAT THIS RELEASE DOES NOT YET INCLUDE** (UI follow-ups): the buy-page rail picker (today the buyer\'s checkout uses the first rail the profile\'s providers serve — fine for single-rail profiles), the product-edit-page merchant-profile picker (new products always go to the default profile until that UI ships), per-profile SMTP override form (the schema fields are in place for the keysat-smtp-emails plan), and rail-preference editing UI (only matters when 2 providers on the same profile both serve the same rail — operators can set them via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` directly). **Entitlement note**: master Keysat\'s Pro and Patron policies need `unlimited_merchant_profiles` added to their entitlement JSON for Pro/Patron customers to actually be able to create multiple profiles — purely a data action on the master keysat.xyz admin UI, no code change.',
'',
'0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.',
@@ -539,7 +540,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:52',
version: '0.2.0:62',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under