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
2026-04-22 17:46:43 -05:00
2026-04-22 17:46:43 -05:00
2026-04-22 17:46:43 -05:00

Keysat

Keysat

Self-hosted licensing server. Sell software on payment channels you control, verify licenses offline, keep the keys + customer list on your hardware. Runs on Start9.

keysat.xyz · docs.keysat.xyz · Releases


Quick start

Operator (install Keysat on your Start9): add registry.keysat.xyz to your StartOS marketplace and install. Sideload the .s9pk from GitHub releases if you prefer. See Install & setup for the full walkthrough.

Developer (verify a license in your software): four official SDKs ship today, all wire-compatible against the same cross-check fixtures in licensing-service/tests/crosscheck/.

Language Install
TypeScript npm install @keysat/licensing-client
Rust cargo add keysat-licensing-client
Python pip install keysat-licensing-client
Go go get github.com/keysat-xyz/keysat-client-go

See Integrate the SDK for the five-line verifier pattern.

Operator agent / automation: the daemon exposes an OpenAPI 3.1 spec, scoped API keys with role-based access, and outbound webhooks. See Agent integration.


About this README. Keysat is a from-scratch service authored for StartOS — there is no upstream project to differ from. The canonical implementation is this package and the Rust daemon it wraps (licensing-service/). Where this README would normally explain "differences from upstream," it instead documents the architecture directly. Anything that isn't documented here matches the source.

Table of Contents

What Keysat is

Keysat lets a software seller issue, validate, and revoke license keys for their own product, with payment in Bitcoin via BTCPay Server. The seller runs Keysat on their own Start9, declares one or more products, and shares a public purchase URL with their customers. Buyers pay in Bitcoin and receive a signed license key whose authenticity their software can verify offline against the seller's embedded public key. Keys can be capped to specific machines, time-limited, suspended, revoked, or marked as trial.

Discount and referral codes (paid and free-license) are first-class primitives. Free-license codes bypass BTCPay entirely and issue a key directly via a public redemption endpoint — useful for press passes, comp keys, beta access, or "first N users free" launch promos.

Image and Container Runtime

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.

start-cli s9pk pack ingests the resulting OCI image, converts it to a squashfs filesystem image, and embeds that in the .s9pk. At runtime StartOS extracts the squashfs and runs the service in its own container runtime.

Volume and Data Layout

Keysat declares a single persistent volume:

Volume Mount Contents
main /data SQLite database (keysat.db); contains the Ed25519 signing keypair, products, policies, licenses, machines, invoices, redemptions, audit log, and BTCPay credentials.

