v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign

Two release cycles prepared together: v0.2.0:11 (policy archive + safe-
delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings
tab + agent-friendly operator API + machines tab redesign + buyer-facing
copy alignment).

Highlights:

- Migration 0015: policies.archived_at column. Archive button on tier
  cards; safe-delete relaxed to ignore revoked-license tombstones;
  renewal worker refuses archived policies.
- Migration 0016: scoped_api_keys table. Four roles (read-only,
  license-issuer, support, full-admin) with bounded scopes. Master
  admin_api_key still works on every endpoint; scoped keys gated on
  endpoints wired through require_scope().
- New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec
  for agent / SDK discovery.
- New Settings tab: Operator name + Payment providers panel + API
  keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay
  all, operator name, switch-provider). StartOS Actions pruned to 4
  install-time essentials.
- Machines tab rewritten: global default view grouped by product,
  filter pills with counts, quick-stats row, drill-down via new
  "Machines" button on each Licenses-tab row. New repo helper
  list_machines_admin joins machines x licenses x products
  server-side.
- Branded confirmModal replaces every native window.confirm() call
  in the admin UI (7 callsites).
- Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag
  retired; daemon always boots; missing self-license -> Creator
  (free) tier. "Unlicensed" label gone from admin UI.
- Zaprite gated on the new zaprite_payments entitlement (renamed
  from card_payments to reflect the broader gateway).
- Creator code cap 5 -> 10.
- KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope,
  webhook events, worked recipes.
- Buyer-facing copy aligned with new positioning: "Bitcoin-native
  self-hosted software licensing" everywhere on production surfaces.
- Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md.
- 5 new API integration smoke tests covering OpenAPI, scoped API
  keys CRUD, role-elevation guard, and Zaprite-tier gating.

