Compare commits

...

2 Commits

Author SHA1 Message Date
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
5 changed files with 122 additions and 52 deletions
+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).
+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,
+4 -2
View File
@@ -15,13 +15,15 @@ export const manifest = setupManifest({
id: 'keysat',
title: 'Keysat Licensing',
license: 'LicenseRef-Keysat-1.0',
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
// 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
+8 -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,8 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
'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.',
@@ -543,7 +526,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:54',
version: '0.2.0:55',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under