diff --git a/KEYSAT_AGENT_GUIDE.md b/KEYSAT_AGENT_GUIDE.md index 61c039b..e2a789a 100644 --- a/KEYSAT_AGENT_GUIDE.md +++ b/KEYSAT_AGENT_GUIDE.md @@ -57,6 +57,7 @@ API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars> | `read-only` | List / get every resource. Mutate nothing. | | `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. | | `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. | +| `merchant-onboard` | All `read-only` scopes + `products:write` + `policies:write` + `licenses:write` — the least-privilege credential for standing up a fresh catalog (create products, define policies/tiers, issue licenses against them) via the API. Deliberately excludes the support writes (subscriptions / machines) and every master-only gate. Tier caps still bound it. | | `full-admin` | Every scope. Equivalent to the master key for most endpoints. | Endpoints that touch settings (operator name, payment provider connections, @@ -138,7 +139,7 @@ upgrade CTA without parsing message strings. | `expired` | Past `expires_at` | | `fingerprint_mismatch` | Different machine than the one bound on first activate | | `product_mismatch` | License is for a different product than the caller asserted | -| `machine_cap_exceeded` | Activating this fingerprint would exceed `max_machines` | +| `too_many_machines` | Activating this fingerprint would exceed `max_machines` | --- @@ -173,7 +174,9 @@ curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \ -d '{"reason":"customer request"}' ``` -Idempotent. The next online validate from the buyer's app returns `reason: revoked`. +The next online validate from the buyer's app returns `reason: revoked`. Not +idempotent — a second revoke of the same license returns `404 not_found` (treat +as success-equivalent on retry; see Idempotency below). Scope required: `licenses:write`. @@ -277,10 +280,16 @@ A few patterns that work well in practice. ### Idempotency -The daemon's mutation endpoints are idempotent where they can be. Revoke, -suspend, unsuspend, archive, unarchive, subscription cancel — all return -success on the second call without changing state. Your agent can safely -retry on network errors. +The daemon's mutation endpoints are idempotent where they can be. Suspend, +unsuspend, archive, unarchive, subscription cancel — all return success on the +second call without changing state. Your agent can safely retry on network +errors. + +One exception: **revoke is not idempotent** — revoking an already-revoked +license returns `404 not_found` (the row no longer matches the +`status != 'revoked'` update guard). When retrying a revoke after an ambiguous +network failure, treat a `404` as success-equivalent: the license is already +revoked. ### Pagination @@ -349,15 +358,26 @@ Some operations are deliberately operator-only and not accessible to any scoped key, including `full-admin`: - Generating / revoking scoped API keys (`/v1/admin/api-keys`) -- Connecting / disconnecting payment providers +- Disconnecting a payment provider, and connecting *any* provider on a + production daemon - Setting the operator name - Activating the self-license (`/v1/admin/self-license`) - Resetting the analytics install_uuid - Changing the web UI password (StartOS Action only) These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent -that can rotate its own credentials, connect arbitrary payment processors, or -change the operator identity is no longer bounded by the role it was given. +that can rotate its own credentials, redirect settled payments, or change the +operator identity is no longer bounded by the role it was given. + +**One narrow exception — agent-delegated payment connect.** A key granted the +à-la-carte `payment_providers:write` scope (never granted by any role — +operators add it explicitly per key) CAN initiate a BTCPay connect, but only +fail-closed under two gates: the daemon must be in **sandbox mode** (an outer +gate — scoped connect is refused outright on a production daemon, even for +regtest), and the target store must be **non-mainnet** (an inner gate enforced +after the OAuth round-trip). Disconnecting a provider, and any connect on a +production / mainnet daemon, remain master-only. This lets an integrating agent +wire up a throwaway sandbox without ever touching a live store's settlement. --- diff --git a/README.md b/README.md index f7cc55f..e0d24bf 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,10 @@ comp keys, beta access, or "first N users free" launch promos. Built from the local `Dockerfile` via `images.main.source.dockerBuild`, with build context set to the parent directory so the Dockerfile can `COPY` from the sibling `licensing-service/` source tree. The Rust binary -is statically linked against musl (target -`*-unknown-linux-musl`) so the runtime image is a `scratch`-based final -stage with no shared-library dependencies. Architectures: `x86_64` and -`aarch64`. +is statically compiled against musl (target `*-unknown-linux-musl`), and the +runtime stage is `debian:bookworm-slim` with `ca-certificates`, `tini` (init / +signal handling), and `sqlite3` (an SQL shell for occasional admin tasks) +installed. Architectures: `x86_64` and `aarch64`. `start-cli s9pk pack` ingests the resulting OCI image, converts it to a squashfs filesystem image, and embeds that in the `.s9pk`. At runtime @@ -105,25 +105,24 @@ mandatory. ## Installation and First-Run Flow 1. Install Keysat via the marketplace (or sideload the `.s9pk`). -2. Resolve the auto-created **critical task** "Connect BTCPay" by - running the **Connect BTCPay** action. This opens a one-click - authorize page on your local BTCPay; after approval, Keysat - auto-detects your store and registers an inbound webhook. No API - keys to copy. -3. Run **Check BTCPay connection** to confirm — the install task clears - automatically. -4. Set your **operator name** (shown on the public homepage and in +2. Resolve the auto-created **important task** "Connect BTCPay" — open + the embedded admin web UI (**Settings → Payment providers**) and + click **Connect BTCPay**. This opens a one-click authorize page on + your local BTCPay; after approval, Keysat auto-detects your store and + registers an inbound webhook. No API keys to copy. The install task + clears automatically once BTCPay reports connected. +3. Set your **operator name** (shown on the public homepage and in buyer-facing receipts). -5. Create one or more **products** — each represents something you sell. -6. Create at least one **policy** per product. Multi-tier ladders +4. Create one or more **products** — each represents something you sell. +5. Create at least one **policy** per product. Multi-tier ladders (Basic / Pro / Max) are first-class: when a product has two or more public policies, the buy page renders a tier picker and the buyer chooses before paying. Policies define duration, grace period, seat cap, entitlements, recurring cadence, trial flag, price overrides, marketing bullets, and per-entitlement hide-on-buy-page toggles. -7. Optionally create **discount / referral / free-license codes** (see - `Create discount code` action). -8. Share the public service URL with buyers. +6. Optionally create **discount / referral / free-license codes** in the + admin web UI. +7. Share the public service URL with buyers. ## Configuration Management @@ -145,7 +144,7 @@ interfaces for clarity: | Interface | Type | Path prefix | Purpose | |-----------|------|-------------|------------------------------------------------------------------------------| | `api` | api | `/` | Public REST API for buyers (purchase, redeem) and licensed apps (validate, machine activation). Bake the URL into your software builds as the licensing endpoint. | -| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically during Connect BTCPay; not for human use. | +| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically when you connect BTCPay in the admin web UI; not for human use. | StartOS terminates TLS at the platform edge. Inside the container every request arrives as plain HTTP. For browser-facing URLs (e.g., the public @@ -153,44 +152,23 @@ purchase page) hardcode `https://`. ## Actions (StartOS UI) -Grouped as displayed in the dashboard. +The StartOS Actions tab is intentionally minimal — only the four operations +that must happen outside the embedded admin web UI are registered as actions: -**General** -- *Set operator name* — your public-facing brand. +- *Set web UI password* — set / recover the admin SPA login password (you + can't reset it from inside the UI if you're locked out). +- *Show credentials* — reveal the admin API key on first install, before + you've logged into the admin UI. +- *Activate Keysat license* — first-install bootstrap for paid self-hosting + tiers, and recovery if `/data/keysat-license.txt` is lost. +- *Show license status* — sanity-check the self-license state without + logging into the admin UI. -**BTCPay** -- *Connect BTCPay* — one-click authorize against your BTCPay; auto-detects store and registers webhook. -- *Check BTCPay connection* — confirm BTCPay state; clears the install task on success. - -**Credentials** -- *Show admin credentials* — admin API key for direct `/v1/admin/*` access. - -**Products + Policies** -- *Create product* — declare something to sell. -- *Create policy* — license template for a product (duration, grace, seat cap, entitlements, trial flag, price override). - -**Discount codes** -- *Create discount code* — percent-off / fixed-sats-off / free-license. -- *List discount codes* — usage stats. -- *Disable / enable discount code*. - -**Licenses** -- *Issue license manually* — comp / press / grandfathered keys. -- *Search licenses* — by email or BTCPay invoice id. -- *Suspend license* — reversible lockout. -- *Unsuspend license*. -- *Revoke license* — terminal kill. - -**Machines** -- *List machines* — installs bound to a license. -- *Deactivate machine* — free a seat. - -**Webhooks (outbound)** -- *Register webhook endpoint* — POST signed events to your URL. -- *List webhook endpoints*. - -**Diagnostics** -- *View audit log* — admin mutation history, filterable. +Everything else — connecting BTCPay (and Zaprite), operator name, products, +policies, discount / referral / free-license codes, licenses, machines, +outbound webhooks, scoped API keys, and the audit log — lives in the embedded +**admin web UI** (Settings tab + the workspace sidebar), not as StartOS +actions. ## Backups and Restore @@ -228,7 +206,7 @@ Known current limitations: - **No bulk / volume licensing UI.** "Buy 10 keys at once with discount" is not built into the buy page. Operators can issue N comp licenses via the admin API in a loop. - **Webhook delivery retries are bounded.** A subscriber down past the 10-attempt retry window lands in the dead-letter queue (visible in admin UI → Webhooks → Failed). BTCPay invoice reconciliation runs as a background poll so dropped *payment* webhooks are recovered. - **Hardware fingerprinting is client-supplied.** Keysat does not derive fingerprints itself; the buyer-side SDK passes whatever the integrator chose. The fingerprint is bound on first activate and enforced thereafter. -- **Card payments not shipped.** The Zaprite payment provider is in design for v0.3 — operators on Pro / Patron will get a card-payment option alongside BTCPay. Until then, payments are BTC / Lightning only. +- **Card payments via Zaprite are gated.** Zaprite ships as an optional second payment provider (card / fiat alongside BTCPay) but is gated by the `zaprite_payments` entitlement — operators on the tiers that grant it can connect Zaprite in the admin web UI. BTCPay remains the required provider; without the entitlement, payments are BTC / Lightning only. ## What Is Unchanged from Upstream @@ -257,7 +235,7 @@ service: marketingUrl: https://keysat.xyz image: source: dockerBuild - baseImage: scratch (musl-static Rust binary) + baseImage: debian:bookworm-slim (musl-compiled Rust binary; bundles ca-certificates, tini, sqlite3) arches: [x86_64, aarch64] volumes: - id: main @@ -292,10 +270,10 @@ backups: firstRun: tasks: - id: btcpay-initial-setup - severity: critical + severity: important runs: configureBtcpay features: - paymentRail: btcpay-server # zaprite planned for v0.3 (card payments) + paymentProviders: [btcpay-server, zaprite] # btcpay required; zaprite optional, gated by the zaprite_payments entitlement signing: ed25519 offlineVerification: true multiSeat: true @@ -315,7 +293,7 @@ features: outboundWebhooks: true webhookDlq: true # failed deliveries retryable from admin UI auditLog: true - scopedApiKeys: [read-only, license-issuer, support, full-admin] + scopedApiKeys: [read-only, license-issuer, support, merchant-onboard, full-admin] openapiSpec: /v1/openapi.json selfLicensingTier: [Creator, Pro, Patron] sdks: diff --git a/licensing-service/README.md b/licensing-service/README.md index e38240a..3a107aa 100644 --- a/licensing-service/README.md +++ b/licensing-service/README.md @@ -56,44 +56,21 @@ See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout. ## Project layout -``` -licensing-service/ -├── Cargo.toml -├── LICENSE # source-available; no redistribution -├── README.md -├── .env.example # required env vars -├── migrations/ -│ └── 0001_initial.sql # SQLite schema -├── src/ -│ ├── main.rs # entry point: wires everything -│ ├── config.rs # env-driven config -│ ├── error.rs # unified error → HTTP mapping -│ ├── models.rs # shared domain types -│ ├── crypto/ -│ │ ├── mod.rs # license key format + sign/verify -│ │ └── keys.rs # server keypair lifecycle -│ ├── db/ -│ │ ├── mod.rs # pool + migrations -│ │ └── repo.rs # all SQL queries -│ ├── btcpay/ -│ │ ├── client.rs # Greenfield API client -│ │ └── webhook.rs # HMAC verification + event parsing -│ └── api/ -│ ├── mod.rs # router + AppState -│ ├── products.rs # public product endpoints -│ ├── purchase.rs # buy + poll -│ ├── validate.rs # the hot path for downstream software -│ ├── webhook.rs # BTCPay landing -│ └── admin.rs # operator-only actions -└── docs/ - ├── API.md # full endpoint reference - ├── INTEGRATION.md # for developers embedding a client - └── ARCHITECTURE.md # deeper design notes -``` +The daemon source lives under `src/`, organized by subsystem (browse it for the current layout — the tree below has grown well past the v0.1 snapshot): + +- `main.rs`, `config.rs`, `error.rs`, `models.rs` — entry point, env-driven config, error → HTTP mapping, shared domain types. +- `crypto/` — the LIC1 license-key byte layout and Ed25519 sign/verify (the contract the four SDKs implement). +- `db/` — SQLite pool, migrations, and `repo.rs` (all SQL). `migrations/` holds the numbered, additive schema (0001 through the latest; the schema has grown substantially since 0001). +- `payment/` (`btcpay/`, `zaprite/`) + `merchant_profiles.rs` — the payment-provider abstraction and multi-profile routing. +- `reconcile.rs`, `subscriptions.rs`, `upgrades.rs` — the background worker (invoice reconciliation, recurring renewals, tier upgrades). +- `api/` — the ~30 route modules: public (`products`, `purchase`, `validate`, `redeem`) and admin (`admin*`, scoped API keys, webhooks, etc.), plus the router and `AppState` in `api/mod.rs`. +- `web/index.html` — the embedded admin SPA. + +Deeper docs: [`docs/API.md`](docs/API.md), [`docs/INTEGRATION.md`](docs/INTEGRATION.md), [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). ## Running locally -Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted). +Prerequisites: Rust 1.88+ (the build toolchain; the crate's Cargo.toml still declares MSRV 1.75, but the dependency tree now requires a newer compiler), a BTCPay Server instance you can point at (local or hosted). ```bash cp .env.example .env @@ -143,7 +120,7 @@ curl -X POST http://localhost:8080/v1/validate \ ## Deploying on Start9 -This repository ships the service only. To package as an `.s9pk` for the 0.4.0.x platform you'll need a separate wrapper repository following [docs.start9.com/packaging/0.4.0.x](https://docs.start9.com/packaging/0.4.0.x/). The service is designed to slot in cleanly: +The StartOS wrapper lives in **this same repository** under `../startos/` (this `licensing-service/` directory is the daemon source it bundles). Build the `.s9pk` for the 0.4.0.x platform from the parent directory — see the build/release guide and `../Makefile`. The service is designed to slot in cleanly: - **Declares a dependency** on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a `.startos` hostname and supply the env vars from the wrapper's action handlers. - **Persists to `/data`**, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups. @@ -170,4 +147,10 @@ Commercial redistribution / resale rights: contact licensing@keysat.xyz. ## Status -v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator. +0.2.0 — shipped and in production. The current feature set: + +- **Four published SDKs** — TypeScript (npm), Rust (crates.io), Python (PyPI), and Go — all wire-compatible against the cross-check fixtures in `tests/crosscheck/`. +- **StartOS wrapper included in this repo** under `../startos/`; build the `.s9pk` from the parent directory (no separate wrapper repository). +- **Embedded admin SPA** (`web/index.html`) for all day-to-day operations. +- **Subscriptions** (recurring auto-renew with trials + grace), **policies / tiers** with per-policy entitlements, **discount / referral / free-license codes**, **outbound webhooks** with a dead-letter queue, and a background **invoice reconciliation** job that recovers dropped payment webhooks. +- **Payment providers**: BTCPay Server is required; Zaprite (card / fiat) is optional and gated by the `zaprite_payments` entitlement. diff --git a/licensing-service/docs/API.md b/licensing-service/docs/API.md index 0a9be62..5371dee 100644 --- a/licensing-service/docs/API.md +++ b/licensing-service/docs/API.md @@ -6,7 +6,7 @@ All endpoints are JSON in / JSON out. Errors return a body of the form: { "ok": false, "error": "not_found", "message": "product 'xyz'" } ``` -Admin endpoints require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`. +Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`. --- @@ -128,7 +128,7 @@ On failure: { "ok": false, "reason": "revoked" } ``` -Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `product_mismatch`, `fingerprint_mismatch`. +Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`, `expired`, `product_mismatch`, `fingerprint_mismatch`, `too_many_machines` (multi-seat cap reached). ### `POST /v1/btcpay/webhook` @@ -138,7 +138,7 @@ Landing point for BTCPay Server webhook events. Only BTCPay should call this. We ## Admin endpoints -All of these require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`. +All of these require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`. ### `POST /v1/admin/products` diff --git a/licensing-service/docs/ARCHITECTURE.md b/licensing-service/docs/ARCHITECTURE.md index 97d0dd4..ff34fe1 100644 --- a/licensing-service/docs/ARCHITECTURE.md +++ b/licensing-service/docs/ARCHITECTURE.md @@ -12,13 +12,18 @@ ## Data model -See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables: +The schema lives in [`migrations/`](../migrations/) as numbered, additive +migrations (0001 through the latest — it has grown substantially past the +original five-table v0.1 schema, adding discount codes, tiered pricing, +multi-currency, subscriptions, tier upgrades, per-product entitlement catalogs, +scoped API keys, merchant profiles, and more). The core tables established in +[`0001_initial.sql`](../migrations/0001_initial.sql): - `products` — what's for sale. Independent pricing per product. - `invoices` — one per purchase attempt, keyed by BTCPay's invoice id. -- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns. +- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns. Later migrations add `expires_at`, entitlements, trial flag, and tier columns. - `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us. -- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot, never rotated in v0.1 (rotation is a planned feature). +- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot. ## License key format @@ -63,7 +68,6 @@ Who might attack this? - **Key rotation.** A single static signing key is fine for first launch. Rotation requires SDK multi-key support and a migration strategy; deferred. - **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app. - **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured). -- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change. - **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`. diff --git a/licensing-service/docs/INTEGRATION.md b/licensing-service/docs/INTEGRATION.md index 480227c..f395f9f 100644 --- a/licensing-service/docs/INTEGRATION.md +++ b/licensing-service/docs/INTEGRATION.md @@ -25,9 +25,18 @@ curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point. +> **Official SDKs exist — use them first.** Four wire-compatible client SDKs +> are published: TypeScript (`@keysat/licensing-client` on npm), Rust +> (`keysat-licensing-client` on crates.io), Python (`keysat-licensing-client` +> on PyPI), and Go (`github.com/keysat-xyz/keysat-client-go`). Install commands +> are in the main README. The by-hand reference implementations below are a +> fallback for languages without an SDK, or for understanding exactly what the +> SDKs do under the hood. + ## Reference integration in Rust -This is what a Start9 package written in Rust might look like. No SDK crate yet — that's planned; here's what you'd write by hand: +This is what a Start9 package written in Rust might look like if you verify by +hand instead of using the Rust SDK: ```rust use anyhow::{Context, Result};