Loss of this volume invalidates every issued license, since the signing keypair is regenerated on first boot. Treat StartOS-managed backups as 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 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 (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.

Configuration Management

All configuration is performed through StartOS actions; there is no on-disk config file the operator should edit. Environment variables passed to the daemon at startup (main.ts) are derived from the package-local store (operator name, admin API key) and from the declared BTCPay dependency hostname.

For advanced operators, the /v1/admin/* HTTP API exposes everything the actions do plus bulk-list operations not yet surfaced in the UI. Retrieve the admin API key via the Show admin credentials action.

Network Access and Interfaces

Keysat exposes one logical port (8080 HTTP) split across two service 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.

StartOS terminates TLS at the platform edge. Inside the container every request arrives as plain HTTP. For browser-facing URLs (e.g., the public purchase page) hardcode https://.

Actions (StartOS UI)

Grouped as displayed in the dashboard.

General

  • Set operator name — your public-facing brand.

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.

Backups and Restore

Keysat opts into StartOS's default volume backup via setupBackups / Backups.ofVolumes('main'). The single main volume contains all state — signing key included — so a backup is sufficient to fully recover the service. On restore, the install-time Connect BTCPay task re-surfaces in case the BTCPay credentials in the restored DB are stale.

Treat backups as mandatory: losing the signing keypair invalidates every key Keysat ever issued, with no recovery path.

Health Checks

A single port-listening check on port 8080 (sdk.healthCheck.checkPortListening). StartOS reports the service as healthy once the daemon is binding the port. The daemon exposes GET /healthz for richer external monitoring.

Dependencies

Dependency Version range Required Purpose
btcpayserver >=1.11.0 Yes Required to receive Bitcoin payments and confirm settlement.

The dependency is kind: 'running', so Keysat will not start until BTCPay is running. The btcpayserver.startos hostname is provided to the container automatically.

Limitations and Differences

Known current limitations:

  • Buyer self-service recovery is by-design minimal. Buyers can re-derive a lost license at /recover using (invoice id, buyer email). They cannot transfer between machines without contacting the operator (use Free a machine seat in the admin / agent API).
  • 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.

What Is Unchanged from Upstream

Not applicable — Keysat is authored fresh for Start9 and has no upstream. The canonical implementation IS this package + the Rust daemon at licensing-service/.

Contributing

For commercial redistribution or resale rights, or to discuss white-label deployment, contact licensing@keysat.xyz. Source-available license terms are in the package's LICENSE file: you may run, audit, modify for self-hosting; you may not redistribute, resell, or publicly host for others.

YAML Quick Reference

Structured summary for AI consumers and automated package introspection.

service:
  id: keysat
  title: Keysat
  category: bitcoin
  license: source-available (LicenseRef-Proprietary)
  marketingUrl: https://keysat.xyz
image:
  source: dockerBuild
  baseImage: scratch (musl-static Rust binary)
  arches: [x86_64, aarch64]
volumes:
  - id: main
    mountpoint: /data
    contents: SQLite DB + Ed25519 signing keypair
network:
  interfaces:
    - id: api
      type: api
      port: 8080
      protocol: http
      pathPrefix: /
      audience: public
    - id: webhook
      type: api
      port: 8080
      protocol: http
      pathPrefix: /btcpay
      audience: btcpay
dependencies:
  btcpayserver:
    required: true
    versionRange: ">=1.11.0"
    kind: running
healthChecks:
  - id: api
    method: portListening
    port: 8080
backups:
  mode: full-volume
  volumes: [main]
firstRun:
  tasks:
    - id: btcpay-initial-setup
      severity: critical
      runs: configureBtcpay
features:
  paymentRail: btcpay-server   # zaprite planned for v0.3 (card payments)
  signing: ed25519
  offlineVerification: true
  multiSeat: true
  trialFlag: true
  expiry: true
  gracePeriod: true
  entitlements: true
  entitlementsCatalog: per-product   # typed slugs with display names + descriptions
  hiddenEntitlements: per-policy    # license-granted but hidden from buy page
  marketingBullets: per-policy      # operator-authored ✓ items on tier cards
  multiCurrency: [SAT, USD, EUR]    # auto-converted at invoice creation
  discountCodes: [percent, fixed_sats, set_price, free_license]
  featuredDiscounts: true   # launch-special, auto-applies on the buy page
  multiPolicyDiscountScope: true   # one code can apply to N policies
  recurringSubscriptions: true   # auto-renew with trials + grace
  tierUpgrades: true   # in-place tier upgrade with proration
  outboundWebhooks: true
  webhookDlq: true   # failed deliveries retryable from admin UI
  auditLog: true
  scopedApiKeys: [read-only, license-issuer, support, full-admin]
  openapiSpec: /v1/openapi.json
  selfLicensingTier: [Creator, Pro, Patron]
sdks:
  - typescript: "@keysat/licensing-client (npm)"
  - rust: "keysat-licensing-client (crates.io)"
  - python: "keysat-licensing-client (PyPI)"
  - go: "github.com/keysat-xyz/keysat-client-go"
S
Description
No description provided
Readme 3.7 MiB
Languages
Rust 63%
HTML 17.9%
TypeScript 16.5%
Shell 2.1%
Makefile 0.3%
Other 0.2%