Compare commits

...

9 Commits

Author SHA1 Message Date
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
12 changed files with 335 additions and 148 deletions
+29 -9
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. | | `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. | | `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. | | `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. | | `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
Endpoints that touch settings (operator name, payment provider connections, Endpoints that touch settings (operator name, payment provider connections,
@@ -138,7 +139,7 @@ upgrade CTA without parsing message strings.
| `expired` | Past `expires_at` | | `expired` | Past `expires_at` |
| `fingerprint_mismatch` | Different machine than the one bound on first activate | | `fingerprint_mismatch` | Different machine than the one bound on first activate |
| `product_mismatch` | License is for a different product than the caller asserted | | `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` |
--- ---
@@ -173,7 +174,9 @@ curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-d '{"reason":"customer request"}' -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`. Scope required: `licenses:write`.
@@ -277,10 +280,16 @@ A few patterns that work well in practice.
### Idempotency ### Idempotency
The daemon's mutation endpoints are idempotent where they can be. Revoke, The daemon's mutation endpoints are idempotent where they can be. Suspend,
suspend, unsuspend, archive, unarchive, subscription cancel — all return unsuspend, archive, unarchive, subscription cancel — all return success on the
success on the second call without changing state. Your agent can safely second call without changing state. Your agent can safely retry on network
retry on network errors. 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 ### Pagination
@@ -349,15 +358,26 @@ Some operations are deliberately operator-only and not accessible to any
scoped key, including `full-admin`: scoped key, including `full-admin`:
- Generating / revoking scoped API keys (`/v1/admin/api-keys`) - 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 - Setting the operator name
- Activating the self-license (`/v1/admin/self-license`) - Activating the self-license (`/v1/admin/self-license`)
- Resetting the analytics install_uuid - Resetting the analytics install_uuid
- Changing the web UI password (StartOS Action only) - Changing the web UI password (StartOS Action only)
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
that can rotate its own credentials, connect arbitrary payment processors, or that can rotate its own credentials, redirect settled payments, or change the
change the operator identity is no longer bounded by the role it was given. 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`. # 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 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`, Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
with build context set to the parent directory so the Dockerfile can with build context set to the parent directory so the Dockerfile can
`COPY` from the sibling `licensing-service/` source tree. The Rust binary `COPY` from the sibling `licensing-service/` source tree. The Rust binary
is statically linked against musl (target is statically compiled against musl (target `*-unknown-linux-musl`), and the
`*-unknown-linux-musl`) so the runtime image is a `scratch`-based final runtime stage is `debian:bookworm-slim` with `ca-certificates`, `tini` (init /
stage with no shared-library dependencies. Architectures: `x86_64` and signal handling), and `sqlite3` (an SQL shell for occasional admin tasks)
`aarch64`. installed. Architectures: `x86_64` and `aarch64`.
`start-cli s9pk pack` ingests the resulting OCI image, converts it to a `start-cli s9pk pack` ingests the resulting OCI image, converts it to a
squashfs filesystem image, and embeds that in the `.s9pk`. At runtime squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
@@ -105,25 +105,24 @@ mandatory.
## Installation and First-Run Flow ## Installation and First-Run Flow
1. Install Keysat via the marketplace (or sideload the `.s9pk`). 1. Install Keysat via the marketplace (or sideload the `.s9pk`).
2. Resolve the auto-created **critical task** "Connect BTCPay" by 2. Resolve the auto-created **important task** "Connect BTCPay" — open
running the **Connect BTCPay** action. This opens a one-click the embedded admin web UI (**Settings → Payment providers**) and
authorize page on your local BTCPay; after approval, Keysat click **Connect BTCPay**. This opens a one-click authorize page on
auto-detects your store and registers an inbound webhook. No API your local BTCPay; after approval, Keysat auto-detects your store and
keys to copy. registers an inbound webhook. No API keys to copy. The install task
3. Run **Check BTCPay connection** to confirm — the install task clears clears automatically once BTCPay reports connected.
automatically. 3. Set your **operator name** (shown on the public homepage and in
4. Set your **operator name** (shown on the public homepage and in
buyer-facing receipts). buyer-facing receipts).
5. Create one or more **products** — each represents something you sell. 4. Create one or more **products** — each represents something you sell.
6. Create at least one **policy** per product. Multi-tier ladders 5. Create at least one **policy** per product. Multi-tier ladders
(Basic / Pro / Max) are first-class: when a product has two or more (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 public policies, the buy page renders a tier picker and the buyer
chooses before paying. Policies define duration, grace period, seat chooses before paying. Policies define duration, grace period, seat
cap, entitlements, recurring cadence, trial flag, price overrides, cap, entitlements, recurring cadence, trial flag, price overrides,
marketing bullets, and per-entitlement hide-on-buy-page toggles. marketing bullets, and per-entitlement hide-on-buy-page toggles.
7. Optionally create **discount / referral / free-license codes** (see 6. Optionally create **discount / referral / free-license codes** in the
`Create discount code` action). admin web UI.
8. Share the public service URL with buyers. 7. Share the public service URL with buyers.
## Configuration Management ## Configuration Management
@@ -145,7 +144,7 @@ interfaces for clarity:
| Interface | Type | Path prefix | Purpose | | 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. | | `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 StartOS terminates TLS at the platform edge. Inside the container every
request arrives as plain HTTP. For browser-facing URLs (e.g., the public request arrives as plain HTTP. For browser-facing URLs (e.g., the public
@@ -153,44 +152,23 @@ purchase page) hardcode `https://`.
## Actions (StartOS UI) ## 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 web UI password* — set / recover the admin SPA login password (you
- *Set operator name* — your public-facing brand. 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** Everything else — connecting BTCPay (and Zaprite), operator name, products,
- *Connect BTCPay* — one-click authorize against your BTCPay; auto-detects store and registers webhook. policies, discount / referral / free-license codes, licenses, machines,
- *Check BTCPay connection* — confirm BTCPay state; clears the install task on success. outbound webhooks, scoped API keys, and the audit log — lives in the embedded
**admin web UI** (Settings tab + the workspace sidebar), not as StartOS
**Credentials** actions.
- *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 ## 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. - **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. - **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. - **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 ## What Is Unchanged from Upstream
@@ -257,7 +235,7 @@ service:
marketingUrl: https://keysat.xyz marketingUrl: https://keysat.xyz
image: image:
source: dockerBuild 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] arches: [x86_64, aarch64]
volumes: volumes:
- id: main - id: main
@@ -292,10 +270,10 @@ backups:
firstRun: firstRun:
tasks: tasks:
- id: btcpay-initial-setup - id: btcpay-initial-setup
severity: critical severity: important
runs: configureBtcpay runs: configureBtcpay
features: 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 signing: ed25519
offlineVerification: true offlineVerification: true
multiSeat: true multiSeat: true
@@ -315,7 +293,7 @@ features:
outboundWebhooks: true outboundWebhooks: true
webhookDlq: true # failed deliveries retryable from admin UI webhookDlq: true # failed deliveries retryable from admin UI
auditLog: true auditLog: true
scopedApiKeys: [read-only, license-issuer, support, full-admin] scopedApiKeys: [read-only, license-issuer, support, merchant-onboard, full-admin]
openapiSpec: /v1/openapi.json openapiSpec: /v1/openapi.json
selfLicensingTier: [Creator, Pro, Patron] selfLicensingTier: [Creator, Pro, Patron]
sdks: sdks:
+21 -38
View File
@@ -56,44 +56,21 @@ See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout.
## Project layout ## Project layout
``` 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):
licensing-service/
├── Cargo.toml - `main.rs`, `config.rs`, `error.rs`, `models.rs` — entry point, env-driven config, error → HTTP mapping, shared domain types.
├── LICENSE # source-available; no redistribution - `crypto/` — the LIC1 license-key byte layout and Ed25519 sign/verify (the contract the four SDKs implement).
├── README.md - `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).
├── .env.example # required env vars - `payment/` (`btcpay/`, `zaprite/`) + `merchant_profiles.rs` — the payment-provider abstraction and multi-profile routing.
├── migrations/ - `reconcile.rs`, `subscriptions.rs`, `upgrades.rs` — the background worker (invoice reconciliation, recurring renewals, tier upgrades).
│ └── 0001_initial.sql # SQLite schema - `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`.
├── src/ - `web/index.html` — the embedded admin SPA.
│ ├── main.rs # entry point: wires everything
│ ├── config.rs # env-driven config Deeper docs: [`docs/API.md`](docs/API.md), [`docs/INTEGRATION.md`](docs/INTEGRATION.md), [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
│ ├── 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
```
## Running locally ## 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 ```bash
cp .env.example .env cp .env.example .env
@@ -109,7 +86,7 @@ On first boot the server generates a fresh Ed25519 keypair and stores it in the
```bash ```bash
curl -X POST http://localhost:8080/v1/admin/products \ 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" \ -H "Content-Type: application/json" \
-d '{ -d '{
"slug": "my-app", "slug": "my-app",
@@ -143,7 +120,7 @@ curl -X POST http://localhost:8080/v1/validate \
## Deploying on Start9 ## 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. - **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. - **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 ## 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'" } { "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 ```json
{ {
"service": "keysat", "service": "keysat",
"version": "0.1.0", "version": "0.2.0",
"operator": "Acme Software", "operator": "Acme Software",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n", "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
"key_algorithm": "ed25519", "key_algorithm": "ed25519",
@@ -128,7 +128,7 @@ On failure:
{ "ok": false, "reason": "revoked" } { "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` ### `POST /v1/btcpay/webhook`
@@ -138,7 +138,7 @@ Landing point for BTCPay Server webhook events. Only BTCPay should call this. We
## Admin endpoints ## 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` ### `POST /v1/admin/products`
+13 -10
View File
@@ -12,13 +12,18 @@
## Data model ## 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. - `products` — what's for sale. Independent pricing per product.
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id. - `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. - `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 ## 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). 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. - 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. - 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. 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. - **Key rotation.** A single static signing key is fine for now. 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.
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product. - **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 ## 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. 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 ## 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 ```rust
use anyhow::{Context, Result}; use anyhow::{Context, Result};
+10 -5
View File
@@ -27,10 +27,14 @@ use uuid::Uuid;
/// A merchant profile row. Mirrors the `merchant_profiles` table. /// A merchant profile row. Mirrors the `merchant_profiles` table.
/// ///
/// SMTP fields are flattened onto the same struct for simplicity; they /// NOTE: the `smtp_*` fields are DORMANT and not consumed by anything.
/// land in the same table on the same row. NULL on all six means /// They were laid down in migration 0020 ahead of the keysat-smtp-emails
/// "inherit StartOS-level SMTP config." See the keysat-smtp-emails /// plan, which was SUPERSEDED 2026-06-18: Keysat will never send buyer
/// plan for how they're consumed. /// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MerchantProfile { pub struct MerchantProfile {
pub id: String, pub id: String,
@@ -42,7 +46,8 @@ pub struct MerchantProfile {
pub post_purchase_redirect_url: Option<String>, pub post_purchase_redirect_url: Option<String>,
pub is_default: bool, pub is_default: bool,
// SMTP override (all-or-nothing for now; the SMTP plan refines this). // Dormant SMTP-override columns (see struct doc) — stored/returned
// but never read to send mail; no send path exists or is planned.
pub smtp_host: Option<String>, pub smtp_host: Option<String>,
pub smtp_port: Option<i64>, pub smtp_port: Option<i64>,
pub smtp_username: Option<String>, pub smtp_username: Option<String>,
+89 -10
View File
@@ -67,7 +67,7 @@ use crate::payment::CreateInvoiceParams;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::{json, Value};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use uuid::Uuid; use uuid::Uuid;
@@ -1349,13 +1349,18 @@ pub async fn capture_zaprite_payment_profile(
/// worker *after* it has created the order; this turns the order /// worker *after* it has created the order; this turns the order
/// from "buyer must pay" into "auto-charged, will settle via the /// from "buyer must pay" into "auto-charged, will settle via the
/// usual webhook." Returns: /// usual webhook." Returns:
/// - `Ok(true)` — the charge call succeeded; the buyer is not /// - `Ok(true)` — the charge settled (order status PAID/COMPLETE/
/// expected to pay manually. The settle webhook /// OVERPAID); the buyer is not expected to pay
/// will fire on its own and flip the sub to /// manually. The settle webhook will fire on its
/// `active` via `on_invoice_settled`. /// own and flip the sub to `active` via
/// - `Ok(false)` — sub has no saved profile, or active provider /// `on_invoice_settled`.
/// isn't Zaprite. Caller proceeds with manual-pay /// - `Ok(false)` — sub has no saved profile, active provider isn't
/// fallback (`subscription.renewal_pending`). /// 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, /// - `Err(_)` — Zaprite returned an error (declined card,
/// expired profile, network blip). Caller treats /// expired profile, network blip). Caller treats
/// this as a soft failure: log, audit, and ALSO /// this as a soft failure: log, audit, and ALSO
@@ -1400,12 +1405,86 @@ async fn try_auto_charge_zaprite(
.await .await
.context("Zaprite charge_order_with_profile")?; .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!( tracing::info!(
sub_id = %sub.id, sub_id = %sub.id,
order_id = %provider_invoice_id, order_id = %provider_invoice_id,
profile_id = %profile_id, profile_id = %profile_id,
order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?"), order_status,
"Zaprite auto-charge succeeded; awaiting settle webhook" "Zaprite auto-charge settled; awaiting settle webhook"
); );
Ok(true) 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 })));
}
}
+11 -11
View File
@@ -415,16 +415,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
border:1px solid var(--border-1); border:1px solid var(--border-1);
} }
.featured-pill-toggle.on { .featured-pill-toggle.on {
background:var(--gold-500); color:var(--navy-950); background:var(--navy-800); color:var(--cream-50);
border-color:var(--gold-500); border-color:var(--navy-800);
box-shadow:0 2px 6px rgba(191,160,104,0.25); box-shadow:0 2px 6px rgba(14,31,51,0.18);
} }
.featured-pill-toggle.on .state { .featured-pill-toggle.on .state {
background:var(--navy-950); color:var(--gold-500); background:var(--cream-50); color:var(--navy-900);
border-color:var(--navy-950); border-color:var(--cream-50);
} }
.featured-pill-toggle.on:hover { .featured-pill-toggle.on:hover {
background:var(--gold-400); background:var(--navy-900);
} }
/* Tier-card drag affordance — cursor signals draggability on hover, /* Tier-card drag affordance — cursor signals draggability on hover,
@@ -534,12 +534,12 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
<div id="tier-banner-msg" style="margin-bottom:8px;"></div> <div id="tier-banner-msg" style="margin-bottom:8px;"></div>
<a id="tier-banner-cta" target="_blank" rel="noopener" style=" <a id="tier-banner-cta" target="_blank" rel="noopener" style="
display:inline-block; padding:5px 10px; 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; font-weight:700; font-size:11px;
border-radius:5px; text-decoration:none; border-radius:8px; text-decoration:none;
transition:background 120ms; transition:background 120ms;
" onmouseover="this.style.background='var(--gold-400)'" " onmouseover="this.style.background='var(--cream-200)'"
onmouseout="this.style.background='var(--gold-500)'"></a> onmouseout="this.style.background='var(--cream-50)'"></a>
</div> </div>
<div class="footer" id="sidebar-footer"> <div class="footer" id="sidebar-footer">
<span class="dot warn"></span> <span class="dot warn"></span>
@@ -5763,7 +5763,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
// -------- Merchant profiles (multi-provider model, :52+) -------- // -------- Merchant profiles (multi-provider model, :52+) --------
// Each profile represents one "business" the operator is running on // Each profile represents one "business" the operator is running on
// this Keysat instance. Owns business identity (brand, support contact, // this Keysat instance. Owns business identity (brand, support contact,
// post-purchase redirect, optional SMTP override) and a set of payment // post-purchase redirect; SMTP-override cols are dormant/unused) and a set of payment
// providers (BTCPay / Zaprite) that legally settle to that business. // providers (BTCPay / Zaprite) that legally settle to that business.
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron // Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
// get unlimited. // get unlimited.
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')."
+5 -1
View File
@@ -39,6 +39,10 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions // in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here. // append here.
const ROUTINE_NOTES = [ const ROUTINE_NOTES = [
'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: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: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.',
@@ -532,7 +536,7 @@ const ROUTINE_NOTES = [
].join('\n\n') ].join('\n\n')
export const v0_2_0 = VersionInfo.of({ export const v0_2_0 = VersionInfo.of({
version: '0.2.0:58', version: '0.2.0:60',
releaseNotes: { en_US: ROUTINE_NOTES }, releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change. // No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under // SQLite-level migrations live separately under