Compare commits

..

7 Commits

Author SHA1 Message Date
Grant b68bb4b882 Add prepare.sh build bootstrap for clean Debian box
Start9's build-from-source flow needs every host prerequisite installed before
'make' runs. Add a bootstrap mirroring the official 0.4.0.x environment-setup
page: apt prereqs, Node 22, Docker (+binfmt for cross-arch), and start-cli via
the official installer. Rust stays inside the package Dockerfile, not the host.
2026-06-18 14:46:22 -05:00
Grant c739d5c515 Bump to 0.2.0:60: ship Zaprite auto-charge silent-lapse fix
Version bump + changelog for the recurring-renewal money-path fix (treat a non-settled Zaprite charge response as not-success and fall through to manual-pay).
2026-06-18 12:30:43 -05:00
Grant 46972be9db Note merchant_profiles SMTP-override fields are dormant in admin SPA comment 2026-06-18 12:00:12 -05:00
Grant 0a6d73aa29 Mark merchant_profiles SMTP columns dormant; email plan dropped
The keysat-smtp-emails plan is superseded: Keysat will not send buyer email itself (operators own that via their app plus the existing webhooks). The smtp_* columns from migration 0020 are never read to send mail; left in place (a removal migration isn't worth it) and flagged so no send path is built against them.
2026-06-18 11:22:28 -05:00
Grant 241478af95 Fix Zaprite auto-charge silent-lapse on 2xx-with-failure status
charge_order_with_profile errors on non-2xx, but on a 2xx try_auto_charge_zaprite returned Ok(true) regardless of the order status, reading it only for a log line. A 200 carrying a non-settled status (declined/expired/in-flight) suppressed the manual-pay notification and left the worker waiting for an order.paid webhook that never arrives, so the subscription silently lapsed.

Classify the response: success iff status is PAID/COMPLETE/OVERPAID (mirrors get_invoice_status's Settled mapping); anything else logs a WARN and returns Ok(false) so renew_one falls through to manual-pay. Allowlist by design -- Zaprite has no documented terminal-failure string, so unknown/missing statuses route to manual-pay too. Adds a unit test on the new zaprite_charge_settled helper.
2026-06-18 11:22:28 -05:00
Grant 51a88f2a2f Fix admin SPA gold-fill design-contract violations; bump to 0.2.0:59
The featured-pill on-state and the sidebar upgrade CTA filled with gold, which
the brand contract and the admin-UI pill convention forbid (gold is a marketing
accent, never a button fill). The Featured toggle is now navy-filled with a
cream pip; the upgrade CTA is cream-filled with navy text and aligned to the
8px button radius. CSS / inline-style only in the embedded web/index.html — no
schema, no SDK, no behavior change.
2026-06-18 08:02:49 -05:00
Grant 554f3b2da0 Sweep residual v0.1 staleness in API/ARCHITECTURE/README docs 2026-06-17 15:41:17 -05:00
8 changed files with 223 additions and 35 deletions
+1 -1
View File
@@ -86,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",
+1 -1
View File
@@ -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",
+5 -6
View File
@@ -33,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.
@@ -63,13 +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).
- **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
+10 -5
View File
@@ -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>,
+89 -10
View File
@@ -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 })));
}
}
+11 -11
View File
@@ -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
View File
@@ -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')."
+5 -1
View File
@@ -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