v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation
This release adds Zaprite as an alternative to BTCPay. Operators can now choose between two payment rails: - BTCPay: Bitcoin-only, you run the BTCPay Server yourself - Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered by Zaprite, settles to your connected wallets Only one is active at a time per Keysat instance. Switching requires Disconnect → Connect; existing license keys are unaffected. Future v0.3 work routes per-policy choice (e.g., "free tier via Zaprite, paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's either-or. What's in this release: **Migration 0011 — recurring subscriptions schema (dormant).** Adds `subscriptions` and `subscription_invoices` tables, plus `is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/ `trial_days` (default 0) on policies. No daemon code uses these yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in follow-up commits. Migration regression test covers the additive contract against populated data. **Migration 0012 — zaprite_config.** Singleton-row table for the operator's Zaprite API key + base URL + recorded webhook id. Mirrors btcpay_config from migration 0002. **ZapriteProvider implementation.** New module at src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs (DB persistence), provider.rs (PaymentProvider trait impl). Maps Zaprite's currency enum (BTC/USD/EUR) to/from the Money type; maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/ OVERPAID/UNDERPAID) to ProviderInvoiceStatus. **Webhook security via externalUniqId round-trip.** Zaprite does NOT publish a webhook signature scheme (verified May 2026 against public OpenAPI + dashboard). Their docs explicitly designate receiver-side idempotency as the security model. Keysat's defense: attach our local invoice UUID as externalUniqId at order creation, then trust the webhook only insofar as the order id resolves to a local invoice in an expected state. Documented in detail in the payment::zaprite module-level comment + the validate_webhook docstring. **Admin endpoints.** - POST /v1/admin/zaprite/connect: validates the API key by pinging GET /v1/orders before persisting; swaps active provider atomically - POST /v1/admin/zaprite/disconnect: clears stored creds + provider - GET /v1/admin/zaprite/status: read-only connection snapshot - POST /v1/zaprite/webhook: webhook landing route (alias of the existing /v1/btcpay/webhook handler since validate_webhook is trait-level) **StartOS Actions** under a new "Zaprite" group: Connect Zaprite, Check Zaprite connection, Disconnect Zaprite. Operator pastes the API key into a masked input; daemon validates + saves. **Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing covers the full event-type mapping + missing-id rejection + malformed-JSON rejection; zaprite_provider_kind pins the identification). Migration regression test for 0011. Test count grows 39 → 41. Operators on BTCPay see no change. Operators wanting Zaprite go through the StartOS Actions tab → Connect Zaprite, paste their API key, register a webhook in Zaprite's dashboard pointing at their public Keysat URL + /v1/zaprite/webhook. Recurring subscriptions are NOT yet operator-visible — schema only in this release. Daemon-code that uses the subscriptions tables (renewal worker, validate-hot-path subscription branch, admin UI) lands in subsequent commits per the design doc's phased plan.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// Action: connect / disconnect / status for Zaprite as the active
|
||||
// payment provider.
|
||||
//
|
||||
// Unlike BTCPay's authorize flow (OAuth-style consent redirect),
|
||||
// Zaprite doesn't expose a programmatic authorize endpoint. The
|
||||
// operator creates an API key in their Zaprite dashboard at
|
||||
// app.zaprite.com/.../settings/api, then pastes it into the form
|
||||
// here. The daemon validates the key by pinging Zaprite's API,
|
||||
// then persists + swaps the active provider atomically.
|
||||
//
|
||||
// Webhook setup is operator-side: after connecting, the operator
|
||||
// adds a webhook in Zaprite's dashboard pointing at
|
||||
// <their-keysat-public-url>/v1/zaprite/webhook. There's no
|
||||
// signing secret — see the daemon's payment::zaprite module
|
||||
// comment for the security model (externalUniqId round-trip).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const connectInput = InputSpec.of({
|
||||
api_key: Value.text({
|
||||
name: 'Zaprite API key',
|
||||
description:
|
||||
'Create an API key at app.zaprite.com → Settings → API. ' +
|
||||
'One key per Keysat instance. The key is stored in your ' +
|
||||
'StartOS volume (encrypted at rest by StartOS) and never ' +
|
||||
'transmitted off your server.',
|
||||
required: true,
|
||||
default: null,
|
||||
masked: true,
|
||||
}),
|
||||
base_url: Value.text({
|
||||
name: 'API base URL',
|
||||
description:
|
||||
'Defaults to https://api.zaprite.com — only override for ' +
|
||||
'sandbox organizations or future regional endpoints.',
|
||||
required: false,
|
||||
default: 'https://api.zaprite.com',
|
||||
}),
|
||||
})
|
||||
|
||||
export const configureZaprite = sdk.Action.withInput(
|
||||
'configure-zaprite',
|
||||
async () => ({
|
||||
name: 'Connect Zaprite',
|
||||
description:
|
||||
'Connect Keysat to your Zaprite account so buyers can pay with ' +
|
||||
'cards (USD/EUR) and Bitcoin via your Zaprite-connected wallets. ' +
|
||||
'Use INSTEAD OF Connect BTCPay — only one payment provider can ' +
|
||||
'be active at a time. Disconnect first if switching providers.',
|
||||
warning:
|
||||
'Switching providers does not affect already-issued license keys; ' +
|
||||
'they continue to validate normally. New purchases route through ' +
|
||||
'whichever provider is active at the time of checkout.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Zaprite',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
connectInput,
|
||||
// Pre-fill base_url; never pre-fill api_key (force operator to paste fresh).
|
||||
async ({ effects: _effects }) => ({
|
||||
api_key: '',
|
||||
base_url: 'https://api.zaprite.com',
|
||||
}),
|
||||
async ({ effects: _effects, input }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/zaprite/connect',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
api_key: input.api_key.trim(),
|
||||
base_url: (input.base_url || 'https://api.zaprite.com').trim(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Connect failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
ok: true
|
||||
provider: string
|
||||
base_url: string
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Zaprite connected',
|
||||
message:
|
||||
`Active payment provider is now Zaprite (${body.base_url}).\n\n` +
|
||||
`Next step: register a webhook in Zaprite's dashboard pointing at:\n` +
|
||||
`<your Keysat public URL>/v1/zaprite/webhook\n\n` +
|
||||
`Zaprite doesn't sign webhook deliveries; Keysat authenticates ` +
|
||||
`each delivery via the externalUniqId we attach at order ` +
|
||||
`creation, so a webhook configured to ANY URL on your daemon ` +
|
||||
`is safe even without a shared secret.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Counterpart to Connect — clears stored credentials + active provider. */
|
||||
export const disconnectZaprite = sdk.Action.withoutInput(
|
||||
'disconnect-zaprite',
|
||||
async () => ({
|
||||
name: 'Disconnect Zaprite',
|
||||
description:
|
||||
'Disconnect Keysat from your Zaprite account. Wipes the stored ' +
|
||||
'API key and clears the active provider. Existing license keys ' +
|
||||
'are unaffected. Run this before re-running Connect Zaprite if ' +
|
||||
'you want to rotate the key or switch organizations.',
|
||||
warning:
|
||||
"Don't forget to also delete the corresponding webhook in your " +
|
||||
"Zaprite dashboard — Keysat can't programmatically delete it for " +
|
||||
'you because the webhook-management API surface is not on the ' +
|
||||
'public Zaprite OpenAPI we have access to.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Zaprite',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/zaprite/disconnect',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Disconnect failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as
|
||||
| { ok: true; noop: true; message: string }
|
||||
| { ok: true; noop: false; message: string }
|
||||
return {
|
||||
version: '1',
|
||||
title: body.noop ? 'Already disconnected' : 'Zaprite disconnected',
|
||||
message: body.message,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Quick read-only check of the current connection state. */
|
||||
export const zapriteStatus = sdk.Action.withoutInput(
|
||||
'zaprite-status',
|
||||
async () => ({
|
||||
name: 'Check Zaprite connection',
|
||||
description: 'Show whether Zaprite is the active payment provider and the configured base URL.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Zaprite',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects: _effects }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/zaprite/status',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Status check failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
connected: boolean
|
||||
active_provider: string | null
|
||||
base_url: string | null
|
||||
webhook_id: string | null
|
||||
}
|
||||
if (!body.connected) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Zaprite not connected',
|
||||
message: 'No Zaprite credentials configured. Run "Connect Zaprite" to paste in an API key.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: body.active_provider === 'zaprite' ? 'Zaprite is active' : 'Zaprite configured (provider not active)',
|
||||
message:
|
||||
`Connected to ${body.base_url ?? '(unknown URL)'}.\n` +
|
||||
`Active provider: ${body.active_provider ?? '(none)'}.` +
|
||||
(body.active_provider === 'zaprite'
|
||||
? ''
|
||||
: "\n\nA different provider (likely BTCPay) is currently active. Disconnect that one first if you want Zaprite to take over."),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -21,6 +21,7 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { activateLicense, showLicenseStatus } from './activateLicense'
|
||||
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
|
||||
import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite'
|
||||
import { setOperatorName } from './setOperatorName'
|
||||
import { setWebUiPassword } from './setWebUiPassword'
|
||||
import { showCredentials } from './showCredentials'
|
||||
@@ -29,10 +30,14 @@ export const actions = sdk.Actions.of()
|
||||
// General
|
||||
.addAction(setOperatorName)
|
||||
.addAction(setWebUiPassword)
|
||||
// BTCPay setup
|
||||
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
|
||||
.addAction(configureBtcpay)
|
||||
.addAction(btcpayStatus)
|
||||
.addAction(disconnectBtcpay)
|
||||
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
|
||||
.addAction(configureZaprite)
|
||||
.addAction(zapriteStatus)
|
||||
.addAction(disconnectZaprite)
|
||||
// Keysat self-license (Keysat-licenses-Keysat)
|
||||
.addAction(activateLicense)
|
||||
.addAction(showLicenseStatus)
|
||||
|
||||
Reference in New Issue
Block a user