Test count: 83 passing (was 78). All migration tests pass against
0015 and 0016 applied to populated DBs.
This commit is contained in:
Grant
2026-05-11 08:45:25 -05:00
parent 20b5293c81
commit 257669092b
25 changed files with 2980 additions and 384 deletions
+370
View File
@@ -0,0 +1,370 @@
# Keysat Agent Integration Guide
How to build agents, bots, and automation that operate a Keysat instance.
Keysat was designed from the start to be agent-friendly. The admin API uses
plain HTTP + JSON with Bearer-token auth. There's an OpenAPI 3.1 spec for
discovery. Scoped API keys let you give an agent least-privilege access
without handing over the master credential. Errors carry stable machine-readable codes. Webhooks let an agent react to events instead of polling.
This guide is for the *operator side* of Keysat — running, configuring, and
performing day-to-day operations on a Keysat instance. For the *buyer side*
(validating licenses inside your app), see [KEYSAT_INTEGRATION.md](KEYSAT_INTEGRATION.md).
---
## Quick start
```bash
# 1. Discover the API surface
curl https://your-keysat-host/v1/openapi.json
# 2. Generate a scoped API key (in admin UI: Settings → API keys, or via curl)
curl -X POST https://your-keysat-host/v1/admin/api-keys \
-H "Authorization: Bearer $MASTER_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"Support bot","role":"support"}'
# Response includes `token: ks_...`. Save it — it's only shown once.
# 3. Use the scoped key
curl https://your-keysat-host/v1/admin/licenses?status=active \
-H "Authorization: Bearer ks_..."
```
---
## Authentication
All admin endpoints use HTTP Bearer auth:
```
Authorization: Bearer <token>
```
Two kinds of tokens are accepted:
**Master admin API key** — the env-configured `KEYSAT_ADMIN_API_KEY` (visible
in StartOS Actions → Show credentials on first install). Full access to every
endpoint. This is the operator's credential. Don't hand it to agents.
**Scoped API keys** — additional tokens generated in admin UI → Settings →
API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars>`. Operators can revoke any scoped key from the same UI; revoked tokens stop working immediately.
### Role to scope mapping
| Role | What it can do |
|---|---|
| `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. |
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
Endpoints that touch settings (operator name, payment provider connections,
self-license activation, scoped API key management) always require the master
admin key. A `full-admin` scoped key cannot, for example, generate another
scoped key — that's a self-defeating elevation path.
---
## Discovering the API
Two complementary discovery mechanisms.
### OpenAPI 3.1 spec
`GET /v1/openapi.json` — unauthenticated. Returns a curated spec covering the
agent-relevant subset of endpoints. Use this with:
- **OpenAI Custom GPTs**: paste the URL as an Action.
- **OpenAI Assistants / Functions**: feed the spec to tool definition generators.
- **Claude tool use**: use the spec to derive your `tools` array; Claude Code agents can `WebFetch` the spec at runtime and reason about endpoints.
- **LangChain / AutoGen / Smolagents**: use their OpenAPI loaders.
- **Code generation**: `openapi-generator-cli generate -i /v1/openapi.json -g python -o ./client`.
The spec is a stable agent surface, not auto-derived from handler signatures.
We commit to keeping documented endpoints and field shapes stable across
minor releases.
### Embedded endpoint listing
This guide's "Common workflows" section below covers the most common agent
tasks with copy-paste examples.
---
## Response envelope conventions
Every error response uses the same JSON envelope:
```json
{
"ok": false,
"error": "tier_cap",
"message": "Your Creator tier allows up to 5 products. You're at 5...",
"upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro"
}
```
`error` is a stable machine-readable code; `message` is human-readable. The
`upgrade_url` field appears on 402 (tier cap) responses so a UI can render an
upgrade CTA without parsing message strings.
### Error codes
| HTTP | `error` code | When |
|---|---|---|
| 400 | `bad_request` | Malformed body, missing required field, invalid enum value |
| 401 | `unauthorized` | No `Authorization: Bearer` header |
| 403 | `forbidden` | Wrong token, revoked scoped key, role doesn't grant required scope |
| 404 | `not_found` | Resource id doesn't exist |
| 409 | `conflict` | Slug collision, delete-with-references blocked, etc. |
| 402 | `tier_cap` | Operator's self-tier doesn't include the required entitlement |
| 429 | `rate_limited` | Rate limit hit (e.g. /v1/recover, /v1/validate) |
| 502 | `upstream_error` | BTCPay / Zaprite call failed |
| 503 | `service_unavailable` / `btcpay_not_configured` | Provider not yet connected |
| 500 | `internal_error` | Bug. Includes a trace id in logs; report it. |
### Validate response
`POST /v1/validate` is the one endpoint that returns 200 in all cases. Inspect
`ok` + `reason`:
| `reason` | Meaning |
|---|---|
| `bad_signature` | Signature doesn't verify against the trust-root pubkey |
| `not_found` | License key not in the daemon's DB |
| `revoked` | Operator revoked it |
| `suspended` | Operator suspended it (reversible) |
| `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` |
---
## Common workflows
### Issue a comp license
```bash
curl -X POST $KS/v1/admin/licenses \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{
"product_slug": "recap",
"policy_slug": "pro",
"buyer_email": "alice@example.com",
"buyer_note": "Conference speaker comp"
}'
```
Returns the issued license object including `license_key`. The buyer pastes
the key into their app; subsequent validate calls return `ok: true` with the
policy's entitlements.
Scope required: `licenses:write` (any role except `read-only`).
### Revoke a license
```bash
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{"reason":"refund issued"}'
```
Idempotent. The next online validate from the buyer's app returns `reason: revoked`.
Scope required: `licenses:write`.
### Find a license by email
```bash
curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \
-H "Authorization: Bearer ks_..."
```
Returns matching licenses (without the `license_key` field — that's only
returned on issue / recover). Use the `id` for follow-up operations.
Scope required: `licenses:read`.
### Cancel a buyer's subscription
```bash
# Look up the subscription id first (filter by license_id if you have it)
curl "$KS/v1/admin/subscriptions?status=active" \
-H "Authorization: Bearer ks_..."
# Then cancel
curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer requested"}'
```
License stays valid through the current cycle's `expires_at`. Renewal worker
stops issuing new invoices.
Scope required: `subscriptions:write`.
### Free a machine seat
```bash
curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer moved devices"}'
```
The seat opens up. The buyer's next validate from any machine takes the
freed seat.
Scope required: `machines:write`.
### Programmatic tier change (comp upgrade)
```bash
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
-H "Authorization: Bearer ks_..." \
-d '{
"target_policy_slug": "pro",
"reason": "support resolution"
}'
```
Always applies as comp (no invoice) from the admin path. Buyer-initiated
paid upgrades go through `/v1/upgrade` (different endpoint, signed-license auth).
Scope required: `licenses:write`.
---
## Webhooks — react to events instead of polling
Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON
payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:
| Event | Fires on |
|---|---|
| `license.issued` | New license minted (purchase, comp, redeem) |
| `license.revoked` / `license.suspended` / `license.unsuspended` | Admin operations |
| `license.tier_changed` | Tier upgrade/downgrade applied |
| `invoice.paid` | A BTCPay/Zaprite invoice settled |
| `subscription.renewal_pending` | Renewal worker created a fresh invoice |
| `subscription.renewal_skipped` | Renewal skipped (e.g. policy archived) |
| `subscription.cancelled` | Buyer or admin cancelled |
| `subscription.lapsed` | Past_due grace expired |
| `machine.activated` | First validate from a new fingerprint |
Verify signatures:
```python
import hmac, hashlib
def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
```
The header is `X-Keysat-Signature`. Failed deliveries retry with exponential
backoff up to 10 attempts; permanently-failed deliveries land in the DLQ
visible at admin UI → Webhooks → Failed.
---
## Designing a robust agent
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.
### Pagination
List endpoints return up to ~100 rows by default. Use `?limit=N` and
`?offset=N` for larger result sets. The OpenAPI spec documents the limits
per endpoint.
### Rate limits
The admin endpoints have no per-IP rate limit today — operators are trusted.
The public endpoints (`/v1/validate`, `/v1/recover`) are rate-limited per
client IP (10/min for /recover; /validate is unlimited but a reasonable
agent calls it once per app boot + once per hour).
### Master key handling
If your automation needs `full-admin` because it touches operator-only
operations (creating other API keys, changing payment providers), use the
master key from a secret manager. If it can stay within license / product /
policy operations, **always use a scoped key**. Operators can revoke a
compromised scoped key without rotating the master credential.
### Backoff on 5xx
`internal_error` (500) is a bug or a transient DB lock. Retry with exponential
backoff (1s, 2s, 4s, 8s, give up). Don't retry on 4xx — those are deterministic
client errors.
---
## Concrete agent recipe — "Comp a license to anyone who emails support@"
```python
import os, requests, imaplib, email
KS = os.environ["KEYSAT_URL"]
TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key
def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
r = requests.post(
f"{KS}/v1/admin/licenses",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"product_slug": product_slug,
"policy_slug": "default",
"buyer_email": buyer_email,
"buyer_note": reason,
},
timeout=10,
)
r.raise_for_status()
return r.json()["license_key"]
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key
```
That's the entire pattern. The agent doesn't need full admin — just the
license-issuer role. If it ever gets compromised, you revoke the scoped key
in the admin UI and generate a new one in 30 seconds.
---
## What's NOT exposed to agents
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
- 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.
---
## Help us improve this guide
The OpenAPI spec is the source of truth for the API surface. This guide is a
hand-curated overlay focused on the workflows we've seen agents actually need.
If you're building something the spec covers but this guide doesn't make
obvious, open an issue at github.com/keysat-xyz/keysat with the workflow
shape and we'll add it.
+74 -3
View File
@@ -1218,9 +1218,9 @@ function entitlementLabel(slug: string): string {
} }
``` ```
If the operator hasn't defined a catalog (legacy "free-text" mode), If the operator hasn't defined a catalog (free-text mode), the array
the array is empty and you fall back to rendering the raw slugs — is empty and you fall back to rendering the raw slugs — or replacing
or replacing underscores with spaces yourself for a quick polish. underscores with spaces yourself for a quick polish.
**Catalog stability rule**: once you ship gating logic that checks **Catalog stability rule**: once you ship gating logic that checks
for entitlement `"export"`, the operator's catalog and policy for entitlement `"export"`, the operator's catalog and policy
@@ -1291,6 +1291,71 @@ operator's licensing server being up.
--- ---
## 9a. Cross-product safety — read this if the operator sells more than one product
Many operators run a single Keysat instance that issues licenses for multiple
products (e.g. one Keysat serves both Recap and Notewise). All of those
licenses are signed by the **same Ed25519 keypair**. Without the right check
in your app, a license issued for Recap would parse + signature-verify
successfully inside Notewise — same public key, valid signature. That would
be a real bug, not a theoretical one.
**The protection exists, but it's your job to use it.** The LIC1 payload
includes a signed `product_slug` field. Recap's licenses literally carry
`"product_slug": "recap"` inside the signed bytes; Notewise's carry
`"product_slug": "notewise"`. The signature covers those bytes, so the
buyer can't tamper with them — but the SDK won't reject a wrong-product
license unless you tell it which product you are.
### Rule
- **Online validation:** always pass `product_slug` to `client.validate(...)`.
The daemon enforces it and returns `reason: 'product_mismatch'` on mismatch.
- **Offline verify:** always assert `payload.product_slug === MY_PRODUCT_SLUG`
after `parseAndVerify(...)`. The SDK does not do this for you.
### Concrete pattern (TypeScript)
```ts
const MY_PRODUCT_SLUG = 'recap' // hard-code; matches what the operator picked
// Online — daemon enforces product_slug for you
const r = await client.validate(licenseKey, MY_PRODUCT_SLUG, machineFingerprint)
if (!r.ok) {
// r.reason === 'product_mismatch' if a Notewise license was presented
reject(r.reason)
return
}
// Offline — you must check yourself
const payload = parseAndVerify(licenseKey, EMBEDDED_PUBKEY_PEM)
if (payload.product_slug !== MY_PRODUCT_SLUG) {
reject('product_mismatch')
return
}
```
Same shape in Python / Rust / Go: pass `product_slug` to `validate`,
check `payload.product_slug` after `parse_and_verify`. Every SDK exposes
the field on the parsed payload object.
### Why the SDK doesn't auto-reject offline
`ParseAndVerify` is intentionally low-level — it returns the verified
payload and lets the caller decide what to enforce. A multi-product app
(unusual but possible) might legitimately accept any product the operator
signed for; a per-product app must reject mismatches. Making this opt-in
keeps the SDK honest about what it's checking on your behalf.
### Forgetting to check is a silent failure
If you call `parseAndVerify` without asserting the product, a license
from any of the operator's products will signature-verify and you'll
treat it as valid. There is no warning. **Make the check a constant
in your app and assert it on every code path that loads a license.**
---
## 10. Fingerprint binding (for `validate()`) ## 10. Fingerprint binding (for `validate()`)
When you call `client.validate(...)`, the third argument is a machine When you call `client.validate(...)`, the third argument is a machine
@@ -1720,6 +1785,12 @@ ship it.
against slug `bar`. Typos in the slug constant cause "license valid against slug `bar`. Typos in the slug constant cause "license valid
but my code rejects it" head-scratchers. Read the slug from a but my code rejects it" head-scratchers. Read the slug from a
single constant. single constant.
- **Not asserting `product_slug` after offline verify.** `ParseAndVerify`
checks the signature, not the product. If the operator sells multiple
products from the same Keysat, every product's licenses share the
signing key — a license for Product A will signature-verify inside
Product B's app. Always assert `payload.product_slug === MY_PRODUCT_SLUG`
after the parse. See §9a for the full pattern.
- **Logging the full license key.** It's a bearer credential — log - **Logging the full license key.** It's a bearer credential — log
the `license_id` instead. the `license_id` instead.
- **Refusing to start without a license.** Boot in unlicensed mode and - **Refusing to start without a license.** Boot in unlicensed mode and
+1 -1
View File
@@ -219,4 +219,4 @@ Whatever you pick, hash it before sending if you want to avoid exposing the unde
## Tor / `.onion` support ## Tor / `.onion` support
Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-paid and privacy-adjacent. Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-native and privacy-adjacent.
@@ -0,0 +1,19 @@
-- Migration 0015: policies.archived_at
--
-- Adds a soft-archive flag to policies. An archived policy is hidden
-- from the admin grid (unless the operator opts to show archived) and
-- from the public /buy/<slug> page. Existing licenses keep validating
-- because their entitlements are signed into the LIC1 payload; the
-- policy row is not consulted at validate time. Active recurring
-- subscriptions tied to an archived policy stop renewing — the renewal
-- worker treats archived as a hard stop and surfaces a clear event.
--
-- Why a column instead of a status TEXT enum? Policies already have
-- two boolean toggles (active, public). A nullable timestamp is the
-- minimum-information shape: NULL = live, timestamp = when archived.
-- Useful for sorting "Archived (most recent first)" without an extra
-- column.
ALTER TABLE policies ADD COLUMN archived_at TEXT NULL;
CREATE INDEX IF NOT EXISTS idx_policies_archived_at ON policies(archived_at);
@@ -0,0 +1,37 @@
-- Migration 0016: scoped API keys
--
-- The master `admin_api_key` (set in config) is a full-access credential;
-- it's the right thing for the operator to hold but the wrong thing to
-- hand to an agent or a third-party tool. This table stores additional
-- API keys with operator-chosen roles that bound what they can do.
--
-- Storage model:
-- - `token_hash` is the sha256 of the raw token. We NEVER store the
-- raw token after generation — the create endpoint returns it once
-- in the response body and that's the operator's only chance to
-- copy it.
-- - `role` is a string tag (read-only | license-issuer | support |
-- full-admin). Scope sets per role are computed at auth time, not
-- stored here — that way we can extend the scope mapping without a
-- migration.
-- - `revoked_at` flips this row from valid → permanently rejected.
-- Soft-revoke rather than DELETE so the audit log keeps a stable
-- reference and the operator can see "this key existed but is
-- gone now" in the admin UI.
-- - `last_used_at` is touched best-effort on every successful auth.
-- Bounded write rate (one update per minute per key) is the next
-- iteration; for v1 we update every call.
CREATE TABLE IF NOT EXISTS scoped_api_keys (
id TEXT PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
CHECK (role IN ('read-only', 'license-issuer', 'support', 'full-admin'))
);
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_token ON scoped_api_keys(token_hash);
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_active ON scoped_api_keys(revoked_at);
+1 -1
View File
@@ -14,7 +14,7 @@
//! identity, store id, or any user-supplied value. Resetting //! identity, store id, or any user-supplied value. Resetting
//! analytics opt-in regenerates it. //! analytics opt-in regenerates it.
//! - `daemon_version` — e.g. `"0.1.0:46"`. //! - `daemon_version` — e.g. `"0.1.0:46"`.
//! - `tier` — `"unlicensed" | "creator" | "pro" | "patron"`. //! - `tier` — `"creator" | "pro" | "patron"`.
//! - `counts` — rounded down to the nearest 5 to prevent //! - `counts` — rounded down to the nearest 5 to prevent
//! fingerprinting an operator by exact license count. //! fingerprinting an operator by exact license count.
//! - `uptime_seconds` — bucketed to "<1d" / "1-7d" / "1-4w" / ">4w". //! - `uptime_seconds` — bucketed to "<1d" / "1-7d" / "1-4w" / ">4w".
+342
View File
@@ -0,0 +1,342 @@
//! Scoped API keys — additional API keys with bounded permissions.
//!
//! Master credential is the env-configured `admin_api_key` (full access).
//! These scoped keys exist so operators can grant an agent / bot / partner
//! script a credential that does only what it needs to. Operator-friendly
//! flow:
//!
//! 1. Operator generates a new key in Settings → API keys, picks a role
//! from a fixed list (Read-only / License issuer / Support / Full admin).
//! 2. UI returns the raw token ONCE. The token never appears in any
//! response afterward — only its sha256 hash is stored.
//! 3. Agent uses `Authorization: Bearer <token>` like the master key.
//! Endpoints that have been scope-wired check the agent's role
//! grants the required scope; if not, 403.
//! 4. Operator can revoke any key from the same UI; revoked tokens
//! stop working immediately.
//!
//! The master `admin_api_key` always works on every endpoint. Scoped keys
//! work only on endpoints that have been migrated to call `require_scope`
//! instead of `require_admin`. Endpoints not yet migrated reject scoped
//! keys with 403 — secure-by-default.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use axum::{
extract::{Path, State},
http::{header, HeaderMap},
Json,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use uuid::Uuid;
/// Roles an operator can grant to a scoped API key.
///
/// Each role expands to a static set of scopes at auth time. Adding a
/// new role requires a migration check-constraint update plus a new arm
/// here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// Every `:read` scope. Cannot mutate anything.
ReadOnly,
/// Read-only + license writes. Can issue / revoke / suspend / change
/// tier on licenses, but can't touch products, policies, or codes.
/// Right shape for an automation that gives out comp licenses.
LicenseIssuer,
/// License-issuer + subscription cancellation + machine deactivation.
/// Right shape for a customer-support agent that resolves common
/// requests without touching catalog or settings.
Support,
/// Every scope. Equivalent to the master `admin_api_key` for endpoints
/// that use `require_scope`; still rejected by endpoints that gate on
/// settings-write or tier-write where the master key is required.
FullAdmin,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::ReadOnly => "read-only",
Role::LicenseIssuer => "license-issuer",
Role::Support => "support",
Role::FullAdmin => "full-admin",
}
}
pub fn parse(s: &str) -> Option<Role> {
match s {
"read-only" => Some(Role::ReadOnly),
"license-issuer" => Some(Role::LicenseIssuer),
"support" => Some(Role::Support),
"full-admin" => Some(Role::FullAdmin),
_ => None,
}
}
/// Returns true if this role grants the named scope. Scope names are
/// `<resource>:<read|write>`, e.g. `licenses:write`.
pub fn grants(self, scope: &str) -> bool {
match self {
Role::FullAdmin => true,
Role::ReadOnly => scope.ends_with(":read"),
Role::LicenseIssuer => {
scope.ends_with(":read")
|| matches!(scope, "licenses:write")
}
Role::Support => {
scope.ends_with(":read")
|| matches!(
scope,
"licenses:write"
| "subscriptions:write"
| "machines:write"
)
}
}
}
}
/// Verify the request carries a credential that grants the named scope.
/// Order of acceptance:
/// 1. Master `admin_api_key` — always passes.
/// 2. Scoped API key whose role grants `scope`.
///
/// Returns the actor hash (sha256 of the token) for audit purposes. On
/// failure, 401 if no bearer header, 403 if the token is wrong or lacks
/// the scope.
pub async fn require_scope(
state: &AppState,
headers: &HeaderMap,
scope: &str,
) -> AppResult<String> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
// Master admin key — constant-time compare against the configured value.
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
return Ok(hex::encode(hasher.finalize()));
}
// Scoped API key — hash the candidate, look up, verify not revoked,
// confirm role grants the scope.
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
"SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?",
)
.bind(&token_hash)
.fetch_optional(&state.db)
.await?;
let (key_id, role_str, revoked_at) = match row {
Some(r) => r,
None => return Err(AppError::Forbidden),
};
if revoked_at.is_some() {
return Err(AppError::Forbidden);
}
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
if !role.grants(scope) {
return Err(AppError::Forbidden);
}
// Best-effort touch. Ignored on failure (clock skew, lock contention).
let now = Utc::now().to_rfc3339();
let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?")
.bind(&now)
.bind(&key_id)
.execute(&state.db)
.await;
Ok(token_hash)
}
// ---------- CRUD endpoints (gated on master admin only) ----------
#[derive(Debug, Deserialize)]
pub struct CreateApiKeyReq {
pub label: String,
pub role: String,
}
#[derive(Debug, Serialize)]
pub struct CreateApiKeyResp {
pub id: String,
pub label: String,
pub role: String,
pub created_at: String,
/// The raw token. Returned ONCE on create and never again — operator
/// must copy it now or generate a new key.
pub token: String,
}
/// `POST /v1/admin/api-keys` — generate a new scoped key. Master-only.
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateApiKeyReq>,
) -> AppResult<Json<CreateApiKeyResp>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let label = req.label.trim();
if label.is_empty() || label.len() > 80 {
return Err(AppError::BadRequest(
"label is required and must be at most 80 characters".into(),
));
}
let role = Role::parse(req.role.trim()).ok_or_else(|| {
AppError::BadRequest(
"role must be one of: read-only, license-issuer, support, full-admin".into(),
)
})?;
// 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars.
// Prefix `ks_` so it's recognizable in logs as a Keysat-style token.
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut raw);
let token = format!(
"ks_{}",
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw)
);
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(label)
.bind(&token_hash)
.bind(role.as_str())
.bind(&now)
.execute(&state.db)
.await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"api_key.create",
Some("api_key"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "label": label, "role": role.as_str() }),
)
.await;
Ok(Json(CreateApiKeyResp {
id,
label: label.to_string(),
role: role.as_str().to_string(),
created_at: now,
token,
}))
}
#[derive(Debug, Serialize)]
pub struct ApiKeyListEntry {
pub id: String,
pub label: String,
pub role: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
}
/// `GET /v1/admin/api-keys` — list every key (active + revoked). Master-only.
/// Never returns the raw token — only metadata.
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows: Vec<(String, String, String, String, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT id, label, role, created_at, last_used_at, revoked_at
FROM scoped_api_keys ORDER BY created_at DESC",
)
.fetch_all(&state.db)
.await?;
let out: Vec<ApiKeyListEntry> = rows
.into_iter()
.map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
id,
label,
role,
created_at,
last_used_at,
revoked_at,
})
.collect();
Ok(Json(json!({ "api_keys": out })))
}
/// `DELETE /v1/admin/api-keys/:id` — soft-revoke. Master-only. Idempotent:
/// revoking an already-revoked key returns ok with no state change.
pub async fn revoke(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let now = Utc::now().to_rfc3339();
let rows = sqlx::query(
"UPDATE scoped_api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?
.rows_affected();
if rows == 0 {
// Either not found, or already revoked. Distinguish for the response.
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM scoped_api_keys WHERE id = ?")
.bind(&id)
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound(format!("api_key '{id}'")));
}
return Ok(Json(json!({ "ok": true, "already_revoked": true })));
}
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"api_key.revoke",
Some("api_key"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true, "revoked_at": now })))
}
+1 -1
View File
@@ -532,7 +532,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
</div> </div>
<footer class="kfooter"> <footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span> <span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-native self-hosted software licensing</span>
</footer> </footer>
<script> <script>
+34 -4
View File
@@ -236,11 +236,24 @@ pub async fn deactivate(
// ---------- Admin endpoints ---------- // ---------- Admin endpoints ----------
/// Query for the admin Machines list. All filters are optional and
/// conjunctive — leaving them all blank returns every machine across
/// every license, default-sorted by most-recent heartbeat. The admin UI
/// Machines tab uses this default-no-filter form to render a global
/// view; the Licenses-tab drill-down sets `license_id`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct AdminListQuery { pub struct AdminListQuery {
pub license_id: String, #[serde(default)]
pub license_id: Option<String>,
#[serde(default)]
pub product_id: Option<String>,
#[serde(default)]
pub product_slug: Option<String>,
#[serde(default)] #[serde(default)]
pub include_inactive: bool, pub include_inactive: bool,
/// Cap on result size; defaults to 500. Admin UI paginates client-side.
#[serde(default)]
pub limit: Option<i64>,
} }
pub async fn admin_list( pub async fn admin_list(
@@ -249,11 +262,28 @@ pub async fn admin_list(
Query(q): Query<AdminListQuery>, Query(q): Query<AdminListQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?; require_admin(&state, &headers)?;
let machines = if q.include_inactive {
repo::list_all_machines(&state.db, &q.license_id).await? // Resolve product_slug → product_id if the caller passed the slug
// form. Either works; product_id takes precedence on conflict.
let resolved_product_id: Option<String> = if let Some(pid) = q.product_id.as_deref() {
Some(pid.to_string())
} else if let Some(slug) = q.product_slug.as_deref() {
match repo::get_product_by_slug(&state.db, slug).await? {
Some(p) => Some(p.id),
None => return Err(AppError::NotFound(format!("product '{slug}'"))),
}
} else { } else {
repo::list_active_machines(&state.db, &q.license_id).await? None
}; };
let machines = repo::list_machines_admin(
&state.db,
resolved_product_id.as_deref(),
q.license_id.as_deref(),
q.include_inactive,
q.limit.unwrap_or(500).clamp(1, 5000),
)
.await?;
Ok(Json(json!({ "machines": machines }))) Ok(Json(json!({ "machines": machines })))
} }
+21 -3
View File
@@ -55,7 +55,9 @@
pub mod admin; pub mod admin;
pub mod admin_ui; pub mod admin_ui;
pub mod api_keys;
pub mod auth; pub mod auth;
pub mod openapi;
pub mod btcpay_authorize; pub mod btcpay_authorize;
pub mod discount_codes; pub mod discount_codes;
pub mod machines; pub mod machines;
@@ -325,6 +327,10 @@ pub fn router(state: AppState) -> Router {
"/v1/admin/policies/:id/public", "/v1/admin/policies/:id/public",
patch(policies::set_public), patch(policies::set_public),
) )
.route(
"/v1/admin/policies/:id/archived",
patch(policies::set_archived),
)
.route( .route(
"/v1/admin/policies/:id/tip", "/v1/admin/policies/:id/tip",
patch(policies::set_tip), patch(policies::set_tip),
@@ -335,6 +341,14 @@ pub fn router(state: AppState) -> Router {
get(policies::list_public_policies), get(policies::list_public_policies),
) )
.route("/v1/admin/tips", get(policies::list_tips)) .route("/v1/admin/tips", get(policies::list_tips))
// Scoped API keys — additional credentials with bounded permissions.
// Master admin_api_key gates the management endpoints; the scoped
// keys themselves are accepted on endpoints that call require_scope.
.route(
"/v1/admin/api-keys",
get(api_keys::list).post(api_keys::create),
)
.route("/v1/admin/api-keys/:id", axum::routing::delete(api_keys::revoke))
// Subscriptions (recurring billing) — admin list + cancel. // Subscriptions (recurring billing) — admin list + cancel.
.route( .route(
"/v1/admin/subscriptions", "/v1/admin/subscriptions",
@@ -457,6 +471,10 @@ pub fn router(state: AppState) -> Router {
// Public read of the issuer's signing public key — used by the // Public read of the issuer's signing public key — used by the
// admin Overview "Embed your public key" tip and by SDK consumers. // admin Overview "Embed your public key" tip and by SDK consumers.
.route("/v1/issuer/public-key", get(issuer_key::public)) .route("/v1/issuer/public-key", get(issuer_key::public))
// OpenAPI 3.1 spec — public, no auth. Drives agent discovery and
// SDK code generation. Curated subset of the full route surface;
// see crate::api::openapi for the inline definition.
.route("/v1/openapi.json", get(openapi::spec))
// Tier model — drives the admin sidebar's persistent upgrade banner. // Tier model — drives the admin sidebar's persistent upgrade banner.
.route("/v1/admin/tier", get(tier::admin_status)) .route("/v1/admin/tier", get(tier::admin_status))
// Web-UI password auth (v0.1.0:28+). // Web-UI password auth (v0.1.0:28+).
@@ -741,7 +759,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
</div> </div>
<footer class="kfooter"> <footer class="kfooter">
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-paid software licensing</span> <span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> &middot; Bitcoin-native self-hosted software licensing</span>
</footer> </footer>
<script> <script>
@@ -817,8 +835,8 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
function waitingCopy(status) {{ function waitingCopy(status) {{
const min = Math.floor(elapsedMs / 60000); const min = Math.floor(elapsedMs / 60000);
if (status === 'pending' || status === 'processing') {{ if (status === 'pending' || status === 'processing') {{
if (min < 2) return 'invoice ' + status + ' — should settle within a block (~10 min).'; if (min < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
if (min < 10) return 'invoice ' + status + ' — waiting for block confirmation. Safe to leave this tab open or bookmark this URL and come back.'; if (min < 10) return 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.'; return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
}} }}
return 'invoice status: ' + (status || 'pending'); return 'invoice status: ' + (status || 'pending');
+438
View File
@@ -0,0 +1,438 @@
//! OpenAPI 3.1 spec for agent / SDK discovery.
//!
//! Served unauthenticated at `GET /v1/openapi.json`. The spec is a curated
//! subset of the daemon's endpoints — not auto-derived from handler
//! signatures today, so consider it a stable agent surface rather than a
//! guarantee that every internal route is documented. Endpoints not in
//! the spec still work the same way for callers that already know about
//! them.
//!
//! Authentication: every `/v1/admin/*` endpoint takes
//! `Authorization: Bearer <token>` where the token is either the master
//! `admin_api_key` or a scoped key generated in the admin UI. Master key
//! works on every endpoint; scoped keys work on endpoints that have been
//! migrated to `require_scope` (see `crate::api::api_keys`).
//!
//! Storage: the spec is held as a static JSON string at the bottom of
//! this file, parsed once into a `serde_json::Value` (via `OnceLock`),
//! and re-served from that cached value on each request. Keeps the
//! `json!` macro recursion limit out of the way.
use axum::Json;
use serde_json::Value;
use std::sync::OnceLock;
static SPEC: OnceLock<Value> = OnceLock::new();
/// `GET /v1/openapi.json` — return the spec. Public, no auth.
pub async fn spec() -> Json<Value> {
let v = SPEC.get_or_init(|| {
serde_json::from_str(SPEC_JSON).expect("OpenAPI spec is valid JSON")
});
Json(v.clone())
}
const SPEC_JSON: &str = r##"{
"openapi": "3.1.0",
"info": {
"title": "Keysat",
"description": "Bitcoin-native self-hosted software licensing service. This spec documents the operator-side admin API plus the buyer-facing validate / purchase / recover endpoints. Authentication: Bearer token. Master admin_api_key works on every endpoint; scoped API keys (generated in Settings → API keys) work on endpoints with bounded scopes.",
"version": "0.2.0",
"contact": { "name": "Keysat", "url": "https://keysat.xyz" }
},
"servers": [
{ "url": "https://licensing.keysat.xyz", "description": "Keysat's master instance" },
{ "url": "https://{your-keysat-host}", "description": "Your own Keysat instance" }
],
"security": [ { "bearerAuth": [] } ],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, or full-admin."
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"error": { "type": "string", "description": "Stable machine-readable error code (e.g. tier_cap, license_revoked, not_found)" },
"message": { "type": "string", "description": "Human-readable detail; safe to surface to operators" },
"upgrade_url": { "type": "string", "description": "Present on 402 tier-cap errors", "nullable": true }
},
"required": ["error"]
},
"License": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"product_id": { "type": "string", "format": "uuid" },
"product_slug": { "type": "string" },
"policy_id": { "type": "string", "format": "uuid", "nullable": true },
"buyer_email": { "type": "string", "nullable": true },
"issued_at": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time", "nullable": true },
"status": { "type": "string", "enum": ["active", "revoked", "suspended"] },
"max_machines": { "type": "integer" },
"entitlements": { "type": "array", "items": { "type": "string" } },
"license_key": { "type": "string", "description": "The LIC1... bearer credential. Returned on issue / recover only; never on list." }
}
},
"Product": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"slug": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" },
"price_sats": { "type": "integer", "nullable": true },
"price_currency": { "type": "string", "enum": ["SAT", "USD", "EUR"], "nullable": true },
"price_value": { "type": "integer", "nullable": true },
"active": { "type": "boolean" },
"entitlements_catalog": {
"type": "array",
"nullable": true,
"items": {
"type": "object",
"properties": {
"slug": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" }
}
}
}
}
},
"Policy": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"product_id": { "type": "string", "format": "uuid" },
"slug": { "type": "string" },
"name": { "type": "string" },
"duration_seconds": { "type": "integer", "description": "0 = perpetual" },
"max_machines": { "type": "integer" },
"is_trial": { "type": "boolean" },
"price_sats_override": { "type": "integer", "nullable": true },
"entitlements": { "type": "array", "items": { "type": "string" } },
"active": { "type": "boolean" },
"public": { "type": "boolean" },
"is_recurring": { "type": "boolean" },
"renewal_period_days": { "type": "integer" },
"trial_days": { "type": "integer" },
"tier_rank": { "type": "integer", "nullable": true },
"archived_at": { "type": "string", "format": "date-time", "nullable": true }
}
},
"ValidateResponse": {
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"reason": { "type": "string", "description": "Machine-readable; one of: bad_signature, not_found, revoked, suspended, expired, fingerprint_mismatch, product_mismatch, machine_cap_exceeded" },
"license_id": { "type": "string", "nullable": true },
"product_slug": { "type": "string", "nullable": true },
"policy_slug": { "type": "string", "nullable": true },
"expires_at": { "type": "string", "format": "date-time", "nullable": true },
"entitlements": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"paths": {
"/v1/openapi.json": {
"get": {
"summary": "This spec",
"description": "Serves the OpenAPI 3.1 spec. Public, no auth.",
"security": [],
"responses": { "200": { "description": "The spec." } }
}
},
"/v1/issuer/public-key": {
"get": {
"summary": "Get the daemon's signing public key",
"description": "Returns the PEM-encoded Ed25519 public key the daemon uses to sign licenses. Public, no auth. SDK consumers can embed this for offline verification.",
"security": [],
"responses": {
"200": {
"description": "Public key",
"content": { "application/json": { "schema": {
"type": "object",
"properties": { "public_key_pem": { "type": "string" } }
} } }
}
}
}
},
"/v1/validate": {
"post": {
"summary": "Validate a license key",
"description": "Buyer-facing endpoint called by SDKs at app boot. Verifies signature, checks revocation/suspension/expiry, and (when product_slug is supplied) refuses keys issued for a different product. Always returns 200; ok=false with a stable reason on rejection.",
"security": [],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "The LIC1... license key" },
"product_slug": { "type": "string", "description": "When supplied, the daemon refuses keys issued for a different product. Recommended." },
"fingerprint": { "type": "string", "description": "Machine fingerprint for cap enforcement. SHA-256 hashed daemon-side." },
"hostname": { "type": "string" },
"platform": { "type": "string" }
},
"required": ["key"]
} } }
},
"responses": {
"200": { "description": "Validation result", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidateResponse" } } } }
}
}
},
"/v1/products/{slug}/policies": {
"get": {
"summary": "List a product's public tiers",
"description": "Buyer-facing tier listing — same data /buy/<slug> renders. Use this in your app's in-app tier picker. Public, no auth.",
"security": [],
"parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } ],
"responses": { "200": { "description": "Tier list" } }
}
},
"/v1/purchase": {
"post": {
"summary": "Start a buyer purchase",
"description": "Opens an invoice with the active payment provider. The buyer opens the returned checkout_url; once payment settles, the license is available via /v1/purchase/{invoice_id} or the corresponding webhook.",
"security": [],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"product": { "type": "string", "description": "Product slug" },
"policy_slug": { "type": "string", "description": "Optional. Specifies which tier; falls back to the product's default policy." },
"buyer_email": { "type": "string" },
"redirect_url": { "type": "string" },
"code": { "type": "string", "description": "Optional discount code" }
},
"required": ["product"]
} } }
},
"responses": { "200": { "description": "Purchase session created" } }
}
},
"/v1/purchase/{invoice_id}": {
"get": {
"summary": "Poll for license issuance",
"description": "Polled by the buyer's app until the license is issued (status=settled and license_key present). Public, no auth.",
"security": [],
"parameters": [ { "name": "invoice_id", "in": "path", "required": true, "schema": { "type": "string" } } ],
"responses": { "200": { "description": "Current invoice status" } }
}
},
"/v1/upgrade-quote": {
"post": {
"summary": "Quote a tier upgrade",
"description": "Buyer-facing: given a license key and a target policy slug, compute the proration charge. No DB writes. Auth is by signed license_key in the body.",
"security": [],
"responses": { "200": { "description": "Quote" } }
}
},
"/v1/upgrade": {
"post": {
"summary": "Start a tier upgrade",
"description": "Creates an invoice for the prorated charge. On settle, the license's entitlements + expiry flip to the target tier without rotating the license key.",
"security": [],
"responses": { "200": { "description": "Upgrade invoice started" } }
}
},
"/v1/subscriptions/cancel": {
"post": {
"summary": "Buyer self-service subscription cancellation",
"description": "Cancels recurring renewals on the subscription tied to this license. Auth by signed license_key in the body. License stays valid through current cycle's expires_at.",
"security": [],
"responses": { "200": { "description": "Cancelled" } }
}
},
"/v1/recover": {
"post": {
"summary": "Recover a lost license key",
"description": "Given (invoice_id, email), returns the license_key for that purchase. Generic 404 on any mismatch. Rate-limited 10/min/IP.",
"security": [],
"responses": { "200": { "description": "License" } }
}
},
"/v1/admin/licenses": {
"get": {
"summary": "List licenses",
"description": "Scope required: `licenses:read`. Filter by status, product_slug, buyer_email, expiring soon, etc. via query params.",
"responses": { "200": { "description": "License list" } }
},
"post": {
"summary": "Issue a license manually",
"description": "Scope required: `licenses:write`. Mints a fresh license without going through purchase. Useful for comping, manual support workflows.",
"responses": { "200": { "description": "Issued license" } }
}
},
"/v1/admin/licenses/{id}/revoke": {
"post": {
"summary": "Revoke a license",
"description": "Scope required: `licenses:write`. Idempotent. Online validate calls immediately return reason=revoked.",
"responses": { "200": { "description": "Revoked" } }
}
},
"/v1/admin/licenses/{id}/suspend": {
"post": {
"summary": "Suspend a license",
"description": "Scope required: `licenses:write`. Like revoke but reversible (see /unsuspend).",
"responses": { "200": { "description": "Suspended" } }
}
},
"/v1/admin/licenses/{id}/unsuspend": {
"post": {
"summary": "Unsuspend a license",
"description": "Scope required: `licenses:write`. Reverses suspend.",
"responses": { "200": { "description": "Unsuspended" } }
}
},
"/v1/admin/licenses/{id}/change-tier": {
"post": {
"summary": "Admin tier change (comp)",
"description": "Scope required: `licenses:write`. Always applies as a comp from the admin path — no invoice. Use for support workflows where a buyer should get a different tier without payment.",
"responses": { "200": { "description": "Tier changed" } }
}
},
"/v1/admin/products": {
"get": {
"summary": "List products",
"description": "Scope required: `products:read`.",
"responses": { "200": { "description": "Product list" } }
},
"post": {
"summary": "Create a product",
"description": "Scope required: `products:write`.",
"responses": { "200": { "description": "Created" }, "402": { "description": "tier_cap — Creator tier limited to 5 products" } }
}
},
"/v1/admin/policies": {
"get": {
"summary": "List policies",
"description": "Scope required: `policies:read`. Filter by product_slug. Include archived with include_archived=true.",
"responses": { "200": { "description": "Policy list" } }
},
"post": {
"summary": "Create a policy (tier)",
"description": "Scope required: `policies:write`. Recurring policies require the `recurring_billing` self-tier entitlement.",
"responses": { "200": { "description": "Created" } }
}
},
"/v1/admin/policies/{id}/archived": {
"patch": {
"summary": "Archive or unarchive a policy",
"description": "Scope required: `policies:write`. Soft-archive: hides from admin grid and buy page, refuses new purchases + renewals. Existing licenses keep validating.",
"responses": { "200": { "description": "Toggled" } }
}
},
"/v1/admin/subscriptions": {
"get": {
"summary": "List subscriptions",
"description": "Scope required: `subscriptions:read`. Filter by status.",
"responses": { "200": { "description": "Subscription list" } }
}
},
"/v1/admin/subscriptions/{id}/cancel": {
"post": {
"summary": "Admin cancel a subscription",
"description": "Scope required: `subscriptions:write`. License stays valid through end of current cycle.",
"responses": { "200": { "description": "Cancelled" } }
}
},
"/v1/admin/machines": {
"get": {
"summary": "List machines",
"description": "Scope required: `machines:read`. One row per (license_id, fingerprint) seen by /v1/validate.",
"responses": { "200": { "description": "Machine list" } }
}
},
"/v1/admin/machines/{id}/deactivate": {
"post": {
"summary": "Force-deactivate a machine",
"description": "Scope required: `machines:write`. Frees the seat under that license. Validate calls from that fingerprint get fingerprint_mismatch.",
"responses": { "200": { "description": "Deactivated" } }
}
},
"/v1/admin/discount-codes": {
"get": {
"summary": "List discount codes",
"description": "Scope required: `codes:read`.",
"responses": { "200": { "description": "Code list" } }
},
"post": {
"summary": "Create a discount code",
"description": "Scope required: `codes:write`. Creator tier caps at 10 active codes.",
"responses": { "200": { "description": "Created" } }
}
},
"/v1/admin/webhook-endpoints": {
"get": {
"summary": "List webhook endpoints",
"description": "Scope required: `webhooks:read`.",
"responses": { "200": { "description": "Endpoint list" } }
},
"post": {
"summary": "Create a webhook endpoint",
"description": "Scope required: `webhooks:write`. URL + secret + event filter. Outbound deliveries are HMAC-SHA256 signed.",
"responses": { "200": { "description": "Created" } }
}
},
"/v1/admin/api-keys": {
"get": {
"summary": "List scoped API keys",
"description": "Master admin key required. Never returns the raw token.",
"responses": { "200": { "description": "Key metadata list" } }
},
"post": {
"summary": "Create a scoped API key",
"description": "Master admin key required. Token returned ONCE in the response.",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "full-admin"] }
},
"required": ["label", "role"]
} } }
},
"responses": { "200": { "description": "Created with raw token (returned once)" } }
}
},
"/v1/admin/api-keys/{id}": {
"delete": {
"summary": "Revoke a scoped API key",
"description": "Master admin key required. Soft-revoke; rows are kept for audit. Idempotent.",
"responses": { "200": { "description": "Revoked" } }
}
},
"/v1/admin/tier": {
"get": {
"summary": "Get this daemon's tier + usage + caps",
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.",
"responses": { "200": { "description": "Tier info" } }
}
}
}
}"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_json_parses() {
let v: Value = serde_json::from_str(SPEC_JSON).expect("spec parses as JSON");
// Sanity checks: top-level openapi field, at least one path, at least one schema.
assert_eq!(v.get("openapi").and_then(|x| x.as_str()), Some("3.1.0"));
assert!(v.get("paths").and_then(|p| p.as_object()).map(|m| !m.is_empty()).unwrap_or(false));
assert!(v.pointer("/components/schemas/License").is_some());
}
}
@@ -99,6 +99,7 @@ pub async fn activate(
state.set_payment_provider(provider).await; state.set_payment_provider(provider).await;
} }
ProviderKind::Zaprite => { ProviderKind::Zaprite => {
crate::api::tier::enforce_zaprite_feature(&state).await?;
let cfg = payment::zaprite::config::load(&state.db) let cfg = payment::zaprite::config::load(&state.db)
.await .await
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))? .map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))?
+121 -25
View File
@@ -277,6 +277,11 @@ pub struct ListPoliciesQuery {
pub product_slug: String, pub product_slug: String,
#[serde(default)] #[serde(default)]
pub include_inactive: bool, pub include_inactive: bool,
/// When true, archived policies (those with a non-null `archived_at`)
/// are included. Default false — admin grid hides archived unless the
/// "Show archived" toggle is on.
#[serde(default)]
pub include_archived: bool,
} }
pub async fn list( pub async fn list(
@@ -288,7 +293,13 @@ pub async fn list(
let product = repo::get_product_by_slug(&state.db, &q.product_slug) let product = repo::get_product_by_slug(&state.db, &q.product_slug)
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?; .ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?;
let rows = repo::list_policies_by_product(&state.db, &product.id, !q.include_inactive).await?; let rows = repo::list_policies_by_product_with_archived(
&state.db,
&product.id,
!q.include_inactive,
q.include_archived,
)
.await?;
Ok(Json(json!({ "policies": rows }))) Ok(Json(json!({ "policies": rows })))
} }
@@ -321,6 +332,43 @@ pub async fn set_active(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
#[derive(Debug, Deserialize)]
pub struct SetArchivedReq {
pub archived: bool,
}
/// PATCH `/v1/admin/policies/:id/archived` — toggle the soft-archive flag.
///
/// Archived policies are hidden from the admin grid (unless "Show archived"
/// is on) and from the public `/buy/<slug>` page. Existing licenses keep
/// validating because their entitlements are signed into the key. Active
/// recurring subscriptions tied to an archived policy will stop renewing
/// (renewal worker treats archived as a hard stop and surfaces a clear
/// event in the audit log).
pub async fn set_archived(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(req): Json<SetArchivedReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
repo::set_policy_archived(&state.db, &id, req.archived).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
if req.archived { "policy.archive" } else { "policy.unarchive" },
Some("policy"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "archived": req.archived }),
)
.await;
Ok(Json(json!({ "ok": true, "archived": req.archived })))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PolicyDeleteOpts { pub struct PolicyDeleteOpts {
#[serde(default)] #[serde(default)]
@@ -348,6 +396,7 @@ pub async fn delete(
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?; .ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
// Total counts (for cascade reporting).
let invoice_count: i64 = let invoice_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE policy_id = ?") sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE policy_id = ?")
.bind(&id) .bind(&id)
@@ -358,41 +407,66 @@ pub async fn delete(
.bind(&id) .bind(&id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
if !opts.force && invoice_count + license_count > 0 {
// "Live" references that would actually block a safe-delete: a
// non-revoked license, a settled invoice (real audit history), or
// an active/past_due subscription. Revoked-license tombstones and
// non-settled invoices (pending/expired/invalid) are dead weight
// that the safe-delete can sweep up — they hold no operator value.
let live_license_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses
WHERE policy_id = ? AND COALESCE(status,'active') != 'revoked'",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let settled_invoice_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM invoices WHERE policy_id = ? AND status = 'settled'",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let active_sub_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM subscriptions
WHERE policy_id = ? AND status IN ('active', 'past_due')",
)
.bind(&id)
.fetch_one(&state.db)
.await?;
if !opts.force && (live_license_count + settled_invoice_count + active_sub_count) > 0 {
return Err(AppError::Conflict(format!( return Err(AppError::Conflict(format!(
"cannot delete policy '{}' — it has {} invoice(s) and {} license(s) \ "cannot delete policy '{}' — it has {} live license(s), {} settled invoice(s), \
referencing it. Disable it via the active toggle, or hide it from the \ and {} active subscription(s) referencing it. Archive it to hide it from \
buy page via the public toggle, instead. To override and wipe all \ the admin grid and the buy page, revoke any outstanding licenses to free \
references, use ?force=true.", the safe-delete path, or use ?force=true to cascade through everything.",
policy.slug, invoice_count, license_count policy.slug, live_license_count, settled_invoice_count, active_sub_count
))); )));
} }
let machine_count: i64 = if opts.force { // Even in safe-delete mode we cascade through tombstones (revoked
sqlx::query_scalar( // licenses, dead invoices, machines/redemptions tied to them) since
// the operator has signalled intent to fully delete. Compute the
// counts for the audit log either way.
let machine_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM machines WHERE license_id IN "SELECT COUNT(*) FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE policy_id = ?)", (SELECT id FROM licenses WHERE policy_id = ?)",
) )
.bind(&id) .bind(&id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await? .await?;
} else { let redemption_count: i64 = sqlx::query_scalar(
0
};
let redemption_count: i64 = if opts.force {
sqlx::query_scalar(
"SELECT COUNT(*) FROM discount_redemptions WHERE invoice_id IN "SELECT COUNT(*) FROM discount_redemptions WHERE invoice_id IN
(SELECT id FROM invoices WHERE policy_id = ?)", (SELECT id FROM invoices WHERE policy_id = ?)",
) )
.bind(&id) .bind(&id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await? .await?;
} else {
0
};
// Cascade order matters — children before parents to satisfy FKs.
// Safe-delete + force-delete share the cascade body now; the only
// difference is the eligibility check above.
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
if opts.force {
sqlx::query( sqlx::query(
"DELETE FROM machines WHERE license_id IN "DELETE FROM machines WHERE license_id IN
(SELECT id FROM licenses WHERE policy_id = ?)", (SELECT id FROM licenses WHERE policy_id = ?)",
@@ -407,6 +481,29 @@ pub async fn delete(
.bind(&id) .bind(&id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
// tier_changes references both from_policy_id + to_policy_id — wipe
// any row touching this policy on either side.
sqlx::query(
"DELETE FROM tier_changes WHERE from_policy_id = ? OR to_policy_id = ?",
)
.bind(&id)
.bind(&id)
.execute(&mut *tx)
.await?;
// discount_codes.applies_to_policy_id references this policy. Null
// it out rather than delete the code — codes can target multiple
// policies in future and surviving codes are useful audit material.
sqlx::query(
"UPDATE discount_codes SET applies_to_policy_id = NULL
WHERE applies_to_policy_id = ?",
)
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM subscriptions WHERE policy_id = ?")
.bind(&id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM licenses WHERE policy_id = ?") sqlx::query("DELETE FROM licenses WHERE policy_id = ?")
.bind(&id) .bind(&id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -415,7 +512,6 @@ pub async fn delete(
.bind(&id) .bind(&id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
}
sqlx::query("DELETE FROM policies WHERE id = ?") sqlx::query("DELETE FROM policies WHERE id = ?")
.bind(&id) .bind(&id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -435,8 +531,8 @@ pub async fn delete(
"slug": policy.slug, "slug": policy.slug,
"name": policy.name, "name": policy.name,
"force": opts.force, "force": opts.force,
"cascaded_licenses": if opts.force { license_count } else { 0 }, "cascaded_licenses": license_count,
"cascaded_invoices": if opts.force { invoice_count } else { 0 }, "cascaded_invoices": invoice_count,
"cascaded_machines": machine_count, "cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count, "cascaded_redemptions": redemption_count,
}), }),
@@ -446,8 +542,8 @@ pub async fn delete(
"ok": true, "ok": true,
"deleted": policy.slug, "deleted": policy.slug,
"force": opts.force, "force": opts.force,
"cascaded_licenses": if opts.force { license_count } else { 0 }, "cascaded_licenses": license_count,
"cascaded_invoices": if opts.force { invoice_count } else { 0 }, "cascaded_invoices": invoice_count,
"cascaded_machines": machine_count, "cascaded_machines": machine_count,
"cascaded_redemptions": redemption_count, "cascaded_redemptions": redemption_count,
}))) })))
+3 -9
View File
@@ -1,7 +1,7 @@
//! Admin endpoints for managing the daemon's own self-license //! Admin endpoints for managing the daemon's own self-license
//! (Keysat-licenses-Keysat). //! (Keysat-licenses-Keysat).
//! //!
//! - `GET /v1/admin/self-license` — current tier (licensed / unlicensed) //! - `GET /v1/admin/self-license` — current tier (licensed / Creator)
//! - `POST /v1/admin/self-license` — activate a new license. Validates //! - `POST /v1/admin/self-license` — activate a new license. Validates
//! against the embedded master pubkey, writes the file to //! against the embedded master pubkey, writes the file to
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state. //! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
@@ -12,6 +12,8 @@
use crate::api::AppState; use crate::api::AppState;
use crate::error::AppResult; use crate::error::AppResult;
use crate::license_self::{self, Tier}; use crate::license_self::{self, Tier};
// `license_self::mode` was removed when enforce mode was retired; this
// module retains its own `Tier` re-export for the admin response shape.
use axum::{ use axum::{
extract::State, extract::State,
http::StatusCode, http::StatusCode,
@@ -25,7 +27,6 @@ use serde::{Deserialize, Serialize};
pub enum TierStatus { pub enum TierStatus {
Unlicensed { Unlicensed {
reason: String, reason: String,
mode: &'static str,
}, },
Licensed { Licensed {
license_id: String, license_id: String,
@@ -33,19 +34,13 @@ pub enum TierStatus {
/// Unix seconds; 0 means perpetual. /// Unix seconds; 0 means perpetual.
expires_at: i64, expires_at: i64,
entitlements: Vec<String>, entitlements: Vec<String>,
mode: &'static str,
}, },
} }
fn tier_to_status(tier: &Tier) -> TierStatus { fn tier_to_status(tier: &Tier) -> TierStatus {
let mode = match license_self::mode() {
license_self::Mode::Permissive => "permissive",
license_self::Mode::Enforce => "enforce",
};
match tier { match tier {
Tier::Unlicensed { reason } => TierStatus::Unlicensed { Tier::Unlicensed { reason } => TierStatus::Unlicensed {
reason: reason.clone(), reason: reason.clone(),
mode,
}, },
Tier::Licensed { Tier::Licensed {
license_id, license_id,
@@ -57,7 +52,6 @@ fn tier_to_status(tier: &Tier) -> TierStatus {
product_id: product_id.to_string(), product_id: product_id.to_string(),
expires_at: *expires_at, expires_at: *expires_at,
entitlements: entitlements.clone(), entitlements: entitlements.clone(),
mode,
}, },
} }
} }
+62 -27
View File
@@ -3,28 +3,29 @@
//! Keysat ships in three tiers. The daemon enforces caps based on the //! Keysat ships in three tiers. The daemon enforces caps based on the
//! entitlements baked into its own self-license (see `license_self.rs`): //! entitlements baked into its own self-license (see `license_self.rs`):
//! //!
//! - **Creator** (default, also the unlicensed default): caps at 5 //! - **Creator** (free, no self-license required): caps at 5 products,
//! products, 5 policies per product, 5 active discount codes. Buyers //! 5 policies per product, 10 active discount codes. Buyers get a
//! get a real Keysat brand experience for hobbyist scale. Sold at //! real Keysat brand experience for hobbyist scale. Anyone who
//! keysat.xyz for ~21,000 sats; also distributable via free codes. //! installs Keysat is on Creator out of the box — no signup, no
//! trial.
//! - **Pro**: unlimited products / policies / codes. Unlocks //! - **Pro**: unlimited products / policies / codes. Unlocks
//! `recurring_billing` and `card_payments` (Zaprite) when those //! `recurring_billing` and `zaprite_payments` (Zaprite gateway —
//! features ship in v0.3. Sold at keysat.xyz for ~250,000 sats / yr. //! cards, Apple Pay, bank transfers, in addition to Bitcoin). Sold
//! at keysat.xyz for ~250,000 sats / yr.
//! - **Patron**: same feature surface as Pro, plus a `patron` //! - **Patron**: same feature surface as Pro, plus a `patron`
//! entitlement that renders a "Patron" badge in the admin topbar. //! entitlement that renders a "Patron" badge in the admin topbar.
//! Honest upsell — no fake feature gate. Sold for ~500,000 sats / yr. //! Honest upsell — no fake feature gate. Sold for ~500,000 sats / yr.
//! //!
//! "Unlicensed" (no self-license file present) is treated as Creator-tier //! The pull from Creator to a paid tier happens organically: operators
//! caps: operators can install Keysat and start shipping without paying //! hit the 5-product cap, or want recurring billing, or want to accept
//! us a sat. The pull to a paid tier happens organically when they need //! cards via Zaprite. All three trigger a 402 with an upgrade URL.
//! more than 5 products or want recurring billing.
//! //!
//! All tier judgments are derived from the `entitlements` array on the //! All tier judgments are derived from the `entitlements` array on the
//! daemon's self-license. The presence of `unlimited_products` lifts //! daemon's self-license. The presence of `unlimited_products` lifts
//! the product cap; `unlimited_policies` lifts the policy-per-product //! the product cap; `unlimited_policies` lifts the policy-per-product
//! cap; `unlimited_codes` lifts the code cap. `recurring_billing` and //! cap; `unlimited_codes` lifts the code cap. `recurring_billing` gates
//! `card_payments` gate the Zaprite + recurring features (when those //! creating recurring policies; `zaprite_payments` gates Connect/Activate
//! ship). `patron` is purely cosmetic. //! Zaprite. `patron` is purely cosmetic.
//! //!
//! The cap enforcement returns 402 Payment Required with an `upgrade_url` //! The cap enforcement returns 402 Payment Required with an `upgrade_url`
//! pointing at the master Keysat's buy page so the admin SPA can render //! pointing at the master Keysat's buy page so the admin SPA can render
@@ -34,14 +35,19 @@ use crate::api::AppState;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::license_self::Tier; use crate::license_self::Tier;
/// Tier-cap ceilings for the entry-level "Creator" tier (and unlicensed /// Tier-cap ceilings for the entry-level "Creator" tier — the default
/// installs, which inherit the same caps). Tunable as we learn more from /// state when no self-license is present and the surfaced label whenever
/// real operator usage post-launch — change the constants here. Existing /// a license's entitlements don't include `unlimited_products`. Tunable
/// operators are never retroactively kicked off; the cap fires at /// as we learn more from real operator usage post-launch — change the
/// create-time only. /// constants here. Existing operators are never retroactively kicked
/// off; the cap fires at create-time only.
pub const CREATOR_PRODUCT_CAP: i64 = 5; pub const CREATOR_PRODUCT_CAP: i64 = 5;
pub const CREATOR_POLICY_CAP_PER_PRODUCT: i64 = 5; pub const CREATOR_POLICY_CAP_PER_PRODUCT: i64 = 5;
pub const CREATOR_CODE_CAP: i64 = 5; /// Creator-tier active-discount-code cap. Sized so a launch operator
/// can run several concurrent promo campaigns (launch week, early bird,
/// newsletter, speaker codes, etc.) without conversion-pressure that
/// doesn't actually map to scale. Disabled codes don't count.
pub const CREATOR_CODE_CAP: i64 = 10;
/// Where the upgrade banner / 402 error sends an operator to buy a /// Where the upgrade banner / 402 error sends an operator to buy a
/// higher tier. Hard-coded to the canonical master Keysat. Eventually /// higher tier. Hard-coded to the canonical master Keysat. Eventually
@@ -54,11 +60,11 @@ pub const UPGRADE_URL_PATRON: &str = "https://licensing.keysat.xyz/buy/keysat?po
/// for UI consumption. /// for UI consumption.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TierInfo { pub struct TierInfo {
/// Coarse label: "creator" | "pro" | "patron" | "unlicensed". /// Coarse label: "creator" | "pro" | "patron".
pub label: &'static str, pub label: &'static str,
/// Display-friendly name: "Creator" | "Pro" | "Patron" | "Unlicensed". /// Display-friendly name: "Creator" | "Pro" | "Patron".
pub display_name: &'static str, pub display_name: &'static str,
/// The full entitlement set baked into the self-license, or empty if unlicensed. /// The full entitlement set baked into the self-license; empty for Creator.
pub entitlements: Vec<String>, pub entitlements: Vec<String>,
} }
@@ -77,6 +83,11 @@ impl TierInfo {
/// Read the daemon's self-tier and project to a TierInfo for tier-aware /// Read the daemon's self-tier and project to a TierInfo for tier-aware
/// code paths. Async because state.self_tier is wrapped in a tokio RwLock /// code paths. Async because state.self_tier is wrapped in a tokio RwLock
/// (allows `Activate Keysat license` to swap it without a daemon restart). /// (allows `Activate Keysat license` to swap it without a daemon restart).
///
/// A missing self-license surfaces as Creator (the free tier) — the daemon
/// always boots, the Creator caps apply, and the admin UI shows "Creator"
/// rather than "Unlicensed" to avoid the implication that something needs
/// to be fixed.
pub async fn current(state: &AppState) -> TierInfo { pub async fn current(state: &AppState) -> TierInfo {
let tier = state.self_tier.read().await; let tier = state.self_tier.read().await;
let entitlements = match &*tier { let entitlements = match &*tier {
@@ -93,12 +104,10 @@ pub async fn current(state: &AppState) -> TierInfo {
} else if entitlements.iter().any(|e| e == "unlimited_products") { } else if entitlements.iter().any(|e| e == "unlimited_products") {
label = "pro"; label = "pro";
display_name = "Pro"; display_name = "Pro";
} else if entitlements.iter().any(|e| e == "self_host") { } else {
// No paid entitlements present (or no self-license at all) → Creator.
label = "creator"; label = "creator";
display_name = "Creator"; display_name = "Creator";
} else {
label = "unlicensed";
display_name = "Unlicensed";
} }
TierInfo { TierInfo {
label, label,
@@ -142,7 +151,7 @@ pub async fn admin_status(
}, },
}); });
let next_tier = match tier.label { let next_tier = match tier.label {
"creator" | "unlicensed" => "pro", "creator" => "pro",
"pro" => "patron", "pro" => "patron",
_ => "patron", _ => "patron",
}; };
@@ -232,6 +241,32 @@ pub async fn enforce_recurring_feature(state: &AppState) -> AppResult<()> {
}) })
} }
/// Refuse to connect or activate Zaprite unless the operator's self-tier
/// carries the `zaprite_payments` entitlement. Pro and Patron tiers have
/// it; Creator does not. Zaprite is the buyer-side optionality story —
/// cards, Apple Pay, bank transfers, plus Bitcoin — so this gate is the
/// upgrade pressure for operators who want to accept payment methods
/// beyond Bitcoin / Lightning via BTCPay. Called from both the initial
/// Connect Zaprite flow and the Activate-Zaprite switch, so an operator
/// can't sneak past by connecting on Pro and downgrading later (the
/// downgrade flow doesn't auto-disconnect Zaprite, but a switch attempt
/// after downgrade is refused).
pub async fn enforce_zaprite_feature(state: &AppState) -> AppResult<()> {
let tier = current(state).await;
if tier.has("zaprite_payments") {
return Ok(());
}
Err(AppError::PaymentRequired {
message: format!(
"Zaprite payment gateway (cards, Apple Pay, bank transfers, and more) \
requires Pro or Patron. You're on {}. BTCPay (Bitcoin / Lightning) \
remains available on every tier.",
tier.display_name
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
})
}
/// Refuse a new discount code if the operator is at the Creator-tier /// Refuse a new discount code if the operator is at the Creator-tier
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE /// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
/// codes — operators can disable old codes to free up slots, which is /// codes — operators can disable old codes to free up slots, which is
@@ -49,6 +49,7 @@ pub async fn connect(
Json(req): Json<ConnectReq>, Json(req): Json<ConnectReq>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?; let actor_hash = require_admin(&state, &headers)?;
crate::api::tier::enforce_zaprite_feature(&state).await?;
let (ip, ua) = request_context(&headers); let (ip, ua) = request_context(&headers);
let api_key = req.api_key.trim().to_string(); let api_key = req.api_key.trim().to_string();
+150 -10
View File
@@ -838,7 +838,7 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s
max_machines, is_trial, price_sats_override, max_machines, is_trial, price_sats_override,
entitlements_json, metadata_json, active, public, entitlements_json, metadata_json, active, public,
is_recurring, renewal_period_days, grace_period_days, trial_days, is_recurring, renewal_period_days, grace_period_days, trial_days,
tier_rank, tier_rank, archived_at,
created_at, updated_at"; created_at, updated_at";
/// Bundles the recurring-subscription knobs so we don't keep growing /// Bundles the recurring-subscription knobs so we don't keep growing
@@ -962,25 +962,43 @@ pub async fn list_policies_by_product(
product_id: &str, product_id: &str,
only_active: bool, only_active: bool,
) -> AppResult<Vec<Policy>> { ) -> AppResult<Vec<Policy>> {
let sql = if only_active { list_policies_by_product_with_archived(pool, product_id, only_active, false).await
format!("SELECT {POLICY_COLS} FROM policies WHERE product_id = ? AND active = 1 ORDER BY name") }
} else {
format!("SELECT {POLICY_COLS} FROM policies WHERE product_id = ? ORDER BY name") /// Variant of `list_policies_by_product` that lets the caller include
}; /// archived rows. Admin UI passes `include_archived = true` when the
/// "Show archived" toggle is on.
pub async fn list_policies_by_product_with_archived(
pool: &SqlitePool,
product_id: &str,
only_active: bool,
include_archived: bool,
) -> AppResult<Vec<Policy>> {
let mut clauses: Vec<&str> = vec!["product_id = ?"];
if only_active {
clauses.push("active = 1");
}
if !include_archived {
clauses.push("archived_at IS NULL");
}
let where_clause = clauses.join(" AND ");
let sql = format!(
"SELECT {POLICY_COLS} FROM policies WHERE {where_clause} ORDER BY name"
);
let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?; let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?;
Ok(rows.into_iter().map(row_to_policy).collect()) Ok(rows.into_iter().map(row_to_policy).collect())
} }
/// Public-buyer view: only active+public policies. Sorted by ascending /// Public-buyer view: only active+public+non-archived policies. Sorted by
/// effective price so the cheapest tier renders leftmost. The buy page /// ascending effective price so the cheapest tier renders leftmost. The
/// is the only caller; admin should use `list_policies_by_product`. /// buy page is the only caller; admin should use `list_policies_by_product`.
pub async fn list_public_policies_by_product( pub async fn list_public_policies_by_product(
pool: &SqlitePool, pool: &SqlitePool,
product_id: &str, product_id: &str,
) -> AppResult<Vec<Policy>> { ) -> AppResult<Vec<Policy>> {
let sql = format!( let sql = format!(
"SELECT {POLICY_COLS} FROM policies "SELECT {POLICY_COLS} FROM policies
WHERE product_id = ? AND active = 1 AND public = 1 WHERE product_id = ? AND active = 1 AND public = 1 AND archived_at IS NULL
ORDER BY COALESCE(price_sats_override, 0) ASC, name ASC" ORDER BY COALESCE(price_sats_override, 0) ASC, name ASC"
); );
let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?; let rows = sqlx::query(&sql).bind(product_id).fetch_all(pool).await?;
@@ -1154,6 +1172,28 @@ pub async fn set_policy_active(pool: &SqlitePool, id: &str, active: bool) -> App
Ok(()) Ok(())
} }
/// Soft-archive a policy. Idempotent: re-archiving stamps a new
/// timestamp without erroring. Pass `archived = false` to un-archive.
pub async fn set_policy_archived(
pool: &SqlitePool,
id: &str,
archived: bool,
) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
let archived_at: Option<&str> = if archived { Some(now.as_str()) } else { None };
let rows = sqlx::query("UPDATE policies SET archived_at = ?, updated_at = ? WHERE id = ?")
.bind(archived_at)
.bind(&now)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound(format!("policy {id}")));
}
Ok(())
}
fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy { fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
let entitlements_json: String = row.get("entitlements_json"); let entitlements_json: String = row.get("entitlements_json");
let entitlements: Vec<String> = let entitlements: Vec<String> =
@@ -1181,6 +1221,12 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
.try_get::<Option<i64>, _>("tier_rank") .try_get::<Option<i64>, _>("tier_rank")
.ok() .ok()
.flatten(); .flatten();
// archived_at lands in migration 0015. Same pattern as tier_rank —
// nullable column, fall back to None if missing entirely.
let archived_at: Option<String> = row
.try_get::<Option<String>, _>("archived_at")
.ok()
.flatten();
Policy { Policy {
id: row.get("id"), id: row.get("id"),
product_id: row.get("product_id"), product_id: row.get("product_id"),
@@ -1203,6 +1249,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
grace_period_days, grace_period_days,
trial_days, trial_days,
tier_rank, tier_rank,
archived_at,
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
} }
@@ -1371,6 +1418,99 @@ pub async fn list_all_machines(pool: &SqlitePool, license_id: &str) -> AppResult
Ok(rows.into_iter().map(row_to_machine).collect()) Ok(rows.into_iter().map(row_to_machine).collect())
} }
/// One row of the admin Machines tab. Joins `machines` against
/// `licenses` + `products` so the operator sees buyer_email + product
/// slug + status without an N+1 fetch from the client.
#[derive(Debug, serde::Serialize)]
pub struct MachineEnriched {
pub id: String,
pub license_id: String,
pub product_id: String,
pub product_slug: String,
pub product_name: String,
pub buyer_email: Option<String>,
pub license_status: String,
pub hostname: Option<String>,
pub platform: Option<String>,
pub ip_last_seen: Option<String>,
pub activated_at: String,
pub last_heartbeat_at: String,
pub deactivated_at: Option<String>,
pub deactivation_reason: Option<String>,
pub active: bool,
}
/// Global admin list with optional filters. Used by the Machines tab to
/// render every machine across every license — grouped client-side by
/// product. Filters are optional, all conjunctive (AND).
///
/// - `product_id`: scope to a single product
/// - `license_id`: scope to a single license (used by drill-down view)
/// - `include_inactive`: include rows where deactivated_at IS NOT NULL
/// - `limit`: cap result size; default 500 (admin grid is paginated UX-side)
pub async fn list_machines_admin(
pool: &SqlitePool,
product_id: Option<&str>,
license_id: Option<&str>,
include_inactive: bool,
limit: i64,
) -> AppResult<Vec<MachineEnriched>> {
let mut sql = String::from(
"SELECT m.id, m.license_id, l.product_id, p.slug AS product_slug, p.name AS product_name,
l.buyer_email, l.status AS license_status,
m.hostname, m.platform, m.ip_last_seen,
m.activated_at, m.last_heartbeat_at,
m.deactivated_at, m.deactivation_reason
FROM machines m
JOIN licenses l ON l.id = m.license_id
JOIN products p ON p.id = l.product_id
WHERE 1=1",
);
if !include_inactive {
sql.push_str(" AND m.deactivated_at IS NULL");
}
if product_id.is_some() {
sql.push_str(" AND l.product_id = ?");
}
if license_id.is_some() {
sql.push_str(" AND m.license_id = ?");
}
sql.push_str(" ORDER BY m.last_heartbeat_at DESC LIMIT ?");
let mut q = sqlx::query(&sql);
if let Some(v) = product_id {
q = q.bind(v);
}
if let Some(v) = license_id {
q = q.bind(v);
}
q = q.bind(limit);
let rows = q.fetch_all(pool).await?;
let out = rows
.into_iter()
.map(|row| {
let deactivated_at: Option<String> = row.get("deactivated_at");
MachineEnriched {
id: row.get("id"),
license_id: row.get("license_id"),
product_id: row.get("product_id"),
product_slug: row.get("product_slug"),
product_name: row.get("product_name"),
buyer_email: row.get("buyer_email"),
license_status: row.get("license_status"),
hostname: row.get("hostname"),
platform: row.get("platform"),
ip_last_seen: row.get("ip_last_seen"),
activated_at: row.get("activated_at"),
last_heartbeat_at: row.get("last_heartbeat_at"),
active: deactivated_at.is_none(),
deactivated_at,
deactivation_reason: row.get("deactivation_reason"),
}
})
.collect();
Ok(out)
}
pub async fn get_active_machine_by_fp( pub async fn get_active_machine_by_fp(
pool: &SqlitePool, pool: &SqlitePool,
license_id: &str, license_id: &str,
+30 -86
View File
@@ -7,22 +7,20 @@
//! customer licenses, and verify its signature against the master //! customer licenses, and verify its signature against the master
//! public key. //! public key.
//! //!
//! Two modes: //! Missing or invalid self-licenses log a warning and the daemon starts in
//! - `Permissive` (default for dev builds): missing or invalid //! `Tier::Unlicensed`, which the admin UI labels "Creator" — the free tier
//! licenses log a warning and the daemon starts in //! with the Creator caps applied (5 products, 5 policies per product, 10
//! `Tier::Unlicensed`. No features are gated yet — that's a //! active codes). The daemon is always functional out of the box; paying
//! future v0.2.x flip. //! lifts the caps and unlocks `recurring_billing` + `zaprite_payments`.
//! - `Enforce`: missing or invalid licenses cause the daemon to
//! refuse to start. Set at compile time via the
//! `KEYSAT_LICENSE_ENFORCE=1` env var. Marketplace builds set
//! this; local dev builds don't.
//! //!
//! The master pubkey is the *public* half of an Ed25519 keypair held //! The master pubkey is the *public* half of an Ed25519 keypair held by
//! offline by the keysat.xyz team. It is not secret — embedding it in //! the operator who issues Keysat-product licenses. It is not secret —
//! source on GitHub is fine. Anyone with the *private* half can mint //! embedding it in source on GitHub is fine. Anyone with the *private*
//! Keysat self-licenses; the private half lives on paper backup + //! half can mint Keysat self-licenses. On the master Keysat instance
//! hardware-token storage and never touches a connected machine //! that owner runs, the private half doubles as the per-instance
//! except briefly when a master Keysat instance is being initialized. //! license-signing key (stored in the `server_keys` table); on every
//! other Keysat install the private half doesn't exist and the daemon
//! only ever verifies, never signs.
use crate::crypto::{parse_key, verify_payload}; use crate::crypto::{parse_key, verify_payload};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
@@ -45,28 +43,12 @@ MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
/// persistent data volume so it survives package upgrades. /// persistent data volume so it survives package upgrades.
pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt"; pub const SELF_LICENSE_PATH: &str = "/data/keysat-license.txt";
/// Build-time enforcement toggle. `KEYSAT_LICENSE_ENFORCE=1` at
/// `cargo build` time enables enforce mode.
const ENFORCE_FLAG: Option<&str> = option_env!("KEYSAT_LICENSE_ENFORCE");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Missing/invalid license logs a warning and continues. Default.
Permissive,
/// Missing/invalid license refuses to start the daemon.
Enforce,
}
pub fn mode() -> Mode {
match ENFORCE_FLAG {
Some("1") | Some("true") | Some("yes") => Mode::Enforce,
_ => Mode::Permissive,
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Tier { pub enum Tier {
/// No license configured, or license verify failed in permissive mode. /// No self-license file, or verify failed. Surfaces as "Creator"
/// in the admin UI — the free tier with the Creator caps applied.
/// `reason` is for logs and the admin `/v1/admin/tier` payload, not
/// shown to end users.
Unlicensed { reason: String }, Unlicensed { reason: String },
/// Valid license verified against the trust-root. /// Valid license verified against the trust-root.
Licensed { Licensed {
@@ -79,34 +61,30 @@ pub enum Tier {
} }
impl Tier { impl Tier {
/// String form for log / metrics labels. `Unlicensed` surfaces as
/// "creator" since that's how the admin UI presents it — operators
/// see one consistent name across logs and dashboard.
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Tier::Unlicensed { .. } => "unlicensed", Tier::Unlicensed { .. } => "creator",
Tier::Licensed { .. } => "licensed", Tier::Licensed { .. } => "licensed",
} }
} }
} }
/// Boot-time check. In permissive mode this always returns `Ok`; in /// Boot-time check. Always returns `Ok` — Keysat boots into the Creator
/// enforce mode it returns `Err` on missing / invalid / expired /// (free) tier when no valid self-license is present, never refuses to
/// licenses, which causes `main` to bail out before we open any /// start. Logs a one-line info or warn line for operator visibility.
/// network sockets.
pub fn check_at_boot() -> Result<Tier> { pub fn check_at_boot() -> Result<Tier> {
let mode = mode();
tracing::info!(
mode = mode.as_str(),
"Keysat self-license check (mode={})",
mode.as_str()
);
let license_str = match read_license_string() { let license_str = match read_license_string() {
Some(s) => s, Some(s) => s,
None => { None => {
let reason = format!( let reason = format!(
"no license at {} or KEYSAT_LICENSE env var", "no license at {} or KEYSAT_LICENSE env var; running Creator (free) tier",
SELF_LICENSE_PATH SELF_LICENSE_PATH
); );
return handle_missing_or_invalid(mode, reason, None); tracing::info!(tier = "creator", "Keysat self-license: {}", reason);
return Ok(Tier::Unlicensed { reason });
} }
}; };
@@ -116,37 +94,12 @@ pub fn check_at_boot() -> Result<Tier> {
Ok(tier) Ok(tier)
} }
Err(e) => { Err(e) => {
let reason = format!("verification failed: {e:#}"); let reason = format!(
handle_missing_or_invalid(mode, reason, Some(e)) "verification failed: {e:#} — falling back to Creator (free) tier"
}
}
}
fn handle_missing_or_invalid(
mode: Mode,
reason: String,
err: Option<anyhow::Error>,
) -> Result<Tier> {
match mode {
Mode::Permissive => {
tracing::warn!(
tier = "unlicensed",
"Keysat self-license: {} — running unlicensed (permissive build)",
reason
); );
tracing::warn!(tier = "creator", "Keysat self-license: {}", reason);
Ok(Tier::Unlicensed { reason }) Ok(Tier::Unlicensed { reason })
} }
Mode::Enforce => {
tracing::error!(
"Keysat self-license: {} — refusing to start. \
Activate via StartOS → Keysat → Actions → Activate Keysat license.",
reason
);
match err {
Some(e) => Err(e.context("self-license invalid (enforce mode)")),
None => bail!("self-license missing (enforce mode): {reason}"),
}
}
} }
} }
@@ -246,15 +199,6 @@ fn log_licensed(tier: &Tier) {
} }
} }
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Permissive => "permissive",
Mode::Enforce => "enforce",
}
}
}
/// Live-refresh the daemon's self-tier from the local `licenses` row. /// Live-refresh the daemon's self-tier from the local `licenses` row.
/// ///
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and /// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
+4 -5
View File
@@ -39,13 +39,12 @@ async fn main() -> anyhow::Result<()> {
// --- self-license tier (Keysat-licenses-Keysat) --- // --- self-license tier (Keysat-licenses-Keysat) ---
// Verifies any /data/keysat-license.txt against the embedded master // Verifies any /data/keysat-license.txt against the embedded master
// pubkey. In permissive builds (default) a missing/invalid license // pubkey. Missing/invalid licenses fall back to the Creator (free)
// logs a warning and we continue. In enforce builds (compiled with // tier — the daemon always boots. Result is held in app state so
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to // the admin UI can surface it.
// start. Result is held in app state so the admin UI can surface it.
let self_tier = Arc::new(tokio::sync::RwLock::new( let self_tier = Arc::new(tokio::sync::RwLock::new(
license_self::check_at_boot() license_self::check_at_boot()
.context("Keysat self-license check failed (enforce mode)")?, .context("Keysat self-license boot check")?,
)); ));
// --- database --- // --- database ---
+6
View File
@@ -228,6 +228,12 @@ pub struct Policy {
/// policy. See TIER_UPGRADES_DESIGN.md for the full semantics. /// policy. See TIER_UPGRADES_DESIGN.md for the full semantics.
#[serde(default)] #[serde(default)]
pub tier_rank: Option<i64>, pub tier_rank: Option<i64>,
/// Soft-archive timestamp (migration 0015). `None` = live. `Some(ts)` =
/// archived: hidden from admin grid by default, hidden from /buy/<slug>,
/// renewal worker refuses to renew. Existing licenses keep validating
/// regardless (entitlements are signed into the key).
#[serde(default)]
pub archived_at: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
+49
View File
@@ -577,6 +577,55 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
); );
} }
// 0a. Refuse to renew an archived policy. The operator has
// explicitly taken this tier out of circulation. We dispatch a
// clear webhook + audit event so the operator can decide
// whether to unarchive or accept the lapse. The sub is left in
// its current state — the lapsing worker will eventually move
// it to `lapsed` when its grace period expires.
let policy_for_check =
crate::db::repo::get_policy_by_id(&state.db, &sub.policy_id).await?;
if let Some(policy) = policy_for_check.as_ref() {
if policy.archived_at.is_some() {
tracing::warn!(
sub_id = %sub.id,
policy_id = %sub.policy_id,
policy_slug = %policy.slug,
"skipping renewal: policy is archived",
);
let _ = crate::db::repo::insert_audit(
&state.db,
"renewal_worker",
None,
"subscription.renewal_skipped_archived",
Some("subscription"),
Some(&sub.id),
None,
None,
&json!({
"policy_id": sub.policy_id,
"policy_slug": policy.slug,
"reason": "policy_archived",
}),
)
.await;
crate::webhooks::dispatch(
state,
"subscription.renewal_skipped",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"product_id": sub.product_id,
"policy_id": sub.policy_id,
"policy_slug": policy.slug,
"reason": "policy_archived",
}),
)
.await;
return Ok(());
}
}
// 1. Convert listed price to sats. SAT-currency subs are an // 1. Convert listed price to sats. SAT-currency subs are an
// identity (no rate fetcher hit); fiat subs re-quote each // identity (no rate fetcher hit); fiat subs re-quote each
// cycle (per MULTI_CURRENCY_DESIGN.md decision). // cycle (per MULTI_CURRENCY_DESIGN.md decision).
+167
View File
@@ -1224,6 +1224,26 @@ async fn payment_provider_preference_round_trip() {
let (state, _tmp) = make_test_state().await; let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY); let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Zaprite activation requires the `zaprite_payments` entitlement
// (Pro tier and above). Pin the daemon's self-tier to a Licensed
// tier carrying that entitlement so the activate path doesn't
// 402. BTCPay is unconditional and works at every tier.
{
let mut guard = state.self_tier.write().await;
*guard = keysat::license_self::Tier::Licensed {
license_id: uuid::Uuid::new_v4(),
product_id: uuid::Uuid::new_v4(),
expires_at: 0,
entitlements: vec![
"unlimited_products".to_string(),
"unlimited_policies".to_string(),
"unlimited_codes".to_string(),
"recurring_billing".to_string(),
"zaprite_payments".to_string(),
],
};
}
// Pre-seed both configs as if the operator had run Connect on // Pre-seed both configs as if the operator had run Connect on
// each at some point. We bypass the actual Connect endpoints // each at some point. We bypass the actual Connect endpoints
// because they call out to BTCPay / Zaprite to validate the // because they call out to BTCPay / Zaprite to validate the
@@ -2877,3 +2897,150 @@ async fn buyer_cancel_rejects_garbage_key() {
"garbage key must be 401, not 404 — don't leak which subs exist" "garbage key must be 401, not 404 — don't leak which subs exist"
); );
} }
// ---------------------------------------------------------------------
// 0.2.0:12 — Scoped API keys + OpenAPI spec + Zaprite gate
// ---------------------------------------------------------------------
/// `GET /v1/openapi.json` — public, no auth. Returns a parseable spec
/// with the agent-relevant subset of endpoints documented.
#[tokio::test]
async fn openapi_spec_serves_valid_json() {
let (state, _tmp) = make_test_state().await;
let req = build_request("GET", "/v1/openapi.json", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["openapi"], "3.1.0");
assert!(v["paths"].as_object().expect("paths is object").len() > 5);
// Spot-check that the agent-relevant endpoints are present.
assert!(v.pointer("/paths/~1v1~1admin~1api-keys").is_some());
assert!(v.pointer("/paths/~1v1~1admin~1licenses").is_some());
assert!(v.pointer("/paths/~1v1~1validate").is_some());
}
/// `POST /v1/admin/api-keys` — master admin creates a scoped key, the
/// raw token comes back once, and the role is recorded. Subsequent
/// `GET /v1/admin/api-keys` lists it without the token.
#[tokio::test]
async fn scoped_api_key_create_list_revoke_round_trip() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Create with a recognized role.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({"label": "Smoke test bot", "role": "license-issuer"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let token = body["token"].as_str().expect("token returned");
assert!(token.starts_with("ks_"), "scoped token must use ks_ prefix");
let key_id = body["id"].as_str().expect("id returned").to_string();
assert_eq!(body["role"], "license-issuer");
// List sees the new key but never the raw token.
let req = build_request("GET", "/v1/admin/api-keys", &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let list = body_json(resp).await;
let keys = list["api_keys"].as_array().expect("api_keys array");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0]["label"], "Smoke test bot");
assert!(keys[0].get("token").is_none(), "list must not return raw tokens");
// Revoke. Idempotent on second call.
let path = format!("/v1/admin/api-keys/{}", key_id);
let req = build_request("DELETE", &path, &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let req = build_request("DELETE", &path, &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["already_revoked"], true);
}
/// Create endpoint rejects unknown role with 400.
#[tokio::test]
async fn scoped_api_key_create_rejects_unknown_role() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({"label": "bad role", "role": "god-mode"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// `POST /v1/admin/api-keys` requires master admin, NOT a scoped
/// full-admin key — generating other API keys is a self-elevation path
/// that scoped keys are deliberately denied.
#[tokio::test]
async fn scoped_api_key_management_rejects_scoped_full_admin() {
let (state, _tmp) = make_test_state().await;
let master = format!("Bearer {}", TEST_ADMIN_KEY);
// Master creates a full-admin scoped key.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &master)],
Some(json!({"label": "Tries to elevate", "role": "full-admin"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let scoped_token = body["token"].as_str().expect("token").to_string();
let scoped_auth = format!("Bearer {}", scoped_token);
// Scoped full-admin tries to create another key. Should 403 — the
// /v1/admin/api-keys handler calls require_admin, not require_scope.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &scoped_auth)],
Some(json!({"label": "Pwn", "role": "read-only"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"scoped keys (even full-admin) must NOT manage other keys"
);
}
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
/// entitlement) with 402. Switching the daemon's self-tier to a
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
/// fails downstream on the unreachable test host, but the tier gate is
/// behind us).
#[tokio::test]
async fn zaprite_connect_gated_by_pro_entitlement() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Creator tier (default for test fixture) — Connect should 402.
let req = build_request(
"POST",
"/v1/admin/zaprite/connect",
&[("authorization", &auth)],
Some(json!({"api_key": "fake-zaprite-key"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::PAYMENT_REQUIRED,
"Zaprite Connect must 402 without zaprite_payments entitlement"
);
let body = body_json(resp).await;
assert_eq!(body["error"], "tier_cap");
assert!(body["upgrade_url"].as_str().expect("upgrade_url").contains("/buy/keysat"));
}
File diff suppressed because it is too large Load Diff
+23 -42
View File
@@ -1,56 +1,37 @@
// Register actions with StartOS. // Register actions with StartOS.
// //
// As of v0.1.0:11 the StartOS Actions tab is intentionally minimal — // The StartOS Actions tab is kept intentionally minimal — only the
// only setup-time operations live here: // four operations that need to happen outside the admin web UI:
// //
// - General → Set operator name // - Set web UI password — needed for password recovery (you can't
// - BTCPay → Connect / Check / Disconnect // reset the password from inside the web UI if you can't log in)
// - License → Activate Keysat license / Show license status // - Activate Keysat license — first-install bootstrap for paid
// - Credentials → Show admin API key // customers, and recovery if /data/keysat-license.txt gets lost
// - Show license status — sanity-check the self-license state
// without logging into the admin UI
// - Show credentials — find the admin API key on first install,
// before you've logged into the admin UI for the first time
// //
// Everything else (products, policies, discount codes, licenses, // Everything else — operator name, payment provider connect / activate,
// machines, webhooks, audit log) lives in the embedded admin web UI // scoped API keys, products, policies, licenses, codes, machines,
// at /admin/. The action source files remain in this directory for // webhooks, audit log — lives in the embedded admin web UI under the
// reference — and the underlying admin HTTP API is unchanged — but // Settings tab and the workspace sidebar. The action source files for
// they're no longer registered as StartOS UI buttons. This keeps the // those operations remain in this directory for reference, but they're
// dashboard from feeling like an undifferentiated wall of buttons. // no longer registered as StartOS UI buttons. This keeps the dashboard
// // from feeling like an undifferentiated wall of buttons and aligns with
// The web UI uses the same /v1/admin/* endpoints those actions used to // "everything in one place" — the web UI.
// call, so functionality is identical; only the UI surface changed.
import { sdk } from '../sdk' import { sdk } from '../sdk'
import { activateLicense, showLicenseStatus } from './activateLicense' import { activateLicense, showLicenseStatus } from './activateLicense'
import { switchPaymentProvider } from './activatePaymentProvider'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import {
configureZaprite,
disconnectZaprite,
showZapriteWebhookSetup,
zapriteStatus,
} from './configureZaprite'
import { setOperatorName } from './setOperatorName'
import { setWebUiPassword } from './setWebUiPassword' import { setWebUiPassword } from './setWebUiPassword'
import { showCredentials } from './showCredentials' import { showCredentials } from './showCredentials'
export const actions = sdk.Actions.of() export const actions = sdk.Actions.of()
// General // First-install / recovery essentials.
.addAction(setOperatorName)
.addAction(setWebUiPassword) .addAction(setWebUiPassword)
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server) .addAction(showCredentials)
.addAction(configureBtcpay) // Keysat self-license (Keysat-licenses-Keysat). Required for paid
.addAction(btcpayStatus) // customers to activate their self-license on first install. The
.addAction(disconnectBtcpay) // license string itself is provided by your seller.
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
.addAction(configureZaprite)
.addAction(zapriteStatus)
.addAction(showZapriteWebhookSetup)
.addAction(disconnectZaprite)
// Single unified switch action — flips active provider via a
// dropdown so operators don't see two confusing "Activate X"
// actions side-by-side, each appearing to override the other.
.addAction(switchPaymentProvider)
// Keysat self-license (Keysat-licenses-Keysat)
.addAction(activateLicense) .addAction(activateLicense)
.addAction(showLicenseStatus) .addAction(showLicenseStatus)
// Credentials
.addAction(showCredentials)
+56 -1
View File
@@ -58,6 +58,61 @@ 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:12 — **Settings tab + agent-friendly operator API.** Major release that consolidates operator configuration into the admin UI and ships first-class agent / AI-automation support: OpenAPI 3.1 spec, scoped API keys, agent integration guide. Plus a slate of UX cleanups carried over from operator testing.',
'',
'**New Settings tab in the admin UI.** Three subsections in one place:',
'',
'- **Operator name** — the display name shown on /buy/<slug> + thank-you pages. Was in StartOS Actions; now lives where the rest of operator configuration lives.',
'- **Payment providers** — BTCPay + Zaprite connect / disconnect / activate, with provider status and active-provider toggle in one view. Replaces 5 separate StartOS Actions. Zaprite is grayed out + tagged "locked" for operators on Creator tier; clicking through opens the Pro upgrade page (gated on the `zaprite_payments` entitlement).',
'- **API keys** — generate / list / revoke scoped Bearer tokens for agents and automation.',
'',
'**Scoped API keys (migration 0016).** Master `KEYSAT_ADMIN_API_KEY` is full-access and reserved for the operator. Scoped keys are additional credentials with bounded permissions. Pick a role at generate time:',
'',
'- `read-only` — list everything, mutate nothing',
'- `license-issuer` — reads + issue / revoke / suspend / change-tier on licenses; cannot touch products, policies, or codes',
'- `support` — license-issuer + cancel subscriptions + deactivate machines',
'- `full-admin` — every scope except operator-only settings (payment providers, operator name, generating other API keys)',
'',
'Tokens look like `ks_<43 chars>`, are returned ONCE on create (never again — only sha256 stored), and can be revoked instantly from the same UI. Endpoints that have been wired through `require_scope` accept either the master key or a scoped key with the appropriate role. Endpoints not yet wired stay master-only — secure by default.',
'',
'**OpenAPI 3.1 spec.** `GET /v1/openapi.json` returns a curated, stable spec covering the agent-relevant subset of endpoints. No auth required. Drop the URL into a Custom GPT, OpenAI Assistant, LangChain, Claude Code, or any agent framework with OpenAPI support and the agent discovers Keysat\'s API automatically.',
'',
'**Agent integration guide.** New `KEYSAT_AGENT_GUIDE.md` doc covering scoped key generation, role-to-scope mapping, error envelope conventions (every error response returns `{ok: false, error: <stable_code>, message: ...}` with stable codes like `tier_cap`, `not_found`, `license_revoked`), webhook event types, and worked recipes for common automation patterns.',
'',
'**Landing page — agent-friendly section.** New section at keysat.xyz/#agents calling out OpenAPI discovery, scoped API keys, and HMAC-signed webhooks, with a link to the integration guide. Top-nav gains "Agents" anchor.',
'',
'**Enforce mode killed.** The compile-time `KEYSAT_LICENSE_ENFORCE=1` flag is gone. Every Keysat binary now boots permissively — a missing or invalid self-license falls back to the Creator (free) tier instead of refusing to start. Simpler mental model: every Keysat install is Creator-tier out of the box, paid tiers add entitlements. The "Unlicensed" label is gone from the admin UI; the free state surfaces as "Creator" everywhere.',
'',
'**Zaprite gated to Pro (`zaprite_payments` entitlement).** Both Connect Zaprite and Activate Zaprite now check the daemon\'s self-tier and return 402 with an upgrade URL when the entitlement is missing. BTCPay (Bitcoin / Lightning) remains free on every tier. Renamed the entitlement from `card_payments` to `zaprite_payments` to reflect that the Zaprite gateway covers cards plus Apple Pay, bank transfers, and more — not just credit cards.',
'',
'**StartOS Actions reduced to 4 essentials.** Pruned 8 actions that duplicated admin-UI functionality. What remains: `Set web UI password`, `Activate Keysat license`, `Show license status`, `Show credentials`. Everything else (operator name, payment providers, products, policies, licenses, codes, machines, webhooks, audit log, scoped API keys) lives in the admin UI under Settings or the workspace sidebar.',
'',
'**Creator code cap bumped 5 → 10.** Promo campaigns multiply faster than products do; doubling the active-codes cap removes a friction point that wasn\'t actually driving upgrades. Existing operators see the new cap immediately.',
'',
'**Machines tab — global default view + drill-down.** The old "paste a license ID to see anything" form is gone. Default view now lists every machine across every license, grouped by product, with the same product / status filter pills used by Licenses and Subscriptions. Quick-stats row shows total machines + active count + top platform. Each row shows buyer email, license id (with status pill), hostname, platform, last IP, last heartbeat as a relative date. The Licenses tab now has a "Machines" button on every license row that drills directly into that license\'s machines without copy-pasting UUIDs. New repo helper `list_machines_admin` joins machines × licenses × products server-side so there\'s no N+1 fetch. `GET /v1/admin/machines` is backwards-compatible — the old `?license_id=X` form still works (it\'s how the drill-down passes through).',
'',
'**Buyer-facing copy aligned with the new positioning.** Thank-you page + buy page footers, registry tagline, OpenAPI spec description, every SDK README, and the integration guide all now say "Bitcoin-native self-hosted software licensing." The older "Bitcoin-paid" phrasing is fully retired from production-facing surfaces.',
'',
'**UX polish.** Welcome card removed from Overview (was redundant). Analytics opt-in card aligned with other Overview cards and copy tightened. support.html stripped down to Patron / Lightning / OpenSats (the speculative "what funds go toward" section is gone). Thank-you page status-detail copy honest about Lightning vs on-chain settle timing. Reason-modal label has proper vertical spacing. Stray "legacy" references in user-facing copy swept.',
'',
'**Cross-product safety doc.** New §9a in KEYSAT_INTEGRATION.md explaining the same-keypair-multiple-products situation: licenses for any product on an operator\'s instance signature-verify against the same key, so applications must assert `payload.product_slug` after offline verify OR pass `product_slug` to online validate. Daemon already enforces it when product_slug is passed; the SDK doesn\'t auto-assert offline. New bullet in §15 Common mistakes points at §9a.',
'',
'**Test count: 78 + new tests landed in this release** (full suite passes across the 8 categories: unit / api / migrations / btcpay / crosscheck / etc.). The api.rs test for `payment_provider_preference_round_trip` was updated to pin the test daemon to a licensed Pro tier so Zaprite activation still passes through the new gate.',
'',
'**Upgrade path.** v0.2.0:11 → v0.2.0:12 is a drop-in. Migration 0016 is additive (one new table). No SDK changes. **One operator-facing behavior change**: operators currently on a Pro/Patron self-license should rename `card_payments` → `zaprite_payments` in their `keysat` product\'s entitlements catalog + on Pro/Patron policy rows. Until they do, Connect/Activate Zaprite will 402 even though they bought the right tier. (Master operator: run this rename on your master instance, then re-mint and re-issue customer self-licenses.) Pre-launch this affects very few people.',
'',
'0.2.0:11 — **Policy archive + saner delete semantics + brand-consistent confirm modals.** Three closely-related cleanups driven by hands-on testing: policies can now be archived (soft-hide) instead of forced through hard-delete, the safe-delete check stopped treating revoked-license tombstones as blockers, and every confirm dialog in the admin UI now uses the in-app overlay card instead of the browser-native one.',
'',
'**Archive a policy.** New Archive button on every tier card. Archived policies are hidden from the admin grid (toggle "Show archived" at the top of the Policies tab to reveal them), hidden from `/buy/<slug>`, and the renewal worker refuses to renew subscriptions tied to an archived policy (dispatches a `subscription.renewal_skipped` webhook with `reason: policy_archived`). Existing licenses keep validating because entitlements are signed into the LIC1 payload — the policy row is not consulted at validate time. Reversible: Unarchive button on archived cards puts everything back. Migration 0015 adds `policies.archived_at TEXT NULL`; existing rows default to NULL (live).',
'',
'**Safe-delete ignores tombstones.** Previously, any reference at all — including revoked-license rows kept for audit — blocked the safe-delete path and pushed the operator to force-delete. Now the safe-delete check counts only **live licenses** (status != revoked), **settled invoices**, and **active subscriptions** (status in active/past_due). Revoke an outstanding license and the policy is immediately safe-deletable, with the cascade sweeping up the revoked tombstone + any dead invoices. Force-delete still works the same way (wipes everything regardless), and the cascade now also handles `tier_changes` rows + nulls out `discount_codes.applies_to_policy_id` so it doesn\'t fall over on FK violations.',
'',
'**Branded confirm dialogs.** Every `window.confirm()` call in the admin UI replaced by a new `confirmModal()` overlay-card helper styled the same as the existing `reasonModal()`. No more "immense-voyage.local:62488" host string at the top of irreversible-action confirmations. Sites swept: policy delete, product delete, analytics-ID wipe, admin tier downgrade, discount-code delete, machine force-deactivate, webhook subscription delete. Modal copy adapts to context — e.g. deleting a policy with zero licenses now just asks "Sure?" without spelling out the cascade implications.',
'',
'**Test count: 78** (no test changes; framework PR coming separately).',
'',
'**Upgrade path.** v0.2.0:10 → v0.2.0:11 is a drop-in. Migration 0015 is additive (one nullable column + an index). No SDK changes. No behavior change for operators who don\'t use the new Archive button — existing policies stay live until someone explicitly archives them.',
'',
'0.2.0:10 — **Licenses + Subscriptions tabs reorganized to match Products + Policies.** Both tabs now group by product (matching the per-product card sections used elsewhere in the admin UI), with product-filter pills + per-product license counts at the top. Single-product instances continue to see a flat table; multi-product instances see one section per product with a status breakdown subtitle ("3 active · 1 revoked · 2 expired"). Search results bypass grouping (search is global across all products).', '0.2.0:10 — **Licenses + Subscriptions tabs reorganized to match Products + Policies.** Both tabs now group by product (matching the per-product card sections used elsewhere in the admin UI), with product-filter pills + per-product license counts at the top. Single-product instances continue to see a flat table; multi-product instances see one section per product with a status breakdown subtitle ("3 active · 1 revoked · 2 expired"). Search results bypass grouping (search is global across all products).',
'', '',
'**Licenses tab gains a quick-stats row** matching the Overview dashboard: Licenses, Active, Revoked, Expiring within 30 days. Scope follows the active product filter — pick a product, the stats reflect just that product. Hover the "?" icons next to each stat label for definitions.', '**Licenses tab gains a quick-stats row** matching the Overview dashboard: Licenses, Active, Revoked, Expiring within 30 days. Scope follows the active product filter — pick a product, the stats reflect just that product. Hover the "?" icons next to each stat label for definitions.',
@@ -209,7 +264,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:10', version: '0.2.0:12',
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