Compare commits

...

46 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
Grant 8bf3d646ab v0.2.0:52 — multi-merchant-profile + multi-provider payment model
Final cut of the multi-merchant-profile work. Adds the Merchant Profiles
admin UI section (list/create/edit/delete profiles + per-profile Connect
BTCPay / Connect Zaprite), bumps the version, and writes the comprehensive
release notes flagging the one-way migration and the master-operator
post-migration manual step (update the Zaprite webhook URL to the new
path-keyed form, or click Disconnect + Reconnect in the new UI to have
Keysat re-register at the right URL automatically).

web/index.html
  New sidebar nav entry + ROUTE_META + routes['merchant-profiles']:
    - Lists every profile with: default badge, support email, brand
      color preview, post-purchase redirect URL summary, attached
      payment-providers table (kind / label / served rails / disconnect),
      and Connect BTCPay / Connect Zaprite buttons for whichever kinds
      aren't already attached.
    - Set-default button on non-default profiles.
    - Delete button on non-default profiles (the backend refuses if any
      product or active subscription is still attached).
    - Create modal: name, support URL, support email, post-purchase
      redirect URL (with {invoice_id} substitution), brand color picker.
    - Edit modal: same fields, populated from the profile row.
    - Connect BTCPay opens the OAuth authorize URL in a new tab with the
      merchant_profile_id baked into the CSRF state token (so the callback
      knows which profile to attach the new provider row to).
    - Connect Zaprite shows a small modal for the API key (+ optional
      base_url for sandbox orgs); on success surfaces the new
      provider-keyed webhook URL the operator pastes into Zaprite's
      dashboard.

  What this UI does NOT cover (deferred follow-ups, called out in the
  release notes):
    - Buy-page rail picker (defaults to first available rail today).
    - Product-edit-page merchant-profile picker (new products always
      attach to the default profile until the picker ships).
    - Per-profile SMTP override form (the schema fields are in place,
      consumed by the keysat-smtp-emails plan when it lands).
    - Rail-preference editing UI (only matters when 2 providers on the
      same profile both serve the same rail — settable today via
      `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail`).

startos/versions/v0.2.0.ts
  Bumps to 0.2.0:52 with a comprehensive release note describing the
  one-way migration, the post-migration manual Zaprite-webhook-URL step
  for the master operator (you), the new tier-cap (unlimited_merchant_
  profiles entitlement), and the four UI follow-ups deferred to later
  releases.

Build: cargo check passes. Two warnings remaining — both expected:
  - recover.rs unused-import (pre-existing, unrelated)
  - SETTING_ACTIVE_PROVIDER inside the deprecated shim's own pre-
    migration fallback branch

The shipped feature set:
  - Migrations 0020 + 0021 + 0022 (one-way data port + invoice→provider
    link + BTCPay-authorize-state profile column).
  - Merchant profile + payment provider data model + repo helpers.
  - Rail enum + served_rails() trait method + build_provider factory.
  - AppState resolution layer (per-product, per-rail provider lookup
    with explicit-preference → unique-candidate → deterministic-earliest-
    connected fallback).
  - Every backend call site (purchase, subscriptions, reconcile,
    upgrade, tipping, capture, auto-charge, boot loader) ported.
  - BTCPay + Zaprite connect/disconnect/status rewritten for the new
    model (per-profile attachment + path-keyed webhook URLs).
  - Webhook router with path-keyed deliveries + legacy back-compat.
  - Thank-you page provider-kind copy reads the invoice's recorded
    provider.
  - Merchant profile CRUD + rail preference CRUD admin endpoints.
  - Tier-cap wiring (enforce_merchant_profile_cap).
  - Admin UI Merchant Profiles section (this commit).
  - Comprehensive :52 release notes.

Master Keysat self-license note: the new `unlimited_merchant_profiles`
entitlement needs to be added to the Pro and Patron policies on the
master keysat.xyz admin UI for Pro/Patron customers to be able to
create multiple profiles. Pure data action, no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 07:35:22 -05:00
Grant 89f1b89705 WIP — merchant profile CRUD endpoints + tier-cap wire-up (part 4)
Backend is now feature-complete for :52. Admin UI still has to consume
these endpoints (part 5) but every operation the UI needs has a
working API surface behind it.

api/merchant_profiles.rs (new module)
  Axum handlers wrapping the merchant_profiles::* business-logic helpers
  and the rail-preference repo helpers. Each endpoint writes an audit
  entry so the operator can see every profile/rail-preference change
  in the audit log.

    GET    /v1/admin/merchant-profiles                                    list + summarize
    POST   /v1/admin/merchant-profiles                                    create (tier-gated)
    GET    /v1/admin/merchant-profiles/:id                                detail + providers + rail prefs + counts
    PATCH  /v1/admin/merchant-profiles/:id                                partial update
    DELETE /v1/admin/merchant-profiles/:id                                refuses if attached
    POST   /v1/admin/merchant-profiles/:id/set-default                    transactional flip
    PUT    /v1/admin/merchant-profiles/:id/rail-preferences/:rail         validates + persists
    DELETE /v1/admin/merchant-profiles/:id/rail-preferences/:rail         clears the override

  set_rail_preference validates THREE things before persisting: rail
  name is one of lightning/onchain/card; the provider exists; the
  provider is attached to THIS profile; AND it serves this rail. So
  the operator can't pin "Card" to a BTCPay row, and can't pin a
  provider that belongs to a different profile.

  list/get redact SMTP password (smtp_configured: bool is enough for
  the UI to render "configured/not configured" status; the actual
  password stays write-only). The edit form submits a new password
  only when the operator explicitly rotates it.

api/tier.rs
  New enforce_merchant_profile_cap helper. Refuses with HTTP 402
  AppError::PaymentRequired when a Creator-tier operator already has
  one profile (the default) and the self-license lacks the new
  `unlimited_merchant_profiles` entitlement. Same shape as the
  existing enforce_product_cap / enforce_policy_cap helpers — the
  admin UI's existing tier-cap modal renders the upgrade CTA from
  the upgrade_url field.

  Note: master Keysat's Pro and Patron policies need
  `unlimited_merchant_profiles` added to their entitlement JSON as a
  separate admin action on the master keysat.xyz instance — purely
  data, no code change. Master operator self-license must be re-
  issued (or naturally renewed) to pick up the new entitlement.

merchant_profiles.rs
  create() now calls enforce_merchant_profile_cap before INSERT.
  Replaces the TODO comment from part 1.

api/mod.rs
  Registers the merchant_profiles module and wires the routes above.

Build: cargo check passes. Two warnings remaining — both expected:
  - recover.rs unused-import (pre-existing, unrelated)
  - SETTING_ACTIVE_PROVIDER inside the shim's own pre-migration
    fallback branch

Backend status: every multi-provider story (purchase routing,
subscription snapshot, webhook delivery, connect/disconnect, profile
CRUD, tier gating) is now wired to the new schema. Only the admin UI
+ a version bump remain.

What's left for :52:
  - Admin UI in web/index.html — Merchant Profiles section, product
    picker, buy-page brand block + rail picker. Roughly 600-1000 lines
    of HTML/CSS/JS consuming the new endpoints. Largest single
    remaining piece.
  - Version bump to :52 + release notes flagging the one-way migration
    + the post-migration manual Zaprite-webhook-URL update.
  - End-to-end sandbox test against two profiles + two Zaprite orgs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:48:54 -05:00
Grant 9df1908328 WIP — BTCPay connect rewrite + webhook URL refactor + thank-you fix (part 3b)
Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.

migrations/0022_btcpay_state_profile.sql (new)
  Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
  the BTCPay OAuth state token can round-trip the operator's profile
  pick between start_connect and the callback. Without this, multi-
  profile operators couldn't authorize a SECOND BTCPay store onto a
  non-default profile.

btcpay/config.rs
  record_authorize_state takes merchant_profile_id; consume_authorize_state
  now returns Option<String> so the callback knows which profile to
  attach the new provider row to.

