Grant 7007bf8204 Recurring subs Phase 2 — renewal worker (committed, not published)
Implements the renewal lifecycle from RECURRING_SUBSCRIPTIONS_DESIGN.md
phase 2. Operators don't see this yet (no admin UI); the worker
only acts on subscriptions that exist in the schema, and creating
subscription rows still requires direct DB insert. Phase 4 (admin
UI) wires the buyer-facing surface that creates them.

src/subscriptions.rs (new module, ~450 LOC):
- find_due_renewals: subs with status active|past_due whose
  next_renewal_at has passed and consecutive_failures < cap
- find_lapsing_subscriptions: past_due subs whose
  (next_renewal_at + grace_period_days) is in the past
- mark_lapsed / mark_active_after_settle / mark_renewal_failed:
  state-transition helpers
- create_subscription: atomic create-sub + first-cycle invoice
  (called by purchase flow when policy.is_recurring; not yet
  wired — that's a separate phase)
- on_invoice_settled: helper for webhook handler to flip a sub
  from past_due back to active and dispatch subscription.renewed
- find_subscription_for_invoice: lookup helper
- tick: 60s sweep, picks up to 25 due renewals + lapse sweep
- spawn: long-lived background task, mirrors webhooks::spawn_delivery_worker

Renewal flow per due sub:
  1. Convert listed_value to sats via rates::convert_to_sats
     (identity for SAT subs; live rate fetcher for USD/EUR — per
     MULTI_CURRENCY_DESIGN.md "USD-stable / re-quote each cycle"
     decision).
  2. Get the active payment provider, call create_invoice with
     the same trait surface used by one-shot purchases. Works
     against BTCPay or Zaprite or any future provider.
  3. Persist the local invoice row carrying the rate audit
     (listed_currency / listed_value / exchange_rate_centibps /
     exchange_rate_source). For SAT subs, rate fields are NULL
     (identity conversion isn't worth recording).
  4. Insert subscription_invoices linking the invoice to the sub
     with monotonic cycle_number.
  5. Update sub: status → past_due, next_renewal_at → end of new
     cycle, last_renewal_attempt_at → now.
  6. Dispatch subscription.renewal_pending webhook to the operator.

On settle (webhook handler): if the invoice is linked via
subscription_invoices, flip sub → active, reset
consecutive_failures to 0, dispatch subscription.renewed.

Failure path: increment consecutive_failures, push next_renewal_at
out by exponential backoff (5min → 30min → 2h → 6h → 12h, capped
at 5 failures ≈ 24h of retries before the worker stops trying).
Operator can see stuck subs via the upcoming admin UI; for now
they show up in the audit log via webhook deliveries.

Lapse path: separate sweep finds past_due subs whose
(next_renewal_at + policy.grace_period_days) is past now, flips
to lapsed, dispatches subscription.lapsed.

Wired into:
- src/lib.rs: pub mod subscriptions
- src/main.rs: subscriptions::spawn(state.clone()) alongside
  reconcile + webhooks + analytics
- src/api/webhook.rs: settle path now calls
  subscriptions::on_invoice_settled before license issuance —
  ordering matters because first-cycle subs create both a sub
  row AND a license; we want the sub state correct on the way
  to the license-issuance branch

Test: 7 integration tests in tests/subscriptions.rs. Drives the
worker against a MockProvider with fail-on-demand semantics:
- renewal_worker_creates_invoice_for_sat_priced_due_sub: SAT sub
  charges listed_value sats verbatim, no rate audit, sub goes
  active → past_due, subscription_invoices gets a new cycle row
- renewal_worker_requotes_rate_for_fiat_priced_sub: $25 USD at
  pinned $50k/BTC = exactly 50,000 sats; rate audit pinned on
  invoice; centibps encoded correctly
- renewal_worker_backs_off_on_failure: failed create_invoice →
  consecutive_failures = 1, no invoice created, sub → past_due
- renewal_worker_stops_retrying_at_max_failures: pre-set failures
  = MAX, tick is a no-op for that sub
- lapse_sweep_flips_past_due_after_grace: 15-day-old past_due
  with grace=7 → lapsed
- settle_webhook_flips_sub_back_to_active: tick creates renewal,
  simulate settle, on_invoice_settled flips sub back to active
- tick_is_no_op_when_nothing_due: empty fixture, tick is safe

Test count: 49 (was 42; +7).

NOT bumping version. The recurring-subs feature isn't operator-
visible until phases 4+5 (admin UI for creating recurring
policies + buy page rendering for "$25/month"). Schema is in,
worker runs, but nothing creates subs yet — so this commit
ships dormant.
2026-05-08 17:26:10 -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
2026-04-22 17:46:43 -05:00

Keysat

Keysat

Self-hosted, Bitcoin-paid software-licensing service for Start9.

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. The policy slugged default is consumed by the standard public purchase flow; other slugs are used for manual issuance. Policies define duration, grace period, seat cap, entitlements, trial flag, and price overrides.
  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, Nostr npub, 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 v0.1 limitations:

  • No buyer self-service portal. Buyers cannot log in to view their licenses, transfer to a new machine, or recover a lost key without contacting the operator. Use Search licenses to recover.
  • No recurring subscriptions. Time-limited licenses expire and require a manual repurchase. BTCPay supports recurring billing but Keysat does not yet model auto-renewal.
  • No license tier upgrade in place. A buyer who got Standard cannot be upgraded to Pro on the existing key — they need a new key.
  • No bulk / volume licensing. "Buy 10 keys at once with discount" is not built in.
  • No in-dashboard list views. Operators query large datasets via the admin API key rather than a paginated UI.
  • Webhook delivery retries are bounded. A subscriber down past the retry window will miss events. 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.

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
  signing: ed25519
  offlineVerification: true
  multiSeat: true
  trialFlag: true
  expiry: true
  gracePeriod: true
  entitlements: true
  discountCodes: [percent, fixed_sats, free_license]
  outboundWebhooks: true
  auditLog: true
  selfLicensingTier: stub-v0.1
S
Description
No description provided
Readme 3.9 MiB
Languages
Rust 63%
HTML 17.9%
TypeScript 16.5%
Shell 2.1%
Makefile 0.3%
Other 0.2%