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:
@@ -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
@@ -1218,9 +1218,9 @@ function entitlementLabel(slug: string): string {
|
||||
}
|
||||
```
|
||||
|
||||
If the operator hasn't defined a catalog (legacy "free-text" mode),
|
||||
the array is empty and you fall back to rendering the raw slugs —
|
||||
or replacing underscores with spaces yourself for a quick polish.
|
||||
If the operator hasn't defined a catalog (free-text mode), the array
|
||||
is empty and you fall back to rendering the raw slugs — or replacing
|
||||
underscores with spaces yourself for a quick polish.
|
||||
|
||||
**Catalog stability rule**: once you ship gating logic that checks
|
||||
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()`)
|
||||
|
||||
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
|
||||
but my code rejects it" head-scratchers. Read the slug from a
|
||||
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
|
||||
the `license_id` instead.
|
||||
- **Refusing to start without a license.** Boot in unlicensed mode and
|
||||
|
||||
@@ -219,4 +219,4 @@ Whatever you pick, hash it before sending if you want to avoid exposing the unde
|
||||
|
||||
## 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);
|
||||
@@ -14,7 +14,7 @@
|
||||
//! identity, store id, or any user-supplied value. Resetting
|
||||
//! analytics opt-in regenerates it.
|
||||
//! - `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
|
||||
//! fingerprinting an operator by exact license count.
|
||||
//! - `uptime_seconds` — bucketed to "<1d" / "1-7d" / "1-4w" / ">4w".
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -532,7 +532,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
</div>
|
||||
|
||||
<footer class="kfooter">
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-paid software licensing</span>
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-native self-hosted software licensing</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -236,11 +236,24 @@ pub async fn deactivate(
|
||||
|
||||
// ---------- 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)]
|
||||
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)]
|
||||
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(
|
||||
@@ -249,11 +262,28 @@ pub async fn admin_list(
|
||||
Query(q): Query<AdminListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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 {
|
||||
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 })))
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
|
||||
pub mod admin;
|
||||
pub mod admin_ui;
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod openapi;
|
||||
pub mod btcpay_authorize;
|
||||
pub mod discount_codes;
|
||||
pub mod machines;
|
||||
@@ -325,6 +327,10 @@ pub fn router(state: AppState) -> Router {
|
||||
"/v1/admin/policies/:id/public",
|
||||
patch(policies::set_public),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/policies/:id/archived",
|
||||
patch(policies::set_archived),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/policies/:id/tip",
|
||||
patch(policies::set_tip),
|
||||
@@ -335,6 +341,14 @@ pub fn router(state: AppState) -> Router {
|
||||
get(policies::list_public_policies),
|
||||
)
|
||||
.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.
|
||||
.route(
|
||||
"/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
|
||||
// admin Overview "Embed your public key" tip and by SDK consumers.
|
||||
.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.
|
||||
.route("/v1/admin/tier", get(tier::admin_status))
|
||||
// Web-UI password auth (v0.1.0:28+).
|
||||
@@ -741,7 +759,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
</div>
|
||||
|
||||
<footer class="kfooter">
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-paid software licensing</span>
|
||||
<span>Powered by <a href="https://keysat.xyz" target="_blank" rel="noopener">Keysat</a> · Bitcoin-native self-hosted software licensing</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
@@ -817,8 +835,8 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
function waitingCopy(status) {{
|
||||
const min = Math.floor(elapsedMs / 60000);
|
||||
if (status === 'pending' || status === 'processing') {{
|
||||
if (min < 2) return 'invoice ' + status + ' — should settle within 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 < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
|
||||
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: ' + (status || 'pending');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
ProviderKind::Zaprite => {
|
||||
crate::api::tier::enforce_zaprite_feature(&state).await?;
|
||||
let cfg = payment::zaprite::config::load(&state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))?
|
||||
|
||||
@@ -277,6 +277,11 @@ pub struct ListPoliciesQuery {
|
||||
pub product_slug: String,
|
||||
#[serde(default)]
|
||||
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(
|
||||
@@ -288,7 +293,13 @@ pub async fn list(
|
||||
let product = repo::get_product_by_slug(&state.db, &q.product_slug)
|
||||
.await?
|
||||
.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 })))
|
||||
}
|
||||
|
||||
@@ -321,6 +332,43 @@ pub async fn set_active(
|
||||
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)]
|
||||
pub struct PolicyDeleteOpts {
|
||||
#[serde(default)]
|
||||
@@ -348,6 +396,7 @@ pub async fn delete(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
|
||||
|
||||
// Total counts (for cascade reporting).
|
||||
let invoice_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE policy_id = ?")
|
||||
.bind(&id)
|
||||
@@ -358,64 +407,111 @@ pub async fn delete(
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.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!(
|
||||
"cannot delete policy '{}' — it has {} invoice(s) and {} license(s) \
|
||||
referencing it. Disable it via the active toggle, or hide it from the \
|
||||
buy page via the public toggle, instead. To override and wipe all \
|
||||
references, use ?force=true.",
|
||||
policy.slug, invoice_count, license_count
|
||||
"cannot delete policy '{}' — it has {} live license(s), {} settled invoice(s), \
|
||||
and {} active subscription(s) referencing it. Archive it to hide it from \
|
||||
the admin grid and the buy page, revoke any outstanding licenses to free \
|
||||
the safe-delete path, or use ?force=true to cascade through everything.",
|
||||
policy.slug, live_license_count, settled_invoice_count, active_sub_count
|
||||
)));
|
||||
}
|
||||
|
||||
let machine_count: i64 = if opts.force {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM machines WHERE license_id IN
|
||||
(SELECT id FROM licenses WHERE policy_id = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let redemption_count: i64 = if opts.force {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM discount_redemptions WHERE invoice_id IN
|
||||
(SELECT id FROM invoices WHERE policy_id = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Even in safe-delete mode we cascade through tombstones (revoked
|
||||
// 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 id FROM licenses WHERE policy_id = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let redemption_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM discount_redemptions WHERE invoice_id IN
|
||||
(SELECT id FROM invoices WHERE policy_id = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
// 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?;
|
||||
if opts.force {
|
||||
sqlx::query(
|
||||
"DELETE FROM machines WHERE license_id IN
|
||||
(SELECT id FROM licenses WHERE policy_id = ?)",
|
||||
)
|
||||
sqlx::query(
|
||||
"DELETE FROM machines WHERE license_id IN
|
||||
(SELECT id FROM licenses WHERE policy_id = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM discount_redemptions WHERE invoice_id IN
|
||||
(SELECT id FROM invoices WHERE policy_id = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.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 discount_redemptions WHERE invoice_id IN
|
||||
(SELECT id FROM invoices WHERE policy_id = ?)",
|
||||
)
|
||||
sqlx::query("DELETE FROM licenses WHERE policy_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM invoices WHERE policy_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM licenses WHERE policy_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM invoices WHERE policy_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query("DELETE FROM policies WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
@@ -435,8 +531,8 @@ pub async fn delete(
|
||||
"slug": policy.slug,
|
||||
"name": policy.name,
|
||||
"force": opts.force,
|
||||
"cascaded_licenses": if opts.force { license_count } else { 0 },
|
||||
"cascaded_invoices": if opts.force { invoice_count } else { 0 },
|
||||
"cascaded_licenses": license_count,
|
||||
"cascaded_invoices": invoice_count,
|
||||
"cascaded_machines": machine_count,
|
||||
"cascaded_redemptions": redemption_count,
|
||||
}),
|
||||
@@ -446,8 +542,8 @@ pub async fn delete(
|
||||
"ok": true,
|
||||
"deleted": policy.slug,
|
||||
"force": opts.force,
|
||||
"cascaded_licenses": if opts.force { license_count } else { 0 },
|
||||
"cascaded_invoices": if opts.force { invoice_count } else { 0 },
|
||||
"cascaded_licenses": license_count,
|
||||
"cascaded_invoices": invoice_count,
|
||||
"cascaded_machines": machine_count,
|
||||
"cascaded_redemptions": redemption_count,
|
||||
})))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Admin endpoints for managing the daemon's own self-license
|
||||
//! (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
|
||||
//! against the embedded master pubkey, writes the file to
|
||||
//! `SELF_LICENSE_PATH`, and swaps the runtime tier in app state.
|
||||
@@ -12,6 +12,8 @@
|
||||
use crate::api::AppState;
|
||||
use crate::error::AppResult;
|
||||
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::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
@@ -25,7 +27,6 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum TierStatus {
|
||||
Unlicensed {
|
||||
reason: String,
|
||||
mode: &'static str,
|
||||
},
|
||||
Licensed {
|
||||
license_id: String,
|
||||
@@ -33,19 +34,13 @@ pub enum TierStatus {
|
||||
/// Unix seconds; 0 means perpetual.
|
||||
expires_at: i64,
|
||||
entitlements: Vec<String>,
|
||||
mode: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
Tier::Unlicensed { reason } => TierStatus::Unlicensed {
|
||||
reason: reason.clone(),
|
||||
mode,
|
||||
},
|
||||
Tier::Licensed {
|
||||
license_id,
|
||||
@@ -57,7 +52,6 @@ fn tier_to_status(tier: &Tier) -> TierStatus {
|
||||
product_id: product_id.to_string(),
|
||||
expires_at: *expires_at,
|
||||
entitlements: entitlements.clone(),
|
||||
mode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,29 @@
|
||||
//! Keysat ships in three tiers. The daemon enforces caps based on the
|
||||
//! entitlements baked into its own self-license (see `license_self.rs`):
|
||||
//!
|
||||
//! - **Creator** (default, also the unlicensed default): caps at 5
|
||||
//! products, 5 policies per product, 5 active discount codes. Buyers
|
||||
//! get a real Keysat brand experience for hobbyist scale. Sold at
|
||||
//! keysat.xyz for ~21,000 sats; also distributable via free codes.
|
||||
//! - **Creator** (free, no self-license required): caps at 5 products,
|
||||
//! 5 policies per product, 10 active discount codes. Buyers get a
|
||||
//! real Keysat brand experience for hobbyist scale. Anyone who
|
||||
//! installs Keysat is on Creator out of the box — no signup, no
|
||||
//! trial.
|
||||
//! - **Pro**: unlimited products / policies / codes. Unlocks
|
||||
//! `recurring_billing` and `card_payments` (Zaprite) when those
|
||||
//! features ship in v0.3. Sold at keysat.xyz for ~250,000 sats / yr.
|
||||
//! `recurring_billing` and `zaprite_payments` (Zaprite gateway —
|
||||
//! 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`
|
||||
//! entitlement that renders a "Patron" badge in the admin topbar.
|
||||
//! Honest upsell — no fake feature gate. Sold for ~500,000 sats / yr.
|
||||
//!
|
||||
//! "Unlicensed" (no self-license file present) is treated as Creator-tier
|
||||
//! caps: operators can install Keysat and start shipping without paying
|
||||
//! us a sat. The pull to a paid tier happens organically when they need
|
||||
//! more than 5 products or want recurring billing.
|
||||
//! The pull from Creator to a paid tier happens organically: operators
|
||||
//! hit the 5-product cap, or want recurring billing, or want to accept
|
||||
//! cards via Zaprite. All three trigger a 402 with an upgrade URL.
|
||||
//!
|
||||
//! All tier judgments are derived from the `entitlements` array on the
|
||||
//! daemon's self-license. The presence of `unlimited_products` lifts
|
||||
//! the product cap; `unlimited_policies` lifts the policy-per-product
|
||||
//! cap; `unlimited_codes` lifts the code cap. `recurring_billing` and
|
||||
//! `card_payments` gate the Zaprite + recurring features (when those
|
||||
//! ship). `patron` is purely cosmetic.
|
||||
//! cap; `unlimited_codes` lifts the code cap. `recurring_billing` gates
|
||||
//! creating recurring policies; `zaprite_payments` gates Connect/Activate
|
||||
//! Zaprite. `patron` is purely cosmetic.
|
||||
//!
|
||||
//! 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
|
||||
@@ -34,14 +35,19 @@ use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::license_self::Tier;
|
||||
|
||||
/// Tier-cap ceilings for the entry-level "Creator" tier (and unlicensed
|
||||
/// installs, which inherit the same caps). Tunable as we learn more from
|
||||
/// real operator usage post-launch — change the constants here. Existing
|
||||
/// operators are never retroactively kicked off; the cap fires at
|
||||
/// create-time only.
|
||||
/// Tier-cap ceilings for the entry-level "Creator" tier — the default
|
||||
/// state when no self-license is present and the surfaced label whenever
|
||||
/// a license's entitlements don't include `unlimited_products`. Tunable
|
||||
/// as we learn more from real operator usage post-launch — change the
|
||||
/// 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_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
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TierInfo {
|
||||
/// Coarse label: "creator" | "pro" | "patron" | "unlicensed".
|
||||
/// Coarse label: "creator" | "pro" | "patron".
|
||||
pub label: &'static str,
|
||||
/// Display-friendly name: "Creator" | "Pro" | "Patron" | "Unlicensed".
|
||||
/// Display-friendly name: "Creator" | "Pro" | "Patron".
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -77,6 +83,11 @@ impl TierInfo {
|
||||
/// 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
|
||||
/// (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 {
|
||||
let tier = state.self_tier.read().await;
|
||||
let entitlements = match &*tier {
|
||||
@@ -93,12 +104,10 @@ pub async fn current(state: &AppState) -> TierInfo {
|
||||
} else if entitlements.iter().any(|e| e == "unlimited_products") {
|
||||
label = "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";
|
||||
display_name = "Creator";
|
||||
} else {
|
||||
label = "unlicensed";
|
||||
display_name = "Unlicensed";
|
||||
}
|
||||
TierInfo {
|
||||
label,
|
||||
@@ -142,7 +151,7 @@ pub async fn admin_status(
|
||||
},
|
||||
});
|
||||
let next_tier = match tier.label {
|
||||
"creator" | "unlicensed" => "pro",
|
||||
"creator" => "pro",
|
||||
"pro" => "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
|
||||
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
|
||||
/// codes — operators can disable old codes to free up slots, which is
|
||||
|
||||
@@ -49,6 +49,7 @@ pub async fn connect(
|
||||
Json(req): Json<ConnectReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
crate::api::tier::enforce_zaprite_feature(&state).await?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
|
||||
let api_key = req.api_key.trim().to_string();
|
||||
|
||||
@@ -838,7 +838,7 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s
|
||||
max_machines, is_trial, price_sats_override,
|
||||
entitlements_json, metadata_json, active, public,
|
||||
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
||||
tier_rank,
|
||||
tier_rank, archived_at,
|
||||
created_at, updated_at";
|
||||
|
||||
/// 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,
|
||||
only_active: bool,
|
||||
) -> AppResult<Vec<Policy>> {
|
||||
let sql = if only_active {
|
||||
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")
|
||||
};
|
||||
list_policies_by_product_with_archived(pool, product_id, only_active, false).await
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
Ok(rows.into_iter().map(row_to_policy).collect())
|
||||
}
|
||||
|
||||
/// Public-buyer view: only active+public policies. Sorted by ascending
|
||||
/// effective price so the cheapest tier renders leftmost. The buy page
|
||||
/// is the only caller; admin should use `list_policies_by_product`.
|
||||
/// Public-buyer view: only active+public+non-archived policies. Sorted by
|
||||
/// ascending effective price so the cheapest tier renders leftmost. The
|
||||
/// buy page is the only caller; admin should use `list_policies_by_product`.
|
||||
pub async fn list_public_policies_by_product(
|
||||
pool: &SqlitePool,
|
||||
product_id: &str,
|
||||
) -> AppResult<Vec<Policy>> {
|
||||
let sql = format!(
|
||||
"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"
|
||||
);
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let entitlements_json: String = row.get("entitlements_json");
|
||||
let entitlements: Vec<String> =
|
||||
@@ -1181,6 +1221,12 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
|
||||
.try_get::<Option<i64>, _>("tier_rank")
|
||||
.ok()
|
||||
.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 {
|
||||
id: row.get("id"),
|
||||
product_id: row.get("product_id"),
|
||||
@@ -1203,6 +1249,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
|
||||
grace_period_days,
|
||||
trial_days,
|
||||
tier_rank,
|
||||
archived_at,
|
||||
created_at: row.get("created_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())
|
||||
}
|
||||
|
||||
/// 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(
|
||||
pool: &SqlitePool,
|
||||
license_id: &str,
|
||||
|
||||
@@ -7,22 +7,20 @@
|
||||
//! customer licenses, and verify its signature against the master
|
||||
//! public key.
|
||||
//!
|
||||
//! Two modes:
|
||||
//! - `Permissive` (default for dev builds): missing or invalid
|
||||
//! licenses log a warning and the daemon starts in
|
||||
//! `Tier::Unlicensed`. No features are gated yet — that's a
|
||||
//! future v0.2.x flip.
|
||||
//! - `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.
|
||||
//! Missing or invalid self-licenses log a warning and the daemon starts in
|
||||
//! `Tier::Unlicensed`, which the admin UI labels "Creator" — the free tier
|
||||
//! with the Creator caps applied (5 products, 5 policies per product, 10
|
||||
//! active codes). The daemon is always functional out of the box; paying
|
||||
//! lifts the caps and unlocks `recurring_billing` + `zaprite_payments`.
|
||||
//!
|
||||
//! The master pubkey is the *public* half of an Ed25519 keypair held
|
||||
//! offline by the keysat.xyz team. It is not secret — embedding it in
|
||||
//! source on GitHub is fine. Anyone with the *private* half can mint
|
||||
//! Keysat self-licenses; the private half lives on paper backup +
|
||||
//! hardware-token storage and never touches a connected machine
|
||||
//! except briefly when a master Keysat instance is being initialized.
|
||||
//! The master pubkey is the *public* half of an Ed25519 keypair held by
|
||||
//! the operator who issues Keysat-product licenses. It is not secret —
|
||||
//! embedding it in source on GitHub is fine. Anyone with the *private*
|
||||
//! half can mint Keysat self-licenses. On the master Keysat instance
|
||||
//! that owner runs, the private half doubles as the per-instance
|
||||
//! 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 anyhow::{bail, Context, Result};
|
||||
@@ -45,28 +43,12 @@ MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA=
|
||||
/// persistent data volume so it survives package upgrades.
|
||||
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)]
|
||||
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 },
|
||||
/// Valid license verified against the trust-root.
|
||||
Licensed {
|
||||
@@ -79,34 +61,30 @@ pub enum 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 {
|
||||
match self {
|
||||
Tier::Unlicensed { .. } => "unlicensed",
|
||||
Tier::Unlicensed { .. } => "creator",
|
||||
Tier::Licensed { .. } => "licensed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot-time check. In permissive mode this always returns `Ok`; in
|
||||
/// enforce mode it returns `Err` on missing / invalid / expired
|
||||
/// licenses, which causes `main` to bail out before we open any
|
||||
/// network sockets.
|
||||
/// Boot-time check. Always returns `Ok` — Keysat boots into the Creator
|
||||
/// (free) tier when no valid self-license is present, never refuses to
|
||||
/// start. Logs a one-line info or warn line for operator visibility.
|
||||
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() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
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
|
||||
);
|
||||
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)
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = format!("verification failed: {e:#}");
|
||||
handle_missing_or_invalid(mode, reason, Some(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
let reason = format!(
|
||||
"verification failed: {e:#} — falling back to Creator (free) tier"
|
||||
);
|
||||
tracing::warn!(tier = "creator", "Keysat self-license: {}", 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.
|
||||
///
|
||||
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
|
||||
|
||||
@@ -39,13 +39,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// --- self-license tier (Keysat-licenses-Keysat) ---
|
||||
// Verifies any /data/keysat-license.txt against the embedded master
|
||||
// pubkey. In permissive builds (default) a missing/invalid license
|
||||
// logs a warning and we continue. In enforce builds (compiled with
|
||||
// KEYSAT_LICENSE_ENFORCE=1) a missing/invalid license refuses to
|
||||
// start. Result is held in app state so the admin UI can surface it.
|
||||
// pubkey. Missing/invalid licenses fall back to the Creator (free)
|
||||
// tier — the daemon always boots. Result is held in app state so
|
||||
// the admin UI can surface it.
|
||||
let self_tier = Arc::new(tokio::sync::RwLock::new(
|
||||
license_self::check_at_boot()
|
||||
.context("Keysat self-license check failed (enforce mode)")?,
|
||||
.context("Keysat self-license boot check")?,
|
||||
));
|
||||
|
||||
// --- database ---
|
||||
|
||||
@@ -228,6 +228,12 @@ pub struct Policy {
|
||||
/// policy. See TIER_UPGRADES_DESIGN.md for the full semantics.
|
||||
#[serde(default)]
|
||||
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 updated_at: String,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// identity (no rate fetcher hit); fiat subs re-quote each
|
||||
// cycle (per MULTI_CURRENCY_DESIGN.md decision).
|
||||
|
||||
@@ -1224,6 +1224,26 @@ async fn payment_provider_preference_round_trip() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
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
|
||||
// each at some point. We bypass the actual Connect endpoints
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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"));
|
||||
}
|
||||
|
||||
|
||||
+943
-140
File diff suppressed because it is too large
Load Diff
+23
-42
@@ -1,56 +1,37 @@
|
||||
// Register actions with StartOS.
|
||||
//
|
||||
// As of v0.1.0:11 the StartOS Actions tab is intentionally minimal —
|
||||
// only setup-time operations live here:
|
||||
// The StartOS Actions tab is kept intentionally minimal — only the
|
||||
// four operations that need to happen outside the admin web UI:
|
||||
//
|
||||
// - General → Set operator name
|
||||
// - BTCPay → Connect / Check / Disconnect
|
||||
// - License → Activate Keysat license / Show license status
|
||||
// - Credentials → Show admin API key
|
||||
// - Set web UI password — needed for password recovery (you can't
|
||||
// reset the password from inside the web UI if you can't log in)
|
||||
// - Activate Keysat license — first-install bootstrap for paid
|
||||
// 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,
|
||||
// machines, webhooks, audit log) lives in the embedded admin web UI
|
||||
// at /admin/. The action source files remain in this directory for
|
||||
// reference — and the underlying admin HTTP API is unchanged — but
|
||||
// they're no longer registered as StartOS UI buttons. This keeps the
|
||||
// dashboard from feeling like an undifferentiated wall of buttons.
|
||||
//
|
||||
// The web UI uses the same /v1/admin/* endpoints those actions used to
|
||||
// call, so functionality is identical; only the UI surface changed.
|
||||
// Everything else — operator name, payment provider connect / activate,
|
||||
// scoped API keys, products, policies, licenses, codes, machines,
|
||||
// webhooks, audit log — lives in the embedded admin web UI under the
|
||||
// Settings tab and the workspace sidebar. The action source files for
|
||||
// those operations remain in this directory for reference, but they're
|
||||
// no longer registered as StartOS UI buttons. This keeps the dashboard
|
||||
// from feeling like an undifferentiated wall of buttons and aligns with
|
||||
// "everything in one place" — the web UI.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
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 { showCredentials } from './showCredentials'
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
// General
|
||||
.addAction(setOperatorName)
|
||||
// First-install / recovery essentials.
|
||||
.addAction(setWebUiPassword)
|
||||
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
|
||||
.addAction(configureBtcpay)
|
||||
.addAction(btcpayStatus)
|
||||
.addAction(disconnectBtcpay)
|
||||
// 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(showCredentials)
|
||||
// Keysat self-license (Keysat-licenses-Keysat). Required for paid
|
||||
// customers to activate their self-license on first install. The
|
||||
// license string itself is provided by your seller.
|
||||
.addAction(activateLicense)
|
||||
.addAction(showLicenseStatus)
|
||||
// Credentials
|
||||
.addAction(showCredentials)
|
||||
|
||||
@@ -58,6 +58,61 @@ const RELEASE_NOTES = [
|
||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||
// append here.
|
||||
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).',
|
||||
'',
|
||||
'**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')
|
||||
|
||||
export const v0_2_0 = VersionInfo.of({
|
||||
version: '0.2.0:10',
|
||||
version: '0.2.0:12',
|
||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||
// SQLite-level migrations live separately under
|
||||
|
||||
Reference in New Issue
Block a user