Compare commits
9 Commits
8c5cdb6468
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b68bb4b882 | |||
| c739d5c515 | |||
| 46972be9db | |||
| 0a6d73aa29 | |||
| 241478af95 | |||
| 51a88f2a2f | |||
| 554f3b2da0 | |||
| 4755639bdc | |||
| eafdc6646e |
+29
-9
@@ -57,6 +57,7 @@ API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars>
|
|||||||
| `read-only` | List / get every resource. Mutate nothing. |
|
| `read-only` | List / get every resource. Mutate nothing. |
|
||||||
| `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. |
|
| `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. |
|
||||||
| `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. |
|
| `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. |
|
||||||
|
| `merchant-onboard` | All `read-only` scopes + `products:write` + `policies:write` + `licenses:write` — the least-privilege credential for standing up a fresh catalog (create products, define policies/tiers, issue licenses against them) via the API. Deliberately excludes the support writes (subscriptions / machines) and every master-only gate. Tier caps still bound it. |
|
||||||
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
|
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
|
||||||
|
|
||||||
Endpoints that touch settings (operator name, payment provider connections,
|
Endpoints that touch settings (operator name, payment provider connections,
|
||||||
@@ -138,7 +139,7 @@ upgrade CTA without parsing message strings.
|
|||||||
| `expired` | Past `expires_at` |
|
| `expired` | Past `expires_at` |
|
||||||
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
|
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
|
||||||
| `product_mismatch` | License is for a different product than the caller asserted |
|
| `product_mismatch` | License is for a different product than the caller asserted |
|
||||||
| `machine_cap_exceeded` | Activating this fingerprint would exceed `max_machines` |
|
| `too_many_machines` | Activating this fingerprint would exceed `max_machines` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -173,7 +174,9 @@ curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
|
|||||||
-d '{"reason":"customer request"}'
|
-d '{"reason":"customer request"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Idempotent. The next online validate from the buyer's app returns `reason: revoked`.
|
The next online validate from the buyer's app returns `reason: revoked`. Not
|
||||||
|
idempotent — a second revoke of the same license returns `404 not_found` (treat
|
||||||
|
as success-equivalent on retry; see Idempotency below).
|
||||||
|
|
||||||
Scope required: `licenses:write`.
|
Scope required: `licenses:write`.
|
||||||
|
|
||||||
@@ -277,10 +280,16 @@ A few patterns that work well in practice.
|
|||||||
|
|
||||||
### Idempotency
|
### Idempotency
|
||||||
|
|
||||||
The daemon's mutation endpoints are idempotent where they can be. Revoke,
|
The daemon's mutation endpoints are idempotent where they can be. Suspend,
|
||||||
suspend, unsuspend, archive, unarchive, subscription cancel — all return
|
unsuspend, archive, unarchive, subscription cancel — all return success on the
|
||||||
success on the second call without changing state. Your agent can safely
|
second call without changing state. Your agent can safely retry on network
|
||||||
retry on network errors.
|
errors.
|
||||||
|
|
||||||
|
One exception: **revoke is not idempotent** — revoking an already-revoked
|
||||||
|
license returns `404 not_found` (the row no longer matches the
|
||||||
|
`status != 'revoked'` update guard). When retrying a revoke after an ambiguous
|
||||||
|
network failure, treat a `404` as success-equivalent: the license is already
|
||||||
|
revoked.
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
@@ -349,15 +358,26 @@ Some operations are deliberately operator-only and not accessible to any
|
|||||||
scoped key, including `full-admin`:
|
scoped key, including `full-admin`:
|
||||||
|
|
||||||
- Generating / revoking scoped API keys (`/v1/admin/api-keys`)
|
- Generating / revoking scoped API keys (`/v1/admin/api-keys`)
|
||||||
- Connecting / disconnecting payment providers
|
- Disconnecting a payment provider, and connecting *any* provider on a
|
||||||
|
production daemon
|
||||||
- Setting the operator name
|
- Setting the operator name
|
||||||
- Activating the self-license (`/v1/admin/self-license`)
|
- Activating the self-license (`/v1/admin/self-license`)
|
||||||
- Resetting the analytics install_uuid
|
- Resetting the analytics install_uuid
|
||||||
- Changing the web UI password (StartOS Action only)
|
- Changing the web UI password (StartOS Action only)
|
||||||
|
|
||||||
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
|
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
|
||||||
that can rotate its own credentials, connect arbitrary payment processors, or
|
that can rotate its own credentials, redirect settled payments, or change the
|
||||||
change the operator identity is no longer bounded by the role it was given.
|
operator identity is no longer bounded by the role it was given.
|
||||||
|
|
||||||
|
**One narrow exception — agent-delegated payment connect.** A key granted the
|
||||||
|
à-la-carte `payment_providers:write` scope (never granted by any role —
|
||||||
|
operators add it explicitly per key) CAN initiate a BTCPay connect, but only
|
||||||
|
fail-closed under two gates: the daemon must be in **sandbox mode** (an outer
|
||||||
|
gate — scoped connect is refused outright on a production daemon, even for
|
||||||
|
regtest), and the target store must be **non-mainnet** (an inner gate enforced
|
||||||
|
after the OAuth round-trip). Disconnecting a provider, and any connect on a
|
||||||
|
production / mainnet daemon, remain master-only. This lets an integrating agent
|
||||||
|
wire up a throwaway sandbox without ever touching a live store's settlement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,9 @@
|
|||||||
#
|
#
|
||||||
# Chain targets when needed: `make clean arm install`.
|
# Chain targets when needed: `make clean arm install`.
|
||||||
|
|
||||||
|
# Only x86_64 and aarch64 are supported and declared in the manifest. The shared
|
||||||
|
# s9pk.mk defaults ARCHES to include riscv; override it here so a bare `make`
|
||||||
|
# (which builds every arch in ARCHES) does not attempt an unverified riscv build.
|
||||||
|
ARCHES := x86 arm
|
||||||
|
|
||||||
include s9pk.mk
|
include s9pk.mk
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ comp keys, beta access, or "first N users free" launch promos.
|
|||||||
Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
|
Built from the local `Dockerfile` via `images.main.source.dockerBuild`,
|
||||||
with build context set to the parent directory so the Dockerfile can
|
with build context set to the parent directory so the Dockerfile can
|
||||||
`COPY` from the sibling `licensing-service/` source tree. The Rust binary
|
`COPY` from the sibling `licensing-service/` source tree. The Rust binary
|
||||||
is statically linked against musl (target
|
is statically compiled against musl (target `*-unknown-linux-musl`), and the
|
||||||
`*-unknown-linux-musl`) so the runtime image is a `scratch`-based final
|
runtime stage is `debian:bookworm-slim` with `ca-certificates`, `tini` (init /
|
||||||
stage with no shared-library dependencies. Architectures: `x86_64` and
|
signal handling), and `sqlite3` (an SQL shell for occasional admin tasks)
|
||||||
`aarch64`.
|
installed. Architectures: `x86_64` and `aarch64`.
|
||||||
|
|
||||||
`start-cli s9pk pack` ingests the resulting OCI image, converts it to a
|
`start-cli s9pk pack` ingests the resulting OCI image, converts it to a
|
||||||
squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
|
squashfs filesystem image, and embeds that in the `.s9pk`. At runtime
|
||||||
@@ -105,25 +105,24 @@ mandatory.
|
|||||||
## Installation and First-Run Flow
|
## Installation and First-Run Flow
|
||||||
|
|
||||||
1. Install Keysat via the marketplace (or sideload the `.s9pk`).
|
1. Install Keysat via the marketplace (or sideload the `.s9pk`).
|
||||||
2. Resolve the auto-created **critical task** "Connect BTCPay" by
|
2. Resolve the auto-created **important task** "Connect BTCPay" — open
|
||||||
running the **Connect BTCPay** action. This opens a one-click
|
the embedded admin web UI (**Settings → Payment providers**) and
|
||||||
authorize page on your local BTCPay; after approval, Keysat
|
click **Connect BTCPay**. This opens a one-click authorize page on
|
||||||
auto-detects your store and registers an inbound webhook. No API
|
your local BTCPay; after approval, Keysat auto-detects your store and
|
||||||
keys to copy.
|
registers an inbound webhook. No API keys to copy. The install task
|
||||||
3. Run **Check BTCPay connection** to confirm — the install task clears
|
clears automatically once BTCPay reports connected.
|
||||||
automatically.
|
3. Set your **operator name** (shown on the public homepage and in
|
||||||
4. Set your **operator name** (shown on the public homepage and in
|
|
||||||
buyer-facing receipts).
|
buyer-facing receipts).
|
||||||
5. Create one or more **products** — each represents something you sell.
|
4. Create one or more **products** — each represents something you sell.
|
||||||
6. Create at least one **policy** per product. Multi-tier ladders
|
5. Create at least one **policy** per product. Multi-tier ladders
|
||||||
(Basic / Pro / Max) are first-class: when a product has two or more
|
(Basic / Pro / Max) are first-class: when a product has two or more
|
||||||
public policies, the buy page renders a tier picker and the buyer
|
public policies, the buy page renders a tier picker and the buyer
|
||||||
chooses before paying. Policies define duration, grace period, seat
|
chooses before paying. Policies define duration, grace period, seat
|
||||||
cap, entitlements, recurring cadence, trial flag, price overrides,
|
cap, entitlements, recurring cadence, trial flag, price overrides,
|
||||||
marketing bullets, and per-entitlement hide-on-buy-page toggles.
|
marketing bullets, and per-entitlement hide-on-buy-page toggles.
|
||||||
7. Optionally create **discount / referral / free-license codes** (see
|
6. Optionally create **discount / referral / free-license codes** in the
|
||||||
`Create discount code` action).
|
admin web UI.
|
||||||
8. Share the public service URL with buyers.
|
7. Share the public service URL with buyers.
|
||||||
|
|
||||||
## Configuration Management
|
## Configuration Management
|
||||||
|
|
||||||
@@ -145,7 +144,7 @@ interfaces for clarity:
|
|||||||
| Interface | Type | Path prefix | Purpose |
|
| Interface | Type | Path prefix | Purpose |
|
||||||
|-----------|------|-------------|------------------------------------------------------------------------------|
|
|-----------|------|-------------|------------------------------------------------------------------------------|
|
||||||
| `api` | api | `/` | Public REST API for buyers (purchase, redeem) and licensed apps (validate, machine activation). Bake the URL into your software builds as the licensing endpoint. |
|
| `api` | api | `/` | Public REST API for buyers (purchase, redeem) and licensed apps (validate, machine activation). Bake the URL into your software builds as the licensing endpoint. |
|
||||||
| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically during Connect BTCPay; not for human use. |
|
| `webhook` | api | `/btcpay` | BTCPay webhook landing endpoint. Registered automatically when you connect BTCPay in the admin web UI; not for human use. |
|
||||||
|
|
||||||
StartOS terminates TLS at the platform edge. Inside the container every
|
StartOS terminates TLS at the platform edge. Inside the container every
|
||||||
request arrives as plain HTTP. For browser-facing URLs (e.g., the public
|
request arrives as plain HTTP. For browser-facing URLs (e.g., the public
|
||||||
@@ -153,44 +152,23 @@ purchase page) hardcode `https://`.
|
|||||||
|
|
||||||
## Actions (StartOS UI)
|
## Actions (StartOS UI)
|
||||||
|
|
||||||
Grouped as displayed in the dashboard.
|
The StartOS Actions tab is intentionally minimal — only the four operations
|
||||||
|
that must happen outside the embedded admin web UI are registered as actions:
|
||||||
|
|
||||||
**General**
|
- *Set web UI password* — set / recover the admin SPA login password (you
|
||||||
- *Set operator name* — your public-facing brand.
|
can't reset it from inside the UI if you're locked out).
|
||||||
|
- *Show credentials* — reveal the admin API key on first install, before
|
||||||
|
you've logged into the admin UI.
|
||||||
|
- *Activate Keysat license* — first-install bootstrap for paid self-hosting
|
||||||
|
tiers, and recovery if `/data/keysat-license.txt` is lost.
|
||||||
|
- *Show license status* — sanity-check the self-license state without
|
||||||
|
logging into the admin UI.
|
||||||
|
|
||||||
**BTCPay**
|
Everything else — connecting BTCPay (and Zaprite), operator name, products,
|
||||||
- *Connect BTCPay* — one-click authorize against your BTCPay; auto-detects store and registers webhook.
|
policies, discount / referral / free-license codes, licenses, machines,
|
||||||
- *Check BTCPay connection* — confirm BTCPay state; clears the install task on success.
|
outbound webhooks, scoped API keys, and the audit log — lives in the embedded
|
||||||
|
**admin web UI** (Settings tab + the workspace sidebar), not as StartOS
|
||||||
**Credentials**
|
actions.
|
||||||
- *Show admin credentials* — admin API key for direct `/v1/admin/*` access.
|
|
||||||
|
|
||||||
**Products + Policies**
|
|
||||||
- *Create product* — declare something to sell.
|
|
||||||
- *Create policy* — license template for a product (duration, grace, seat cap, entitlements, trial flag, price override).
|
|
||||||
|
|
||||||
**Discount codes**
|
|
||||||
- *Create discount code* — percent-off / fixed-sats-off / free-license.
|
|
||||||
- *List discount codes* — usage stats.
|
|
||||||
- *Disable / enable discount code*.
|
|
||||||
|
|
||||||
**Licenses**
|
|
||||||
- *Issue license manually* — comp / press / grandfathered keys.
|
|
||||||
- *Search licenses* — by email or BTCPay invoice id.
|
|
||||||
- *Suspend license* — reversible lockout.
|
|
||||||
- *Unsuspend license*.
|
|
||||||
- *Revoke license* — terminal kill.
|
|
||||||
|
|
||||||
**Machines**
|
|
||||||
- *List machines* — installs bound to a license.
|
|
||||||
- *Deactivate machine* — free a seat.
|
|
||||||
|
|
||||||
**Webhooks (outbound)**
|
|
||||||
- *Register webhook endpoint* — POST signed events to your URL.
|
|
||||||
- *List webhook endpoints*.
|
|
||||||
|
|
||||||
**Diagnostics**
|
|
||||||
- *View audit log* — admin mutation history, filterable.
|
|
||||||
|
|
||||||
## Backups and Restore
|
## Backups and Restore
|
||||||
|
|
||||||
@@ -228,7 +206,7 @@ Known current limitations:
|
|||||||
- **No bulk / volume licensing UI.** "Buy 10 keys at once with discount" is not built into the buy page. Operators can issue N comp licenses via the admin API in a loop.
|
- **No bulk / volume licensing UI.** "Buy 10 keys at once with discount" is not built into the buy page. Operators can issue N comp licenses via the admin API in a loop.
|
||||||
- **Webhook delivery retries are bounded.** A subscriber down past the 10-attempt retry window lands in the dead-letter queue (visible in admin UI → Webhooks → Failed). BTCPay invoice reconciliation runs as a background poll so dropped *payment* webhooks are recovered.
|
- **Webhook delivery retries are bounded.** A subscriber down past the 10-attempt retry window lands in the dead-letter queue (visible in admin UI → Webhooks → Failed). BTCPay invoice reconciliation runs as a background poll so dropped *payment* webhooks are recovered.
|
||||||
- **Hardware fingerprinting is client-supplied.** Keysat does not derive fingerprints itself; the buyer-side SDK passes whatever the integrator chose. The fingerprint is bound on first activate and enforced thereafter.
|
- **Hardware fingerprinting is client-supplied.** Keysat does not derive fingerprints itself; the buyer-side SDK passes whatever the integrator chose. The fingerprint is bound on first activate and enforced thereafter.
|
||||||
- **Card payments not shipped.** The Zaprite payment provider is in design for v0.3 — operators on Pro / Patron will get a card-payment option alongside BTCPay. Until then, payments are BTC / Lightning only.
|
- **Card payments via Zaprite are gated.** Zaprite ships as an optional second payment provider (card / fiat alongside BTCPay) but is gated by the `zaprite_payments` entitlement — operators on the tiers that grant it can connect Zaprite in the admin web UI. BTCPay remains the required provider; without the entitlement, payments are BTC / Lightning only.
|
||||||
|
|
||||||
## What Is Unchanged from Upstream
|
## What Is Unchanged from Upstream
|
||||||
|
|
||||||
@@ -257,7 +235,7 @@ service:
|
|||||||
marketingUrl: https://keysat.xyz
|
marketingUrl: https://keysat.xyz
|
||||||
image:
|
image:
|
||||||
source: dockerBuild
|
source: dockerBuild
|
||||||
baseImage: scratch (musl-static Rust binary)
|
baseImage: debian:bookworm-slim (musl-compiled Rust binary; bundles ca-certificates, tini, sqlite3)
|
||||||
arches: [x86_64, aarch64]
|
arches: [x86_64, aarch64]
|
||||||
volumes:
|
volumes:
|
||||||
- id: main
|
- id: main
|
||||||
@@ -292,10 +270,10 @@ backups:
|
|||||||
firstRun:
|
firstRun:
|
||||||
tasks:
|
tasks:
|
||||||
- id: btcpay-initial-setup
|
- id: btcpay-initial-setup
|
||||||
severity: critical
|
severity: important
|
||||||
runs: configureBtcpay
|
runs: configureBtcpay
|
||||||
features:
|
features:
|
||||||
paymentRail: btcpay-server # zaprite planned for v0.3 (card payments)
|
paymentProviders: [btcpay-server, zaprite] # btcpay required; zaprite optional, gated by the zaprite_payments entitlement
|
||||||
signing: ed25519
|
signing: ed25519
|
||||||
offlineVerification: true
|
offlineVerification: true
|
||||||
multiSeat: true
|
multiSeat: true
|
||||||
@@ -315,7 +293,7 @@ features:
|
|||||||
outboundWebhooks: true
|
outboundWebhooks: true
|
||||||
webhookDlq: true # failed deliveries retryable from admin UI
|
webhookDlq: true # failed deliveries retryable from admin UI
|
||||||
auditLog: true
|
auditLog: true
|
||||||
scopedApiKeys: [read-only, license-issuer, support, full-admin]
|
scopedApiKeys: [read-only, license-issuer, support, merchant-onboard, full-admin]
|
||||||
openapiSpec: /v1/openapi.json
|
openapiSpec: /v1/openapi.json
|
||||||
selfLicensingTier: [Creator, Pro, Patron]
|
selfLicensingTier: [Creator, Pro, Patron]
|
||||||
sdks:
|
sdks:
|
||||||
|
|||||||
+21
-38
@@ -56,44 +56,21 @@ See [`src/crypto/mod.rs`](src/crypto/mod.rs) for the exact byte layout.
|
|||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
The daemon source lives under `src/`, organized by subsystem (browse it for the current layout — the tree below has grown well past the v0.1 snapshot):
|
||||||
licensing-service/
|
|
||||||
├── Cargo.toml
|
- `main.rs`, `config.rs`, `error.rs`, `models.rs` — entry point, env-driven config, error → HTTP mapping, shared domain types.
|
||||||
├── LICENSE # source-available; no redistribution
|
- `crypto/` — the LIC1 license-key byte layout and Ed25519 sign/verify (the contract the four SDKs implement).
|
||||||
├── README.md
|
- `db/` — SQLite pool, migrations, and `repo.rs` (all SQL). `migrations/` holds the numbered, additive schema (0001 through the latest; the schema has grown substantially since 0001).
|
||||||
├── .env.example # required env vars
|
- `payment/` (`btcpay/`, `zaprite/`) + `merchant_profiles.rs` — the payment-provider abstraction and multi-profile routing.
|
||||||
├── migrations/
|
- `reconcile.rs`, `subscriptions.rs`, `upgrades.rs` — the background worker (invoice reconciliation, recurring renewals, tier upgrades).
|
||||||
│ └── 0001_initial.sql # SQLite schema
|
- `api/` — the ~30 route modules: public (`products`, `purchase`, `validate`, `redeem`) and admin (`admin*`, scoped API keys, webhooks, etc.), plus the router and `AppState` in `api/mod.rs`.
|
||||||
├── src/
|
- `web/index.html` — the embedded admin SPA.
|
||||||
│ ├── main.rs # entry point: wires everything
|
|
||||||
│ ├── config.rs # env-driven config
|
Deeper docs: [`docs/API.md`](docs/API.md), [`docs/INTEGRATION.md`](docs/INTEGRATION.md), [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||||
│ ├── error.rs # unified error → HTTP mapping
|
|
||||||
│ ├── models.rs # shared domain types
|
|
||||||
│ ├── crypto/
|
|
||||||
│ │ ├── mod.rs # license key format + sign/verify
|
|
||||||
│ │ └── keys.rs # server keypair lifecycle
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── mod.rs # pool + migrations
|
|
||||||
│ │ └── repo.rs # all SQL queries
|
|
||||||
│ ├── btcpay/
|
|
||||||
│ │ ├── client.rs # Greenfield API client
|
|
||||||
│ │ └── webhook.rs # HMAC verification + event parsing
|
|
||||||
│ └── api/
|
|
||||||
│ ├── mod.rs # router + AppState
|
|
||||||
│ ├── products.rs # public product endpoints
|
|
||||||
│ ├── purchase.rs # buy + poll
|
|
||||||
│ ├── validate.rs # the hot path for downstream software
|
|
||||||
│ ├── webhook.rs # BTCPay landing
|
|
||||||
│ └── admin.rs # operator-only actions
|
|
||||||
└── docs/
|
|
||||||
├── API.md # full endpoint reference
|
|
||||||
├── INTEGRATION.md # for developers embedding a client
|
|
||||||
└── ARCHITECTURE.md # deeper design notes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
Prerequisites: Rust 1.75+, a BTCPay Server instance you can point at (local or hosted).
|
Prerequisites: Rust 1.88+ (the build toolchain; the crate's Cargo.toml still declares MSRV 1.75, but the dependency tree now requires a newer compiler), a BTCPay Server instance you can point at (local or hosted).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -109,7 +86,7 @@ On first boot the server generates a fresh Ed25519 keypair and stores it in the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/v1/admin/products \
|
curl -X POST http://localhost:8080/v1/admin/products \
|
||||||
-H "Authorization: Bearer $LICENSING_ADMIN_API_KEY" \
|
-H "Authorization: Bearer $KEYSAT_ADMIN_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"slug": "my-app",
|
"slug": "my-app",
|
||||||
@@ -143,7 +120,7 @@ curl -X POST http://localhost:8080/v1/validate \
|
|||||||
|
|
||||||
## Deploying on Start9
|
## Deploying on Start9
|
||||||
|
|
||||||
This repository ships the service only. To package as an `.s9pk` for the 0.4.0.x platform you'll need a separate wrapper repository following [docs.start9.com/packaging/0.4.0.x](https://docs.start9.com/packaging/0.4.0.x/). The service is designed to slot in cleanly:
|
The StartOS wrapper lives in **this same repository** under `../startos/` (this `licensing-service/` directory is the daemon source it bundles). Build the `.s9pk` for the 0.4.0.x platform from the parent directory — see the build/release guide and `../Makefile`. The service is designed to slot in cleanly:
|
||||||
|
|
||||||
- **Declares a dependency** on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a `.startos` hostname and supply the env vars from the wrapper's action handlers.
|
- **Declares a dependency** on BTCPay Server in the manifest. StartOS will make BTCPay reachable at a `.startos` hostname and supply the env vars from the wrapper's action handlers.
|
||||||
- **Persists to `/data`**, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups.
|
- **Persists to `/data`**, so everything (SQLite DB including the signing key) is covered by one-click encrypted backups.
|
||||||
@@ -170,4 +147,10 @@ Commercial redistribution / resale rights: contact licensing@keysat.xyz.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
v0.1 — minimal working implementation. Feature direction after this is expected to cover: SDK crates for Rust and TypeScript, s9pk wrapper repository, richer admin UI, invoice reconciliation job for dropped webhooks, per-product webhook endpoints for the operator.
|
0.2.0 — shipped and in production. The current feature set:
|
||||||
|
|
||||||
|
- **Four published SDKs** — TypeScript (npm), Rust (crates.io), Python (PyPI), and Go — all wire-compatible against the cross-check fixtures in `tests/crosscheck/`.
|
||||||
|
- **StartOS wrapper included in this repo** under `../startos/`; build the `.s9pk` from the parent directory (no separate wrapper repository).
|
||||||
|
- **Embedded admin SPA** (`web/index.html`) for all day-to-day operations.
|
||||||
|
- **Subscriptions** (recurring auto-renew with trials + grace), **policies / tiers** with per-policy entitlements, **discount / referral / free-license codes**, **outbound webhooks** with a dead-letter queue, and a background **invoice reconciliation** job that recovers dropped payment webhooks.
|
||||||
|
- **Payment providers**: BTCPay Server is required; Zaprite (card / fiat) is optional and gated by the `zaprite_payments` entitlement.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ All endpoints are JSON in / JSON out. Errors return a body of the form:
|
|||||||
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
|
{ "ok": false, "error": "not_found", "message": "product 'xyz'" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Admin endpoints require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
|
Admin endpoints require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Service metadata including the Ed25519 public key. Useful for SDKs to fetch the
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"service": "keysat",
|
"service": "keysat",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"operator": "Acme Software",
|
"operator": "Acme Software",
|
||||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
|
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCow...\n-----END PUBLIC KEY-----\n",
|
||||||
"key_algorithm": "ed25519",
|
"key_algorithm": "ed25519",
|
||||||
@@ -128,7 +128,7 @@ On failure:
|
|||||||
{ "ok": false, "reason": "revoked" }
|
{ "ok": false, "reason": "revoked" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `product_mismatch`, `fingerprint_mismatch`.
|
Possible `reason` values: `bad_format`, `bad_signature`, `not_found`, `revoked`, `suspended`, `expired`, `product_mismatch`, `fingerprint_mismatch`, `too_many_machines` (multi-seat cap reached).
|
||||||
|
|
||||||
### `POST /v1/btcpay/webhook`
|
### `POST /v1/btcpay/webhook`
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ Landing point for BTCPay Server webhook events. Only BTCPay should call this. We
|
|||||||
|
|
||||||
## Admin endpoints
|
## Admin endpoints
|
||||||
|
|
||||||
All of these require `Authorization: Bearer $LICENSING_ADMIN_API_KEY`.
|
All of these require `Authorization: Bearer $KEYSAT_ADMIN_API_KEY`.
|
||||||
|
|
||||||
### `POST /v1/admin/products`
|
### `POST /v1/admin/products`
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,18 @@
|
|||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
See [`migrations/0001_initial.sql`](../migrations/0001_initial.sql). Five tables:
|
The schema lives in [`migrations/`](../migrations/) as numbered, additive
|
||||||
|
migrations (0001 through the latest — it has grown substantially past the
|
||||||
|
original five-table v0.1 schema, adding discount codes, tiered pricing,
|
||||||
|
multi-currency, subscriptions, tier upgrades, per-product entitlement catalogs,
|
||||||
|
scoped API keys, merchant profiles, and more). The core tables established in
|
||||||
|
[`0001_initial.sql`](../migrations/0001_initial.sql):
|
||||||
|
|
||||||
- `products` — what's for sale. Independent pricing per product.
|
- `products` — what's for sale. Independent pricing per product.
|
||||||
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
|
- `invoices` — one per purchase attempt, keyed by BTCPay's invoice id.
|
||||||
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns.
|
- `licenses` — one per successful payment (or manual issuance). Has optional `fingerprint` (machine bind) and `bound_identity` (user bind) columns. Later migrations add `expires_at`, entitlements, trial flag, and tier columns.
|
||||||
- `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us.
|
- `validation_log` — append-only audit log of every validate call. Useful for detecting abuse (same key, many fingerprints) and for rate-limiting layers above us.
|
||||||
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot, never rotated in v0.1 (rotation is a planned feature).
|
- `server_keys` — singleton table holding the server's Ed25519 keypair. Generated on first boot.
|
||||||
|
|
||||||
## License key format
|
## License key format
|
||||||
|
|
||||||
@@ -28,7 +33,7 @@ LIC1 - <base32(74-byte payload)> - <base32(64-byte signature)>
|
|||||||
|
|
||||||
The payload is a fixed binary layout, not JSON, to keep keys short. Details in [`src/crypto/mod.rs`](../src/crypto/mod.rs).
|
The payload is a fixed binary layout, not JSON, to keep keys short. Details in [`src/crypto/mod.rs`](../src/crypto/mod.rs).
|
||||||
|
|
||||||
Why base32 Crockford-style (no padding)?
|
Why base32 (RFC 4648, no padding)?
|
||||||
|
|
||||||
- Uppercase only, unambiguous chars, easy to read aloud or type from a screen.
|
- Uppercase only, unambiguous chars, easy to read aloud or type from a screen.
|
||||||
- Slightly longer than base64 but less error-prone for humans copying keys.
|
- Slightly longer than base64 but less error-prone for humans copying keys.
|
||||||
@@ -58,14 +63,12 @@ Who might attack this?
|
|||||||
|
|
||||||
5. **Chargeback / dispute** (applicable to non-Bitcoin rails, but worth noting). Bitcoin payments are irreversible, so the normal fraud model that motivates software DRM mostly doesn't apply here. Most revocations will be: key leaked publicly, legitimate business decision, mistaken issuance.
|
5. **Chargeback / dispute** (applicable to non-Bitcoin rails, but worth noting). Bitcoin payments are irreversible, so the normal fraud model that motivates software DRM mostly doesn't apply here. Most revocations will be: key leaked publicly, legitimate business decision, mistaken issuance.
|
||||||
|
|
||||||
## What's deliberately NOT in v0.1
|
## Deliberately out of scope
|
||||||
|
|
||||||
- **Key rotation.** A single static signing key is fine for first launch. Rotation requires SDK multi-key support and a migration strategy; deferred.
|
- **Key rotation.** A single static signing key is fine for now. Rotation requires SDK multi-key support and a migration strategy; deferred.
|
||||||
- **Trial periods / demos.** This is a pure paid-license server. Trials are the developer's responsibility in-app.
|
|
||||||
- **Payment currencies other than BTC.** BTCPay supports Lightning, altcoins, and fiat; we only send BTC-denominated invoices. Adding Lightning is straightforward (BTCPay handles it transparently if the store has LN configured).
|
|
||||||
- **Subscription / time-limited licenses.** The payload has an `issued_at` field but no `expires_at`. Adding expiry is a later schema + payload change.
|
|
||||||
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
|
- **Multi-tenant / SaaS mode.** This is a *single-operator* server by design. Running multiple logical operators on one instance is a different product.
|
||||||
- **Admin UI.** Everything is API-driven. Wrap it in whatever UI you like — or just use `curl`.
|
|
||||||
|
(Trial/time-limited policies, multi-currency pricing, the optional Zaprite card rail, and the embedded admin UI all shipped after v0.1 and are no longer on this list.)
|
||||||
|
|
||||||
## Notes on Start9 dependencies
|
## Notes on Start9 dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,18 @@ curl -s https://license.example.com/v1/pubkey | jq -r .public_key_pem
|
|||||||
|
|
||||||
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
|
Commit the resulting PEM into your client source tree. **Do not fetch it dynamically at runtime** — that would let an attacker who compromises your licensing server swap the key and re-sign forged licenses retroactively. A pinned public key is the whole point.
|
||||||
|
|
||||||
|
> **Official SDKs exist — use them first.** Four wire-compatible client SDKs
|
||||||
|
> are published: TypeScript (`@keysat/licensing-client` on npm), Rust
|
||||||
|
> (`keysat-licensing-client` on crates.io), Python (`keysat-licensing-client`
|
||||||
|
> on PyPI), and Go (`github.com/keysat-xyz/keysat-client-go`). Install commands
|
||||||
|
> are in the main README. The by-hand reference implementations below are a
|
||||||
|
> fallback for languages without an SDK, or for understanding exactly what the
|
||||||
|
> SDKs do under the hood.
|
||||||
|
|
||||||
## Reference integration in Rust
|
## Reference integration in Rust
|
||||||
|
|
||||||
This is what a Start9 package written in Rust might look like. No SDK crate yet — that's planned; here's what you'd write by hand:
|
This is what a Start9 package written in Rust might look like if you verify by
|
||||||
|
hand instead of using the Rust SDK:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
/// A merchant profile row. Mirrors the `merchant_profiles` table.
|
/// A merchant profile row. Mirrors the `merchant_profiles` table.
|
||||||
///
|
///
|
||||||
/// SMTP fields are flattened onto the same struct for simplicity; they
|
/// NOTE: the `smtp_*` fields are DORMANT and not consumed by anything.
|
||||||
/// land in the same table on the same row. NULL on all six means
|
/// They were laid down in migration 0020 ahead of the keysat-smtp-emails
|
||||||
/// "inherit StartOS-level SMTP config." See the keysat-smtp-emails
|
/// plan, which was SUPERSEDED 2026-06-18: Keysat will never send buyer
|
||||||
/// plan for how they're consumed.
|
/// email itself (operators own that via their own app + the existing
|
||||||
|
/// webhooks). The columns are left in place because a removal migration
|
||||||
|
/// isn't worth it — do not build a send path against them. See
|
||||||
|
/// `plans/keysat-smtp-emails.md` (superseded banner) and the
|
||||||
|
/// "Operability & alerts" ROADMAP item.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MerchantProfile {
|
pub struct MerchantProfile {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -42,7 +46,8 @@ pub struct MerchantProfile {
|
|||||||
pub post_purchase_redirect_url: Option<String>,
|
pub post_purchase_redirect_url: Option<String>,
|
||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
|
|
||||||
// SMTP override (all-or-nothing for now; the SMTP plan refines this).
|
// Dormant SMTP-override columns (see struct doc) — stored/returned
|
||||||
|
// but never read to send mail; no send path exists or is planned.
|
||||||
pub smtp_host: Option<String>,
|
pub smtp_host: Option<String>,
|
||||||
pub smtp_port: Option<i64>,
|
pub smtp_port: Option<i64>,
|
||||||
pub smtp_username: Option<String>,
|
pub smtp_username: Option<String>,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ use crate::payment::CreateInvoiceParams;
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
use std::time::Duration as StdDuration;
|
use std::time::Duration as StdDuration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -1349,13 +1349,18 @@ pub async fn capture_zaprite_payment_profile(
|
|||||||
/// worker *after* it has created the order; this turns the order
|
/// worker *after* it has created the order; this turns the order
|
||||||
/// from "buyer must pay" into "auto-charged, will settle via the
|
/// from "buyer must pay" into "auto-charged, will settle via the
|
||||||
/// usual webhook." Returns:
|
/// usual webhook." Returns:
|
||||||
/// - `Ok(true)` — the charge call succeeded; the buyer is not
|
/// - `Ok(true)` — the charge settled (order status PAID/COMPLETE/
|
||||||
/// expected to pay manually. The settle webhook
|
/// OVERPAID); the buyer is not expected to pay
|
||||||
/// will fire on its own and flip the sub to
|
/// manually. The settle webhook will fire on its
|
||||||
/// `active` via `on_invoice_settled`.
|
/// own and flip the sub to `active` via
|
||||||
/// - `Ok(false)` — sub has no saved profile, or active provider
|
/// `on_invoice_settled`.
|
||||||
/// isn't Zaprite. Caller proceeds with manual-pay
|
/// - `Ok(false)` — sub has no saved profile, active provider isn't
|
||||||
/// fallback (`subscription.renewal_pending`).
|
/// Zaprite, OR Zaprite accepted the request (HTTP
|
||||||
|
/// 2xx) but the order did NOT reach a settled status
|
||||||
|
/// (declined/expired/in-flight/unknown). In every
|
||||||
|
/// `Ok(false)` case the caller proceeds with the
|
||||||
|
/// manual-pay fallback (`subscription.renewal_pending`)
|
||||||
|
/// so the buyer keeps a path to recover the cycle.
|
||||||
/// - `Err(_)` — Zaprite returned an error (declined card,
|
/// - `Err(_)` — Zaprite returned an error (declined card,
|
||||||
/// expired profile, network blip). Caller treats
|
/// expired profile, network blip). Caller treats
|
||||||
/// this as a soft failure: log, audit, and ALSO
|
/// this as a soft failure: log, audit, and ALSO
|
||||||
@@ -1400,12 +1405,86 @@ async fn try_auto_charge_zaprite(
|
|||||||
.await
|
.await
|
||||||
.context("Zaprite charge_order_with_profile")?;
|
.context("Zaprite charge_order_with_profile")?;
|
||||||
|
|
||||||
|
// A 2xx from `/v1/orders/charge` only means Zaprite ACCEPTED the
|
||||||
|
// request — the order's `status` says whether the money actually
|
||||||
|
// moved. A charge that came back declined/expired/in-flight (or any
|
||||||
|
// status we don't positively recognize as settled) leaves no settle
|
||||||
|
// webhook to wait for, so returning Ok(true) here would silently
|
||||||
|
// lapse the sub: we'd suppress the manual-pay notification and wait
|
||||||
|
// forever for an `order.paid` that never arrives. Fail safe — only
|
||||||
|
// suppress manual-pay when the order is in a recognized settled
|
||||||
|
// state; otherwise fall through (Ok(false)) so the buyer still gets
|
||||||
|
// a pay link and can recover the cycle.
|
||||||
|
let order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
if !zaprite_charge_settled(&resp) {
|
||||||
|
tracing::warn!(
|
||||||
|
sub_id = %sub.id,
|
||||||
|
order_id = %provider_invoice_id,
|
||||||
|
profile_id = %profile_id,
|
||||||
|
order_status,
|
||||||
|
"Zaprite auto-charge accepted (HTTP 2xx) but order is not settled; \
|
||||||
|
falling back to manual-pay renewal"
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
sub_id = %sub.id,
|
sub_id = %sub.id,
|
||||||
order_id = %provider_invoice_id,
|
order_id = %provider_invoice_id,
|
||||||
profile_id = %profile_id,
|
profile_id = %profile_id,
|
||||||
order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
|
order_status,
|
||||||
"Zaprite auto-charge succeeded; awaiting settle webhook"
|
"Zaprite auto-charge settled; awaiting settle webhook"
|
||||||
);
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does a Zaprite `/v1/orders/charge` response (HTTP 2xx already
|
||||||
|
/// confirmed by the client) indicate the charge actually settled?
|
||||||
|
///
|
||||||
|
/// Mirrors the PAID/COMPLETE/OVERPAID → `Settled` mapping in
|
||||||
|
/// `ZapriteProvider::get_invoice_status`. Deliberately an **allowlist**,
|
||||||
|
/// not a failure blocklist: Zaprite's confirmed order-status enum is
|
||||||
|
/// PENDING|PROCESSING|PAID|COMPLETE|OVERPAID|UNDERPAID with no documented
|
||||||
|
/// terminal-failure string, so any unrecognized or missing status must be
|
||||||
|
/// treated as "not settled" and routed to manual-pay rather than
|
||||||
|
/// optimistically assumed paid.
|
||||||
|
fn zaprite_charge_settled(resp: &Value) -> bool {
|
||||||
|
matches!(
|
||||||
|
resp.get("status").and_then(|v| v.as_str()),
|
||||||
|
Some("PAID") | Some("COMPLETE") | Some("OVERPAID")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::zaprite_charge_settled;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charge_settled_only_for_recognized_paid_statuses() {
|
||||||
|
// Settled states → suppress manual-pay (Ok(true) upstream).
|
||||||
|
for s in ["PAID", "COMPLETE", "OVERPAID"] {
|
||||||
|
assert!(
|
||||||
|
zaprite_charge_settled(&json!({ "status": s })),
|
||||||
|
"{s} should count as settled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The silent-lapse guard: a 2xx carrying any non-settled status
|
||||||
|
// must NOT be treated as success. In-flight, underpaid,
|
||||||
|
// terminal-failure, and unknown statuses all fall through to the
|
||||||
|
// manual-pay path.
|
||||||
|
for s in [
|
||||||
|
"PENDING", "PROCESSING", "UNDERPAID", "FAILED", "DECLINED", "EXPIRED",
|
||||||
|
"CANCELED", "REFUNDED", "",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!zaprite_charge_settled(&json!({ "status": s })),
|
||||||
|
"{s} must NOT count as settled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Malformed / absent / non-string status fields fall through too.
|
||||||
|
assert!(!zaprite_charge_settled(&json!({})));
|
||||||
|
assert!(!zaprite_charge_settled(&json!({ "status": null })));
|
||||||
|
assert!(!zaprite_charge_settled(&json!({ "status": 200 })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -415,16 +415,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
border:1px solid var(--border-1);
|
border:1px solid var(--border-1);
|
||||||
}
|
}
|
||||||
.featured-pill-toggle.on {
|
.featured-pill-toggle.on {
|
||||||
background:var(--gold-500); color:var(--navy-950);
|
background:var(--navy-800); color:var(--cream-50);
|
||||||
border-color:var(--gold-500);
|
border-color:var(--navy-800);
|
||||||
box-shadow:0 2px 6px rgba(191,160,104,0.25);
|
box-shadow:0 2px 6px rgba(14,31,51,0.18);
|
||||||
}
|
}
|
||||||
.featured-pill-toggle.on .state {
|
.featured-pill-toggle.on .state {
|
||||||
background:var(--navy-950); color:var(--gold-500);
|
background:var(--cream-50); color:var(--navy-900);
|
||||||
border-color:var(--navy-950);
|
border-color:var(--cream-50);
|
||||||
}
|
}
|
||||||
.featured-pill-toggle.on:hover {
|
.featured-pill-toggle.on:hover {
|
||||||
background:var(--gold-400);
|
background:var(--navy-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tier-card drag affordance — cursor signals draggability on hover,
|
/* Tier-card drag affordance — cursor signals draggability on hover,
|
||||||
@@ -534,12 +534,12 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
<div id="tier-banner-msg" style="margin-bottom:8px;"></div>
|
<div id="tier-banner-msg" style="margin-bottom:8px;"></div>
|
||||||
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
|
<a id="tier-banner-cta" target="_blank" rel="noopener" style="
|
||||||
display:inline-block; padding:5px 10px;
|
display:inline-block; padding:5px 10px;
|
||||||
background:var(--gold-500); color:var(--navy-950);
|
background:var(--cream-50); color:var(--navy-900);
|
||||||
font-weight:700; font-size:11px;
|
font-weight:700; font-size:11px;
|
||||||
border-radius:5px; text-decoration:none;
|
border-radius:8px; text-decoration:none;
|
||||||
transition:background 120ms;
|
transition:background 120ms;
|
||||||
" onmouseover="this.style.background='var(--gold-400)'"
|
" onmouseover="this.style.background='var(--cream-200)'"
|
||||||
onmouseout="this.style.background='var(--gold-500)'"></a>
|
onmouseout="this.style.background='var(--cream-50)'"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer" id="sidebar-footer">
|
<div class="footer" id="sidebar-footer">
|
||||||
<span class="dot warn"></span>
|
<span class="dot warn"></span>
|
||||||
@@ -5763,7 +5763,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
// -------- Merchant profiles (multi-provider model, :52+) --------
|
// -------- Merchant profiles (multi-provider model, :52+) --------
|
||||||
// Each profile represents one "business" the operator is running on
|
// Each profile represents one "business" the operator is running on
|
||||||
// this Keysat instance. Owns business identity (brand, support contact,
|
// this Keysat instance. Owns business identity (brand, support contact,
|
||||||
// post-purchase redirect, optional SMTP override) and a set of payment
|
// post-purchase redirect; SMTP-override cols are dormant/unused) and a set of payment
|
||||||
// providers (BTCPay / Zaprite) that legally settle to that business.
|
// providers (BTCPay / Zaprite) that legally settle to that business.
|
||||||
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
|
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
|
||||||
// get unlimited.
|
// get unlimited.
|
||||||
|
|||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# prepare.sh — bootstrap a clean Debian/Ubuntu box to build the Keysat s9pk.
|
||||||
|
#
|
||||||
|
# Start9's build-from-source flow clones this repo onto a fresh box, then runs a
|
||||||
|
# bootstrap script followed by `make`. This installs every HOST prerequisite that
|
||||||
|
# `make` needs (npm → wrapper bundle; start-cli s9pk pack → Docker image build).
|
||||||
|
# It mirrors the official StartOS 0.4.0.x environment-setup page:
|
||||||
|
# https://docs.start9.com/packaging/0.4.0.x/environment-setup.html
|
||||||
|
#
|
||||||
|
# Note: `prepare.sh` is a 0.3.5.x community-submission convention; the 0.4.x docs
|
||||||
|
# don't mention it, so the 0.4.x submission flow may not invoke it. This file is
|
||||||
|
# still the runnable, single-source record of what a clean build box needs.
|
||||||
|
#
|
||||||
|
# The Rust daemon is NOT built on the host — it compiles inside this package's
|
||||||
|
# Dockerfile (FROM rust:1.88-slim-bookworm), so no rustup/cargo is installed here.
|
||||||
|
#
|
||||||
|
# Idempotent: re-running skips tools already present. Targets apt-based distros.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Use sudo only when not already root (Start9's build box may run either way).
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_MAJOR=22
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
# --- apt prerequisites -------------------------------------------------------
|
||||||
|
# build-essential → make/gcc; squashfs-tools(-ng) → start-cli s9pk packing;
|
||||||
|
# jq → used by s9pk.mk's build summary; git → the s9pk embeds the commit hash.
|
||||||
|
log "Installing apt prerequisites (make, jq, git, squashfs, curl)"
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
squashfs-tools \
|
||||||
|
squashfs-tools-ng
|
||||||
|
|
||||||
|
# --- Node.js 22 --------------------------------------------------------------
|
||||||
|
# The wrapper (@start9labs/start-sdk + @vercel/ncc bundle) needs Node 22. We
|
||||||
|
# install it system-wide via NodeSource so it's on PATH for the non-interactive
|
||||||
|
# `make` that follows (the docs' nvm method would need a shell rc sourced first).
|
||||||
|
if command -v node >/dev/null 2>&1 && node -v | grep -q "^v${NODE_MAJOR}\."; then
|
||||||
|
log "Node.js $(node -v) already present — skipping"
|
||||||
|
else
|
||||||
|
log "Installing Node.js ${NODE_MAJOR} (NodeSource)"
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | $SUDO -E bash -
|
||||||
|
$SUDO apt-get install -y nodejs
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Docker (+ buildx) -------------------------------------------------------
|
||||||
|
# start-cli s9pk pack builds the daemon image from the Dockerfile via Docker
|
||||||
|
# buildx. get.docker.com is Docker's official installer and bundles the buildx
|
||||||
|
# plugin.
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
log "Docker $(docker --version | awk '{print $3}' | tr -d ,) already present — skipping"
|
||||||
|
else
|
||||||
|
log "Installing Docker (official get.docker.com installer)"
|
||||||
|
curl -fsSL https://get.docker.com | $SUDO sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cross-architecture builds (`make universal` / `make arm` on an x86 host) need
|
||||||
|
# QEMU binfmt handlers registered. Best-effort: requires the Docker daemon to be
|
||||||
|
# running. Harmless to skip if you only build the host's native arch (`make x86`).
|
||||||
|
if $SUDO docker info >/dev/null 2>&1; then
|
||||||
|
log "Registering QEMU binfmt handlers for cross-arch builds (best-effort)"
|
||||||
|
$SUDO docker run --privileged --rm tonistiigi/binfmt --install all ||
|
||||||
|
echo " (binfmt registration skipped — only native-arch builds will work)"
|
||||||
|
else
|
||||||
|
echo " (Docker daemon not reachable yet — skipping binfmt setup; start Docker"
|
||||||
|
echo " and re-run this script if you need cross-arch/universal builds.)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- start-cli (StartOS 0.4.x SDK) -------------------------------------------
|
||||||
|
# Official installer: fetches the latest prebuilt binary into ~/.local/bin.
|
||||||
|
# For a reproducible build, pin a release instead, e.g.:
|
||||||
|
# curl -fsSLo ~/.local/bin/start-cli \
|
||||||
|
# https://github.com/Start9Labs/start-os/releases/download/<tag>/start-cli_x86_64-linux
|
||||||
|
# chmod +x ~/.local/bin/start-cli
|
||||||
|
if command -v start-cli >/dev/null 2>&1; then
|
||||||
|
log "start-cli $(start-cli --version 2>/dev/null | awk '{print $2}') already present — skipping"
|
||||||
|
else
|
||||||
|
log "Installing start-cli (StartOS 0.4.x SDK)"
|
||||||
|
curl -fsSL https://start9.com/start-cli/install.sh | sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The installer drops start-cli in ~/.local/bin and appends it to your shell rc.
|
||||||
|
# Persist it to .profile for future shells (only if not already recorded, so
|
||||||
|
# re-runs don't pile up duplicates), and export it for the rest of THIS session.
|
||||||
|
if ! grep -qsF '.local/bin' "${HOME}/.profile"; then
|
||||||
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >>"${HOME}/.profile"
|
||||||
|
fi
|
||||||
|
export PATH="${HOME}/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
log "Done. Initialise your signing key with 'start-cli init', then run 'make' (or 'make x86')."
|
||||||
@@ -39,6 +39,10 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:60 — **Fix a Zaprite auto-charge silent-lapse on the recurring-renewal money path.** `charge_order_with_profile` already errors on a non-2xx response (those route correctly to WARN + `auto_charge_failed` audit + manual-pay fallback), but on a 2xx `try_auto_charge_zaprite` returned `Ok(true)` regardless of the order\'s actual status — it read `status` only for a log line. So a 200 carrying a non-settled status (a declined or expired charge, or an in-flight PENDING/PROCESSING) suppressed the manual-pay renewal notification and left the worker waiting for an `order.paid` webhook that never arrives: the subscription silently lapsed, the buyer got no pay link, and the operator saw no error. Fixed by classifying the charge response — the auto-charge is treated as successful (and manual-pay suppressed) only when the order is in a recognized settled state (`PAID`/`COMPLETE`/`OVERPAID`, mirroring `get_invoice_status`\'s `Settled` mapping); any other or unrecognized status logs a WARN and falls through to the existing manual-pay `subscription.renewal_pending` path so the buyer can always recover the cycle. Allowlist by design — Zaprite has no documented terminal-failure status string, so an unknown or missing status is treated as not-settled rather than optimistically assumed paid. Adds a unit test on the new `zaprite_charge_settled` helper covering settled / in-flight / failed / unknown statuses. BTCPay subscriptions and any sub without a saved Zaprite profile are unaffected. Also docs-only: flagged the dormant `merchant_profiles.smtp_*` columns (the buyer-email / SMTP plan was dropped — Keysat does not send email). No schema change, no SDK change — straight drop-in over :59.',
|
||||||
|
'',
|
||||||
|
'0.2.0:59 — **Admin UI: drop the gold button-fill design-contract violations.** Two admin-SPA controls filled with gold, which the brand contract (`design/DESIGN.md`) and the admin-UI pill convention forbid (gold is a marketing accent, never a button fill): the "Featured" tier toggle\'s on-state and the sidebar tier-upgrade CTA. Both now follow the convention — the Featured toggle is navy-filled with a cream pip when on; the upgrade CTA is cream-filled with navy text (the on-brand high-contrast treatment for a primary action on the navy sidebar), and its corner radius is aligned to the 8px button spec. CSS / inline-style only in the embedded `web/index.html` — no schema, no SDK, no behavior change. Straight drop-in over :58. (The matching public-landing fix — the Buy button\'s pill radius set to 8px — ships in the keysat-xyz-landing repo, deployed separately.)',
|
||||||
|
'',
|
||||||
'0.2.0:58 — **Agent-delegable BTCPay connect, gated to sandbox + non-mainnet.** Makes Keysat fully agent-operable for *dev/test setup*: an operator can hand an agent a scoped key that connects a BTCPay payment provider over the API — no master key, no browser click — but only on a sandbox daemon and only for a non-mainnet (regtest/testnet/signet) store. On a production daemon, or for a mainnet store, connecting a provider stays master-only, and disconnect is always master-only. The reasoning: a credential that can repoint where settlement lands is a fund-redirection key, so the capability is deliberately narrow and fails closed. **Gated in three layers:** (1) a daemon-level `KEYSAT_SANDBOX_MODE` flag, read at boot and never settable via any API, is the outer gate — scoped connect is disabled entirely on a production box; (2) `payment_providers:write` is an à-la-carte per-key scope that belongs to no role (not even full-admin), granted explicitly when an operator mints a key; (3) at OAuth-callback time the daemon resolves the target store\'s Bitcoin network from its on-chain receive address and refuses anything not provably non-mainnet, failing closed to mainnet on any ambiguity (no on-chain wallet, unreachable BTCPay, unrecognized address) — it denies rather than guesses. Migrations 0024 (`scoped_api_keys.extra_scopes`) and 0025 (`btcpay_authorize_state.scoped_initiator` + actor hash, to carry the initiator across the browser round-trip) are additive — straight drop-in over :57. The served OpenAPI spec now documents the BTCPay connect/callback/status/disconnect paths and the key-creation `scopes` field, and `/v1/admin/tier` surfaces a read-only `sandbox` flag. Also hardened: the GET authorize-callback now returns the real HTTP status on a denied connect (was a misleading 200 with an error page). Validated end-to-end against a live regtest BTCPay; the docs-onboarding harness (a fresh agent integrating from the published docs alone) converged completed-clean on the full buyer-pays journey. Daemon api test suite is at 65, up from 57. Zaprite connect stays master-only. No SDK change.',
|
'0.2.0:58 — **Agent-delegable BTCPay connect, gated to sandbox + non-mainnet.** Makes Keysat fully agent-operable for *dev/test setup*: an operator can hand an agent a scoped key that connects a BTCPay payment provider over the API — no master key, no browser click — but only on a sandbox daemon and only for a non-mainnet (regtest/testnet/signet) store. On a production daemon, or for a mainnet store, connecting a provider stays master-only, and disconnect is always master-only. The reasoning: a credential that can repoint where settlement lands is a fund-redirection key, so the capability is deliberately narrow and fails closed. **Gated in three layers:** (1) a daemon-level `KEYSAT_SANDBOX_MODE` flag, read at boot and never settable via any API, is the outer gate — scoped connect is disabled entirely on a production box; (2) `payment_providers:write` is an à-la-carte per-key scope that belongs to no role (not even full-admin), granted explicitly when an operator mints a key; (3) at OAuth-callback time the daemon resolves the target store\'s Bitcoin network from its on-chain receive address and refuses anything not provably non-mainnet, failing closed to mainnet on any ambiguity (no on-chain wallet, unreachable BTCPay, unrecognized address) — it denies rather than guesses. Migrations 0024 (`scoped_api_keys.extra_scopes`) and 0025 (`btcpay_authorize_state.scoped_initiator` + actor hash, to carry the initiator across the browser round-trip) are additive — straight drop-in over :57. The served OpenAPI spec now documents the BTCPay connect/callback/status/disconnect paths and the key-creation `scopes` field, and `/v1/admin/tier` surfaces a read-only `sandbox` flag. Also hardened: the GET authorize-callback now returns the real HTTP status on a denied connect (was a misleading 200 with an error page). Validated end-to-end against a live regtest BTCPay; the docs-onboarding harness (a fresh agent integrating from the published docs alone) converged completed-clean on the full buyer-pays journey. Daemon api test suite is at 65, up from 57. Zaprite connect stays master-only. No SDK change.',
|
||||||
'',
|
'',
|
||||||
'0.2.0:57 —**New `merchant-onboard` scoped-API-key role for least-privilege self-serve onboarding.** A fifth scoped-key role sits between `license-issuer` and `full-admin`, granting read access plus `products:write` + `policies:write` + `licenses:write` — the minimum a merchant (or an integrating agent) needs to stand up a catalog end-to-end over the API: create a product, define its policies/tiers, and issue licenses against them, all without holding the master key. The catalog write *scopes* already existed and were enforced on the endpoints since :55; only a role that expands to them was missing, so this is a `Role`-variant addition, not a scope-model change. `Role::grants` matches the write scopes explicitly (never by `:write` suffix), so the role can never widen into settings / payment-provider / merchant-profile / webhook writes, and every master-only operation (signing-key rotation, payment connect, web-admin password, API-key management, server settings, per-license tier change, DB introspection) stays behind `require_admin` and is structurally unreachable from any scoped key. Existing Creator-tier caps still bound it (5 products / 5 policies per product / 10 active codes). **Caveat:** the role covers catalog setup + manual license issuance fully, but connecting a BTCPay/Zaprite payment provider stays master-only by design, so the buyer-paid purchase flow still needs a one-time operator step. Migration 0023 rebuilds `scoped_api_keys` to widen the role CHECK constraint (SQLite can\'t alter a CHECK in place; the table has no foreign keys, so it\'s a plain copy/drop/rename) — additive, a straight drop-in over :56. Daemon api test suite is at 57, up from 56. No SDK change.',
|
'0.2.0:57 —**New `merchant-onboard` scoped-API-key role for least-privilege self-serve onboarding.** A fifth scoped-key role sits between `license-issuer` and `full-admin`, granting read access plus `products:write` + `policies:write` + `licenses:write` — the minimum a merchant (or an integrating agent) needs to stand up a catalog end-to-end over the API: create a product, define its policies/tiers, and issue licenses against them, all without holding the master key. The catalog write *scopes* already existed and were enforced on the endpoints since :55; only a role that expands to them was missing, so this is a `Role`-variant addition, not a scope-model change. `Role::grants` matches the write scopes explicitly (never by `:write` suffix), so the role can never widen into settings / payment-provider / merchant-profile / webhook writes, and every master-only operation (signing-key rotation, payment connect, web-admin password, API-key management, server settings, per-license tier change, DB introspection) stays behind `require_admin` and is structurally unreachable from any scoped key. Existing Creator-tier caps still bound it (5 products / 5 policies per product / 10 active codes). **Caveat:** the role covers catalog setup + manual license issuance fully, but connecting a BTCPay/Zaprite payment provider stays master-only by design, so the buyer-paid purchase flow still needs a one-time operator step. Migration 0023 rebuilds `scoped_api_keys` to widen the role CHECK constraint (SQLite can\'t alter a CHECK in place; the table has no foreign keys, so it\'s a plain copy/drop/rename) — additive, a straight drop-in over :56. Daemon api test suite is at 57, up from 56. No SDK change.',
|
||||||
@@ -532,7 +536,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:58',
|
version: '0.2.0:60',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user