api/btcpay_authorize.rs (full rewrite)
  start_connect accepts an optional merchant_profile_id (defaulting to
  the default profile), refuses if that profile already has a BTCPay
  provider attached (unique-index-friendly 409 message), and records
  the profile id on the CSRF state token. The OAuth round-trip carries
  the profile id back via the state token, not via a query param —
  state-token-by-row is more robust than depending on BTCPay preserving
  redirect-URL query params during the consent dance.

  finish_connect (the callback's inner path):
    - Pre-generates the payment_providers row id so it can be baked into
      the BTCPay-side webhook callback URL.
    - The webhook URL we register with BTCPay is now path-keyed:
      /v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
      isolated deliveries.
    - INSERTs into payment_providers (kind='btcpay', api_key, base_url,
      webhook_id, webhook_secret, store_id, attached to the chosen
      profile) instead of upserting the singleton btcpay_config row.
    - Populates the back-compat state.payment singleton ONLY when this
      is the first provider on the default profile (so the few remaining
      legacy state.payment_provider() callers still work without a
      daemon restart).

  disconnect accepts an optional provider_id; defaults to "the BTCPay
  provider on the default profile" for back-compat with the existing
  admin UI's single Disconnect button. Best-effort BTCPay-side webhook
  + API key revocation unchanged. DELETE FROM payment_providers WHERE
  id = ? instead of clearing btcpay_config.

  status + payment_methods report on the default-profile BTCPay row for
  the legacy admin UI. Multi-profile operators will use the new
  /v1/admin/merchant-profiles endpoints (part 4).

api/webhook.rs
  Split into two entry points:
    - handle_for_provider — the new path-keyed shape
      (`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
      via state.payment_provider_by_id, validates the payload against
      THAT specific provider's secret, then runs the inner pipeline.
    - handle — back-compat for the bare /v1/{kind}/webhook path. Routes
      to whichever provider is on the default profile. Kept so any
      in-flight pre-:52 webhook delivery or admin misconfiguration
      doesn't silently drop on the floor.
  Both share an extracted handle_inner that does the actual settle /
  expire / refund processing.

api/mod.rs
  Route registrations:
    - Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
    - Removes the legacy /v1/admin/payment-provider/activate route
      (the shim function is gone).

  Thank-you page provider-kind lookup ports from the deprecated
  read_active_provider_preference to: invoice.payment_provider_id ->
  payment_providers.kind -> ProviderKind. Falls back to the default
  profile's first provider if the invoice predates migration 0021.

api/payment_provider.rs
  Reduced to just the back-compat status endpoint. The activate
  endpoint is removed entirely — there's no "active" preference to
  flip in the merchant-profile model. Status returns the same
  btcpay_configured / zaprite_configured / active shape the existing
  admin UI consumes, plus a new providers[] array for callers that
  want the full picture.

Build: cargo check passes. Only two warnings remaining — both
expected:
  - recover.rs unused-import (pre-existing, unrelated)
  - SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
    branch in read_active_provider_preference that runs during the
    pre-:52 upgrade window before migration 0020 has dropped the
    settings row)

What's left for :52:
  - New admin endpoints for merchant-profile + rail-preference CRUD
  - Admin UI in web/index.html (biggest remaining chunk — Merchant
    Profiles section + product picker + buy-page brand block +
    rail picker)
  - Tier-cap wire-up for unlimited_merchant_profiles
  - Version bump + release notes + sandbox test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:43 -05:00
Grant cf251fc63f WIP — rewrite Zaprite connect/disconnect/status for merchant profiles (part 3a)
Replaces the singleton-config-row implementation in api/zaprite_authorize.rs
with the new payment_providers + merchant_profiles model. The connect
flow now takes an optional `merchant_profile_id` (default = the auto-
created default profile) and INSERTs a row in `payment_providers`
instead of upserting the singleton `zaprite_config` table. Operators on
Pro/Patron can pass a non-default profile id to set up per-business
Zaprite orgs side-by-side.

Webhook URLs returned to the operator now include the provider id —
`/v1/zaprite/webhook/{provider-id}` instead of the legacy bare
`/v1/zaprite/webhook` — so each profile's Zaprite org gets its own
isolated webhook receiver. The webhook router refactor that consumes
this URL shape lands in a follow-up commit (today the legacy route
still works because the path-param refactor hasn't happened yet — this
commit just changes what URL the connect endpoint reports back).

Disconnect now takes an optional `provider_id` body field. When NULL,
falls back to "the Zaprite provider on the default profile" for
back-compat with the existing single-profile admin UI's disconnect
button. Multi-profile operators name the specific provider via the
new merchant-profile-scoped admin endpoints (landing in part 4).

Status endpoint similarly reports on the default profile's Zaprite
attachment for the existing admin UI's payment-providers card.

Removed the `write_active_provider_preference` call (deprecated no-op
in the new model — providers aren't "active," they attach to profiles
and are looked up per-product). Removed the `state.set_payment_provider`
call EXCEPT when this is the very first provider on the default
profile — in that case we populate the back-compat singleton so the
small number of remaining state.payment_provider() callers (currently
just the thank-you page) keep working without a daemon restart.

Build: cargo check passes. Eight remaining deprecation warnings in
api/btcpay_authorize.rs (same rewrite due in part 3b),
api/payment_provider.rs (the legacy activate endpoint — to be
replaced), and api/mod.rs (thank-you page provider lookup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:28:36 -05:00
Grant 7c4dfbacd2 WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.

migrations/0021_invoice_provider_link.sql (new)
  Adds invoices.payment_provider_id (nullable FK), backfills existing
  pending/settled rows to the earliest-connected provider on the default
  profile. Additive — no drops, no removals. Companion to 0020 from the
  foundation commit.

models.rs
  Invoice gains payment_provider_id: Option<String>.

db/repo.rs
  row_to_invoice reads the new column. All three invoice SELECTs include
  it. create_invoice + create_invoice_with_currency take a new optional
  payment_provider_id parameter and persist it on INSERT.

subscriptions.rs
  Subscription struct gains merchant_profile_id + payment_provider_id
  (snapshotted on create). SUB_COLS + row_to_subscription + the manual
  SELECT in find_lapsing_subscriptions all updated. create_subscription
  accepts both new fields and writes them on the INSERT row.

  renew_one — reads the sub's payment_provider_id snapshot and resolves
  the provider via state.payment_provider_by_id(). Falls back to the
  legacy state.payment_provider() for any subs created pre-:52 that
  the migration backfill missed.

  capture_zaprite_payment_profile — uses the INVOICE's provider, not
  "the active one." Saved-profile ids are scoped per Zaprite org; using
  the wrong provider would fail the lookup.

  try_auto_charge_zaprite — uses the sub's snapshotted provider (same
  rationale).

reconcile.rs
  Per-invoice provider lookup. Each pending invoice is reconciled
  against state.payment_provider_by_id(inv.payment_provider_id), with
  graceful fallback for NULL provider ids. No more single-global-
  provider assumption.

tipping.rs
  Tip pay-out uses the provider that settled the license's purchase
  invoice (joined via licenses.invoice_id). Same rationale as the
  capture hook — the tip needs to draw from the right LN node.

api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
  Tier-change invoices ride on existing licenses. The right provider
  is whichever the license's subscription is snapshotted to (so the
  proration charge settles to the same merchant identity that collects
  renewal fees). Falls back to the invoice's recorded provider, then
  the legacy default, for licenses with no subscription or pre-
  snapshot rows.

api/purchase.rs
  StartPurchaseReq gains an optional `rail` field
  ("lightning"/"onchain"/"card") for the future buy-page multi-rail
  picker. When omitted (today's behavior), the daemon picks the first
  rail the product's merchant profile exposes — which is correct for
  single-provider operators AND back-compat for any pre-:52 client
  not yet sending the field.

  Provider resolution: product → merchant_profile → rail →
  resolve_provider_for_profile_rail. The redirect_url defaults to the
  profile's post_purchase_redirect_url (with {invoice_id} substitution)
  if set, else Keysat's own /thank-you. New invoices carry their
  provider's id via the new create_invoice_with_currency parameter.

api/webhook.rs
  issue_license_for_invoice now passes snapshot fields when calling
  subscriptions::create_subscription — both merchant_profile_id (from
  product lookup) and payment_provider_id (from the invoice row).

main.rs
  Replaces the legacy "active provider preference" boot loader with a
  default-profile-first-provider warm-up. The legacy state.payment
  singleton stays populated for back-compat with call sites that
  haven't yet migrated to the on-demand resolution path. Pre-migration
  fallback to the old singleton-config loaders preserved so the
  daemon still boots cleanly on a DB that hasn't run 0020 yet.

Remaining for part 3:
  - BTCPay + Zaprite connect flows take merchant_profile_id and
    INSERT into payment_providers (currently still write to the
    dropped singleton tables, broken post-migration).
  - api/payment_provider.rs activate endpoint becomes irrelevant in
    the new model — repurpose as list-providers, or delete.
  - Thank-you page (api/mod.rs) provider-kind lookup ports to the
    invoice's recorded provider.
  - Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
  - Admin UI for Merchant Profiles + product picker + buy-page brand
    block + rail picker.
  - Tier-cap wire-up for unlimited_merchant_profiles entitlement.
  - Version bump to :52 + release notes.

Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:26:22 -05:00
Grant 04e0dcd591 WIP — merchant profile foundation (multi-provider payment model, part 1)
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.

This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.

What landed:

migrations/0020_merchant_profiles.sql
  Full schema + data port + DROP of the singleton tables. Creates
  merchant_profiles, payment_providers (FK to profile, unique per
  (profile, kind)), merchant_profile_rail_preferences (tie-breaker
  when a profile has 2 providers serving the same rail). Adds
  merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
  to subscriptions for the snapshot-on-create semantics. Ports
  btcpay_config + zaprite_config + active_payment_provider setting
  into the new tables, then drops them. Master operator post-migration
  step: update the Zaprite webhook URL on the Zaprite dashboard to
  the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
  Zaprite in the new UI once it ships).

src/merchant_profiles.rs (new module)
  MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
  input types. Business-logic CRUD helpers: create, get, get_default,
  require_default, list, update, set_default, delete, for_product.
  Delete refuses if products or active subs are attached or if it's
  the default profile. Tier-cap check stubbed with a TODO for the
  next chunk's tier.rs wire-up.

src/db/repo.rs (+469 lines)
  Repo helpers: create/get_by_id/get_default/get_for_product/list/
  update/set_default/delete for merchant_profiles + count helpers
  for products/active_subscriptions per profile. PaymentProviderRow
  struct + create/get/list_for_profile/list_all/delete. RailPreference
  struct + list/set/clear helpers. update_merchant_profile builds a
  dynamic SET clause so partial updates don't clobber fields the
  caller didn't touch.

src/payment/mod.rs
  Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
  rails_for_kind static mapping. build_provider(row, public_base) ->
  Arc<dyn PaymentProvider> factory that dispatches on kind to construct
  a typed BtcpayProvider or ZapriteProvider from a payment_providers
  row. PaymentProvider trait gains a default served_rails() impl
  returning rails_for_kind(self.kind()).

  Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
  read_active_provider_preference + write_active_provider_preference
  stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
  thank-you page still build. read_active_provider_preference now
  reads from the new payment_providers table (returns the kind of
  the first provider attached to the default profile), falling back
  to the legacy settings-table read pre-migration. write_* is a no-op.
  Each shim has a #[deprecated] attribute so the build surfaces
  exactly which call sites still need porting (lit up in the
  follow-up cycle's TODO).

src/api/mod.rs (AppState)
  New methods alongside the existing payment_provider() shim:
    - payment_provider_by_id(id) — looks up a row, builds the provider
    - merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
    - resolve_provider_for_profile_rail(profile_id, rail) —
      preference table -> single candidate -> deterministic earliest-
      connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
    - resolve_provider_for_product_rail(product_id, rail) — convenience
      wrapping the previous two.

src/lib.rs
  Registers the new merchant_profiles module.

Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:00:00 -05:00
Grant 4cde540b60 v0.2.0:51 — Zaprite recurring polish from sandbox testing (:46-:51)
Six routine bumps land together, all driven by end-to-end sandbox testing
of the Zaprite recurring auto-charge path that shipped in :45:

:46  Provider create-invoice failures now surface the underlying cause.
     Switched user-facing format from `{e}` to `{e:#}` so the full anyhow
     chain reaches the buy page; added `tracing::error!` for symmetric
     daemon-log visibility. Without this, failed checkouts showed only
     "ZapriteProvider.create_invoice" with no clue what actually went wrong.

:47  Zaprite recurring purchases now create the contact upfront. Sandbox
     surfaced that `allowSavePaymentProfile: true` requires an explicit
     `contactId` on the order — passing only `customerData: { email }`
     returns 400. Added `client.create_contact(email, name)` + threaded
     the returned id as `contactId`. Graceful degradation: recurring +
     no buyer_email → one-shot mode with a warn log; renewals fall back
     to manual-pay.

:48  Thank-you page copy is now provider-aware. The wait-page lede
     hardcoded "Your Bitcoin payment was received" + Lightning/on-chain
     timing — wrong for Zaprite card payments. Reads SETTING_ACTIVE_PROVIDER
     and branches the copy + the JS polling-status text accordingly.

:49  Zaprite saved-profile capture: full diagnostic logging + reconciler
     path. Discovered five recurring subscriptions settled successfully
     but with NULL `zaprite_payment_profile_id`. Root cause: capture
     hook had six silent early-return paths, AND the reconciler (which
     catches missed webhooks) never called `on_invoice_settled` so subs
     created via that path never got their profile captured. Added warn
     logs on every early-return + wired capture into `reconcile.rs`'s
     post-license-issuance flow.

:50  Webhook event-type extraction probes multiple field names. Confirmed
     deliveries were arriving but all logged as "non-actionable event_type=
     " — Zaprite doesn't use the convention-suggested `event` field. Now
     probes `event` / `eventType` / `type` / `name`, first non-empty wins.
     Also widened the order-id probe to include `data.object.id`. On a
     miss, warn-logs the raw payload truncated to 2KB so the actual field
     name can be added to the probe list.

:51  Zaprite `order.change` event is now actionable. The :50 probe-fix
     surfaced that Zaprite's primary delivery shape is a generic
     `order.change` event that just says "something about this order
     changed" — the receiver has to look at `/data/status` to figure out
     what actually changed. They do NOT send the convention-suggested
     `order.paid` / `order.complete` events. Added an `order.change`
     match arm that branches on status (PAID/COMPLETE/OVERPAID →
     InvoiceSettled, EXPIRED → InvoiceExpired, INVALID/CANCELLED →
     InvoiceInvalid, in-flight states → Other). End result: webhook-
     driven settles now flip subscriptions within seconds of Zaprite's
     callback instead of waiting ~45s for the reconciler.

Net effect of the batch: the recurring auto-charge flow is now validated
end-to-end against the Zaprite sandbox. Buyers paying with a card via
Stripe-backed Zaprite trigger contact + saved-profile creation, the
webhook fires `order.change` with status PAID, Keysat captures the
saved-profile id within seconds, and the renewal worker is wired to
auto-charge subsequent cycles. Manual-pay fallback intact for buyers
who decline save-card or pay via Bitcoin/Lightning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 21:23:09 -05:00
Grant fea6995192 v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI
Two routine bumps land together in this release:

:44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and
hamburger-driven off-canvas drawer (≤720px) to the embedded
web/index.html so triage flows (status check, license lookup, revoke)
work from a phone. Tables now scroll horizontally inside their card,
tap targets bump to ~40px, stats grid collapses to 1-up, toolbar
inputs go full-width. Desktop layout unchanged. CSS + small JS toggle.

:45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap
the subscriptions.rs module comment promised but never delivered:
first-cycle invoices on recurring policies set allow_save_payment_profile,
the on-settle hook captures the resulting Zaprite paymentProfileId
into four new nullable columns on the subscriptions table (migration
0019, additive only), and the renewal worker calls
POST /v1/orders/charge against the saved profile instead of waiting
for manual pay. On charge failure (declined card, expired profile,
network) the worker logs + audits + falls through to the existing
subscription.renewal_pending event so the buyer still has a recovery
path. Two new operator webhook events: subscription.auto_charge_initiated
and subscription.auto_charge_failed. BTCPay subs and Zaprite subs
whose buyer paid with Bitcoin/Lightning or declined the save-card
prompt are untouched. NOT yet end-to-end tested against the Zaprite
sandbox — control flow follows api.zaprite.com/llms.txt but exact
failure-body shapes for declined cards aren't documented; sandbox
validation pass recommended before relying in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:20:53 -05:00
Grant c71345f002 v0.2.0:43 — BTCPay success page: return to Keysat, not StartOS
Connect now lives inside Keysat's admin UI, so the post-authorize
return target is Keysat's own tab. One-line copy in two paths.
2026-05-12 12:42:10 -05:00
Grant 17d5df72d3 v0.2.0:42 — revert implicit Patron→Pro expansion from :41
The only affected license was the operator's own pre-launch
self-license under an earlier entitlement scheme. New Patron licenses
issued from the corrected master-Keysat policy carry the right
entitlements in their signed payload. The implicit expansion was
paying ongoing complexity (magic-slug behavior, hardcoded list
divergence on rename) for a one-shot migration case.

Affected operators: re-issue + Activate Keysat license. The new key
overwrites /data/keysat-license.txt and self_tier picks up live
without a restart.
2026-05-12 12:27:18 -05:00
96 changed files with 8588 additions and 1116 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,49 @@
-- Zaprite saved-payment-profile metadata for recurring subscriptions.
--
-- Wires up the auto-charge path that the v0.2.0:1+ subscriptions
-- module comment promised but never delivered: when a buyer pays the
-- FIRST cycle of a recurring subscription via Zaprite (Stripe card),
-- Keysat asks Zaprite to save the payment profile and persists the
-- profile id here. The renewal worker then calls
-- `POST /v1/orders/charge` against the saved profile instead of
-- waiting for the buyer to manually pay each renewal.
--
-- All four columns are nullable + nothing in the existing read path
-- requires them, so this migration is a pure additive drop-in:
-- - BTCPay subscriptions stay NULL on all four (BTCPay has no
-- equivalent concept; renewals continue to require manual pay).
-- - Pre-feature Zaprite subscriptions stay NULL — the renewal
-- worker falls through to the existing "buyer pays manually"
-- branch when `zaprite_payment_profile_id IS NULL`.
-- - Zaprite subscriptions whose buyer either paid with Bitcoin/
-- Lightning instead of card, OR declined the save-card prompt,
-- also stay NULL. Same fallback.
--
-- Decisions encoded here:
-- - `zaprite_contact_id`: needed because Zaprite's order endpoint
-- doesn't surface the profile id directly. After settle we fetch
-- the contact, find the profile whose `sourceOrder.externalUniqId`
-- matches our invoice id, and persist both.
-- - `zaprite_payment_profile_method` / `expires_at`: informational
-- only — the admin UI uses them to render "card ending 4242,
-- expires 03/27" on the subscription detail. The renewal worker
-- doesn't gate on either today; if Zaprite returns expired-card
-- errors on the auto-charge we fall through to manual pay and
-- log the failure, same as any other decline.
PRAGMA foreign_keys = ON;
ALTER TABLE subscriptions
ADD COLUMN zaprite_contact_id TEXT;
ALTER TABLE subscriptions
ADD COLUMN zaprite_payment_profile_id TEXT;
ALTER TABLE subscriptions
ADD COLUMN zaprite_payment_profile_method TEXT;
ALTER TABLE subscriptions
ADD COLUMN zaprite_payment_profile_expires_at TEXT;
-- Helps the admin-UI "subs with auto-charge configured" filter and
-- any future "subs whose saved card is about to expire" sweep.
CREATE INDEX IF NOT EXISTS idx_subs_zaprite_profile
ON subscriptions(zaprite_payment_profile_id)
WHERE zaprite_payment_profile_id IS NOT NULL;
@@ -0,0 +1,242 @@
-- Multi-merchant-profile + multi-provider model.
--
-- Replaces the singleton btcpay_config + zaprite_config + SETTING_ACTIVE_PROVIDER
-- pattern with a generalized two-table model:
--
-- merchant_profiles — one row per business identity (brand, redirect,
-- optional SMTP override). Creator tier: 1 profile.
-- Pro/Patron: unlimited.
-- payment_providers — one row per configured BTCPay/Zaprite account,
-- attached to a merchant profile via FK. A profile
-- can have multiple providers (BTCPay for Bitcoin
-- AND Zaprite for card). Unique per (profile, kind).
--
-- Products and subscriptions both get a merchant_profile_id column;
-- subscriptions additionally snapshot the payment_provider_id at creation
-- so mid-cycle product edits don't redirect existing buyers to a different
-- merchant or payment account.
--
-- One-way migration: drops btcpay_config + zaprite_config + the
-- active_payment_provider setting after porting their data into the new
-- tables. The master operator (the only person running Keysat today) needs
-- one post-migration manual step: update the Zaprite webhook URL on the
-- Zaprite dashboard to the new `/v1/zaprite/webhook/{provider_id}` form,
-- or click "Reconnect Zaprite" in the new admin UI to have Keysat
-- re-register the webhook with the correct URL automatically.
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- merchant_profiles: business identity layer
-- ---------------------------------------------------------------------------
-- Each profile represents one "business" the operator is running on this
-- Keysat instance. Owns its own brand block, support contact, post-purchase
-- redirect URL, and optionally an SMTP override (paired with the
-- keysat-smtp-emails plan — the columns are added now so the SMTP work
-- layers on cleanly later without another schema migration).
--
-- Tier gating is enforced at the Rust layer (`merchant_profiles::create`
-- checks the operator's tier and refuses with AppError::TierCap if a
-- Creator already has one profile). No CHECK at the schema layer because
-- tier resolution requires reading the operator's signed license, not just
-- counting rows.
CREATE TABLE IF NOT EXISTS merchant_profiles (
id TEXT PRIMARY KEY, -- UUID v4
name TEXT NOT NULL, -- "Recaps", "Keysat"
legal_name TEXT, -- optional, for receipts/tax
support_url TEXT,
support_email TEXT,
brand_color TEXT, -- hex, e.g. '#1E3A5F'
post_purchase_redirect_url TEXT, -- NULL = Keysat's /thank-you
is_default INTEGER NOT NULL DEFAULT 0,
-- Per-profile SMTP override. NULL = inherit StartOS-level SMTP config.
-- See keysat-smtp-emails.md for the email-sending plan that consumes
-- these. Added in this migration so the SMTP plan doesn't need its
-- own migration to add per-profile branding fields.
smtp_host TEXT,
smtp_port INTEGER,
smtp_username TEXT,
smtp_password TEXT, -- TODO: encryption at rest
smtp_from_address TEXT,
smtp_from_name TEXT,
smtp_use_starttls INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (is_default IN (0, 1)),
CHECK (smtp_use_starttls IN (0, 1))
);
-- Exactly one default profile. Partial unique index enforces this without
-- needing a trigger; updates to is_default must clear the previous default
-- in the same transaction (Rust layer handles this).
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_profiles_one_default
ON merchant_profiles(is_default) WHERE is_default = 1;
-- ---------------------------------------------------------------------------
-- payment_providers: replaces btcpay_config + zaprite_config singletons
-- ---------------------------------------------------------------------------
-- One row per configured payment account. Multiple rows allowed per
-- profile, but at most one of each `kind` (no two BTCPay stores on the
-- same business — operators wanting that should split into two profiles).
CREATE TABLE IF NOT EXISTS payment_providers (
id TEXT PRIMARY KEY, -- UUID v4
merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id),
kind TEXT NOT NULL, -- 'btcpay' | 'zaprite'
label TEXT NOT NULL, -- operator-set, e.g. "Recaps BTCPay"
api_key TEXT NOT NULL,
base_url TEXT NOT NULL,
webhook_id TEXT, -- provider-side webhook id, for delete on disconnect
webhook_secret TEXT, -- BTCPay HMAC secret; NULL for Zaprite
store_id TEXT, -- BTCPay only
connected_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (kind IN ('btcpay', 'zaprite'))
);
CREATE INDEX IF NOT EXISTS idx_payment_providers_profile
ON payment_providers(merchant_profile_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_providers_profile_kind
ON payment_providers(merchant_profile_id, kind);
-- ---------------------------------------------------------------------------
-- merchant_profile_rail_preferences: tie-breaker for multi-provider profiles
-- ---------------------------------------------------------------------------
-- When a profile has 2 providers that BOTH serve the same payment rail
-- (e.g., both BTCPay and Zaprite can settle Lightning), the operator picks
-- which provider serves that rail for THIS profile here. Without an entry,
-- the routing layer picks the provider with the earliest connected_at
-- (deterministic but warns in the admin UI).
--
-- Rails-per-kind are inherent (BTCPay → Lightning + OnChain; Zaprite →
-- Card + Lightning + OnChain) — declared via the trait method
-- `served_rails()` in Rust, not stored per provider row. This table
-- is purely the ambiguity resolver.
CREATE TABLE IF NOT EXISTS merchant_profile_rail_preferences (
merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id),
rail TEXT NOT NULL, -- 'lightning' | 'onchain' | 'card'
payment_provider_id TEXT NOT NULL REFERENCES payment_providers(id),
PRIMARY KEY (merchant_profile_id, rail),
CHECK (rail IN ('lightning', 'onchain', 'card'))
);
-- ---------------------------------------------------------------------------
-- products: attach to a merchant profile
-- ---------------------------------------------------------------------------
-- Nullable during the data-port window (we set it in the UPDATE below).
-- After backfill the Rust create_product path requires it (enforced at
-- the application layer; can't add NOT NULL via ALTER on SQLite).
ALTER TABLE products
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
CREATE INDEX IF NOT EXISTS idx_products_profile
ON products(merchant_profile_id);
-- ---------------------------------------------------------------------------
-- subscriptions: snapshot profile + provider at creation
-- ---------------------------------------------------------------------------
-- The snapshot semantics matter: if an operator later edits a product to
-- attach a different profile / point at a different provider, existing
-- subscriptions keep renewing through their ORIGINAL profile + provider.
-- Re-routing an existing sub to a new merchant is a deliberate admin
-- action, never an automatic consequence of editing a product.
ALTER TABLE subscriptions
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
ALTER TABLE subscriptions
ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id);
CREATE INDEX IF NOT EXISTS idx_subs_profile
ON subscriptions(merchant_profile_id);
CREATE INDEX IF NOT EXISTS idx_subs_provider
ON subscriptions(payment_provider_id);
-- ---------------------------------------------------------------------------
-- Data port: singletons → multi-row tables
-- ---------------------------------------------------------------------------
-- 1. Create the default merchant profile. Name = the operator_name setting
-- if present; else 'Keysat'. UUID-style id via SQLite's randomblob hex.
INSERT INTO merchant_profiles(
id, name, support_url, support_email, brand_color,
post_purchase_redirect_url, is_default, created_at, updated_at
)
SELECT
lower(hex(randomblob(16))),
COALESCE((SELECT value FROM settings WHERE key = 'operator_name'), 'Keysat'),
NULL, NULL, NULL, NULL,
1,
datetime('now'),
datetime('now')
WHERE NOT EXISTS (SELECT 1 FROM merchant_profiles WHERE is_default = 1);
-- 2. Port btcpay_config (if a row exists) into payment_providers, attached
-- to the default profile.
INSERT INTO payment_providers(
id, merchant_profile_id, kind, label,
api_key, base_url, webhook_id, webhook_secret, store_id,
connected_at, updated_at
)
SELECT
lower(hex(randomblob(16))),
(SELECT id FROM merchant_profiles WHERE is_default = 1),
'btcpay',
'BTCPay (migrated)',
api_key, base_url, webhook_id, webhook_secret, store_id,
connected_at, connected_at
FROM btcpay_config;
-- 3. Port zaprite_config (if a row exists). Zaprite has no webhook_secret
-- or store_id; map both to NULL.
INSERT INTO payment_providers(
id, merchant_profile_id, kind, label,
api_key, base_url, webhook_id, webhook_secret, store_id,
connected_at, updated_at
)
SELECT
lower(hex(randomblob(16))),
(SELECT id FROM merchant_profiles WHERE is_default = 1),
'zaprite',
'Zaprite (migrated)',
api_key, base_url, webhook_id, NULL, NULL,
connected_at, connected_at
FROM zaprite_config;
-- 4. Backfill existing products to point at the default profile.
UPDATE products
SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
WHERE merchant_profile_id IS NULL;
-- 5. Backfill existing subscriptions. Pick the provider whose kind matches
-- SETTING_ACTIVE_PROVIDER if set; otherwise pick the earliest-connected
-- provider on the default profile (deterministic). Subs sitting on a
-- provider that no longer exists in payment_providers (extremely
-- unlikely — would require corrupted singleton data) are left NULL
-- and the operator's admin UI will flag them.
UPDATE subscriptions
SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1),
payment_provider_id = (
SELECT id FROM payment_providers
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
AND kind = COALESCE(
(SELECT value FROM settings WHERE key = 'active_payment_provider'),
(SELECT kind FROM payment_providers
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
ORDER BY connected_at ASC
LIMIT 1)
)
)
WHERE merchant_profile_id IS NULL OR payment_provider_id IS NULL;
-- 6. Drop the singleton tables + the active-provider setting. Now the only
-- source of truth for payment configuration is payment_providers +
-- merchant_profiles.
DROP TABLE IF EXISTS btcpay_config;
DROP TABLE IF EXISTS zaprite_config;
DELETE FROM settings WHERE key = 'active_payment_provider';
-- Note: btcpay_authorize_state stays (it's the in-flight OAuth CSRF
-- token table from migration 0002; nothing to migrate, just continues
-- to scope per-attempt). Its `state_token` rows will now carry a
-- `merchant_profile_id` in their associated payload — see the
-- btcpay_authorize.rs changes that add this column in a future
-- micro-migration if needed (today the state token is opaque to the
-- DB and the profile id is round-tripped via the OAuth state param).
@@ -0,0 +1,48 @@
-- Link invoices to the payment provider that created them.
--
-- Companion to migration 0020 (merchant profiles + multi-provider). With a
-- single active provider, the reconciler could just iterate pending
-- invoices and call `provider.get_invoice_status()` on every one — every
-- invoice was implicitly from the only configured provider. With
-- N providers per profile and M profiles per Keysat instance, that
-- assumption breaks: each invoice needs to record WHICH provider it was
-- created against so the reconciler can dispatch to the right
-- `get_invoice_status()` and the webhook handler can validate against
-- the right secret.
--
-- Additive: nullable column + index. Backfill points every pre-migration
-- invoice at whatever provider was active when 0020 ran (same heuristic
-- the subscriptions backfill uses — earliest-connected on the default
-- profile). Post-migration, `repo::create_invoice_with_currency` always
-- writes the provider id.
--
-- Why not part of 0020: 0020 has shipped to the master operator's git
-- history (commit 04e0dcd) but not yet been *applied* to any DB (the
-- master box is still on :51, which has neither migration). The append-
-- only convention for migrations is the safer pattern even when we could
-- technically still rewrite 0020 — keeps the sqlx migration hashes
-- stable for anyone who ever runs an intermediate WIP build.
PRAGMA foreign_keys = ON;
ALTER TABLE invoices
ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id);
CREATE INDEX IF NOT EXISTS idx_invoices_provider
ON invoices(payment_provider_id);
-- Backfill existing pending/settled invoices to point at the provider
-- that was active when 0020 ran. Heuristic: pick the provider on the
-- default merchant profile whose kind matches the (now-removed)
-- active_payment_provider setting if it existed pre-0020; else the
-- earliest-connected provider on the default profile. Mirrors the
-- backfill logic in 0020's UPDATE subscriptions block — same merchant
-- identity, same provider, deterministic across re-runs.
UPDATE invoices
SET payment_provider_id = (
SELECT id FROM payment_providers
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
ORDER BY connected_at ASC
LIMIT 1
)
WHERE payment_provider_id IS NULL;
@@ -0,0 +1,24 @@
-- Carry merchant_profile_id through the BTCPay OAuth round trip.
--
-- Operator hits POST /v1/admin/btcpay/connect with a merchant_profile_id,
-- daemon generates a CSRF state token and stores it; operator opens
-- BTCPay's authorize URL in their browser; BTCPay POSTs back to our
-- callback with the apiKey + the state token; daemon consumes the state
-- token and uses it to look up which merchant profile the new provider
-- row should attach to.
--
-- Pre-multi-provider, `btcpay_authorize_state` was a singleton-ish
-- pattern (one in-flight authorize at a time) and the resulting provider
-- always attached to "the singleton btcpay_config row." With multi-
-- profile, the operator might want to authorize a SECOND BTCPay store
-- onto a different profile (Pro/Patron); the state token has to
-- remember which profile they kicked off the flow from.
--
-- Additive: nullable column, NULL = "attach to the default profile"
-- (back-compat for any pre-:52 state tokens that survived a daemon
-- restart mid-flow, though the table is also pruned by timestamp).
PRAGMA foreign_keys = ON;
ALTER TABLE btcpay_authorize_state
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
@@ -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,8 +123,36 @@ 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.
@@ -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
}
}
+269 -110
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;
@@ -56,25 +61,62 @@ pub struct ConnectResp {
pub authorize_url: String,
/// CSRF state token tied to this round trip.
pub state: String,
/// Merchant profile the resulting provider row will attach to.
pub merchant_profile_id: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct StartConnectReq {
/// Which merchant profile to attach the BTCPay provider to. NULL =
/// the default profile (single-profile operators never see this).
#[serde(default)]
pub merchant_profile_id: Option<String>,
/// Operator-set label for the resulting payment_providers row. NULL =
/// auto-generated from the profile name.
#[serde(default)]
pub label: Option<String>,
}
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
/// URL for the StartOS wrapper action to open in the operator's browser.
///
/// Accepts an optional `merchant_profile_id` so Pro/Patron operators can
/// connect multiple BTCPay stores onto different profiles side-by-side.
/// Single-profile operators (Creator tier, or anyone without an explicit
/// pick) get the default profile.
pub async fn start_connect(
State(state): State<AppState>,
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();
// Idempotency: if BTCPay is already connected, refuse to issue a new
// authorize URL. Re-clicking Connect today produces a duplicate
// webhook subscription on BTCPay, which results in every payment
// event being delivered to Keysat twice. Make the operator go
// through Disconnect first if they really want to re-authorize.
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
// Resolve the target merchant profile (defaulting to the default).
let profile = match req.merchant_profile_id.as_deref() {
Some(id) => crate::merchant_profiles::get(&state.db, id)
.await?
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))?,
None => crate::merchant_profiles::require_default(&state.db).await?,
};
// Idempotency: refuse to issue a new authorize URL if the same
// profile already has a BTCPay provider attached. Re-clicking
// Connect would otherwise INSERT-conflict at callback time (unique
// index on (merchant_profile_id, kind)) AND register a duplicate
// BTCPay webhook, producing duplicate-deliveries on every settle.
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
.await?;
if existing.iter().any(|p| p.kind == "btcpay") {
return Err(AppError::Conflict(format!(
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
existing.store_id,
"merchant profile '{}' already has a BTCPay provider attached. \
Disconnect it first if you want to re-authorize, or pick a different profile.",
profile.name
)));
}
@@ -83,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)
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)?;
@@ -124,9 +174,11 @@ pub async fn start_connect(
urlencoding::encode(&redirect),
);
let _ = req.label; // captured but not yet used — see finish_connect TODO for the future round-trip
Ok(Json(ConnectResp {
authorize_url,
state: state_token,
merchant_profile_id: profile.id,
}))
}
@@ -158,7 +210,7 @@ pub async fn callback(
Form(form): Form<CallbackForm>,
) -> AppResult<Response> {
finish_connect(&state, &q.state, &form.api_key).await?;
Ok(success_page("BTCPay connected successfully. You can close this tab and return to StartOS."))
Ok(success_page("BTCPay connected successfully. You can close this tab and return to Keysat."))
}
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
@@ -190,58 +242,81 @@ pub async fn callback_get(
};
match finish_connect(&state, &q.state, &api_key).await {
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to StartOS.",
"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(),
}
}
/// Admin endpoint: list payment methods configured on the connected
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
/// Used by the wrapper / future web UI to surface a "no wallet
/// configured" state.
/// BTCPay store. Defaults to the default-profile's BTCPay provider for
/// back-compat with the existing admin UI; the new merchant-profile
/// admin endpoint passes an explicit `provider_id` query param when
/// multiple BTCPay providers exist.
pub async fn payment_methods(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?
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?;
let row = rows
.into_iter()
.find(|p| p.kind == "btcpay")
.ok_or(AppError::BtcpayNotConfigured)?;
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
let store_id = row.store_id.as_deref().unwrap_or("");
let methods = btcpay_client::list_payment_methods(&row.base_url, &row.api_key, store_id)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
// Return both the raw array for callers that want detail, and a
// boolean summary for the common "is anything configured?" check.
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e:#}")))?;
let count = methods.len();
Ok(Json(json!({
"store_id": cfg.store_id,
"store_id": store_id,
"count": count,
"methods": methods,
})))
}
/// Admin endpoint: report current BTCPay connection status.
/// Admin endpoint: report BTCPay connection status for the default
/// profile (back-compat with the existing admin UI's payment-providers
/// card). Multi-profile operators use `/v1/admin/merchant-profiles` to
/// see all attached providers.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
Ok(Json(match cfg {
require_scope(&state, &headers, "payment_providers:read").await?;
let default = crate::merchant_profiles::get_default(&state.db).await?;
let row = match &default {
Some(profile) => {
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
.await?;
rows.into_iter().find(|p| p.kind == "btcpay")
}
None => None,
};
Ok(Json(match row {
None => json!({ "connected": false }),
Some(c) => json!({
Some(p) => json!({
"connected": true,
"store_id": c.store_id,
"webhook_id": c.webhook_id,
"base_url": c.base_url,
"provider_id": p.id,
"store_id": p.store_id,
"webhook_id": p.webhook_id,
"base_url": p.base_url,
"label": p.label,
"merchant_profile_id": default.as_ref().map(|d| d.id.clone()),
"merchant_profile_name": default.as_ref().map(|d| d.name.clone()),
}),
}))
}
@@ -249,9 +324,22 @@ pub async fn status(
// --- internals ---
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
btcpay_cfg::consume_authorize_state(&state.db, state_token)
// 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 auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
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!(
"merchant profile {id} no longer exists — the operator may have \
deleted it during the authorize round-trip. Reconnect from a \
valid profile."
)))?,
None => crate::merchant_profiles::require_default(&state.db).await?,
};
let base_url = &state.config.btcpay_url;
@@ -260,7 +348,7 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
// first one that the key can see.
let stores = btcpay_client::list_stores(base_url, api_key)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e:#}")))?;
let store = stores
.into_iter()
.next()
@@ -268,12 +356,56 @@ 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);
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
// Pre-generate the provider id so we can bake it into the webhook
// URL we register with BTCPay. The webhook router routes by this
// path-param id, isolating deliveries per-provider per-profile.
let provider_id = uuid::Uuid::new_v4().to_string();
let callback_url = format!(
"{}/v1/btcpay/webhook/{}",
state.config.public_base_url, provider_id
);
let created_webhook = btcpay_client::create_webhook(
base_url,
@@ -283,51 +415,74 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
&webhook_secret,
)
.await
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e:#}")))?;
// Persist.
let cfg = btcpay_cfg::BtcpayConfig {
base_url: base_url.clone(),
api_key: api_key.to_string(),
store_id: store.id.clone(),
webhook_id: Some(created_webhook.id.clone()),
webhook_secret: webhook_secret.clone(),
};
btcpay_cfg::save(&state.db, &cfg)
.await
.map_err(AppError::Internal)?;
// Persist as a payment_providers row attached to the chosen profile.
let label = format!("BTCPay — {}", profile.name);
let now = chrono::Utc::now().to_rfc3339();
crate::db::repo::create_payment_provider(
&state.db,
&provider_id,
&profile.id,
"btcpay",
&label,
api_key,
base_url,
Some(&created_webhook.id),
Some(&webhook_secret),
Some(&store.id),
&now,
)
.await?;
// Swap runtime — wrap a fresh BtcpayProvider into the
// PaymentProvider trait object held by AppState. Pass the
// public-facing BTCPay URL too so that checkout URLs returned to
// buyers get rewritten from the internal Docker hostname to a
// browser-reachable host.
// If this is the first provider on the default profile, also
// populate the back-compat singleton so the few remaining
// state.payment_provider() callers work without a daemon restart.
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
.await?;
if profile.is_default && existing.len() == 1 {
let client = BtcpayClient::new(base_url, api_key, &store.id);
let provider = Arc::new(
BtcpayProvider::new(client, webhook_secret)
BtcpayProvider::new(client, webhook_secret.clone())
.with_public_base(state.config.btcpay_public_url.clone()),
);
state.set_payment_provider(provider).await;
// Persist active-provider preference so the boot-time loader
// picks BTCPay on next restart even if Zaprite's config row
// is also still in the DB. Failure here is non-fatal (BTCPay
// is the historical default, so the fallback loader picks it
// anyway) but logged.
if let Err(e) = crate::payment::write_active_provider_preference(
&state.db,
crate::payment::ProviderKind::Btcpay,
)
.await
{
tracing::warn!(error = %e, "failed to record BTCPay as active payment provider");
}
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(())
}
@@ -342,31 +497,52 @@ h2{{color:#0a7}}</style></head>
(StatusCode::OK, Html(body)).into_response()
}
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
/// webhook + API key on BTCPay's side, then unconditional clear of the
/// local config row. If BTCPay is unreachable, the local state is still
/// cleared and the operator gets a warning to clean up BTCPay manually.
#[derive(Debug, Deserialize, Default)]
pub struct DisconnectReq {
/// Which provider row to disconnect. NULL = the BTCPay provider on
/// the default merchant profile (back-compat for the existing admin
/// UI's single-button Disconnect).
#[serde(default)]
pub provider_id: Option<String>,
}
/// Admin endpoint: disconnect a BTCPay provider. Best-effort revocation
/// of the webhook + API key on BTCPay's side, then unconditional delete
/// of the local payment_providers row. If BTCPay is unreachable, the
/// local state is still cleared and the operator gets a warning to
/// clean up BTCPay manually.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
body: Option<Json<DisconnectReq>>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = crate::api::admin::request_context(&headers);
let req = body.map(|Json(b)| b).unwrap_or_default();
let cfg = btcpay_cfg::load(&state.db)
.await
.map_err(AppError::Internal)?;
let Some(cfg) = cfg else {
let provider_row = match req.provider_id.as_deref() {
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, pid)
.await?
.filter(|p| p.kind == "btcpay"),
None => {
// Default-profile fallback for the existing admin UI.
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?;
rows.into_iter().find(|p| p.kind == "btcpay")
}
};
let Some(provider_row) = provider_row else {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "BTCPay was not connected; nothing to do.",
"message": "no BTCPay provider connected on the named profile",
})));
};
// Capture metadata for the response BEFORE we clear local state.
let store_id = cfg.store_id.clone();
let webhook_id = cfg.webhook_id.clone();
let provider_id = provider_row.id.clone();
let store_id = provider_row.store_id.clone().unwrap_or_default();
let webhook_id = provider_row.webhook_id.clone();
// Best-effort remote cleanup. We DON'T short-circuit if either of
// these calls fails — the operator's intent is to disconnect, and
@@ -377,9 +553,9 @@ pub async fn disconnect(
let mut warnings: Vec<String> = Vec::new();
if let Some(webhook_id) = webhook_id.as_deref() {
if let Err(e) = btcpay_client::delete_webhook(
&cfg.base_url,
&cfg.api_key,
&cfg.store_id,
&provider_row.base_url,
&provider_row.api_key,
&store_id,
webhook_id,
)
.await
@@ -390,52 +566,35 @@ pub async fn disconnect(
));
}
}
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
if let Err(e) = btcpay_client::revoke_api_key(&provider_row.base_url, &provider_row.api_key).await {
warnings.push(format!(
"Could not revoke BTCPay API key: {e}. \
You may want to manually revoke it in BTCPay's account API-keys page."
));
}
btcpay_cfg::clear(&state.db)
.await
.map_err(AppError::Internal)?;
crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?;
// Replace the runtime payment provider so subsequent purchase
// attempts return BtcpayNotConfigured cleanly.
// Clear the back-compat singleton if it was holding this one.
state.clear_payment_provider().await;
// If BTCPay was the recorded active-provider preference, clear
// it. Don't blindly clear if it was Zaprite — different operator
// intent.
if matches!(
crate::payment::read_active_provider_preference(&state.db).await,
Some(crate::payment::ProviderKind::Btcpay)
) {
let _ = crate::db::repo::settings_set(
&state.db,
crate::payment::SETTING_ACTIVE_PROVIDER,
None,
)
.await;
}
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"btcpay.disconnect",
Some("btcpay_config"),
None,
"payment_provider.disconnect",
Some("payment_provider"),
Some(&provider_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
&json!({ "kind": "btcpay", "store_id": store_id, "webhook_id": webhook_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"provider_id": provider_id,
"store_id": store_id,
"webhook_id": webhook_id,
"warnings": warnings,
+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()
@@ -0,0 +1,341 @@
//! Admin CRUD endpoints for merchant profiles + rail preferences.
//!
//! Thin Axum handlers wrapping the business-logic helpers in
//! `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_scope};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::merchant_profiles::{
self, MerchantProfile, MerchantProfileUpdate, NewMerchantProfile,
};
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
fn profile_to_json(p: &MerchantProfile, with_providers: Option<&[crate::db::repo::PaymentProviderRow]>) -> Value {
let mut obj = json!({
"id": p.id,
"name": p.name,
"legal_name": p.legal_name,
"support_url": p.support_url,
"support_email": p.support_email,
"brand_color": p.brand_color,
"post_purchase_redirect_url": p.post_purchase_redirect_url,
"is_default": p.is_default,
// SMTP credentials are redacted in list/get responses — operators
// see whether they're set, not the password itself. The edit
// form submits new credentials only when the operator explicitly
// wants to rotate them.
"smtp_configured": p.smtp_host.is_some(),
"smtp_host": p.smtp_host,
"smtp_port": p.smtp_port,
"smtp_username": p.smtp_username,
"smtp_from_address": p.smtp_from_address,
"smtp_from_name": p.smtp_from_name,
"smtp_use_starttls": p.smtp_use_starttls,
"created_at": p.created_at,
"updated_at": p.updated_at,
});
if let Some(providers) = with_providers {
let arr: Vec<Value> = providers
.iter()
.map(|row| {
let rails: Vec<&'static str> = crate::payment::ProviderKind::parse(&row.kind)
.map(|kind| {
crate::payment::rails_for_kind(kind)
.into_iter()
.map(|r| r.as_str())
.collect()
})
.unwrap_or_default();
json!({
"id": row.id,
"kind": row.kind,
"label": row.label,
"base_url": row.base_url,
"store_id": row.store_id,
"webhook_id": row.webhook_id,
"connected_at": row.connected_at,
"served_rails": rails,
})
})
.collect();
obj["providers"] = json!(arr);
}
obj
}
/// `GET /v1/admin/merchant-profiles` — list every profile + a brief
/// summary of attached providers per profile.
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
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 {
let providers = crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?;
out.push(profile_to_json(p, Some(&providers)));
}
Ok(Json(json!({ "profiles": out })))
}
/// `GET /v1/admin/merchant-profiles/:id` — full detail for a profile,
/// including providers + rail preferences.
pub async fn get(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
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}")))?;
let providers = crate::db::repo::list_payment_providers_for_profile(&state.db, &id).await?;
let rail_prefs = crate::db::repo::list_rail_preferences_for_profile(&state.db, &id).await?;
let mut obj = profile_to_json(&profile, Some(&providers));
obj["rail_preferences"] = json!(rail_prefs
.into_iter()
.map(|p| json!({ "rail": p.rail, "payment_provider_id": p.payment_provider_id }))
.collect::<Vec<_>>());
let product_count =
crate::db::repo::count_products_for_profile(&state.db, &id)
.await
.map_err(AppError::Internal)?;
let active_subscription_count =
crate::db::repo::count_active_subscriptions_for_profile(&state.db, &id)
.await
.map_err(AppError::Internal)?;
obj["product_count"] = json!(product_count);
obj["active_subscription_count"] = json!(active_subscription_count);
Ok(Json(obj))
}
/// `POST /v1/admin/merchant-profiles` — create a new profile.
/// Tier-gated: Creator hits cap on the second profile.
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<NewMerchantProfile>,
) -> AppResult<Json<Value>> {
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(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.create",
Some("merchant_profile"),
Some(&created.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "name": created.name }),
)
.await;
Ok(Json(profile_to_json(&created, None)))
}
/// `PATCH /v1/admin/merchant-profiles/:id` — partial update.
pub async fn update(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(patch): Json<MerchantProfileUpdate>,
) -> AppResult<Json<Value>> {
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(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.update",
Some("merchant_profile"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "name": updated.name }),
)
.await;
Ok(Json(profile_to_json(&updated, None)))
}
/// `DELETE /v1/admin/merchant-profiles/:id` — delete a non-default
/// profile with no attached products or active subscriptions.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
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(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.delete",
Some("merchant_profile"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true, "id": id })))
}
/// `POST /v1/admin/merchant-profiles/:id/set-default` — flip the
/// default-profile flag to this id.
pub async fn set_default(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
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(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.set_default",
Some("merchant_profile"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true, "id": id })))
}
// ---------------------------------------------------------------------
// Rail preferences
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct SetRailPreferenceReq {
pub payment_provider_id: String,
}
/// `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` —
/// pin the provider that should serve this rail on this profile.
/// Validates that the provider belongs to the profile AND serves
/// the requested rail before persisting.
pub async fn set_rail_preference(
State(state): State<AppState>,
headers: HeaderMap,
Path((profile_id, rail)): Path<(String, String)>,
Json(req): Json<SetRailPreferenceReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?;
let (ip, ua) = request_context(&headers);
// Validate the rail name.
let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| {
AppError::BadRequest(format!(
"unknown rail '{rail}'; accepted: lightning, onchain, card"
))
})?;
// Validate the provider exists, belongs to THIS profile, and serves
// THIS rail.
let provider_row = crate::db::repo::get_payment_provider_by_id(
&state.db,
&req.payment_provider_id,
)
.await?
.ok_or_else(|| {
AppError::BadRequest(format!("payment provider {} not found", req.payment_provider_id))
})?;
if provider_row.merchant_profile_id != profile_id {
return Err(AppError::BadRequest(format!(
"payment provider {} is not attached to merchant profile {profile_id}",
req.payment_provider_id
)));
}
let served = crate::payment::ProviderKind::parse(&provider_row.kind)
.map(crate::payment::rails_for_kind)
.unwrap_or_default();
if !served.contains(&parsed_rail) {
return Err(AppError::BadRequest(format!(
"payment provider {} (kind={}) does not serve the '{rail}' rail; \
pick a provider that does, or remove this preference",
req.payment_provider_id, provider_row.kind
)));
}
crate::db::repo::set_rail_preference(
&state.db,
&profile_id,
parsed_rail.as_str(),
&req.payment_provider_id,
)
.await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.rail_preference.set",
Some("merchant_profile"),
Some(&profile_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "rail": parsed_rail.as_str(), "payment_provider_id": req.payment_provider_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"merchant_profile_id": profile_id,
"rail": parsed_rail.as_str(),
"payment_provider_id": req.payment_provider_id,
})))
}
/// `DELETE /v1/admin/merchant-profiles/:id/rail-preferences/:rail` —
/// clear a rail preference, letting the deterministic-earliest-connected
/// fallback take over.
pub async fn clear_rail_preference(
State(state): State<AppState>,
headers: HeaderMap,
Path((profile_id, rail)): Path<(String, String)>,
) -> AppResult<Json<Value>> {
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(|| {
AppError::BadRequest(format!(
"unknown rail '{rail}'; accepted: lightning, onchain, card"
))
})?;
crate::db::repo::clear_rail_preference(&state.db, &profile_id, parsed_rail.as_str()).await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.rail_preference.clear",
Some("merchant_profile"),
Some(&profile_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "rail": parsed_rail.as_str() }),
)
.await;
Ok(Json(json!({
"ok": true,
"merchant_profile_id": profile_id,
"rail": parsed_rail.as_str(),
})))
}
+287 -8
View File
@@ -75,6 +75,7 @@ pub mod tier;
pub mod validate;
pub mod community;
pub mod db_info;
pub mod merchant_profiles;
pub mod payment_provider;
pub mod rates_admin;
pub mod recover;
@@ -107,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.
@@ -174,6 +184,150 @@ impl AppState {
let mut guard = self.payment.write().await;
*guard = None;
}
// ---------------------------------------------------------------------
// Merchant-profile-aware resolution layer (migration 0020+)
// ---------------------------------------------------------------------
//
// The legacy `payment_provider()` / `set_payment_provider()` accessors
// above continue to work as a "default provider for the default
// profile" compatibility shim during the multi-provider transition.
// New call sites should use one of the methods below instead.
/// Look up a payment provider by its row id. Reads the row from the
/// DB, instantiates a typed `PaymentProvider` impl via
/// `payment::build_provider`. Not cached today — the caller is
/// usually invoking it once per request lifecycle so the cost is
/// nil. Add a cache here when profiling says we need one.
pub async fn payment_provider_by_id(
&self,
provider_id: &str,
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
let row = crate::db::repo::get_payment_provider_by_id(&self.db, provider_id)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("payment provider {provider_id}"))
})?;
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)
}
/// Resolve the merchant profile a product belongs to. Falls back to
/// the default profile if the product has no `merchant_profile_id`
/// set (defensive — shouldn't happen post-migration, but handles
/// any rows that slip through).
pub async fn merchant_profile_for_product(
&self,
product_id: &str,
) -> AppResult<crate::merchant_profiles::MerchantProfile> {
crate::merchant_profiles::for_product(self, product_id).await
}
/// Pick the provider on `profile_id` that serves the given `rail`.
/// Resolution order:
/// 1. Honor `merchant_profile_rail_preferences` if the operator
/// set an explicit preference for this (profile, rail) pair.
/// 2. If exactly one attached provider serves the rail, use it.
/// 3. If multiple serve the rail and no preference is set, use
/// the earliest-`connected_at` one (deterministic) and log a
/// warning so the operator knows to set an explicit preference.
/// 4. If no attached provider serves the rail, return
/// `AppError::BadRequest` — caller should treat this as
/// "buyer's pick isn't available for this merchant."
pub async fn resolve_provider_for_profile_rail(
&self,
profile_id: &str,
rail: crate::payment::Rail,
) -> AppResult<(crate::db::repo::PaymentProviderRow, Arc<dyn crate::payment::PaymentProvider>)> {
// 1. Check the explicit preference table first.
let preferences = crate::db::repo::list_rail_preferences_for_profile(&self.db, profile_id).await?;
if let Some(pref) = preferences.iter().find(|p| p.rail == rail.as_str()) {
let row = crate::db::repo::get_payment_provider_by_id(&self.db, &pref.payment_provider_id)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"rail preference points at missing provider {}",
pref.payment_provider_id
))
})?;
let provider = self.provider_from_row(&row)?;
return Ok((row, provider));
}
// 2. + 3. No explicit preference — find providers on this profile
// whose kind serves the requested rail.
let providers = crate::db::repo::list_payment_providers_for_profile(&self.db, profile_id).await?;
let mut candidates: Vec<&crate::db::repo::PaymentProviderRow> = providers
.iter()
.filter(|row| {
crate::payment::ProviderKind::parse(&row.kind)
.map(|kind| crate::payment::rails_for_kind(kind).contains(&rail))
.unwrap_or(false)
})
.collect();
// Earliest-connected-first is already the order from list_payment_providers_for_profile
// (ORDER BY connected_at ASC), but be explicit for clarity.
candidates.sort_by(|a, b| a.connected_at.cmp(&b.connected_at));
match candidates.as_slice() {
[] => Err(AppError::BadRequest(format!(
"merchant profile {profile_id} has no provider that serves the '{}' rail. \
Connect one in the admin UI's Merchant Profiles page, or set a rail \
preference if multiple providers serve this rail.",
rail.as_str()
))),
[only] => {
let row = (*only).clone();
let provider = self.provider_from_row(&row)?;
Ok((row, provider))
}
[first, ..] => {
tracing::warn!(
profile_id = %profile_id,
rail = rail.as_str(),
chosen = %first.id,
candidate_count = candidates.len(),
"multiple providers serve this rail on the profile; using earliest-connected \
deterministically. Set an explicit rail preference in the admin UI to silence \
this warning."
);
let row = (*first).clone();
let provider = self.provider_from_row(&row)?;
Ok((row, provider))
}
}
}
/// Convenience for the most common purchase-flow case: given a
/// product id and a buyer-picked rail, resolve to (profile, provider
/// row, provider impl). Used by `purchase.rs` and `subscriptions.rs`
/// renewals.
pub async fn resolve_provider_for_product_rail(
&self,
product_id: &str,
rail: crate::payment::Rail,
) -> AppResult<(
crate::merchant_profiles::MerchantProfile,
crate::db::repo::PaymentProviderRow,
Arc<dyn crate::payment::PaymentProvider>,
)> {
let profile = self.merchant_profile_for_product(product_id).await?;
let (row, provider) = self.resolve_provider_for_profile_rail(&profile.id, rail).await?;
Ok((profile, row, provider))
}
}
impl FromRef<AppState> for SqlitePool {
@@ -215,6 +369,7 @@ pub fn router(state: AppState) -> Router {
.route("/v1/machines/heartbeat", post(machines::heartbeat))
.route("/v1/machines/deactivate", post(machines::deactivate))
.route("/v1/btcpay/webhook", post(webhook::handle))
.route("/v1/btcpay/webhook/:provider_id", post(webhook::handle_for_provider))
.route(
"/v1/admin/btcpay/connect",
post(btcpay_authorize::start_connect),
@@ -252,15 +407,35 @@ pub fn router(state: AppState) -> Router {
get(zaprite_authorize::status),
)
// Provider-agnostic active-payment-provider control.
// Operators with both BTCPay and Zaprite configured can flip
// the active one without re-running Connect.
// Back-compat snapshot of the default profile's providers. The
// legacy `activate` endpoint is removed — in the merchant-profile
// model providers attach to profiles and products pick a profile
// at resolution time; there's no singleton "active" preference to
// flip. Multi-profile operators should use the new
// /v1/admin/merchant-profiles endpoints below.
.route(
"/v1/admin/payment-provider/status",
get(payment_provider::status),
)
// Merchant profile CRUD + rail preferences.
.route(
"/v1/admin/payment-provider/activate",
post(payment_provider::activate),
"/v1/admin/merchant-profiles",
get(merchant_profiles::list).post(merchant_profiles::create),
)
.route(
"/v1/admin/merchant-profiles/:id",
get(merchant_profiles::get)
.patch(merchant_profiles::update)
.delete(merchant_profiles::delete),
)
.route(
"/v1/admin/merchant-profiles/:id/set-default",
post(merchant_profiles::set_default),
)
.route(
"/v1/admin/merchant-profiles/:id/rail-preferences/:rail",
axum::routing::put(merchant_profiles::set_rail_preference)
.delete(merchant_profiles::clear_rail_preference),
)
// Zaprite webhook landing — operator points Zaprite's
// webhook setting at this URL. Same handler as
@@ -268,6 +443,7 @@ pub fn router(state: AppState) -> Router {
// is on the trait surface and the active provider self-
// identifies its event shape.
.route("/v1/zaprite/webhook", post(webhook::handle))
.route("/v1/zaprite/webhook/:provider_id", post(webhook::handle_for_provider))
.route("/v1/admin/products", post(admin::create_product))
.route(
"/v1/admin/products/:id",
@@ -573,6 +749,75 @@ async fn thank_you(
.or(state.config.operator_name.as_deref())
.unwrap_or("Keysat");
let operator = html_escape(operator_str);
// Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning
// + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus
// Bitcoin. The lede and the polling-status copy reflect which payment
// rails actually settled THIS invoice, not "the currently active
// provider" (which is meaningless in the multi-provider model).
//
// Look up the invoice's own `payment_provider_id` (recorded by
// migration 0021) → resolve to its kind via payment_providers. Falls
// back to whichever provider is attached to the default profile if
// the invoice predates 0021, then to BTCPay if even THAT can't be
// resolved (operator visited /thank-you with no providers connected
// at all — rare).
let invoice_provider_kind: Option<crate::payment::ProviderKind> = if !invoice_id.is_empty() {
let row: Option<(Option<String>,)> = sqlx::query_as(
"SELECT i.payment_provider_id FROM invoices i WHERE i.id = ? LIMIT 1",
)
.bind(&invoice_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
match row.and_then(|(pid,)| pid) {
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, &pid)
.await
.ok()
.flatten()
.and_then(|p| crate::payment::ProviderKind::parse(&p.kind)),
None => None,
}
} else {
None
};
let provider_kind = match invoice_provider_kind {
Some(k) => Some(k),
None => {
// Fall back to the default profile's first provider.
let default = crate::merchant_profiles::get_default(&state.db).await.ok().flatten();
match default {
Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id)
.await
.ok()
.and_then(|rows| rows.into_iter().next())
.and_then(|row| crate::payment::ProviderKind::parse(&row.kind)),
None => None,
}
}
};
let (lede_text, provider_kind_str) = match provider_kind {
Some(crate::payment::ProviderKind::Zaprite) => (
"Your payment was received. We\u{2019}re waiting for it to settle and \
for the license to be signed. Card payments confirm in seconds; \
Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically \
settles in 10\u{2013}20 minutes (one block confirmation).",
"zaprite",
),
// BTCPay or unconfigured → original Bitcoin-only copy. Unconfigured
// is rare on this page (operator hit /thank-you without a provider
// connected) so we keep it Bitcoin-flavored rather than introducing
// a third "unknown" branch.
_ => (
"Your Bitcoin payment was received. We\u{2019}re waiting for it to settle \
and for the license to be signed. Lightning settles in seconds; on-chain \
typically settles in 10\u{2013}20 minutes (one block confirmation).",
"btcpay",
),
};
let provider_kind_json = serde_json::to_string(provider_kind_str)
.unwrap_or_else(|_| "\"btcpay\"".into());
let body = format!(
r#"<!doctype html>
<html lang="en">
@@ -748,7 +993,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<div class="wrap">
<div class="eyebrow">Payment received</div>
<h1 id="page-title">Issuing your license&hellip;</h1>
<p class="lede" id="page-lede">Your Bitcoin payment was received. We&rsquo;re waiting for it to settle and for the license to be signed. Lightning settles in seconds; on-chain typically settles in 10&ndash;20 minutes (one block confirmation).</p>
<p class="lede" id="page-lede">{lede_text}</p>
<!-- pending state (default): polling for the license -->
<div class="pending-card" id="pending-card">
@@ -788,6 +1033,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<script>
(function() {{
const INVOICE_ID = {invoice_id_json};
// 'zaprite' | 'btcpay' — selects which payment-rail copy the
// polling status uses (Zaprite: card + Lightning + on-chain; BTCPay:
// Lightning + on-chain only).
const PROVIDER_KIND = {provider_kind_json};
if (!INVOICE_ID) {{
document.getElementById('pending-card').classList.add('hide');
document.getElementById('error-card').classList.remove('hide');
@@ -857,10 +1106,21 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
function waitingCopy(status) {{
const min = Math.floor(elapsedMs / 60000);
const isZaprite = PROVIDER_KIND === 'zaprite';
if (status === 'pending' || status === 'processing') {{
if (min < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
if (min < 10) return 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
if (min < 2) {{
return isZaprite
? 'invoice ' + status + ' — card payments confirm in seconds; Bitcoin Lightning in seconds; on-chain takes a block (~10 min).'
: 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
}}
if (min < 10) {{
return isZaprite
? 'invoice ' + status + ' — waiting for confirmation. Card auth or on-chain Bitcoin can take a few minutes. Safe to leave this tab open or bookmark this URL.'
: 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
}}
return isZaprite
? 'invoice ' + status + ' — slow confirmation. Still polling. Bookmark this URL and refresh later if you close the tab.'
: 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
}}
return 'invoice status: ' + (status || 'pending');
}}
@@ -933,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)" } }
}
}
}
}"##;
+49 -125
View File
@@ -1,140 +1,64 @@
//! Active-provider swap endpoint.
//! Payment-provider status endpoint (multi-merchant-profile model).
//!
//! When an operator has both BTCPay AND Zaprite configured (i.e.,
//! they ran Connect on both at some point), this lets them flip
//! the active one without re-authorizing. The Connect flows are
//! still where credentials live; this endpoint only changes which
//! credentials the daemon currently routes through.
//! Pre-:52 this module held two endpoints:
//! - `GET /v1/admin/payment-provider/status` — which provider was
//! active, plus configured flags for BTCPay + Zaprite.
//! - `POST /v1/admin/payment-provider/activate` — flip the singleton
//! active-provider preference between two configured ones.
//!
//! Both became meaningless in the merchant-profile model — providers
//! aren't "active," they attach to profiles, and products pick a profile
//! at the resolution layer. The activate endpoint is removed. The status
//! endpoint stays as a back-compat shim so the existing admin UI's
//! payment-providers card keeps rendering until the new Merchant
//! Profiles UI replaces it: it now reports against the DEFAULT profile
//! (single-profile operators see no change). Multi-profile operators
//! should use the new `/v1/admin/merchant-profiles` endpoints to see
//! all providers across all profiles.
use crate::api::admin::{request_context, require_admin};
use crate::api::admin::require_scope;
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::payment::{
self, btcpay::BtcpayProvider, zaprite::ZapriteProvider, ProviderKind,
};
use crate::error::AppResult;
use axum::{extract::State, http::HeaderMap, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct ActivateReq {
/// `'btcpay'` or `'zaprite'`. Other values → 400.
pub provider: String,
}
/// `GET /v1/admin/payment-provider/status` — both providers'
/// configuration state at a glance, plus the active preference.
/// Lets the SPA render a "BTCPay [active] / Zaprite [configured,
/// not active]" header without two separate fetches.
/// `GET /v1/admin/payment-provider/status` — back-compat snapshot of
/// providers attached to the default merchant profile. Returns the same
/// shape as pre-:52 with `btcpay_configured` / `zaprite_configured` /
/// `active` for compatibility with the existing admin UI; new code
/// should use `/v1/admin/merchant-profiles/{id}` instead.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let btcpay_configured = crate::btcpay::config::load(&state.db)
.await
.map(|o| o.is_some())
.unwrap_or(false);
let zaprite_configured = payment::zaprite::config::load(&state.db)
.await
.map(|o| o.is_some())
.unwrap_or(false);
let preference = payment::read_active_provider_preference(&state.db).await;
let active_runtime = match state.payment.read().await.as_ref() {
Some(p) => Some(p.kind().as_str().to_string()),
None => None,
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?,
None => Vec::new(),
};
let btcpay_row = providers.iter().find(|p| p.kind == "btcpay").cloned();
let zaprite_row = providers.iter().find(|p| p.kind == "zaprite").cloned();
// "active" used to mean "the singleton active-provider preference."
// In the new model there isn't one. For back-compat we report the
// FIRST provider on the default profile (which is what the legacy
// boot-loader semantics would have picked) so the existing admin UI
// shows a sensible active badge. Multi-rail operators get the full
// picture from the new merchant-profile endpoints.
let active_runtime = providers.first().map(|p| p.kind.clone());
Ok(Json(json!({
"btcpay_configured": btcpay_configured,
"zaprite_configured": zaprite_configured,
"preferred": preference.map(|k| k.as_str().to_string()),
"btcpay_configured": btcpay_row.is_some(),
"zaprite_configured": zaprite_row.is_some(),
"preferred": active_runtime.clone(),
"active": active_runtime,
})))
}
/// `POST /v1/admin/payment-provider/activate` — swap the active
/// provider to whichever already-configured one the operator
/// names. 400 if the named provider isn't configured (run Connect
/// first).
pub async fn activate(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ActivateReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let kind = match req.provider.to_lowercase().as_str() {
"btcpay" => ProviderKind::Btcpay,
"zaprite" => ProviderKind::Zaprite,
other => {
return Err(AppError::BadRequest(format!(
"unknown provider '{other}'; accepted: btcpay, zaprite"
)))
}
};
// Build the provider from its persisted config. Refuse if the
// config row isn't there — operator has to run Connect first.
match kind {
ProviderKind::Btcpay => {
let cfg = crate::btcpay::config::load(&state.db)
.await
.map_err(AppError::Internal)?
.ok_or_else(|| {
AppError::BadRequest(
"BTCPay not configured. Run Connect BTCPay first.".into(),
)
})?;
let client = crate::btcpay::client::BtcpayClient::new(
&cfg.base_url,
&cfg.api_key,
&cfg.store_id,
);
let provider = Arc::new(
BtcpayProvider::new(client, cfg.webhook_secret)
.with_public_base(state.config.btcpay_public_url.clone()),
);
state.set_payment_provider(provider).await;
}
ProviderKind::Zaprite => {
crate::api::tier::enforce_zaprite_feature(&state).await?;
let cfg = payment::zaprite::config::load(&state.db)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))?
.ok_or_else(|| {
AppError::BadRequest(
"Zaprite not configured. Run Connect Zaprite first.".into(),
)
})?;
let client = payment::zaprite::ZapriteClient::new(&cfg.base_url, &cfg.api_key);
let provider = Arc::new(ZapriteProvider::new(client));
state.set_payment_provider(provider).await;
}
}
// Persist the preference so the boot-time loader picks the
// same one on next restart.
payment::write_active_provider_preference(&state.db, kind)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("write preference: {e:#}")))?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"payment_provider.activate",
Some("payment_provider"),
Some(kind.as_str()),
ip.as_deref(),
ua.as_deref(),
&json!({ "provider": kind.as_str() }),
)
.await;
Ok(Json(json!({
"ok": true,
"active": kind.as_str(),
"merchant_profile_id": default.as_ref().map(|p| p.id.clone()),
"merchant_profile_name": default.as_ref().map(|p| p.name.clone()),
"providers": providers.iter().map(|p| json!({
"id": p.id,
"kind": p.kind,
"label": p.label,
"base_url": p.base_url,
"store_id": p.store_id,
})).collect::<Vec<_>>(),
})))
}
+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(),
+125 -24
View File
@@ -37,6 +37,14 @@ pub struct StartPurchaseReq {
/// issuance time. When omitted, the daemon falls back to the product's
/// default policy at issuance — same as pre-:27 behaviour.
pub policy_slug: Option<String>,
/// Optional payment rail the buyer picked on the buy page. One of
/// `lightning` / `onchain` / `card`. When omitted, the daemon picks
/// the first rail the product's merchant profile exposes — which is
/// the right behavior for single-rail profiles AND back-compat for
/// pre-:52 callers that don't know about rails yet. When the buyer
/// is on a multi-rail profile and the buy page surfaces a picker,
/// this field carries the choice.
pub rail: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -419,29 +427,16 @@ pub async fn start(
// before we've persisted the BTCPay invoice id.
let internal_id = uuid::Uuid::new_v4().to_string();
// If the caller didn't supply a redirect_url, default to our own
// /thank-you page with the invoice id baked in. After payment
// BTCPay sends the buyer's browser there; the page polls
// /v1/purchase/<invoice_id> until the license is issued, then
// renders it. Internal ID (UUID) goes in the URL so the buyer can
// bookmark it / refresh later if they close the tab.
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_id
);
let redirect_url = req
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&default_redirect);
// Step C: provider-agnostic invoice creation. The trait method
// handles provider-specific concerns (HMAC-headered request, URL
// rewriting from internal hostname to public, metadata enrichment
// with `orderId`/`source`) inside its impl, so this code path is
// identical for any future provider (Zaprite, etc.). On failure,
// release the slot and bail.
let provider = match state.payment_provider().await {
// Step B.5: resolve the merchant profile + payment provider for THIS
// purchase. The product is attached to exactly one merchant profile;
// the profile exposes one or more payment providers (BTCPay / Zaprite).
// The buyer (or their UA) names a rail via `req.rail` if the buy page's
// multi-rail picker surfaced one — otherwise we pick the first rail the
// profile exposes, which is the right behavior for the common
// single-rail-per-profile case. The resolution layer also returns the
// provider row so we can record its id on the invoice; the renewal
// worker reads that id off the snapshot when auto-charging future cycles.
let merchant_profile = match state.merchant_profile_for_product(&product.id).await {
Ok(p) => p,
Err(e) => {
if let Some(code) = &reservation {
@@ -450,6 +445,91 @@ pub async fn start(
return Err(e);
}
};
let requested_rail = req
.rail
.as_deref()
.and_then(crate::payment::Rail::parse);
let rail = match requested_rail {
Some(r) => r,
None => {
// No buyer pick — collect the union of rails this profile's
// providers offer and use the first. With one provider this
// is its primary rail; with multiple, this is whatever the
// earliest-connected provider serves first.
let providers = repo::list_payment_providers_for_profile(
&state.db, &merchant_profile.id,
)
.await?;
let first_rail = providers.iter().find_map(|row| {
crate::payment::ProviderKind::parse(&row.kind)
.and_then(|kind| crate::payment::rails_for_kind(kind).into_iter().next())
});
match first_rail {
Some(r) => r,
None => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(AppError::BadRequest(format!(
"merchant profile '{}' has no payment providers connected — \
buyers can't pay yet. Connect one in the admin UI.",
merchant_profile.name
)));
}
}
}
};
let (provider_row, provider) = match state
.resolve_provider_for_profile_rail(&merchant_profile.id, rail)
.await
{
Ok(t) => t,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// If the caller didn't supply a redirect_url, prefer the merchant
// profile's configured post_purchase_redirect_url (operator's app
// landing page — e.g. recaps.cc/welcome). Fall back to Keysat's own
// /thank-you?invoice_id=… page if neither is set.
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_id
);
let profile_redirect = merchant_profile
.post_purchase_redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.map(|tmpl| {
// Allow `{invoice_id}` substitution so operators can land
// buyers on a per-purchase URL on their own app.
tmpl.replace("{invoice_id}", &internal_id)
});
let profile_redirect_ref = profile_redirect.as_deref();
let redirect_url = req
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.or(profile_redirect_ref)
.unwrap_or(&default_redirect);
// Recurring policy: ask the provider to prompt the buyer to
// save their payment profile at checkout so the renewal worker
// can later auto-charge it via `charge_order_with_profile`.
// Zaprite honors this for autopay-supporting rails (Stripe card
// via a connected merchant account); BTCPay has no equivalent
// and silently ignores the flag. We always set this on
// recurring purchases — if the buyer ends up paying with
// Bitcoin / Lightning, or declines the save-card prompt at
// Zaprite's checkout, no profile gets created and the post-
// settle profile-capture step finds nothing. The sub then
// behaves like a pre-feature recurring sub: renewals still
// create fresh invoices the buyer pays manually.
let allow_save_profile =
chosen_policy.as_ref().map(|p| p.is_recurring).unwrap_or(false);
let created = match provider
.create_invoice(CreateInvoiceParams {
amount: Money::sats(final_price),
@@ -461,6 +541,7 @@ pub async fn start(
metadata: json!({ "productId": product.id }),
external_order_id: &internal_id,
buyer_email: req.buyer_email.as_deref(),
allow_save_payment_profile: if allow_save_profile { Some(true) } else { None },
})
.await
{
@@ -469,8 +550,27 @@ pub async fn start(
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
// `{e:#}` (alternate format) walks the anyhow error chain so
// the buy page surfaces the underlying provider error directly
// — e.g. "Zaprite create_order returned HTTP 400: {...}" —
// instead of just the outermost `context()` wrapper. Without
// this, a failed create-invoice shows only
// "ZapriteProvider.create_invoice" to the operator, and the
// real cause (currency mismatch / missing payment rail / API-
// key scope / Zaprite-side validation error) is hidden. We
// ALSO emit an explicit tracing::error! before returning so
// the same chain shows up in the daemon logs — without this
// line, the provider's underlying error string is never
// logged anywhere (the trait method just RETURNS the
// anyhow error; only the tower trace layer fires, and it
// only sees the HTTP status code, not the body).
tracing::error!(
product_id = %product.id,
error = format!("{e:#}"),
"payment provider create_invoice failed"
);
return Err(AppError::Upstream(format!(
"payment provider create-invoice failed: {e}"
"payment provider create-invoice failed: {e:#}"
)));
}
};
@@ -494,6 +594,7 @@ pub async fn start(
listed_value,
exchange_rate_centibps,
exchange_rate_source.as_deref(),
Some(&provider_row.id),
)
.await
{
+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());
+39 -26
View File
@@ -90,36 +90,12 @@ impl TierInfo {
/// to be fixed.
pub async fn current(state: &AppState) -> TierInfo {
let tier = state.self_tier.read().await;
let mut entitlements = match &*tier {
let entitlements = match &*tier {
Tier::Licensed { entitlements, .. } => entitlements.clone(),
Tier::Unlicensed { .. } => Vec::new(),
};
drop(tier);
// Patron implies Pro by design (see module docstring: "Patron: same
// feature surface as Pro, plus a `patron` entitlement..."). Without
// this expansion, every downstream `tier.has(<pro-entitlement>)`
// check requires the Patron POLICY on the master Keysat to
// redundantly list every Pro entitlement. That's brittle: a single
// missing slug on the policy (e.g. operator forgets
// `zaprite_payments`) breaks Pro-equivalence for every Patron
// customer. Treating `patron` as a strict superset of Pro at the
// resolution layer means policy authors can list `patron` alone
// and have everything Pro grants flow through automatically.
if entitlements.iter().any(|e| e == "patron") {
for implied in [
"unlimited_products",
"unlimited_policies",
"unlimited_codes",
"recurring_billing",
"zaprite_payments",
] {
if !entitlements.iter().any(|e| e == implied) {
entitlements.push(implied.to_string());
}
}
}
let label: &'static str;
let display_name: &'static str;
if entitlements.iter().any(|e| e == "patron") {
@@ -148,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)
@@ -186,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,
@@ -245,6 +225,39 @@ pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult
Ok(())
}
/// Refuse a new merchant profile if the operator is at the Creator-tier
/// merchant-profile cap (= 1) and lacks `unlimited_merchant_profiles`.
/// Counts every profile including the auto-created default. So Creator
/// operators have the default profile (auto-created by migration 0020)
/// and can't add more; Pro and Patron operators are unlimited.
///
/// The `unlimited_merchant_profiles` entitlement needs to be added to
/// the master Keysat's Pro and Patron policies as a separate admin
/// action — see plans/multi-provider-payment-model.md "Tier gating"
/// section.
pub async fn enforce_merchant_profile_cap(state: &AppState) -> AppResult<()> {
let tier = current(state).await;
if tier.has("unlimited_merchant_profiles") {
return Ok(());
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM merchant_profiles")
.fetch_one(&state.db)
.await?;
// Creator gets 1 (the default profile).
if count >= 1 {
return Err(AppError::PaymentRequired {
message: format!(
"Your {} tier allows a single merchant profile (the default). \
You're at {}. Upgrade to Pro to run multiple businesses \
from one Keysat instance.",
tier.display_name, count
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
});
}
Ok(())
}
/// Refuse to mark a policy as recurring unless the operator's self-tier
/// carries the `recurring_billing` entitlement. Pro and Patron tiers
/// have it; Creator does not. Called from both create-policy and
+76 -2
View File
@@ -123,7 +123,40 @@ pub async fn start(
// Create provider invoice. Same trait method the purchase + renewal
// paths use, so any provider-specific concerns (URL rewriting,
// metadata enrichment) live inside the impl.
let provider = state.payment_provider().await?;
//
// Tier-change invoices ride on an existing license. The right provider
// is whichever one the license's existing subscription is snapshotted
// to — so the proration charge settles to the same merchant identity
// that's been collecting renewal fees. Falls back to the license's
// first-cycle invoice provider, then the legacy default, for licenses
// with no subscription (one-shot upgrades) or pre-snapshot rows.
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
&state.db, &license.id,
)
.await
.ok()
.flatten()
.and_then(|s| s.payment_provider_id);
let provider_id_for_upgrade = match snapshot_provider_id {
Some(p) => Some(p),
None => {
sqlx::query_scalar::<_, Option<String>>(
"SELECT i.payment_provider_id FROM invoices i \
JOIN licenses l ON l.invoice_id = i.id \
WHERE l.id = ?",
)
.bind(&license.id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten()
}
};
let provider = match provider_id_for_upgrade.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await?,
None => state.payment_provider().await?,
};
let internal_invoice_id = Uuid::new_v4().to_string();
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
@@ -148,6 +181,12 @@ pub async fn start(
}),
external_order_id: &internal_invoice_id,
buyer_email: license.buyer_email.as_deref(),
// Tier-change invoices ride on an existing license; if
// the underlying subscription already captured a saved
// payment profile on its first cycle, we keep using it
// for future renewals. No need to re-prompt for
// save-card here.
allow_save_payment_profile: None,
})
.await
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
@@ -169,6 +208,7 @@ pub async fn start(
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
provider_id_for_upgrade.as_deref(),
)
.await?;
@@ -450,7 +490,36 @@ pub async fn admin_change(
.map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?;
let amount_sats = conversion.sats.max(1);
let provider = state.payment_provider().await?;
// Same provider-resolution pattern as the buyer-driven tier-change
// above: prefer the license's snapshotted subscription provider so
// the admin charge settles to the same merchant identity.
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
&state.db, &license.id,
)
.await
.ok()
.flatten()
.and_then(|s| s.payment_provider_id);
let provider_id_for_upgrade = match snapshot_provider_id {
Some(p) => Some(p),
None => {
sqlx::query_scalar::<_, Option<String>>(
"SELECT i.payment_provider_id FROM invoices i \
JOIN licenses l ON l.invoice_id = i.id \
WHERE l.id = ?",
)
.bind(&license.id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten()
}
};
let provider = match provider_id_for_upgrade.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await?,
None => state.payment_provider().await?,
};
let internal_invoice_id = Uuid::new_v4().to_string();
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
@@ -470,6 +539,10 @@ pub async fn admin_change(
}),
external_order_id: &internal_invoice_id,
buyer_email: license.buyer_email.as_deref(),
// Admin-driven tier change — same as the buyer-driven
// tier-change path above: existing subscription keeps
// its saved profile (if any), so no re-prompt.
allow_save_payment_profile: None,
})
.await
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
@@ -488,6 +561,7 @@ pub async fn admin_change(
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
provider_id_for_upgrade.as_deref(),
)
.await?;
+170 -4
View File
@@ -23,20 +23,51 @@ use crate::error::{AppError, AppResult};
use crate::payment::ProviderWebhookEvent;
use axum::{
body::Bytes,
extract::State,
extract::{Path, State},
http::{HeaderMap, StatusCode},
};
use chrono::Utc;
/// Multi-provider webhook landing: `/v1/{kind}/webhook/:provider_id`.
/// The provider id picks WHICH provider's secret validates this delivery.
/// Without that, an operator with two BTCPay providers across two merchant
/// profiles would have indistinguishable webhook URLs and BTCPay payloads
/// would round-robin to whoever happened to be "the active provider" at
/// request time. The path-param resolution ensures every delivery is
/// validated against the secret it was created with.
pub async fn handle_for_provider(
State(state): State<AppState>,
Path(provider_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<StatusCode> {
let provider = state.payment_provider_by_id(&provider_id).await?;
handle_inner(state, provider, headers, body).await
}
/// Back-compat landing for the pre-:52 URL shape. Routes to whichever
/// provider is on the default merchant profile. New webhooks registered
/// against `:52`+ use the path-keyed shape above; this exists so any
/// in-flight pre-:52 delivery (or operator misconfiguration) doesn't
/// silently drop on the floor.
pub async fn handle(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<StatusCode> {
// Active provider validates its own webhooks (each provider has a
// different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
// Zaprite's TBD). On any verification failure we 401.
let provider = state.payment_provider().await?;
handle_inner(state, provider, headers, body).await
}
async fn handle_inner(
state: AppState,
provider: std::sync::Arc<dyn crate::payment::PaymentProvider>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<StatusCode> {
// The resolved provider validates its own webhooks (each provider has
// a different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
// Zaprite's externalUniqId round-trip). On verification failure: 401.
let event = provider
.validate_webhook(&headers, &body)
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
@@ -86,6 +117,59 @@ pub async fn handle(
"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?;
@@ -124,6 +208,12 @@ pub async fn handle(
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
@@ -165,6 +255,65 @@ pub async fn handle(
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.
@@ -278,6 +427,21 @@ pub async fn issue_license_for_invoice(
.ok()
.flatten();
if existing.is_none() {
// Snapshot the merchant profile + payment provider that
// settled this purchase, so the renewal worker uses the
// SAME business + payment account on subsequent cycles
// even if the operator later moves the product to a
// different profile. Falls back to the product's
// current profile (and the invoice's recorded provider)
// when the snapshot fields aren't already on the invoice.
let snapshot_profile_id = crate::db::repo::get_merchant_profile_for_product(
&state.db, &invoice.product_id,
)
.await
.ok()
.flatten()
.map(|p| p.id);
let snapshot_provider_id = invoice.payment_provider_id.clone();
match crate::subscriptions::create_subscription(
&state.db,
&license_id,
@@ -287,6 +451,8 @@ pub async fn issue_license_for_invoice(
&listed_currency,
listed_value,
&invoice.id,
snapshot_profile_id.as_deref(),
snapshot_provider_id.as_deref(),
)
.await
{
@@ -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(
+184 -124
View File
@@ -1,48 +1,58 @@
//! Zaprite connect / disconnect / status admin endpoints.
//!
//! Zaprite doesn't expose an OAuth-style consent flow the way
//! BTCPay does — there's no `/authorize` redirect chain. Operators
//! just create an API key in their Zaprite dashboard and paste it
//! in. So this module is much smaller than `btcpay_authorize.rs`:
//! a single connect endpoint validates + stores the key, a
//! disconnect endpoint wipes it, a status endpoint reports state.
//! Zaprite doesn't expose an OAuth-style consent flow the way BTCPay
//! does — there's no `/authorize` redirect chain. Operators just create
//! an API key in their Zaprite dashboard and paste it in. So this
//! module is much smaller than `btcpay_authorize.rs`: a single connect
//! endpoint validates + stores the key, a disconnect endpoint wipes it,
//! a status endpoint reports state.
//!
//! The active provider on `AppState` is swapped atomically as part
//! of connect/disconnect so request handlers immediately see the
//! new state without a daemon restart.
//! Multi-merchant-profile model (migration 0020+): the connect endpoint
//! now takes a `merchant_profile_id` (defaulting to the default profile)
//! and INSERTs a row in `payment_providers` attached to that profile.
//! The disconnect endpoint takes a provider id and deletes that row.
//! 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::{
config as zaprite_config, ZapriteClient, ZapriteProvider,
};
use crate::payment::zaprite::{ZapriteClient, ZapriteProvider};
use axum::{extract::State, http::HeaderMap, Json};
use chrono::Utc;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use uuid::Uuid;
const DEFAULT_BASE_URL: &str = "https://api.zaprite.com";
#[derive(Debug, Deserialize)]
pub struct ConnectReq {
pub api_key: String,
/// Optional override — defaults to https://api.zaprite.com.
/// Useful for sandbox orgs that point at a different host or
/// for future regional endpoints.
/// Optional override — defaults to https://api.zaprite.com. Useful
/// for sandbox orgs (which point at a different host) or for future
/// regional endpoints.
#[serde(default)]
pub base_url: Option<String>,
/// Optional operator-set label distinguishing this Zaprite account
/// from other providers in the admin UI (e.g. "Recaps Zaprite" vs
/// "Keysat Zaprite"). Defaults to "Zaprite — {merchant profile name}".
#[serde(default)]
pub label: Option<String>,
/// Which merchant profile to attach this Zaprite account to. NULL =
/// the default profile. Operators with Pro/Patron tier can name a
/// non-default profile to set up per-business Zaprite orgs.
#[serde(default)]
pub merchant_profile_id: Option<String>,
}
/// `POST /v1/admin/zaprite/connect` — validate + store an API
/// key, then swap the active payment provider to Zaprite. The
/// operator pastes the key from
/// `app.zaprite.com/.../settings/api`.
///
/// `POST /v1/admin/zaprite/connect` — validate + store an API key as a
/// `payment_providers` row attached to the requested merchant profile.
/// Validates the key by calling `GET /v1/orders?limit=1` against
/// Zaprite — auth-guarded, so a 200 confirms the key works for
/// the right org. A 401 / 403 / network error short-circuits
/// before we persist anything.
/// Zaprite — auth-guarded, so a 200 confirms the key works for the
/// right org. A 401 / 403 / network error short-circuits before we
/// persist anything.
pub async fn connect(
State(state): State<AppState>,
headers: HeaderMap,
@@ -57,17 +67,32 @@ pub async fn connect(
return Err(AppError::BadRequest("api_key is required".into()));
}
// Short-circuit: refuse to overwrite an existing config silently.
// Operators get confused when they re-run Connect after already
// being connected — they expect a "you're already set up" message,
// not a form re-prompt that can clobber their working config.
if let Ok(Some(_)) = zaprite_config::load(&state.db).await {
return Err(AppError::Conflict(
"Zaprite is already connected. Run 'Disconnect Zaprite' first \
if you want to rotate the API key or switch organizations."
.into(),
));
// Resolve the target merchant profile. Defaults to the auto-created
// default profile when not specified — single-profile operators
// never see this concept.
let profile = match req.merchant_profile_id.as_deref() {
Some(id) => crate::merchant_profiles::get(&state.db, id)
.await?
.ok_or_else(|| {
AppError::BadRequest(format!("merchant profile {id} not found"))
})?,
None => crate::merchant_profiles::require_default(&state.db).await?,
};
// Refuse if this profile already has a Zaprite provider attached —
// the unique index on (merchant_profile_id, kind) would also catch
// this but a clean 409 message is friendlier than a constraint error.
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
.await?;
if existing.iter().any(|p| p.kind == "zaprite") {
return Err(AppError::Conflict(format!(
"merchant profile '{}' already has a Zaprite provider attached. \
Disconnect it first if you want to rotate the API key or switch \
organizations, or pick a different merchant profile.",
profile.name
)));
}
let base_url = req
.base_url
.as_deref()
@@ -81,10 +106,9 @@ pub async fn connect(
));
}
// Smoke-test the key before saving anything. Zaprite will
// 401 a bad key — surface that as a clean operator-facing
// error rather than letting it crash later in the purchase
// flow.
// Smoke-test the key before saving anything. Zaprite will 401 a
// bad key — surface that as a clean operator-facing error rather
// than letting it crash later in the purchase flow.
let client = ZapriteClient::new(&base_url, &api_key);
client.ping().await.map_err(|e| {
AppError::Upstream(format!(
@@ -92,159 +116,195 @@ pub async fn connect(
))
})?;
// Persist + swap.
zaprite_config::save(
// Persist the new payment_providers row.
let label = req
.label
.as_deref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("Zaprite — {}", profile.name));
let provider_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
crate::db::repo::create_payment_provider(
&state.db,
&zaprite_config::ZapriteConfig {
api_key: api_key.clone(),
base_url: base_url.clone(),
webhook_id: None, // operator configures the webhook in Zaprite's dashboard
},
&provider_id,
&profile.id,
"zaprite",
&label,
&api_key,
&base_url,
None, // webhook_id — operator configures the webhook on Zaprite's dashboard
None, // webhook_secret — Zaprite doesn't sign webhooks
None, // store_id — BTCPay only
&now,
)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("save zaprite_config: {e:#}")))?;
.await?;
// If this is the very first provider on the default profile, also
// populate the legacy state.payment singleton so back-compat call
// sites (the few that still use state.payment_provider()) work
// without waiting for a daemon restart. Per-product resolution
// doesn't use this singleton.
if profile.is_default && existing.is_empty() {
let provider = ZapriteProvider::new(client);
state
.set_payment_provider(Arc::new(provider))
.await;
// Persist the operator's preference so the boot-time loader
// picks Zaprite on next restart, even if BTCPay's config row
// is also still in the DB.
crate::payment::write_active_provider_preference(
&state.db,
crate::payment::ProviderKind::Zaprite,
)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("write provider preference: {e:#}")))?;
state.set_payment_provider(Arc::new(provider)).await;
}
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"zaprite.connect",
"payment_provider.connect",
Some("payment_provider"),
Some("zaprite"),
Some(&provider_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "base_url": base_url }),
&json!({
"kind": "zaprite",
"merchant_profile_id": profile.id,
"base_url": base_url,
}),
)
.await;
// Compute the absolute webhook URL so the StartOS Action can
// surface the full https://... endpoint to the operator. They
// paste this into the Zaprite dashboard exactly. Zaprite's
// webhook form requires a full URL, not a path; the previous
// copy showed a placeholder which was confusing.
// The webhook URL is now path-keyed by provider id so multiple
// Zaprite orgs (one per profile) get isolated webhook deliveries.
// Operator pastes this exact URL into the corresponding Zaprite
// dashboard's webhooks page.
let webhook_url = format!(
"{}/v1/zaprite/webhook",
state.config.public_base_url.trim_end_matches('/')
"{}/v1/zaprite/webhook/{}",
state.config.public_base_url.trim_end_matches('/'),
provider_id
);
Ok(Json(json!({
"ok": true,
"provider": "zaprite",
"provider_id": provider_id,
"merchant_profile_id": profile.id,
"merchant_profile_name": profile.name,
"label": label,
"base_url": base_url,
"webhook_url": webhook_url,
})))
}
/// `POST /v1/admin/zaprite/disconnect` — wipe the stored key,
/// clear the active provider. Operator should also delete the
/// corresponding webhook on Zaprite's side, but we don't reach
/// out to Zaprite to delete it — the operator uses Zaprite's
/// dashboard for that. We can't delete it programmatically because
#[derive(Debug, Deserialize)]
pub struct DisconnectReq {
/// Which provider row to disconnect. NULL = disconnect the Zaprite
/// provider on the default profile (back-compat for the single-
/// profile case).
#[serde(default)]
pub provider_id: Option<String>,
}
/// `POST /v1/admin/zaprite/disconnect` — delete the named provider
/// row (or the default-profile Zaprite row when no id is supplied).
/// Operator should also delete the corresponding webhook on Zaprite's
/// dashboard — we don't reach out to Zaprite to delete it because
/// Zaprite's webhook-management endpoints aren't on the public
/// OpenAPI we have access to.
pub async fn disconnect(
State(state): State<AppState>,
headers: HeaderMap,
body: Option<Json<DisconnectReq>>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let req = body.map(|Json(b)| b).unwrap_or_default();
// No-op if nothing's connected.
let existing = zaprite_config::load(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
})?;
if existing.is_none() {
let provider_id = match req.provider_id {
Some(id) => id,
None => {
// Default-profile fallback: find the Zaprite provider on the
// default profile, if any.
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?;
match rows.into_iter().find(|p| p.kind == "zaprite") {
Some(row) => row.id,
None => {
return Ok(Json(json!({
"ok": true,
"noop": true,
"message": "Zaprite was not connected",
"message": "no Zaprite provider connected on the default merchant profile",
})));
}
zaprite_config::clear(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
})?;
state.clear_payment_provider().await;
// If the active-provider preference was Zaprite, clear it.
// Don't blindly clear if it was BTCPay — that's a different
// operator's choice we shouldn't undo just because they ran
// Disconnect Zaprite.
if matches!(
crate::payment::read_active_provider_preference(&state.db).await,
Some(crate::payment::ProviderKind::Zaprite)
) {
let _ = crate::db::repo::settings_set(
&state.db,
crate::payment::SETTING_ACTIVE_PROVIDER,
None,
)
.await;
}
}
};
crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?;
// Clear the back-compat singleton if it happens to be the one we
// just deleted. This is best-effort — the singleton may be holding
// a different provider entirely.
state.clear_payment_provider().await;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"zaprite.disconnect",
"payment_provider.disconnect",
Some("payment_provider"),
Some("zaprite"),
Some(&provider_id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
&json!({ "kind": "zaprite" }),
)
.await;
Ok(Json(json!({
"ok": true,
"noop": false,
"message": "Zaprite disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
"provider_id": provider_id,
"message": "Zaprite provider disconnected. Don't forget to delete the corresponding webhook on Zaprite's side at app.zaprite.com.",
})))
}
/// `GET /v1/admin/zaprite/status` — operator-facing connection
/// snapshot. Reports whether Zaprite is the active provider, the
/// base URL, and whether a webhook id has been recorded. Does NOT
/// return the API key (mirroring how btcpay/status redacts).
impl Default for DisconnectReq {
fn default() -> Self {
Self { provider_id: None }
}
}
/// `GET /v1/admin/zaprite/status` — connection snapshot for the
/// default profile (back-compat with the existing admin UI's
/// payment-providers card). Multi-profile operators should use the
/// new `/v1/admin/merchant-profiles/{id}` endpoint instead, which
/// lists ALL providers across all profiles.
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let cfg = zaprite_config::load(&state.db).await.map_err(|e| {
AppError::Internal(anyhow::anyhow!("load zaprite_config: {e:#}"))
})?;
let active_provider = match state.payment.read().await.as_ref() {
Some(p) => Some(p.kind().as_str().to_string()),
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) => {
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
.await?;
rows.into_iter().find(|p| p.kind == "zaprite")
}
None => None,
};
let webhook_url = format!(
let webhook_url = match &connected_row {
Some(row) => format!(
"{}/v1/zaprite/webhook/{}",
state.config.public_base_url.trim_end_matches('/'),
row.id
),
None => format!(
"{}/v1/zaprite/webhook",
state.config.public_base_url.trim_end_matches('/')
);
),
};
Ok(Json(json!({
"connected": cfg.is_some(),
"active_provider": active_provider,
"base_url": cfg.as_ref().map(|c| c.base_url.clone()),
"webhook_id": cfg.as_ref().and_then(|c| c.webhook_id.clone()),
// Surfaced unconditionally so an operator who lost the
// first-connect message can still find the URL to paste
// into Zaprite's dashboard. Webhook-not-yet-registered
// doesn't change the URL — it's the same address Zaprite
// would POST to once registered.
"connected": connected_row.is_some(),
"provider_id": connected_row.as_ref().map(|r| r.id.clone()),
"base_url": connected_row.as_ref().map(|r| r.base_url.clone()),
"label": connected_row.as_ref().map(|r| r.label.clone()),
"webhook_id": connected_row.as_ref().and_then(|r| r.webhook_id.clone()),
"merchant_profile_id": default.as_ref().map(|p| p.id.clone()),
"merchant_profile_name": default.as_ref().map(|p| p.name.clone()),
"webhook_url": webhook_url,
"webhook_explainer": "Zaprite doesn't sign webhook deliveries. \
Keysat authenticates each delivery via the externalUniqId we attach \
+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))
}
+58 -10
View File
@@ -79,15 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
Ok(())
}
/// Record a new in-flight authorize state token. The caller has already
/// generated a cryptographically-random token.
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
/// 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, 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")?;
@@ -101,11 +133,18 @@ pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()
}
/// Validate that `token` was issued recently and has not been consumed.
/// Consumes (deletes) the token on success so a replay fails.
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
/// 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<AuthorizeState> {
use sqlx::Row;
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let row = sqlx::query(
"SELECT state_token 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)
@@ -113,13 +152,22 @@ pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<(
.fetch_optional(pool)
.await?;
if row.is_none() {
let Some(row) = row else {
return Err(anyhow!("unknown or expired authorize state token"));
}
};
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(())
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,
})
}
}
+537 -8
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")?,
})
@@ -355,6 +398,7 @@ pub async fn create_invoice(
buyer_email: Option<&str>,
buyer_note: Option<&str>,
policy_id: Option<&str>,
payment_provider_id: Option<&str>,
) -> AppResult<Invoice> {
create_invoice_with_currency(
pool,
@@ -370,6 +414,7 @@ pub async fn create_invoice(
None,
None,
None,
payment_provider_id,
)
.await
}
@@ -395,6 +440,7 @@ pub async fn create_invoice_with_currency(
listed_value: Option<i64>,
exchange_rate_centibps: Option<i64>,
exchange_rate_source: Option<&str>,
payment_provider_id: Option<&str>,
) -> AppResult<Invoice> {
let now = Utc::now().to_rfc3339();
sqlx::query(
@@ -402,8 +448,9 @@ pub async fn create_invoice_with_currency(
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, policy_id,
listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source,
payment_provider_id,
created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(btcpay_invoice_id)
@@ -417,6 +464,7 @@ pub async fn create_invoice_with_currency(
.bind(listed_value)
.bind(exchange_rate_centibps)
.bind(exchange_rate_source)
.bind(payment_provider_id)
.bind(&now)
.bind(&now)
.execute(pool)
@@ -466,7 +514,8 @@ pub async fn create_free_invoice(
pub async fn get_invoice_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Invoice>> {
let row = sqlx::query(
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at, policy_id
amount_sats, checkout_url, created_at, updated_at, policy_id,
listed_currency, listed_value, payment_provider_id
FROM invoices WHERE id = ?",
)
.bind(id)
@@ -481,7 +530,8 @@ pub async fn get_invoice_by_btcpay_id(
) -> AppResult<Option<Invoice>> {
let row = sqlx::query(
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at, policy_id
amount_sats, checkout_url, created_at, updated_at, policy_id,
listed_currency, listed_value, payment_provider_id
FROM invoices WHERE btcpay_invoice_id = ?",
)
.bind(btcpay_invoice_id)
@@ -517,7 +567,8 @@ pub async fn list_pending_invoices(
let cutoff = (Utc::now() - chrono::Duration::hours(max_age_hours)).to_rfc3339();
let rows = sqlx::query(
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
amount_sats, checkout_url, created_at, updated_at, policy_id
amount_sats, checkout_url, created_at, updated_at, policy_id,
listed_currency, listed_value, payment_provider_id
FROM invoices
WHERE status = 'pending' AND created_at >= ?
ORDER BY created_at ASC",
@@ -546,6 +597,10 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
.ok()
.flatten(),
listed_value: row.try_get::<Option<i64>, _>("listed_value").ok().flatten(),
payment_provider_id: row
.try_get::<Option<String>, _>("payment_provider_id")
.ok()
.flatten(),
}
}
@@ -2891,3 +2946,477 @@ pub async fn settings_set(pool: &SqlitePool, key: &str, value: Option<&str>) ->
.await?;
Ok(())
}
// =========================================================================
// Merchant profiles (migration 0020)
// =========================================================================
const MERCHANT_PROFILE_COLS: &str =
"id, name, legal_name, support_url, support_email, brand_color, \
post_purchase_redirect_url, is_default, \
smtp_host, smtp_port, smtp_username, smtp_password, \
smtp_from_address, smtp_from_name, smtp_use_starttls, \
created_at, updated_at";
fn row_to_merchant_profile(
row: sqlx::sqlite::SqliteRow,
) -> crate::merchant_profiles::MerchantProfile {
use sqlx::Row;
crate::merchant_profiles::MerchantProfile {
id: row.get("id"),
name: row.get("name"),
legal_name: row.try_get("legal_name").ok(),
support_url: row.try_get("support_url").ok(),
support_email: row.try_get("support_email").ok(),
brand_color: row.try_get("brand_color").ok(),
post_purchase_redirect_url: row.try_get("post_purchase_redirect_url").ok(),
is_default: row.get::<i64, _>("is_default") != 0,
smtp_host: row.try_get("smtp_host").ok(),
smtp_port: row.try_get("smtp_port").ok(),
smtp_username: row.try_get("smtp_username").ok(),
smtp_password: row.try_get("smtp_password").ok(),
smtp_from_address: row.try_get("smtp_from_address").ok(),
smtp_from_name: row.try_get("smtp_from_name").ok(),
smtp_use_starttls: row.get::<i64, _>("smtp_use_starttls") != 0,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_merchant_profile(
pool: &SqlitePool,
id: &str,
name: &str,
legal_name: Option<&str>,
support_url: Option<&str>,
support_email: Option<&str>,
brand_color: Option<&str>,
post_purchase_redirect_url: Option<&str>,
is_default: bool,
now: &str,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO merchant_profiles(\
id, name, legal_name, support_url, support_email, brand_color, \
post_purchase_redirect_url, is_default, \
smtp_use_starttls, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
)
.bind(id)
.bind(name)
.bind(legal_name)
.bind(support_url)
.bind(support_email)
.bind(brand_color)
.bind(post_purchase_redirect_url)
.bind(is_default as i64)
.bind(now)
.bind(now)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_merchant_profile_by_id(
pool: &SqlitePool,
id: &str,
) -> AppResult<Option<crate::merchant_profiles::MerchantProfile>> {
let row = sqlx::query(&format!(
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles WHERE id = ?"
))
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_merchant_profile))
}
pub async fn get_default_merchant_profile(
pool: &SqlitePool,
) -> AppResult<Option<crate::merchant_profiles::MerchantProfile>> {
let row = sqlx::query(&format!(
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles WHERE is_default = 1 LIMIT 1"
))
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_merchant_profile))
}
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 \
WHERE id = (SELECT merchant_profile_id FROM products WHERE id = ?) LIMIT 1"
))
.bind(product_id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_merchant_profile))
}
pub async fn list_merchant_profiles(
pool: &SqlitePool,
) -> AppResult<Vec<crate::merchant_profiles::MerchantProfile>> {
let rows = sqlx::query(&format!(
"SELECT {MERCHANT_PROFILE_COLS} FROM merchant_profiles \
ORDER BY is_default DESC, created_at DESC"
))
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_merchant_profile).collect())
}
pub async fn update_merchant_profile(
pool: &SqlitePool,
id: &str,
patch: &crate::merchant_profiles::MerchantProfileUpdate,
) -> AppResult<()> {
use crate::merchant_profiles::MerchantProfileUpdate;
let MerchantProfileUpdate {
name,
legal_name,
support_url,
support_email,
brand_color,
post_purchase_redirect_url,
smtp_host,
smtp_port,
smtp_username,
smtp_password,
smtp_from_address,
smtp_from_name,
smtp_use_starttls,
} = patch;
// Build the SET clause dynamically — only update fields the caller
// explicitly set. Outer Option means "skip if None"; inner Option
// (on nullable fields) means "set to NULL if Some(None), set to a
// value if Some(Some(value))."
let mut sets: Vec<&'static str> = Vec::new();
if name.is_some() { sets.push("name = ?"); }
if legal_name.is_some() { sets.push("legal_name = ?"); }
if support_url.is_some() { sets.push("support_url = ?"); }
if support_email.is_some() { sets.push("support_email = ?"); }
if brand_color.is_some() { sets.push("brand_color = ?"); }
if post_purchase_redirect_url.is_some() { sets.push("post_purchase_redirect_url = ?"); }
if smtp_host.is_some() { sets.push("smtp_host = ?"); }
if smtp_port.is_some() { sets.push("smtp_port = ?"); }
if smtp_username.is_some() { sets.push("smtp_username = ?"); }
if smtp_password.is_some() { sets.push("smtp_password = ?"); }
if smtp_from_address.is_some() { sets.push("smtp_from_address = ?"); }
if smtp_from_name.is_some() { sets.push("smtp_from_name = ?"); }
if smtp_use_starttls.is_some() { sets.push("smtp_use_starttls = ?"); }
if sets.is_empty() {
return Ok(()); // nothing to update
}
sets.push("updated_at = ?");
let sql = format!(
"UPDATE merchant_profiles SET {} WHERE id = ?",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
if let Some(v) = name { q = q.bind(v); }
if let Some(v) = legal_name { q = q.bind(v.as_deref()); }
if let Some(v) = support_url { q = q.bind(v.as_deref()); }
if let Some(v) = support_email { q = q.bind(v.as_deref()); }
if let Some(v) = brand_color { q = q.bind(v.as_deref()); }
if let Some(v) = post_purchase_redirect_url { q = q.bind(v.as_deref()); }
if let Some(v) = smtp_host { q = q.bind(v.as_deref()); }
if let Some(v) = smtp_port { q = q.bind(*v); }
if let Some(v) = smtp_username { q = q.bind(v.as_deref()); }
if let Some(v) = smtp_password { q = q.bind(v.as_deref()); }
if let Some(v) = smtp_from_address { q = q.bind(v.as_deref()); }
if let Some(v) = smtp_from_name { q = q.bind(v.as_deref()); }
if let Some(v) = smtp_use_starttls { q = q.bind(*v as i64); }
let now = Utc::now().to_rfc3339();
q = q.bind(&now).bind(id);
q.execute(pool).await?;
Ok(())
}
/// Flip a profile to be the default. Two-step UPDATE in a single
/// transaction to maintain the partial unique index on is_default = 1.
pub async fn set_default_merchant_profile(
pool: &SqlitePool,
new_default_id: &str,
) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
let mut tx = pool.begin().await?;
sqlx::query("UPDATE merchant_profiles SET is_default = 0, updated_at = ? WHERE is_default = 1")
.bind(&now)
.execute(&mut *tx)
.await?;
let rows = sqlx::query("UPDATE merchant_profiles SET is_default = 1, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(new_default_id)
.execute(&mut *tx)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("merchant profile {new_default_id}")));
}
tx.commit().await?;
Ok(())
}
pub async fn delete_merchant_profile(pool: &SqlitePool, id: &str) -> AppResult<()> {
// Also cascade the rail_preferences entries (no ON DELETE CASCADE
// on that table since it's a composite primary key; cleaner to
// delete explicitly).
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM merchant_profile_rail_preferences WHERE merchant_profile_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
let rows = sqlx::query("DELETE FROM merchant_profiles WHERE id = ? AND is_default = 0")
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::BadRequest(format!(
"merchant profile {id} not found or is the default"
)));
}
tx.commit().await?;
Ok(())
}
pub async fn count_products_for_profile(pool: &SqlitePool, profile_id: &str) -> anyhow::Result<i64> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM products WHERE merchant_profile_id = ?",
)
.bind(profile_id)
.fetch_one(pool)
.await?;
Ok(n)
}
pub async fn count_active_subscriptions_for_profile(
pool: &SqlitePool,
profile_id: &str,
) -> anyhow::Result<i64> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM subscriptions \
WHERE merchant_profile_id = ? AND status IN ('active', 'past_due')",
)
.bind(profile_id)
.fetch_one(pool)
.await?;
Ok(n)
}
// =========================================================================
// Payment providers (migration 0020) — replaces btcpay_config + zaprite_config
// =========================================================================
/// Stored shape of a payment_providers row. Used by the provider factory
/// in `payment::build_provider` to reconstruct a typed PaymentProvider
/// trait object from a row.
#[derive(Debug, Clone)]
pub struct PaymentProviderRow {
pub id: String,
pub merchant_profile_id: String,
pub kind: String,
pub label: String,
pub api_key: String,
pub base_url: String,
pub webhook_id: Option<String>,
pub webhook_secret: Option<String>,
pub store_id: Option<String>,
pub connected_at: String,
pub updated_at: String,
}
const PAYMENT_PROVIDER_COLS: &str =
"id, merchant_profile_id, kind, label, api_key, base_url, \
webhook_id, webhook_secret, store_id, connected_at, updated_at";
fn row_to_payment_provider(row: sqlx::sqlite::SqliteRow) -> PaymentProviderRow {
use sqlx::Row;
PaymentProviderRow {
id: row.get("id"),
merchant_profile_id: row.get("merchant_profile_id"),
kind: row.get("kind"),
label: row.get("label"),
api_key: row.get("api_key"),
base_url: row.get("base_url"),
webhook_id: row.try_get("webhook_id").ok(),
webhook_secret: row.try_get("webhook_secret").ok(),
store_id: row.try_get("store_id").ok(),
connected_at: row.get("connected_at"),
updated_at: row.get("updated_at"),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_payment_provider(
pool: &SqlitePool,
id: &str,
merchant_profile_id: &str,
kind: &str,
label: &str,
api_key: &str,
base_url: &str,
webhook_id: Option<&str>,
webhook_secret: Option<&str>,
store_id: Option<&str>,
now: &str,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO payment_providers(\
id, merchant_profile_id, kind, label, api_key, base_url, \
webhook_id, webhook_secret, store_id, connected_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(merchant_profile_id)
.bind(kind)
.bind(label)
.bind(api_key)
.bind(base_url)
.bind(webhook_id)
.bind(webhook_secret)
.bind(store_id)
.bind(now)
.bind(now)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_payment_provider_by_id(
pool: &SqlitePool,
id: &str,
) -> AppResult<Option<PaymentProviderRow>> {
let row = sqlx::query(&format!(
"SELECT {PAYMENT_PROVIDER_COLS} FROM payment_providers WHERE id = ?"
))
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_payment_provider))
}
pub async fn list_payment_providers_for_profile(
pool: &SqlitePool,
profile_id: &str,
) -> AppResult<Vec<PaymentProviderRow>> {
let rows = sqlx::query(&format!(
"SELECT {PAYMENT_PROVIDER_COLS} FROM payment_providers \
WHERE merchant_profile_id = ? ORDER BY connected_at ASC"
))
.bind(profile_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_payment_provider).collect())
}
pub async fn list_all_payment_providers(pool: &SqlitePool) -> AppResult<Vec<PaymentProviderRow>> {
let rows = sqlx::query(&format!(
"SELECT {PAYMENT_PROVIDER_COLS} FROM payment_providers ORDER BY connected_at ASC"
))
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_payment_provider).collect())
}
pub async fn delete_payment_provider(pool: &SqlitePool, id: &str) -> AppResult<()> {
let mut tx = pool.begin().await?;
// Cascade rail preferences pointing at this provider.
sqlx::query("DELETE FROM merchant_profile_rail_preferences WHERE payment_provider_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
let rows = sqlx::query("DELETE FROM payment_providers WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("payment provider {id}")));
}
tx.commit().await?;
Ok(())
}
// =========================================================================
// Merchant profile rail preferences
// =========================================================================
/// (rail, provider_id) tuple representing one preference row.
#[derive(Debug, Clone)]
pub struct RailPreference {
pub rail: String,
pub payment_provider_id: String,
}
pub async fn list_rail_preferences_for_profile(
pool: &SqlitePool,
profile_id: &str,
) -> AppResult<Vec<RailPreference>> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT rail, payment_provider_id FROM merchant_profile_rail_preferences \
WHERE merchant_profile_id = ?",
)
.bind(profile_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| RailPreference {
rail: r.get("rail"),
payment_provider_id: r.get("payment_provider_id"),
})
.collect())
}
/// Upsert a (profile, rail) → provider mapping. Replaces any existing
/// preference for the same (profile, rail) pair.
pub async fn set_rail_preference(
pool: &SqlitePool,
profile_id: &str,
rail: &str,
provider_id: &str,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO merchant_profile_rail_preferences(\
merchant_profile_id, rail, payment_provider_id) \
VALUES (?, ?, ?) \
ON CONFLICT(merchant_profile_id, rail) DO UPDATE SET \
payment_provider_id = excluded.payment_provider_id",
)
.bind(profile_id)
.bind(rail)
.bind(provider_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn clear_rail_preference(
pool: &SqlitePool,
profile_id: &str,
rail: &str,
) -> AppResult<()> {
sqlx::query(
"DELETE FROM merchant_profile_rail_preferences \
WHERE merchant_profile_id = ? AND rail = ?",
)
.bind(profile_id)
.bind(rail)
.execute(pool)
.await?;
Ok(())
}
+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"
}
};
+1
View File
@@ -15,6 +15,7 @@ pub mod crypto;
pub mod db;
pub mod error;
pub mod license_self;
pub mod merchant_profiles;
pub mod models;
pub mod payment;
pub mod rate_limit;
+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"])
);
}
}
+50 -40
View File
@@ -57,61 +57,70 @@ async fn main() -> anyhow::Result<()> {
keypair.public_key_pem.trim()
);
// --- payment provider (may be None until operator connects) ---
// Resolution order:
// 1. operator's explicit preference from the
// active_payment_provider setting (set by the most recent
// Connect or Activate action),
// 2. fallback for legacy installs without the setting:
// BTCPay first, Zaprite second. Once we ship v0.3 with the
// multi-provider routing layer this fallback retires.
let preferred = payment::read_active_provider_preference(&pool).await;
let provider: Option<Arc<dyn payment::PaymentProvider>> = match preferred {
Some(payment::ProviderKind::Zaprite) => {
// Operator explicitly chose Zaprite. Try Zaprite; if it
// can't be loaded (e.g., the row was deleted out from
// under the setting), fall through to BTCPay rather
// than booting unconfigured.
load_zaprite_provider(&pool)
.await
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>)
.or_else(|| {
// --- payment provider boot-time warm-up ---
//
// With the multi-merchant-profile model (migration 0020+) we no longer
// load a single "active" provider at boot. Providers are looked up by
// id on demand via `AppState::payment_provider_by_id` (which builds
// from a `payment_providers` row each time it's called) and resolved
// per purchase via `resolve_provider_for_product_rail`.
//
// For back-compat we still populate the legacy `state.payment`
// singleton with the FIRST provider attached to the default merchant
// profile — this is what `state.payment_provider()` returns to the
// remaining legacy call sites (and is a sensible fallback for any
// code path that runs before the operator has linked a product to a
// specific profile). Empty profile → empty singleton; the on-demand
// resolution layer takes over from there.
let provider: Option<Arc<dyn payment::PaymentProvider>> = match keysat::db::repo::get_default_merchant_profile(&pool).await {
Ok(Some(profile)) => match keysat::db::repo::list_payment_providers_for_profile(&pool, &profile.id).await {
Ok(rows) => match rows.first() {
Some(row) => match payment::build_provider(row, cfg.btcpay_public_url.as_deref()) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!(
"active_payment_provider=zaprite but zaprite_config is missing; \
falling back to BTCPay"
provider_id = %row.id,
error = %e,
"failed to build provider from default-profile row; \
leaving legacy state.payment empty"
);
None
})
.or(load_btcpay_provider(&pool, &cfg)
.await
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>))
}
Some(payment::ProviderKind::Btcpay) | None => {
// Either operator chose BTCPay, or no preference recorded
// yet (legacy install). Either way, BTCPay wins if
// configured; Zaprite as fallback.
},
None => None,
},
Err(e) => {
tracing::warn!(
profile_id = %profile.id,
error = %e,
"failed to list providers on default profile at boot"
);
None
}
},
Ok(None) => {
// Pre-migration: no default profile exists yet (operator hasn't
// installed :52 yet). Fall back to the legacy singleton-config
// loaders so the daemon still boots cleanly during the upgrade
// window — these run against btcpay_config / zaprite_config
// until migration 0020 drops those tables.
load_btcpay_provider(&pool, &cfg)
.await
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>)
.or_else(|| {
if preferred == Some(payment::ProviderKind::Btcpay) {
tracing::warn!(
"active_payment_provider=btcpay but btcpay_config is missing; \
falling back to Zaprite"
);
}
None
})
.or(load_zaprite_provider(&pool)
.await
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>))
}
Err(e) => {
tracing::warn!(error = %e, "failed to read default merchant profile at boot");
None
}
};
match &provider {
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
Some(p) => tracing::info!(provider = p.kind().as_str(), "default payment provider warmed up"),
None => tracing::warn!(
"no payment provider yet configured — purchases will return 503 until the \
operator completes the 'Connect BTCPay' or 'Connect Zaprite' flow"
operator completes 'Connect BTCPay' or 'Connect Zaprite' in the admin UI"
),
}
@@ -119,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(),
+230
View File
@@ -0,0 +1,230 @@
//! Merchant profile layer.
//!
//! A merchant profile represents one "business" the operator is running
//! on a Keysat instance. Owns business identity (brand, support contact,
//! redirect URL, optional SMTP) and a set of payment providers attached
//! to it (BTCPay + Zaprite + future kinds). Products attach to a
//! merchant profile, not directly to a provider.
//!
//! Tier gating:
//! - **Creator (free)**: exactly 1 profile (the auto-created default).
//! - **Pro / Patron**: unlimited profiles.
//!
//! The schema lives in `migrations/0020_merchant_profiles.sql`. Repo
//! helpers (raw SQL) live in `db::repo`; this module wraps them with
//! business-logic guards (tier check, single-default enforcement, etc.).
//!
//! See `plans/multi-provider-payment-model.md` for the design rationale.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use anyhow::Context;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;
/// A merchant profile row. Mirrors the `merchant_profiles` table.
///
/// 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,
pub name: String,
pub legal_name: Option<String>,
pub support_url: Option<String>,
pub support_email: Option<String>,
pub brand_color: Option<String>,
pub post_purchase_redirect_url: Option<String>,
pub is_default: bool,
// 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>,
pub smtp_password: Option<String>,
pub smtp_from_address: Option<String>,
pub smtp_from_name: Option<String>,
pub smtp_use_starttls: bool,
pub created_at: String,
pub updated_at: String,
}
/// Input for `create` — only the operator-set fields. id, is_default,
/// created_at, updated_at are filled in by this layer.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct NewMerchantProfile {
pub name: String,
pub legal_name: Option<String>,
pub support_url: Option<String>,
pub support_email: Option<String>,
pub brand_color: Option<String>,
pub post_purchase_redirect_url: Option<String>,
}
/// Input for `update` — every field optional. None means "leave unchanged."
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MerchantProfileUpdate {
pub name: Option<String>,
pub legal_name: Option<Option<String>>,
pub support_url: Option<Option<String>>,
pub support_email: Option<Option<String>>,
pub brand_color: Option<Option<String>>,
pub post_purchase_redirect_url: Option<Option<String>>,
pub smtp_host: Option<Option<String>>,
pub smtp_port: Option<Option<i64>>,
pub smtp_username: Option<Option<String>>,
pub smtp_password: Option<Option<String>>,
pub smtp_from_address: Option<Option<String>>,
pub smtp_from_name: Option<Option<String>>,
pub smtp_use_starttls: Option<bool>,
}
/// Look up a profile by id. Returns `Ok(None)` if not found.
pub async fn get(pool: &SqlitePool, id: &str) -> AppResult<Option<MerchantProfile>> {
repo::get_merchant_profile_by_id(pool, id).await
}
/// Return the default profile. Migration 0020 guarantees exactly one
/// exists post-migration, so this returning None at runtime is an
/// invariant violation and the caller should treat it as fatal.
pub async fn get_default(pool: &SqlitePool) -> AppResult<Option<MerchantProfile>> {
repo::get_default_merchant_profile(pool).await
}
/// Required default profile lookup. Returns AppError::Internal if no
/// default exists (which would mean the migration was skipped or the
/// row was somehow deleted — neither should happen in normal operation).
pub async fn require_default(pool: &SqlitePool) -> AppResult<MerchantProfile> {
get_default(pool).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"no default merchant profile — migration 0020 may not have run"
))
})
}
/// List all merchant profiles, newest-first.
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<MerchantProfile>> {
repo::list_merchant_profiles(pool).await
}
/// Look up the merchant profile a product belongs to. Resolves via
/// `products.merchant_profile_id`. Returns the DEFAULT profile if the
/// product has no profile id set (back-compat for any rows that slipped
/// through the migration with NULL — shouldn't happen but defensive).
pub async fn for_product(state: &AppState, product_id: &str) -> AppResult<MerchantProfile> {
if let Some(p) = repo::get_merchant_profile_for_product(&state.db, product_id).await? {
return Ok(p);
}
require_default(&state.db).await
}
/// Create a new merchant profile. Enforces the Creator tier cap: if the
/// operator's current tier returns a `merchant_profile` cap of 1 and
/// at least one profile already exists, returns `AppError::TierCap`
/// pointing at the upgrade URL.
///
/// New profiles default to `is_default = 0`. Use `set_default` to flip
/// the default flag explicitly — the auto-created post-migration profile
/// is always the default; subsequent profiles never become default by
/// creation alone.
pub async fn create(
state: &AppState,
input: NewMerchantProfile,
) -> AppResult<MerchantProfile> {
// Tier gate: Creator gets 1 profile (the auto-created default).
// Pro / Patron with `unlimited_merchant_profiles` get N. Returns
// AppError::PaymentRequired (HTTP 402) with the upgrade URL so the
// admin UI can render the existing tier-cap modal.
crate::api::tier::enforce_merchant_profile_cap(state).await?;
if input.name.trim().is_empty() {
return Err(AppError::BadRequest("merchant profile name required".into()));
}
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
repo::create_merchant_profile(
&state.db,
&id,
&input.name,
input.legal_name.as_deref(),
input.support_url.as_deref(),
input.support_email.as_deref(),
input.brand_color.as_deref(),
input.post_purchase_redirect_url.as_deref(),
false, // is_default
&now,
)
.await?;
get(&state.db, &id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("created profile not found")))
}
/// Update a profile. Only fields with `Some(...)` are written;
/// double-Option wraps nullable fields so callers can distinguish
/// "leave unchanged" (`None`) from "set to NULL" (`Some(None)`).
pub async fn update(
pool: &SqlitePool,
id: &str,
patch: MerchantProfileUpdate,
) -> AppResult<MerchantProfile> {
repo::update_merchant_profile(pool, id, &patch).await?;
get(pool, id)
.await?
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))
}
/// Flip a profile to be the default. Atomic: clears the previous
/// default in the same transaction so the partial unique index holds.
pub async fn set_default(pool: &SqlitePool, id: &str) -> AppResult<()> {
repo::set_default_merchant_profile(pool, id).await
}
/// Delete a profile. Refuses if any product OR active subscription
/// is still attached. Refuses if it's the default profile (operator
/// must set another profile as default first).
pub async fn delete(pool: &SqlitePool, id: &str) -> AppResult<()> {
let profile = get(pool, id).await?.ok_or_else(|| {
AppError::BadRequest(format!("merchant profile {id} not found"))
})?;
if profile.is_default {
return Err(AppError::BadRequest(
"cannot delete the default merchant profile — set another profile as default first"
.into(),
));
}
let product_count = repo::count_products_for_profile(pool, id)
.await
.context("count_products_for_profile")
.map_err(AppError::Internal)?;
if product_count > 0 {
return Err(AppError::BadRequest(format!(
"cannot delete merchant profile: {product_count} products still attached. \
Move or delete the products first."
)));
}
let active_sub_count = repo::count_active_subscriptions_for_profile(pool, id)
.await
.context("count_active_subscriptions_for_profile")
.map_err(AppError::Internal)?;
if active_sub_count > 0 {
return Err(AppError::BadRequest(format!(
"cannot delete merchant profile: {active_sub_count} active subscriptions \
still attached. Cancel them first or migrate them to another profile."
)));
}
repo::delete_merchant_profile(pool, id).await
}
+13
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,
}
@@ -114,6 +120,13 @@ pub struct Invoice {
/// `amount_sats` (which is correct for SAT-priced products).
#[serde(default)]
pub listed_value: Option<i64>,
/// Which payment provider settled this invoice. Added by migration
/// 0021 alongside the multi-merchant-profile work; NULL on pre-0021
/// invoices (backfilled by the migration to the first provider on
/// the default profile). Required for reconcile.rs to dispatch
/// status checks to the right provider when multiple are configured.
#[serde(default)]
pub payment_provider_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+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(
+171 -27
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
@@ -40,43 +41,63 @@ use std::any::Any;
pub mod btcpay;
pub mod zaprite;
/// Settings-table key that records which provider the operator
/// last activated. Used by the boot-time loader to pick which
/// provider to load when both `btcpay_config` and `zaprite_config`
/// are populated. Values: `'btcpay'` | `'zaprite'`. Absent means
/// "use whichever single provider is configured" (back-compat
/// for installs that pre-date this setting).
// =========================================================================
// Legacy compatibility shims — DEPRECATED, will be removed once all call
// sites migrate to the merchant-profile-aware resolution layer.
// =========================================================================
//
// During the multi-provider transition the singleton-config-and-active-
// provider-preference helpers stay callable so the existing connect flows
// (`btcpay_authorize.rs`, `zaprite_authorize.rs`) and the boot loader in
// `main.rs` keep working. Each shim wraps the new schema with the old
// semantics: `read_active_provider_preference` looks up the first provider
// attached to the default merchant profile and returns its kind;
// `write_active_provider_preference` is a no-op (the new model doesn't
// track an "active provider" preference — providers attach to profiles,
// profiles attach to products).
#[deprecated(
note = "use merchant-profile-aware resolution: \
state.payment_provider_for(product_id, rail)"
)]
pub const SETTING_ACTIVE_PROVIDER: &str = "active_payment_provider";
/// Convenience getter for the active-provider setting. Returns
/// `Some(ProviderKind)` if the operator has explicitly chosen
/// one, `None` if they haven't (caller falls back to the
/// load-order heuristic).
#[deprecated(
note = "look up providers via list_payment_providers_for_profile or \
payment_provider_by_id on AppState"
)]
pub async fn read_active_provider_preference(
pool: &sqlx::SqlitePool,
) -> Option<ProviderKind> {
// Post-migration: derive from the first provider attached to the
// default merchant profile (deterministic by connected_at ASC).
// Pre-migration (if the migration hasn't run yet on this DB):
// fall back to the legacy settings-table read.
let default_profile = crate::db::repo::get_default_merchant_profile(pool).await.ok().flatten();
if let Some(profile) = default_profile {
if let Ok(rows) = crate::db::repo::list_payment_providers_for_profile(pool, &profile.id).await {
if let Some(first) = rows.first() {
return ProviderKind::parse(&first.kind);
}
}
}
// Legacy fallback for the pre-migration window.
match crate::db::repo::settings_get(pool, SETTING_ACTIVE_PROVIDER).await {
Ok(Some(s)) => match s.as_str() {
"btcpay" => Some(ProviderKind::Btcpay),
"zaprite" => Some(ProviderKind::Zaprite),
_ => None,
},
Ok(Some(s)) => ProviderKind::parse(&s),
_ => None,
}
}
/// Persist the operator's active-provider preference. Called by
/// the connect endpoints (Connect BTCPay, Connect Zaprite) and
/// by the new "Activate <provider>" endpoint that flips between
/// already-configured providers without re-authorizing.
#[deprecated(
note = "providers are now attached to merchant profiles, not implicitly active. \
This shim is a no-op; remove the call."
)]
pub async fn write_active_provider_preference(
pool: &sqlx::SqlitePool,
kind: ProviderKind,
_pool: &sqlx::SqlitePool,
_kind: ProviderKind,
) -> anyhow::Result<()> {
let value = kind.as_str();
crate::db::repo::settings_set(pool, SETTING_ACTIVE_PROVIDER, Some(value))
.await
.map_err(|e| anyhow::anyhow!("write active provider preference: {e:#}"))?;
// No-op. In the multi-provider model there's no "active" preference
// to write — providers are looked up by id (per-product) or by profile.
Ok(())
}
@@ -94,6 +115,95 @@ impl ProviderKind {
ProviderKind::Zaprite => "zaprite",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"btcpay" => Some(Self::Btcpay),
"zaprite" => Some(Self::Zaprite),
_ => None,
}
}
}
/// Buyer-facing payment method. The buy page renders a picker over these
/// (when a merchant profile exposes more than one); the routing layer maps
/// the buyer's pick to a specific provider via the profile's attached
/// providers + optional `merchant_profile_rail_preferences` tie-breakers.
///
/// Rails-per-provider-kind are **inherent** (declared by each provider
/// impl's `served_rails()` trait method), not configurable per provider
/// row. BTCPay serves Lightning + OnChain. Zaprite serves Card +
/// Lightning + OnChain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rail {
Lightning,
Onchain,
Card,
}
impl Rail {
pub fn as_str(&self) -> &'static str {
match self {
Rail::Lightning => "lightning",
Rail::Onchain => "onchain",
Rail::Card => "card",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"lightning" => Some(Self::Lightning),
"onchain" | "on-chain" | "on_chain" => Some(Self::Onchain),
"card" => Some(Self::Card),
_ => None,
}
}
}
/// Static rails served by a provider kind. Returned by
/// `PaymentProvider::served_rails()`; centralized here so callers that
/// just want to know "what does kind X support" (e.g., the admin UI's
/// connect-flow guidance) don't have to instantiate a provider.
pub fn rails_for_kind(kind: ProviderKind) -> Vec<Rail> {
match kind {
ProviderKind::Btcpay => vec![Rail::Lightning, Rail::Onchain],
ProviderKind::Zaprite => vec![Rail::Card, Rail::Lightning, Rail::Onchain],
}
}
/// Build a typed `PaymentProvider` trait object from a `payment_providers`
/// row. Dispatch on `kind`. Used by the AppState provider cache when
/// resolving by provider id.
pub fn build_provider(
row: &crate::db::repo::PaymentProviderRow,
public_base_url: Option<&str>,
) -> anyhow::Result<std::sync::Arc<dyn PaymentProvider>> {
use crate::btcpay::client::BtcpayClient;
use crate::payment::btcpay::BtcpayProvider;
use crate::payment::zaprite::{ZapriteClient, ZapriteProvider};
match ProviderKind::parse(&row.kind) {
Some(ProviderKind::Btcpay) => {
let store_id = row.store_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("BTCPay provider row {} missing store_id", row.id)
})?;
let webhook_secret = row.webhook_secret.clone().unwrap_or_default();
let client = BtcpayClient::new(&row.base_url, &row.api_key, store_id);
let provider = BtcpayProvider::new(client, webhook_secret)
.with_public_base(public_base_url.map(|s| s.to_string()));
Ok(std::sync::Arc::new(provider))
}
Some(ProviderKind::Zaprite) => {
let client = ZapriteClient::new(row.base_url.clone(), row.api_key.clone());
Ok(std::sync::Arc::new(ZapriteProvider::new(client)))
}
None => Err(anyhow::anyhow!(
"unknown payment provider kind {:?} on row {}",
row.kind,
row.id
)),
}
}
/// A monetary amount + the unit it's denominated in.
@@ -137,6 +247,15 @@ pub struct CreateInvoiceParams<'a> {
pub external_order_id: &'a str,
/// Buyer email if known. Some providers use this for receipts.
pub buyer_email: Option<&'a str>,
/// Ask the provider to prompt the buyer to save their payment
/// profile for future merchant-initiated charges. Zaprite honors
/// this for autopay-supporting rails (Stripe card, etc.); BTCPay
/// has no equivalent concept and silently ignores it. Set
/// `Some(true)` on the FIRST cycle of a recurring purchase so the
/// renewal worker can later call `charge_order_with_profile`
/// against the saved profile. `None` / `Some(false)` is the
/// one-shot default.
pub allow_save_payment_profile: Option<bool>,
}
/// Result of `create_invoice`. Whatever the provider returned, narrowed
@@ -162,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)]
@@ -221,6 +357,14 @@ pub struct PaymentReceipt {
pub trait PaymentProvider: Send + Sync + Any {
fn kind(&self) -> ProviderKind;
/// Payment rails this provider can settle. Default impl uses the
/// static `rails_for_kind()` mapping; impls only override if they
/// expose a non-default set (e.g., a degraded BTCPay configured
/// without Lightning support — not currently a Keysat concern).
fn served_rails(&self) -> Vec<Rail> {
rails_for_kind(self.kind())
}
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
@@ -229,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
@@ -51,6 +51,15 @@ pub struct CreateOrderBody<'a> {
/// recurring charges. Set when the policy is recurring.
#[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")]
pub allow_save_payment_profile: Option<bool>,
/// Zaprite contact id to attach this order to. REQUIRED by
/// Zaprite when `allow_save_payment_profile` is true — without
/// it the create-order call returns
/// `400 contactId is required when allowSavePaymentProfile is true`.
/// Optional otherwise; passing it for one-shot purchases just
/// associates the order with a known contact in the operator's
/// Zaprite dashboard.
#[serde(rename = "contactId", skip_serializing_if = "Option::is_none")]
pub contact_id: Option<String>,
}
impl ZapriteClient {
@@ -157,6 +166,84 @@ impl ZapriteClient {
serde_json::from_str(&raw).context("parse charge response")
}
/// `POST /v1/contacts` — create a Zaprite contact. Required
/// upstream step before creating an order with
/// `allowSavePaymentProfile: true` (Zaprite needs to know which
/// contact the saved profile attaches to). Returns the full
/// contact JSON; the caller extracts `id` to pass as
/// `contactId` on the subsequent order create.
///
/// `legal_name` is required by Zaprite's schema; we fall back to
/// the email itself when the buyer didn't supply a name. The
/// operator can rename the contact in the Zaprite dashboard if
/// they care about display polish.
///
/// NOTE on duplicates: Zaprite's duplicate-email behavior on
/// `POST /v1/contacts` is undocumented (their llms.txt explicitly
/// says "Not documented"). Empirically we accept whatever Zaprite
/// does — if they create a duplicate, the operator's Zaprite
/// contact list gets a row per recurring purchase from the same
/// buyer. The multi-provider work (planned `:47+`) will introduce
/// a Keysat-side `zaprite_contacts` cache keyed on (email,
/// provider_id) to dedup upfront. For sandbox testing + early
/// production this is acceptable noise.
pub async fn create_contact(
&self,
email: &str,
name: Option<&str>,
) -> Result<Value> {
let legal_name = name.unwrap_or(email);
let url = format!("{}/v1/contacts", self.base_url);
let body = serde_json::json!({
"email": email,
"legalName": legal_name,
});
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.json(&body)
.send()
.await
.context("Zaprite create_contact request")?;
let status = resp.status();
let raw = resp.text().await.context("read create_contact body")?;
if !status.is_success() {
return Err(anyhow!(
"Zaprite create_contact returned HTTP {status}: {raw}"
));
}
serde_json::from_str(&raw).context("parse create_contact response")
}
/// `GET /v1/contacts/{id}` — fetch a Zaprite contact, which
/// includes the `paymentProfiles[]` array we mine for the
/// saved-card id after a recurring first-cycle settle. Each
/// profile has `id`, `method`, `expiresAt`, and a `sourceOrder`
/// nested object whose `externalUniqId` is the invoice UUID we
/// passed when creating the order — that's how we identify the
/// profile the buyer just saved on the order that triggered
/// this lookup.
pub async fn get_contact(&self, contact_id: &str) -> Result<Value> {
let encoded = urlencoding::encode(contact_id);
let url = format!("{}/v1/contacts/{encoded}", self.base_url);
let resp = self
.http
.get(&url)
.headers(self.auth_headers()?)
.send()
.await
.context("Zaprite get_contact request")?;
let status = resp.status();
let raw = resp.text().await.context("read get_contact body")?;
if !status.is_success() {
return Err(anyhow!(
"Zaprite get_contact({contact_id}) returned HTTP {status}: {raw}"
));
}
serde_json::from_str(&raw).context("parse get_contact response")
}
/// Smoke test for Connect-flow validation. Pings `GET /v1/orders`
/// (the list endpoint) — auth-guarded, so a 200 confirms the
/// API key works against the right org.
+183 -20
View File
@@ -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;
@@ -61,6 +61,63 @@ impl PaymentProvider for ZapriteProvider {
}
};
// If we're going to ask Zaprite to save the buyer's payment
// profile (recurring first cycle), Zaprite REQUIRES an
// explicit `contactId` on the order — passing only
// `customerData: { email }` returns
// `400 contactId is required when allowSavePaymentProfile is true`
// even though their llms.txt docs claim contactId is
// optional. The API is the source of truth, so we create a
// contact first and pass its id below.
//
// Three paths:
// 1. Recurring + buyer_email present → create contact,
// attach contactId, set allow_save_payment_profile=true.
// 2. Recurring + buyer_email MISSING → can't create a
// contact (Zaprite requires email). Log a warning and
// degrade to one-shot mode for THIS cycle — the buyer
// gets a license, but subsequent renewals will fall
// through to manual-pay (zaprite_payment_profile_id
// stays NULL). Reason for degrading rather than failing:
// blocking the purchase entirely is worse than letting
// the operator collect cycle-1 revenue and prompt the
// buyer for an email at next renewal.
// 3. Non-recurring → no contact needed; pass customerData
// only (current behavior preserved).
let want_save_profile = params.allow_save_payment_profile == Some(true);
let (contact_id, effective_allow_save) = if want_save_profile {
match params.buyer_email {
Some(email) => {
let contact = self
.client
.create_contact(email, None)
.await
.context("ZapriteProvider.create_invoice: create_contact")?;
let id = contact
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow!(
"Zaprite create_contact response missing 'id': {contact}"
)
})?
.to_string();
(Some(id), Some(true))
}
None => {
tracing::warn!(
external_order_id = %params.external_order_id,
"recurring purchase has no buyer_email; degrading to one-shot \
(allow_save_payment_profile=false). Renewals for this \
subscription will fall back to manual-pay."
);
(None, None)
}
}
} else {
(None, params.allow_save_payment_profile)
};
// Build the Zaprite order. externalUniqId carries OUR
// invoice UUID; this is what the webhook handler uses as
// the trust anchor (see `validate_webhook` below).
@@ -75,12 +132,18 @@ impl PaymentProvider for ZapriteProvider {
customer_data: params.buyer_email.map(|email| {
serde_json::json!({ "email": email })
}),
// For one-shot purchases, don't prompt the buyer to
// save their card. The recurring-subscriptions
// renewal flow sets this to true on the FIRST
// purchase of a sub so subsequent cycles can charge
// the saved profile.
allow_save_payment_profile: None,
// For one-shot purchases (`None` / `Some(false)`) we
// don't prompt the buyer to save their card. The
// recurring-subscriptions purchase path sets this to
// `Some(true)` on the FIRST cycle of a sub so Zaprite
// shows the save-payment-profile prompt; subsequent
// cycles are then merchant-initiated charges against
// the saved profile via
// `charge_order_with_profile`. May be reset to None
// above if we couldn't satisfy Zaprite's contactId
// requirement.
allow_save_payment_profile: effective_allow_save,
contact_id,
};
let order = self
@@ -112,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)
@@ -135,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
@@ -144,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.
@@ -176,19 +262,47 @@ impl PaymentProvider for ZapriteProvider {
let v: Value = serde_json::from_slice(body)
.context("Zaprite webhook body must be JSON")?;
// Zaprite event shape (from OpenAPI excerpt + ecosystem
// conventions): top-level `event` string + `data.id`
// (the order UUID). Examples expected:
// order.paid, order.complete, order.overpaid, order.underpaid,
// order.pending, order.expired, order.refunded
// We map liberally and let unknowns fall through to Other.
let event_type = v
.get("event")
// Zaprite event shape: their docs don't enumerate event names
// or payload shape. The `:49` sandbox test surfaced an empty
// event_type because we were only checking the top-level
// `event` field; Zaprite seems to put it elsewhere. We now
// probe four common top-level field names — first non-empty
// string wins. If even that fails, dump the raw payload at
// WARN so we can see what Zaprite actually sends and add the
// correct field name here.
let event_type = ["event", "eventType", "type", "name"]
.iter()
.find_map(|field| {
v.get(*field)
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.unwrap_or_default();
if event_type.is_empty() {
// Truncated to 2KB to bound log volume on weird payloads.
let raw_preview = String::from_utf8_lossy(body);
let truncated = if raw_preview.len() > 2048 {
format!(
"{}…[truncated {} bytes]",
&raw_preview[..2048],
raw_preview.len() - 2048
)
} else {
raw_preview.to_string()
};
tracing::warn!(
payload = %truncated,
"Zaprite webhook: no event/eventType/type/name field found at top \
level — webhook will be treated as non-actionable. Inspect the \
payload above to find the actual event-name field and add it to \
the probe list in validate_webhook."
);
}
let provider_invoice_id = v
.pointer("/data/id")
.or_else(|| v.pointer("/data/object/id"))
.or_else(|| v.get("orderId"))
.or_else(|| v.get("id"))
.and_then(|s| s.as_str())
@@ -214,6 +328,55 @@ impl PaymentProvider for ZapriteProvider {
provider_invoice_id: id,
refunded_amount: None, // amount field shape TBD when we see a real refund event
},
// Zaprite's primary delivery shape (sandbox-confirmed :50):
// a generic `order.change` event that just says "something
// about this order changed" — the receiver has to look at
// `/data/status` to figure out what actually changed. They
// do NOT (empirically) send the convention-suggested
// `order.paid` / `order.complete` events — every state
// transition comes through as `order.change` and the
// payload's status field tells the story. Branch on
// status here so we dispatch the right action.
//
// Status values from Zaprite's get_invoice_status mapping:
// PAID | COMPLETE | OVERPAID → settled
// EXPIRED → expired
// INVALID | CANCELLED → invalid
// PENDING | PROCESSING |
// UNDERPAID → in-flight; no action yet
// <anything else> → Other (logged + ignored)
"order.change" => {
let status = v
.pointer("/data/status")
.and_then(|s| s.as_str())
.unwrap_or("");
match status {
"PAID" | "COMPLETE" | "OVERPAID" => {
ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: id,
}
}
"EXPIRED" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: id,
},
"INVALID" | "CANCELLED" => {
ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: id,
}
}
// In-flight transitions (PENDING/PROCESSING/UNDERPAID)
// and anything unfamiliar fall through to Other — the
// handler logs them as non-actionable, which is right:
// we don't want to fire the settle hook every time
// Zaprite transitions an order from PENDING to
// PROCESSING on the way to PAID. The terminal-state
// delivery is what actually drives our state machine.
_ => ProviderWebhookEvent::Other {
kind: format!("order.change[status={status}]"),
provider_invoice_id: provider_invoice_id,
},
}
}
other => ProviderWebhookEvent::Other {
kind: other.to_string(),
provider_invoice_id: provider_invoice_id,
+75 -7
View File
@@ -45,11 +45,15 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
// provider-specific status-string normalization (BTCPay's
// "Settled"/"Complete"/"Expired"/"Invalid" → ProviderInvoiceStatus
// enum); this loop just operates on the typed result.
let provider = match state.payment_provider().await {
Ok(p) => p,
Err(_) => return Ok(()), // not configured yet — skip silently
};
//
// With multi-provider, each pending invoice is reconciled against
// its OWN provider (recorded on the invoice row, migration 0021).
// We can't iterate against a single global provider because the
// operator may have multiple providers configured across multiple
// merchant profiles. Pre-0021 invoices that slipped through with
// a NULL provider id fall back to the legacy `payment_provider()`
// accessor (which the migration's backfill should prevent from
// ever being needed in practice).
let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS)
.await
.map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?;
@@ -60,10 +64,28 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
tracing::debug!(count = pending.len(), "reconciling pending invoices");
for inv in pending {
let provider = match inv.payment_provider_id.as_deref() {
Some(pid) => match state.payment_provider_by_id(pid).await {
Ok(p) => p,
Err(e) => {
tracing::debug!(
error = %e,
invoice_id = %inv.id,
provider_id = pid,
"reconciler skipping invoice — its provider is unavailable"
);
continue;
}
},
None => match state.payment_provider().await {
Ok(p) => p,
Err(_) => continue, // not configured yet — skip silently
},
};
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",
@@ -102,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,
@@ -137,10 +169,46 @@ async fn ensure_license(
.map_err(|e| anyhow::anyhow!("{e:?}"))?
.is_some()
{
// Even if the license already exists, the reconciler may be
// running because the webhook never delivered. In that case
// `on_invoice_settled` (which runs the Zaprite-saved-profile
// capture for recurring first-cycle subs) never fired either.
// Try the post-settle hook now — it's idempotent (early-returns
// if the sub already has a captured profile, or if the active
// provider isn't Zaprite, or if no matching profile exists on
// the contact). Without this, a subscription created via the
// reconciler path never gets its `zaprite_payment_profile_id`
// populated, and renewals fall back to manual-pay forever
// even though the saved profile is sitting on Zaprite's side.
if let Err(e) =
crate::subscriptions::on_invoice_settled(state, invoice).await
{
tracing::warn!(
error = %e,
invoice_id = %invoice.id,
"reconciler post-settle hook failed (non-fatal — license already exists)"
);
}
return Ok(());
}
crate::api::webhook::issue_license_for_invoice(state, invoice)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
// Same rationale as the early-return branch above — if the
// reconciler is running, the webhook may have missed; run the
// post-settle hook so a brand-new recurring sub also captures its
// Zaprite saved profile. issue_license_for_invoice already created
// the subscription row by this point, so on_invoice_settled can
// find it.
if let Err(e) =
crate::subscriptions::on_invoice_settled(state, invoice).await
{
tracing::warn!(
error = %e,
invoice_id = %invoice.id,
"reconciler post-settle hook failed (non-fatal — license issued ok)"
);
}
Ok(())
}
+618 -16
View File
@@ -35,10 +35,29 @@
//! through the end of the current cycle.
//!
//! Auto-charge via saved payment profiles (Zaprite's
//! `paymentProfileId` flow) is NOT in this version. The first
//! renewal-worker iteration creates fresh invoices that the buyer
//! pays manually. v0.2.0:5+ adds the auto-charge path so cycles
//! after the first don't require buyer interaction.
//! `paymentProfileId` flow) is now wired. When a buyer pays the
//! first cycle of a recurring subscription via Zaprite AND saves
//! a card at checkout, the renewal worker calls
//! `POST /v1/orders/charge` against the saved profile on each
//! cycle instead of waiting for manual pay. The wiring lives in
//! three places:
//! - `api::purchase` sets `allow_save_payment_profile=Some(true)`
//! on the first-cycle invoice when the policy is recurring,
//! prompting Zaprite to show the save-card UI at checkout.
//! - `on_invoice_settled` here calls
//! `capture_zaprite_payment_profile`, which fetches the
//! buyer's contact from Zaprite and persists the resulting
//! profile id onto the subscriptions row.
//! - `renew_one` here invokes `try_auto_charge_zaprite` after
//! creating each renewal order. On success the buyer does
//! nothing — the order settles via the usual webhook. On
//! failure (decline, expired card, network) we fall through
//! to the existing manual-pay `subscription.renewal_pending`
//! event so the buyer can still recover the cycle.
//! BTCPay subscriptions and Zaprite subscriptions whose buyer
//! paid with Bitcoin / declined the save-card prompt have NULL
//! profile fields and continue to use the manual-pay branch
//! exclusively.
use crate::api::AppState;
use crate::db::repo;
@@ -48,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;
@@ -80,6 +99,42 @@ pub struct Subscription {
pub next_renewal_at: Option<String>,
pub cancelled_at: Option<String>,
pub consecutive_failures: i64,
/// Zaprite contact id for the buyer who paid the first cycle.
/// Only ever populated for subs whose first-cycle invoice was
/// settled via Zaprite AND whose buyer saved a payment profile
/// at checkout. NULL otherwise (BTCPay subs, Bitcoin-paid
/// Zaprite subs, declined-the-save-prompt Zaprite subs).
pub zaprite_contact_id: Option<String>,
/// Zaprite saved-profile id used by the renewal worker to
/// auto-charge subsequent cycles via
/// `POST /v1/orders/charge`. NULL means "no saved profile,
/// fall through to manual-pay renewal" — the pre-feature
/// behavior.
pub zaprite_payment_profile_id: Option<String>,
/// e.g. "CARD" / "BANK" — informational for the admin UI's
/// subscription detail card. Not consulted by the worker
/// today; Zaprite returns a decline error if the method
/// doesn't support merchant-initiated charges.
pub zaprite_payment_profile_method: Option<String>,
/// ISO-8601. Informational for the admin UI ("card expires
/// 03/27"). The renewal worker doesn't gate on this — if
/// Zaprite reports the profile as expired we'll see it as
/// an `/v1/orders/charge` failure and fall through to the
/// manual-pay branch.
pub zaprite_payment_profile_expires_at: Option<String>,
/// Merchant profile the subscription was attached to at
/// creation. Frozen for the lifetime of the sub so an operator
/// editing the product's profile attachment doesn't redirect
/// existing buyers to a different business mid-cycle. NULL on
/// subs created pre-migration 0020 (backfilled to the default
/// profile during the migration).
pub merchant_profile_id: Option<String>,
/// Payment provider used for THIS subscription's billing cycle.
/// Frozen at creation (same rationale as merchant_profile_id).
/// The renewal worker uses this to look up the provider — it
/// never re-resolves from the product (which might have moved
/// to a different profile / different providers).
pub payment_provider_id: Option<String>,
}
fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
@@ -96,12 +151,23 @@ fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
next_renewal_at: row.get("next_renewal_at"),
cancelled_at: row.get("cancelled_at"),
consecutive_failures: row.get("consecutive_failures"),
zaprite_contact_id: row.try_get("zaprite_contact_id").ok(),
zaprite_payment_profile_id: row.try_get("zaprite_payment_profile_id").ok(),
zaprite_payment_profile_method: row.try_get("zaprite_payment_profile_method").ok(),
zaprite_payment_profile_expires_at: row
.try_get("zaprite_payment_profile_expires_at")
.ok(),
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
payment_provider_id: row.try_get("payment_provider_id").ok().flatten(),
}
}
const SUB_COLS: &str = "id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
cancelled_at, consecutive_failures";
cancelled_at, consecutive_failures, \
zaprite_contact_id, zaprite_payment_profile_id, \
zaprite_payment_profile_method, zaprite_payment_profile_expires_at, \
merchant_profile_id, payment_provider_id";
/// Subs that are due for the worker to act on right now: status
/// is `active` or `past_due`, `next_renewal_at` is in the past,
@@ -143,7 +209,11 @@ pub async fn find_lapsing_subscriptions(
let rows = sqlx::query(&format!(
"SELECT s.id AS id, s.license_id, s.policy_id, s.product_id, s.period_days, \
s.listed_currency, s.listed_value, s.status, s.started_at, \
s.next_renewal_at, s.cancelled_at, s.consecutive_failures \
s.next_renewal_at, s.cancelled_at, s.consecutive_failures, \
s.zaprite_contact_id, s.zaprite_payment_profile_id, \
s.zaprite_payment_profile_method, \
s.zaprite_payment_profile_expires_at, \
s.merchant_profile_id, s.payment_provider_id \
FROM subscriptions s \
JOIN policies p ON p.id = s.policy_id \
WHERE s.status = 'past_due' \
@@ -320,6 +390,8 @@ pub async fn create_subscription(
listed_currency: &str,
listed_value: i64,
first_cycle_invoice_id: &str,
merchant_profile_id: Option<&str>,
payment_provider_id: Option<&str>,
) -> Result<Subscription> {
let id = Uuid::new_v4().to_string();
let now = Utc::now();
@@ -329,8 +401,9 @@ pub async fn create_subscription(
sqlx::query(
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
consecutive_failures, created_at, updated_at) \
VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?)",
consecutive_failures, merchant_profile_id, payment_provider_id, \
created_at, updated_at) \
VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)",
)
.bind(&id)
.bind(license_id)
@@ -341,6 +414,8 @@ pub async fn create_subscription(
.bind(listed_value)
.bind(&started_at)
.bind(&next_renewal_at)
.bind(merchant_profile_id)
.bind(payment_provider_id)
.bind(&started_at)
.bind(&started_at)
.execute(pool)
@@ -375,6 +450,16 @@ pub async fn create_subscription(
next_renewal_at: Some(next_renewal_at),
cancelled_at: None,
consecutive_failures: 0,
// Zaprite saved-profile metadata is populated by a separate
// post-settle hook (see `capture_zaprite_payment_profile`),
// not here — at create-subscription time we don't yet know
// whether the buyer saved a card.
zaprite_contact_id: None,
zaprite_payment_profile_id: None,
zaprite_payment_profile_method: None,
zaprite_payment_profile_expires_at: None,
merchant_profile_id: merchant_profile_id.map(|s| s.to_string()),
payment_provider_id: payment_provider_id.map(|s| s.to_string()),
})
}
@@ -635,12 +720,24 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
.context("rate conversion")?;
let amount_sats = conversion.sats.max(1);
// 2. Get the active provider. If no provider is configured
// we can't bill — surfaces as a renewal failure that
// backs off (operator probably mid-Disconnect).
let provider = state.payment_provider().await.map_err(|e| {
anyhow!("payment provider unavailable for renewal: {e:#}")
})?;
// 2. Get the provider snapshotted on this sub at creation. The
// snapshot semantics protect existing buyers from operator-side
// re-routing: if the product was moved to a different merchant
// profile or its providers changed, this sub keeps renewing
// through the same business + payment account it started with.
// Falls back to the default profile's first provider if the
// snapshot is NULL (pre-migration subs that the 0020 backfill
// missed, or any rows that slipped through with NULL).
let provider = match sub.payment_provider_id.as_deref() {
Some(pid) => state
.payment_provider_by_id(pid)
.await
.map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?,
None => state
.payment_provider()
.await
.map_err(|e| anyhow!("payment provider unavailable for renewal: {e:#}"))?,
};
// 3. Compute the next cycle window.
let now = Utc::now();
@@ -679,6 +776,14 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
metadata,
external_order_id: &internal_invoice_id,
buyer_email: None, // renewal email comes from the license, not solicited fresh
// The save-card prompt only matters on the FIRST cycle.
// By the time we're here the sub either already has a
// `zaprite_payment_profile_id` (we'll auto-charge below)
// or doesn't (it never will — buyer paid with Bitcoin /
// declined the prompt). Either way, re-prompting on
// every renewal would be confusing UX; renewals always
// pass `None` here.
allow_save_payment_profile: None,
})
.await
.context("provider.create_invoice for renewal")?;
@@ -717,6 +822,7 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
} else {
Some(conversion.source.as_str())
},
None, // payment_provider_id — set when this call site is ported to the multi-provider resolution layer
)
.await
.map_err(|e: AppError| anyhow!("repo create_invoice: {e:?}"))?;
@@ -765,7 +871,93 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
.await
.context("UPDATE subscriptions on renewal create")?;
// 9. Webhook event: operator's app gets notified that a
// 9. If this subscription has a saved Zaprite payment profile
// (captured on first-cycle settle via
// `capture_zaprite_payment_profile`), try to merchant-
// initiate the charge against it now. On success, the buyer
// is NOT expected to do anything — Zaprite will run the
// charge and fire the usual `order.paid` webhook, which
// `on_invoice_settled` will pick up to flip the sub back to
// `active` and dispatch `subscription.renewed`. On failure
// (declined card, expired profile, Zaprite hiccup) we log
// + audit + fall through to the manual-pay
// `subscription.renewal_pending` event below so the buyer
// still has a path to recover this cycle.
let auto_charged = match try_auto_charge_zaprite(
state,
sub,
&handle.provider_invoice_id,
)
.await
{
Ok(charged) => charged,
Err(e) => {
tracing::warn!(
sub_id = %sub.id,
invoice_id = %internal_invoice_id,
error = %e,
"Zaprite auto-charge failed; falling back to manual-pay renewal"
);
let _ = repo::insert_audit(
&state.db,
"renewal_worker",
None,
"subscription.auto_charge_failed",
Some("subscription"),
Some(&sub.id),
None,
None,
&json!({
"invoice_id": internal_invoice_id,
"provider_invoice_id": handle.provider_invoice_id,
"error": format!("{e:#}"),
}),
)
.await;
crate::webhooks::dispatch(
state,
"subscription.auto_charge_failed",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"invoice_id": internal_invoice_id,
"reason": format!("{e:#}"),
}),
)
.await;
false
}
};
if auto_charged {
// Auto-charge succeeded — Zaprite will fire `order.paid`
// shortly and the webhook handler runs the rest of the
// renewal lifecycle. Fire an operator-visible event so
// the operator's app can render "renewed automatically"
// copy in their notification UI, distinct from "buyer
// needs to pay" copy.
crate::webhooks::dispatch(
state,
"subscription.auto_charge_initiated",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"product_id": sub.product_id,
"policy_id": sub.policy_id,
"invoice_id": internal_invoice_id,
"amount_sats": amount_sats,
"listed_currency": sub.listed_currency,
"listed_value": sub.listed_value,
"cycle_number": next_cycle_num,
"cycle_start_at": cycle_start.to_rfc3339(),
"cycle_end_at": cycle_end.to_rfc3339(),
}),
)
.await;
return Ok(());
}
// 10. Manual-pay path. Operator's app gets notified that a
// renewal invoice exists and the buyer needs to pay. The
// operator's webhook receiver renders an email / push /
// in-app notification with `checkout_url` and sends it to
@@ -874,6 +1066,22 @@ pub async fn on_invoice_settled(state: &AppState, invoice: &Invoice) -> Result<(
None => return Ok(()), // not a subscription invoice
};
mark_active_after_settle(&state.db, &sub_id).await?;
// Best-effort: if this was the FIRST cycle of a Zaprite-paid
// recurring subscription AND the buyer saved a payment profile
// at checkout, capture the profile id so the renewal worker can
// auto-charge subsequent cycles. Failures here are logged but
// never block — the sub stays valid; renewals just fall back to
// the manual-pay branch.
if let Err(e) = capture_zaprite_payment_profile(state, &sub_id, invoice).await {
tracing::warn!(
sub_id = %sub_id,
invoice_id = %invoice.id,
error = %e,
"capture_zaprite_payment_profile failed; renewals will fall back to manual pay"
);
}
crate::webhooks::dispatch(
state,
"subscription.renewed",
@@ -886,3 +1094,397 @@ pub async fn on_invoice_settled(state: &AppState, invoice: &Invoice) -> Result<(
.await;
Ok(())
}
/// Best-effort capture of the Zaprite saved-payment-profile after a
/// first-cycle settle. No-ops in any of these cases:
/// - sub already has `zaprite_payment_profile_id` set (idempotent
/// re-delivery of the same settle webhook)
/// - active provider isn't Zaprite (BTCPay subs have no equivalent)
/// - the invoice predates the saved-profile feature (pre-:44
/// Zaprite subs)
/// - buyer paid with Bitcoin/Lightning, or declined the save-card
/// prompt — no profile gets created on Zaprite's side
///
/// When it does fire, we:
/// 1. Fetch the Zaprite order to find the buyer's `contact.id`
/// 2. Fetch the contact to enumerate `paymentProfiles[]`
/// 3. Find the profile whose `sourceOrder.externalUniqId` matches
/// our local invoice id (= the externalUniqId we set at order
/// creation) — that's the profile saved on THIS purchase
/// 4. UPDATE the subscriptions row with id / method / expiresAt
pub async fn capture_zaprite_payment_profile(
state: &AppState,
sub_id: &str,
invoice: &Invoice,
) -> Result<()> {
use crate::payment::ProviderKind;
tracing::info!(
sub_id = %sub_id,
invoice_id = %invoice.id,
provider_invoice_id = %invoice.btcpay_invoice_id,
"capture_zaprite_payment_profile: starting"
);
// Idempotency: already captured?
let existing: Option<String> = sqlx::query_scalar(
"SELECT zaprite_payment_profile_id FROM subscriptions WHERE id = ?",
)
.bind(sub_id)
.fetch_optional(&state.db)
.await
.context("read existing zaprite_payment_profile_id")?
.flatten();
if existing.is_some() {
tracing::info!(sub_id = %sub_id, "capture: already captured, skipping");
return Ok(());
}
// The provider that settled THIS invoice — not "the active one." With
// multi-merchant-profile, the operator may have several providers
// configured across different profiles; capturing the saved profile
// has to talk to the SAME Zaprite org that the order was created
// against (saved-profile ids are scoped per org).
let provider = match invoice.payment_provider_id.as_deref() {
Some(pid) => match state.payment_provider_by_id(pid).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(
sub_id = %sub_id,
provider_id = pid,
error = %e,
"capture: invoice's provider unavailable — skipping"
);
return Ok(());
}
},
None => {
// Pre-0021 invoice with NULL provider — fall back to the legacy
// default. The 0021 backfill should have populated this column
// on the first migration run, so this branch is defensive only.
match state.payment_provider().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(
sub_id = %sub_id, error = %e,
"capture: no active payment provider AND invoice has no \
payment_provider_id — skipping"
);
return Ok(());
}
}
}
};
if provider.kind() != ProviderKind::Zaprite {
tracing::info!(
sub_id = %sub_id, kind = ?provider.kind(),
"capture: active provider is not Zaprite — skipping"
);
return Ok(());
}
let zaprite = match provider
.as_any()
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
{
Some(z) => z,
None => {
tracing::warn!(
sub_id = %sub_id,
"capture: provider kind is Zaprite but downcast failed — skipping"
);
return Ok(());
}
};
let client = zaprite.client();
// 1. Fetch the order so we can read its contact.
let order = client
.get_order(&invoice.btcpay_invoice_id)
.await
.context("fetch Zaprite order for profile capture")?;
let contact_id = order
.pointer("/contact/id")
.or_else(|| order.get("contactId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let contact_id = match contact_id {
Some(c) => c,
None => {
// Order has no contact — buyer paid without an email /
// Zaprite didn't materialize a contact. No profile to
// capture; renewals fall back to manual pay.
tracing::warn!(
sub_id = %sub_id,
order_status = order.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
order_has_contact = order.get("contact").is_some(),
order_has_contactId = order.get("contactId").is_some(),
"capture: order has no contact.id / contactId — cannot capture profile. \
Check that buyer_email was present at purchase + that :47+ contact \
creation ran."
);
return Ok(());
}
};
tracing::info!(
sub_id = %sub_id, contact_id = %contact_id,
"capture: resolved contact_id from order"
);
// 2. Fetch the contact and enumerate its payment profiles.
let contact = client
.get_contact(&contact_id)
.await
.context("fetch Zaprite contact for profile capture")?;
let profiles = match contact.get("paymentProfiles").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => {
tracing::warn!(
sub_id = %sub_id, contact_id = %contact_id,
"capture: contact has no paymentProfiles array — likely the buyer \
didn't check 'save card' at Zaprite checkout, OR profile creation \
is async on Zaprite's side and not yet visible at webhook time"
);
return Ok(());
}
};
tracing::info!(
sub_id = %sub_id, contact_id = %contact_id,
profile_count = profiles.len(),
"capture: enumerated contact's payment profiles"
);
// 3. Find the profile whose sourceOrder.externalUniqId is
// THIS invoice. Zaprite scopes saved profiles to a contact,
// but a contact may have multiple profiles from prior
// purchases (e.g. the buyer subscribed to another product
// too). The sourceOrder pin is how we identify the one
// Zaprite just minted on this purchase.
let matching = profiles.iter().find(|p| {
p.pointer("/sourceOrder/externalUniqId")
.and_then(|v| v.as_str())
.map(|s| s == invoice.id)
.unwrap_or(false)
});
let profile = match matching {
Some(p) => p,
None => {
// Most common reason: buyer paid with Bitcoin / Lightning
// (no autopay-supporting rail) OR declined the save-
// payment-profile prompt on the card form. Both are
// legitimate; renewals fall back to manual pay.
//
// Also possible: race condition — Zaprite's profile-save
// step hasn't finished by the time the order.paid webhook
// fires. If you see this with profile_count > 0 but no
// match for invoice.id, that's the race.
let sample = profiles.iter().take(3).map(|p| {
p.pointer("/sourceOrder/externalUniqId")
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string()
}).collect::<Vec<_>>();
tracing::warn!(
sub_id = %sub_id,
contact_id = %contact_id,
invoice_id = %invoice.id,
profile_count = profiles.len(),
sample_source_external_uniq_ids = ?sample,
"capture: no profile matches sourceOrder.externalUniqId == invoice.id — \
either the buyer declined the save-card prompt, paid via a non-saving \
rail (BTC/Lightning), OR Zaprite's profile-attach is racing the \
webhook delivery"
);
return Ok(());
}
};
let profile_id = match profile.get("id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
tracing::warn!(
sub_id = %sub_id, contact_id = %contact_id,
"capture: matched profile has no 'id' field — skipping"
);
return Ok(());
}
};
let method = profile.get("method").and_then(|v| v.as_str()).map(|s| s.to_string());
let expires_at = profile
.get("expiresAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 4. Persist.
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE subscriptions \
SET zaprite_contact_id = ?, zaprite_payment_profile_id = ?, \
zaprite_payment_profile_method = ?, \
zaprite_payment_profile_expires_at = ?, \
updated_at = ? \
WHERE id = ?",
)
.bind(&contact_id)
.bind(&profile_id)
.bind(&method)
.bind(&expires_at)
.bind(&now)
.bind(sub_id)
.execute(&state.db)
.await
.context("UPDATE subscriptions with Zaprite profile metadata")?;
tracing::info!(
sub_id = %sub_id,
contact_id = %contact_id,
profile_id = %profile_id,
method = method.as_deref().unwrap_or("?"),
"captured Zaprite saved payment profile for auto-charge on renewal"
);
Ok(())
}
/// Attempt a merchant-initiated charge against the saved Zaprite
/// payment profile on this subscription. Called by the renewal
/// 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 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
/// fall through to manual-pay so the buyer has
/// a path to recover.
async fn try_auto_charge_zaprite(
state: &AppState,
sub: &Subscription,
provider_invoice_id: &str,
) -> Result<bool> {
use crate::payment::ProviderKind;
let profile_id = match sub.zaprite_payment_profile_id.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(false),
};
// Use the provider snapshotted on the sub — saved-profile ids are
// scoped per Zaprite org, so we can't fall back to "the active
// provider" if the operator added another Zaprite provider since.
let provider = match sub.payment_provider_id.as_deref() {
Some(pid) => state
.payment_provider_by_id(pid)
.await
.map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?,
None => state
.payment_provider()
.await
.map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?,
};
if provider.kind() != ProviderKind::Zaprite {
return Ok(false);
}
let zaprite = provider
.as_any()
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
.ok_or_else(|| anyhow!("provider.kind is Zaprite but downcast failed"))?;
let resp = zaprite
.client()
.charge_order_with_profile(provider_invoice_id, profile_id)
.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,
"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 })));
}
}
+23 -6
View File
@@ -199,12 +199,29 @@ async fn run_tip(
}
};
// Pay it via the active provider's LN node. Provider-agnostic;
// BTCPay implements `pay_lightning_invoice` today, future
// providers either implement it (Zaprite via Strike?) or fall
// through to the trait default which returns a "not supported"
// error that we record as a failed tip.
let provider = match state.payment_provider().await {
// Pay it via the provider's LN node — same provider that settled
// this license's purchase invoice (so the tip draws from the right
// Bitcoin balance). Provider-agnostic; BTCPay implements
// `pay_lightning_invoice` today, future providers either implement
// it (Zaprite via Strike?) or fall through to the trait default
// which returns a "not supported" error that we record as a failed
// tip. Falls back to the legacy active-provider accessor if the
// license's invoice has no payment_provider_id set (pre-0021).
let invoice_provider_id: Option<String> = sqlx::query_scalar(
"SELECT i.payment_provider_id FROM invoices i \
JOIN licenses l ON l.invoice_id = i.id \
WHERE l.id = ?",
)
.bind(license_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let provider_result = match invoice_provider_id.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await,
None => state.payment_provider().await,
};
let provider = match provider_result {
Ok(p) => p,
Err(e) => {
let detail = format!("payment provider unavailable: {e:?}");
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(),
+592 -19
View File
@@ -180,6 +180,14 @@ table.t {
border-radius:10px; overflow:hidden;
}
.card > table.t { border:0; border-radius:0 0 10px 10px; }
/* Horizontally scrollable wrapper for tables on narrow screens. When the
table is wider than the card, the wrapper scrolls instead of the row
clipping. The wrapper carries the bottom rounding so the table itself
can stay square; otherwise the rounded table corners would clip mid-row
when scrolled. */
.t-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
.card > .t-wrap { border-radius:0 0 10px 10px; }
.card > .t-wrap > table.t { border:0; border-radius:0; }
table.t thead th {
text-align:left; font-size:11px; font-weight:700;
letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-500);
@@ -315,6 +323,29 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
margin-top:22px;
}
/* ---------- Mobile nav (hamburger + off-canvas drawer) ---------- */
/* The hamburger button lives in the topbar and is hidden by default; the
≤720px breakpoint below reveals it and reframes the sidebar as a slide-in
drawer. The backdrop dims the content and provides a tap target for
closing. */
.nav-toggle {
display:none;
align-items:center; justify-content:center;
width:38px; height:38px; padding:0;
background:transparent; color:var(--navy-900);
border:1px solid var(--border-2); border-radius:7px;
cursor:pointer; transition:all 120ms;
}
.nav-toggle:hover { background:var(--cream-200); }
.nav-toggle [data-lucide] { width:20px; height:20px; }
.sidebar-backdrop {
display:none;
position:fixed; inset:0; background:rgba(14,31,51,0.45);
z-index:40; opacity:0; pointer-events:none;
transition:opacity 200ms;
}
.sidebar-backdrop.open { opacity:1; pointer-events:auto; }
@media (max-width: 980px) {
.app { grid-template-columns:1fr; }
.sidebar { position:static; max-height:none; height:auto; }
@@ -324,6 +355,41 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.topbar { padding:14px 20px; }
}
/* Tablet/phone: collapse the sidebar into a true off-canvas drawer. Above
720px the stacked-sidebar layout from the 980px breakpoint is fine; below
720px the sidebar takes too much vertical space before content, so we
convert it to a slide-in instead. */
@media (max-width: 720px) {
.nav-toggle { display:inline-flex; }
.sidebar-backdrop { display:block; }
.sidebar {
position:fixed; top:0; left:0; bottom:0;
width:min(280px, 80vw); height:100vh; max-height:100vh;
padding:20px 12px;
transform:translateX(-100%); transition:transform 200ms ease;
z-index:50; overflow-y:auto;
}
.sidebar.open { transform:translateX(0); }
.sidebar a.nav { padding:12px 12px; font-size:14.5px; }
.sidebar a.nav [data-lucide] { width:18px; height:18px; }
}
/* Phone tier: tighten chrome, drop stats to a single column, let toolbar
inputs fill the row, bump button tap targets. */
@media (max-width: 640px) {
.stats { grid-template-columns:1fr; }
.content { padding:14px 14px 56px; }
.topbar { padding:12px 14px; gap:10px; }
.topbar h1 { font-size:18px; }
.topbar .who { display:none; }
.toolbar .input, .toolbar .select { min-width:0; width:100%; }
.card .card-head { padding:12px 14px; flex-wrap:wrap; }
.card .card-head .sub { margin-left:0; flex-basis:100%; }
.card .card-body { padding:14px; }
.btn { padding:10px 14px; }
.btn.sm { padding:8px 12px; }
}
/* Featured (launch special) pill toggle — used on the Discount Codes
create + edit forms. Click anywhere on the pill to flip the
underlying hidden checkbox. Off = muted; on = gold accent. Reads as
@@ -349,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,
@@ -422,7 +488,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
<!-- Main app shell (shown after login) -->
<section id="app-view" class="hide">
<div class="app">
<aside class="sidebar">
<aside class="sidebar" id="sidebar">
<div class="brand">
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true" style="width:26px; height:26px">
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
@@ -445,6 +511,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
<a class="nav" data-route="licenses"><i data-lucide="key-round"></i>Licenses</a>
<a class="nav" data-route="machines"><i data-lucide="cpu"></i>Machines</a>
<div class="group-label">System</div>
<a class="nav" data-route="merchant-profiles"><i data-lucide="store"></i>Merchant profiles</a>
<a class="nav" data-route="webhooks"><i data-lucide="webhook"></i>Webhooks</a>
<a class="nav" data-route="audit"><i data-lucide="scroll-text"></i>Audit log</a>
<a class="nav" data-route="settings"><i data-lucide="settings"></i>Settings</a>
@@ -467,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>
@@ -495,6 +562,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
</aside>
<main class="main">
<header class="topbar">
<button class="nav-toggle" id="nav-toggle" type="button"
aria-label="Open navigation" aria-expanded="false" aria-controls="sidebar">
<i data-lucide="menu"></i>
</button>
<div>
<div class="crumb" id="crumb">Workspace</div>
<h1 id="page-title">Overview</h1>
@@ -506,6 +577,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
</header>
<div class="content" id="route-target"></div>
</main>
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
</div>
</section>
@@ -1129,7 +1201,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tb = el('tbody')
for (const r of rows) tb.appendChild(r)
t.appendChild(tb)
return el('div', { class: 'card' }, [head, t])
return el('div', { class: 'card' }, [head, el('div', { class: 't-wrap' }, t)])
}
function statusBadge(status) {
const map = {
@@ -1157,6 +1229,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
codes: { title: 'Discount codes', crumb: 'Workspace · Discount codes' },
licenses: { title: 'Licenses', crumb: 'Workspace · Licenses' },
machines: { title: 'Machines', crumb: 'Workspace · Machines' },
'merchant-profiles': { title: 'Merchant profiles', crumb: 'System · Merchant profiles' },
webhooks: { title: 'Webhooks', crumb: 'System · Webhooks' },
settings: { title: 'Settings', crumb: 'System · Settings' },
audit: { title: 'Audit log', crumb: 'System · Audit log' },
@@ -1501,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;',
@@ -1513,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
@@ -1570,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 () {
@@ -1590,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()
@@ -1618,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
@@ -1648,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' }, [
@@ -1677,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.
@@ -1708,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)
@@ -1765,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',
@@ -3827,7 +3939,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tb = el('tbody')
rows.forEach((r) => tb.appendChild(r))
t.appendChild(tb)
return t
return el('div', { class: 't-wrap' }, t)
}
function render() {
@@ -5648,6 +5760,439 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
load()
}
// -------- 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; 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.
routes['merchant-profiles'] = async function () {
const target = document.getElementById('route-target')
target.innerHTML = ''
// Lead-in explainer + Create button.
const createBtn = el('button', { class: 'btn primary' }, [
el('i', { 'data-lucide': 'plus' }), 'Add merchant profile',
])
createBtn.addEventListener('click', () => openCreateMerchantProfileModal(reload))
target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 12px' },
'A merchant profile bundles one businesss brand, post-purchase redirect, ' +
'and a set of payment providers (BTCPay / Zaprite). Products attach to a profile; ' +
'the buyer sees the profiles brand at checkout and the payment-method picker ' +
'reflects whichever rails its providers serve.'),
el('div', { class: 'toolbar' }, [createBtn]),
]))
const listHost = el('div', { style: 'margin-top:18px' })
target.appendChild(listHost)
async function reload() {
listHost.innerHTML = ''
listHost.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
try {
const j = await api('/v1/admin/merchant-profiles')
const profiles = j.profiles || []
listHost.innerHTML = ''
if (profiles.length === 0) {
listHost.appendChild(plainCard([el('div', { class: 'empty' }, 'No merchant profiles yet.')]))
return
}
for (const p of profiles) {
listHost.appendChild(renderMerchantProfileCard(p, reload))
}
} catch (e) {
listHost.innerHTML = ''
listHost.appendChild(plainCard([err(e.message)]))
}
}
reload()
}
function renderMerchantProfileCard(p, reload) {
const head = el('div', { class: 'card-head' }, [
el('div', null, [
el('h3', null, [
p.name,
p.is_default ? el('span', {
class: 'badge b-gold',
style: 'margin-left:10px; vertical-align:middle;',
}, 'default') : null,
].filter(Boolean)),
p.support_email
? el('div', { class: 'sub' }, p.support_email)
: null,
].filter(Boolean)),
el('div', { class: 'actions-row' }, [
!p.is_default
? el('button', { class: 'btn ghost sm' }, [
el('i', { 'data-lucide': 'star' }), 'Set default',
])
: null,
el('button', { class: 'btn ghost sm' }, [
el('i', { 'data-lucide': 'pencil' }), 'Edit',
]),
!p.is_default
? el('button', { class: 'btn danger sm' }, [
el('i', { 'data-lucide': 'trash-2' }), 'Delete',
])
: null,
].filter(Boolean)),
])
// Wire action buttons.
const setDefaultBtn = head.querySelectorAll('button')[0]
if (setDefaultBtn && !p.is_default) {
setDefaultBtn.addEventListener('click', async () => {
if (!confirm('Make "' + p.name + '" the default profile?')) return
try {
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id) + '/set-default', {
method: 'POST',
})
reload()
} catch (e) {
alert('Set-default failed: ' + e.message)
}
})
}
const editBtn = head.querySelector('button.ghost:not(:first-child)') ||
head.querySelectorAll('button.ghost')[p.is_default ? 0 : 1]
if (editBtn) {
editBtn.addEventListener('click', () => openEditMerchantProfileModal(p, reload))
}
const deleteBtn = head.querySelector('button.danger')
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete merchant profile "' + p.name + '"? Refused if products or active subs are attached.')) return
try {
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id), { method: 'DELETE' })
reload()
} catch (e) {
alert('Delete failed: ' + e.message)
}
})
}
const body = el('div', { class: 'card-body' })
// Brand / redirect summary.
const meta = el('div', { class: 'row-2', style: 'margin-bottom:14px' }, [
el('div', null, [
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
el('div', { class: p.post_purchase_redirect_url ? '' : 'muted' },
p.post_purchase_redirect_url || 'Keysat default /thank-you page'),
]),
]),
el('div', null, [
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Brand color'),
el('div', { class: p.brand_color ? '' : 'muted' },
p.brand_color || 'Keysat default navy'),
]),
]),
])
body.appendChild(meta)
// Providers list.
body.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Payment providers'))
const providers = p.providers || []
if (providers.length === 0) {
body.appendChild(el('div', { class: 'empty', style: 'padding:14px' },
'No providers connected. Buyers cant pay on products attached to this profile until you add one.'))
} else {
const tb = el('tbody')
for (const pr of providers) {
const rails = (pr.served_rails || []).join(', ')
const disconnectBtn = el('button', { class: 'btn danger sm' }, [
el('i', { 'data-lucide': 'unplug' }), 'Disconnect',
])
disconnectBtn.addEventListener('click', async () => {
if (!confirm('Disconnect ' + pr.kind + ' provider "' + pr.label + '"?')) return
try {
const path = pr.kind === 'btcpay' ? '/v1/admin/btcpay/disconnect' : '/v1/admin/zaprite/disconnect'
await api(path, {
method: 'POST',
body: JSON.stringify({ provider_id: pr.id }),
})
reload()
} catch (e) {
alert('Disconnect failed: ' + e.message)
}
})
tb.appendChild(el('tr', null, [
el('td', null, el('strong', null, pr.label || pr.kind)),
el('td', { class: 'muted' }, pr.kind),
el('td', { class: 'muted' }, rails || '—'),
el('td', null, disconnectBtn),
]))
}
const table = el('table', { class: 't' }, [
el('thead', null, el('tr', null, [
el('th', null, 'Label'),
el('th', null, 'Kind'),
el('th', null, 'Rails'),
el('th', null, ''),
])),
tb,
])
body.appendChild(el('div', { class: 't-wrap' }, table))
}
// Connect buttons (offer whichever provider kinds aren't yet attached).
const haveBtcpay = providers.some((pr) => pr.kind === 'btcpay')
const haveZaprite = providers.some((pr) => pr.kind === 'zaprite')
const connectActions = el('div', { class: 'toolbar', style: 'margin-top:14px' }, [])
if (!haveBtcpay) {
const btn = el('button', { class: 'btn secondary' }, [
el('i', { 'data-lucide': 'bitcoin' }), 'Connect BTCPay',
])
btn.addEventListener('click', () => connectBtcpayForProfile(p.id, reload))
connectActions.appendChild(btn)
}
if (!haveZaprite) {
const btn = el('button', { class: 'btn secondary' }, [
el('i', { 'data-lucide': 'credit-card' }), 'Connect Zaprite',
])
btn.addEventListener('click', () => connectZapriteForProfile(p.id, reload))
connectActions.appendChild(btn)
}
if (connectActions.children.length) body.appendChild(connectActions)
if (window.lucide) lucide.createIcons()
return el('div', { class: 'card' }, [head, body])
}
function openCreateMerchantProfileModal(onDone) {
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;',
})
const nameInput = el('input', { class: 'input', placeholder: 'e.g. Recaps' })
const supportUrlInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/support (optional)' })
const supportEmailInput = el('input', { class: 'input', placeholder: 'support@recaps.cc (optional)' })
const redirectInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/welcome?invoice_id={invoice_id} (optional)' })
const brandColorInput = el('input', { class: 'input', type: 'color', value: '#1E3A5F' })
const errBox = el('div')
const submitBtn = el('button', { class: 'btn primary' }, 'Create profile')
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;',
}, [
el('h3', { style: 'margin:0 0 14px' }, 'New merchant profile'),
el('p', { class: 'muted', style: 'margin:0 0 18px; font-size:13px' },
'Each profile is one business identity. Buyers see the brand on the buy page; ' +
'products attached to this profile route their payments through the providers you connect to it.'),
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]),
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Support URL'),
supportUrlInput,
el('div', { class: 'hint' }, 'Linked from the buy page so buyers can contact your team.'),
]),
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Support email'),
supportEmailInput,
]),
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
redirectInput,
el('div', { class: 'hint' }, '{invoice_id} is substituted at purchase time. Leave blank to use Keysats /thank-you page.'),
]),
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Brand color'),
brandColorInput,
]),
errBox,
el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
submitBtn,
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
nameInput.focus()
submitBtn.addEventListener('click', async () => {
errBox.innerHTML = ''
const name = nameInput.value.trim()
if (!name) {
errBox.appendChild(err('Name is required.'))
return
}
submitBtn.disabled = true
try {
await api('/v1/admin/merchant-profiles', {
method: 'POST',
body: JSON.stringify({
name,
support_url: supportUrlInput.value.trim() || null,
support_email: supportEmailInput.value.trim() || null,
post_purchase_redirect_url: redirectInput.value.trim() || null,
brand_color: brandColorInput.value || null,
}),
})
overlay.remove()
if (onDone) onDone()
} catch (e) {
errBox.appendChild(err(e.message))
submitBtn.disabled = false
}
})
}
function openEditMerchantProfileModal(profile, onDone) {
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;',
})
const nameInput = el('input', { class: 'input', value: profile.name || '' })
const supportUrlInput = el('input', { class: 'input', value: profile.support_url || '' })
const supportEmailInput = el('input', { class: 'input', value: profile.support_email || '' })
const redirectInput = el('input', { class: 'input', value: profile.post_purchase_redirect_url || '' })
const brandColorInput = el('input', { class: 'input', type: 'color', value: profile.brand_color || '#1E3A5F' })
const errBox = el('div')
const submitBtn = el('button', { class: 'btn primary' }, 'Save')
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;',
}, [
el('h3', { style: 'margin:0 0 14px' }, 'Edit merchant profile'),
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]),
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support URL'), supportUrlInput]),
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support email'), supportEmailInput]),
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
redirectInput,
el('div', { class: 'hint' }, '{invoice_id} substituted at purchase time.'),
]),
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Brand color'), brandColorInput]),
errBox,
el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
submitBtn,
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
submitBtn.addEventListener('click', async () => {
errBox.innerHTML = ''
submitBtn.disabled = true
try {
// Build the patch: outer Option means "leave unchanged" (omit
// the key); we always send all the editable fields so the
// operator can also CLEAR them by leaving the input empty.
const patch = {
name: nameInput.value.trim() || null,
support_url: [supportUrlInput.value.trim() || null],
support_email: [supportEmailInput.value.trim() || null],
post_purchase_redirect_url: [redirectInput.value.trim() || null],
brand_color: [brandColorInput.value || null],
}
// Wire-format note: the Rust patch uses double-Option (outer Some
// wraps inner None for "set to NULL"). serde_json deserializes
// a single value into outer Some(value); arrays into Some(Some(v)).
// We use the bare value for outer Some/Some(v), and the [null]
// array trick to express Some(None) (clear). For simplicity here
// we just send the bare value — Rust treats null → Some(None) →
// sets to NULL; a string → Some(Some(s)) → updates.
const wirePatch = {}
if (patch.name !== null) wirePatch.name = patch.name
wirePatch.support_url = patch.support_url[0]
wirePatch.support_email = patch.support_email[0]
wirePatch.post_purchase_redirect_url = patch.post_purchase_redirect_url[0]
wirePatch.brand_color = patch.brand_color[0]
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(profile.id), {
method: 'PATCH',
body: JSON.stringify(wirePatch),
})
overlay.remove()
if (onDone) onDone()
} catch (e) {
errBox.appendChild(err(e.message))
submitBtn.disabled = false
}
})
}
async function connectBtcpayForProfile(profileId, onDone) {
try {
const r = await api('/v1/admin/btcpay/connect', {
method: 'POST',
body: JSON.stringify({ merchant_profile_id: profileId }),
})
if (r.authorize_url) {
if (confirm('Open BTCPays consent page in a new tab to complete connection?')) {
window.open(r.authorize_url, '_blank', 'noopener')
}
}
if (onDone) onDone()
} catch (e) {
alert('Connect BTCPay failed: ' + e.message)
}
}
function connectZapriteForProfile(profileId, onDone) {
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;',
})
const apiKeyInput = el('input', { class: 'input', type: 'password', placeholder: 'paste Zaprite API key' })
const baseUrlInput = el('input', { class: 'input', placeholder: 'https://api.zaprite.com (default)' })
const errBox = el('div')
const submitBtn = el('button', { class: 'btn primary' }, 'Connect')
const card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:480px; width:100%; padding:24px;',
}, [
el('h3', { style: 'margin:0 0 12px' }, 'Connect Zaprite'),
el('p', { class: 'muted', style: 'margin:0 0 14px; font-size:13px' },
'Paste an API key from app.zaprite.com Settings → API.'),
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'API key'), apiKeyInput]),
el('div', { class: 'field' }, [
el('div', { class: 'lbl' }, 'Base URL (optional)'),
baseUrlInput,
el('div', { class: 'hint' }, 'Override only for sandbox orgs that point at a different host.'),
]),
errBox,
el('div', { style: 'display:flex; gap:10px; margin-top:14px; justify-content:flex-end;' }, [
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
submitBtn,
]),
])
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
apiKeyInput.focus()
submitBtn.addEventListener('click', async () => {
errBox.innerHTML = ''
const key = apiKeyInput.value.trim()
if (!key) { errBox.appendChild(err('API key required.')); return }
submitBtn.disabled = true
try {
const r = await api('/v1/admin/zaprite/connect', {
method: 'POST',
body: JSON.stringify({
api_key: key,
base_url: baseUrlInput.value.trim() || undefined,
merchant_profile_id: profileId,
}),
})
overlay.remove()
if (r.webhook_url) {
alert('Zaprite connected. Register this webhook URL on the Zaprite dashboard:\n\n' + r.webhook_url)
}
if (onDone) onDone()
} catch (e) {
errBox.appendChild(err(e.message))
submitBtn.disabled = false
}
})
}
// -------- Settings --------
// Three subsections:
// 1. Operator name — the human-readable name on /buy/<slug> + thank-you.
@@ -6073,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' }, '')
@@ -6467,8 +7013,35 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
}
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
a.addEventListener('click', (e) => {
e.preventDefault()
setRoute(a.getAttribute('data-route'))
// Close the off-canvas drawer on phones after navigating, otherwise
// the sidebar stays parked over the content the operator just opened.
closeSidebarDrawer()
})
})
// Mobile nav drawer. Above 720px the sidebar is a static column and these
// toggles are no-ops (the CSS keeps it visible regardless of `.open`).
const sidebarEl = document.getElementById('sidebar')
const backdropEl = document.getElementById('sidebar-backdrop')
const toggleEl = document.getElementById('nav-toggle')
function openSidebarDrawer() {
sidebarEl.classList.add('open')
backdropEl.classList.add('open')
toggleEl.setAttribute('aria-expanded', 'true')
}
function closeSidebarDrawer() {
sidebarEl.classList.remove('open')
backdropEl.classList.remove('open')
toggleEl.setAttribute('aria-expanded', 'false')
}
toggleEl.addEventListener('click', () => {
if (sidebarEl.classList.contains('open')) closeSidebarDrawer()
else openSidebarDrawer()
})
backdropEl.addEventListener('click', closeSidebarDrawer)
// Tier status (label + usage + caps) — cached after first fetch so
// multiple consumers within a single route render don't all re-hit
+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
+48 -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,48 @@ 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.',
'',
'0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.',
'',
'0.2.0:49 — **Zaprite saved-profile capture: full diagnostic logging + reconciler path.** Sandbox testing of `:47` revealed five recurring subscriptions all settled successfully but with NULL `zaprite_payment_profile_id` — even though Zaprite confirmed the saved card on the contact. Two root causes addressed: (1) `capture_zaprite_payment_profile` had six different early-return-Ok branches (no provider, not Zaprite, downcast fail, no contact_id, no profiles array, no matching profile) that ALL silently returned with no logging, so there was no way to know which branch fired. Every branch now emits a `tracing::info!` or `tracing::warn!` explaining what it found, including a sample of the profiles\' `sourceOrder.externalUniqId` values when no match is found (to detect the timing race where Zaprite\'s profile-attach lags the order.paid webhook). (2) The polling reconciler (which catches missed webhook deliveries) called `issue_license_for_invoice` to recover the license + subscription, but never called `on_invoice_settled` — so a recurring sub created via the reconciler path NEVER got its Zaprite profile captured even though the saved profile was sitting on Zaprite\'s contact. Fixed in `reconcile.rs::ensure_license`: now invokes `on_invoice_settled` after license issuance (and on the idempotency early-return, in case a prior license-exists run missed the hook). The hook is itself idempotent and a no-op for BTCPay subs, so this is safe to call from both webhook and reconciler paths. Together these mean: even if your Zaprite webhook never delivers, the reconciler will pick up the slack within ~60 seconds AND capture the saved profile so auto-charge still works on the next renewal cycle.',
'',
'0.2.0:48 — **Thank-you page copy is now provider-aware.** The `/thank-you` landing page (where buyers wait while their license is signed) hardcoded "Your Bitcoin payment was received" + "Lightning settles in seconds; on-chain typically settles in 1020 minutes" — true for BTCPay-routed purchases, awkward for Zaprite-routed card payments where the buyer never touched Bitcoin. Fixed in `api/mod.rs::thank_you`: read `SETTING_ACTIVE_PROVIDER`, branch the lede copy on it. For Zaprite: "Your payment was received. Card payments confirm in seconds; Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically settles in 1020 minutes." For BTCPay (and the unconfigured fallback): unchanged Bitcoin-only copy. Also passed the provider kind into the polling JS so the running-status copy (`waitingCopy()`) makes the same distinction at every elapsed-time threshold (2 min, 10 min, slow-block). When the planned multi-provider work lands, this lookup will switch from the singleton setting to the invoice\'s own `payment_provider_id` so the copy matches the rail that actually settled THIS purchase rather than whatever\'s currently active on the daemon.',
'',
'0.2.0:47 — **Zaprite recurring purchases now create the contact upfront.** First-time test purchase against a live Zaprite sandbox surfaced the gap: when the order body has `allowSavePaymentProfile: true`, Zaprite\'s API requires an explicit `contactId` and returns `400 contactId is required when allowSavePaymentProfile is true` if you only pass `customerData: { email }`. Their llms.txt docs say contactId is optional in that case; the API itself disagrees, and the API is the source of truth. Fixed in `payment/zaprite/provider.rs`: when about to send `allowSavePaymentProfile: true`, first call a new `client.create_contact(email, name)` helper (`POST /v1/contacts`), then pass the returned id as `contactId` on the subsequent `create_order` call. Three handling paths: (1) recurring + buyer_email present → create contact + save profile, the happy path; (2) recurring + buyer_email MISSING → degrade to one-shot for THIS cycle (buyer gets a license, renewals fall back to manual-pay, warn-logged); (3) non-recurring → unchanged (no contact created, customerData only). Known minor: Zaprite\'s duplicate-email behavior on `POST /v1/contacts` is undocumented, so the same buyer purchasing recurring twice may end up with duplicate contacts in the operator\'s Zaprite dashboard until the multi-provider work introduces a Keysat-side dedup cache.',
'',
'0.2.0:46 — **Provider create-invoice failures now surface the underlying cause.** When `provider.create_invoice` failed (Zaprite or BTCPay rejection, network error, currency validation), the buy page rendered only "payment provider create-invoice failed: ZapriteProvider.create_invoice" — the outermost `context()` wrapper — and the actual cause (HTTP status + response body from the upstream) was never logged anywhere either. The trait method returned the anyhow error; only the tower trace layer fired, and it only sees the HTTP status code, not the body. Fixed in `api/purchase.rs`: switch user-facing format from `{e}` to `{e:#}` so the full anyhow chain shows up on the buy page, and add an explicit `tracing::error!` before returning so the same chain lands in daemon logs. Operator-visible: failed checkouts now actually tell you what went wrong ("Zaprite create_order returned HTTP 400: missing payment_methods", etc.) without log-spelunking.',
'',
'0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.',
'',
'0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.',
'',
'0.2.0:43 — **BTCPay authorize success page now says "return to Keysat" instead of "return to StartOS".** The success page is the lightweight HTML BTCPay redirects to after the operator clicks Authorize. With the BTCPay connect flow living inside Keysat\'s admin UI (since the :40-era admin-UI redesign), "return to StartOS" was misdirecting the operator — Keysat\'s own tab is what they came from and what they want to return to. One-line copy change in `success_page()` and the GET fallback path in `btcpay_authorize.rs`; no behavior change.',
'',
'0.2.0:42 — **Revert the implicit Patron→Pro entitlement expansion shipped in :41.** Reasoning on revert: the only license affected by the missing-entitlement bug was the master operator\'s own pre-launch self-license, issued under an earlier entitlement scheme. The Patron policy on the master Keysat now lists the correct entitlements, so any fresh Patron license issued today carries them in the signed payload directly. Making `patron` a magic superset at the resolution layer was paying ongoing complexity tax (entitlement-renames have to update a hardcoded list; the gate behavior diverges from what the policy literally says) for a one-shot migration that won\'t recur. Operators with a stuck old-scheme Patron license should re-issue + run "Activate Keysat license" — the new license overwrites `/data/keysat-license.txt` and the daemon picks up the fresh entitlements without a restart. The :41 BTCPay one-click authorize-flow restoration in the admin UI is unchanged.',
'',
'0.2.0:41 — **Two fixes: Patron tier now implies the full Pro feature surface, and BTCPay Connect is back to one-click authorize.** Both came from operator-side bugs that the admin-UI redesign exposed.',
'',
'**Patron implies Pro at the resolution layer.** Previously, every `tier.has(<pro-entitlement>)` check required the Patron POLICY on the master Keysat to redundantly list every Pro entitlement (`unlimited_products`, `unlimited_policies`, `unlimited_codes`, `recurring_billing`, `zaprite_payments`) — if the operator forgot even one slug on the Patron policy, every Patron customer was silently locked out of that feature. The Zaprite gate caught this in the wild: a Patron license without `zaprite_payments` got an "Upgrade to Pro" CTA on the payment-providers page. Fixed at the right layer: `tier::current()` now expands `patron` into the full Pro entitlement set on read, so a Patron policy can list just `patron` and have everything Pro grants flow through automatically. Existing Patron customers get the implied entitlements without re-issuing a license. Recommended cleanup: also list the entitlements explicitly on the Patron policy itself so the buy-page tier card stays informative — but the gate behavior no longer depends on it.',
@@ -517,7 +540,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:41',
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