257669092b
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.
371 lines
12 KiB
Markdown
371 lines
12 KiB
Markdown
# 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.
|