54f7ea08b5
Bundle of bugfixes from the P1 testing batch. None individually
huge; together they close several "tested it, hit a sharp edge"
items.
1. Change-tier modal — kill the paid path from UI
The Apply-as-comp toggle is gone. Admin tier changes always
apply as comp now. The reasoning (per Grant's testing): admin
tier changes are operator-driven, payment has either already
happened off-rails or it's a comp; the "admin generates
invoice and forwards URL" flow is a tiny niche that just
produces orphan invoices when the modal gets dismissed.
Buyers who want to pay use the SDK's /v1/upgrade.
The API path is unchanged for back-compat with scripted
operators (skip_payment defaults to true here).
2. Change-tier modal — downgrade detection + warning banner
Detects target.tier_rank < current.tier_rank (or price-diff
when ranks aren't set), renders a yellow warning card listing
the entitlements the buyer is about to lose, and confirms via
browser dialog before submit. Operator sees what they're
doing.
3. Self-tier guard on admin change-tier
POST /v1/admin/licenses/<id>/change-tier rejects when <id>
is the daemon's own self_license. Avoids the recursion Grant
hit when trying to downgrade himself: the on-disk signed key
is the source-of-truth at boot, so the DB tier_change just
produces a half-applied state. Error message points at the
right paths (re-mint via master Keysat OR rename
/data/keysat-license.txt for testing). With the P0 self-tier
live-refresh in place the recursion is now fully resolved
anyway, but the guard is good belt-and-suspenders for
operator clarity.
4. Zaprite webhook — full URL in copy + persistent action
- The Connect Zaprite action now shows the EXACT
https://your-keysat-url/v1/zaprite/webhook URL to paste
into Zaprite's dashboard. Previous copy showed a
placeholder "<your Keysat public URL>/...", which Zaprite's
form rejects (it requires full https://). Daemon's
/v1/admin/zaprite/connect now returns webhook_url; the
action displays it.
- New "Show Zaprite webhook setup" StartOS Action — operators
who skipped the step on first connect, or who lost the
output, can run this any time and get the URL again.
- Full explainer of what webhooks unlock vs polling-only:
"without webhooks, Keysat polls /v1/orders every 60s, so
license issuance lags settle by up to a minute; with
webhooks, ~1s." Lives on /v1/admin/zaprite/status response
as `webhook_explainer` + in the action's display text.
5. Connect-while-connected short-circuit
POST /v1/admin/zaprite/connect now returns 409 Conflict with a
clear "already connected — disconnect first" message instead
of silently overwriting an existing config. (BTCPay's
start_connect already had this guard since the durable
provider switch work.)
6. Lightning vs on-chain copy on the wait page
/thank-you was hard-coded to "next block confirms" — wrong
for Lightning payments (instant) and confusing in the common
case where buyers paid via Lightning and saw a "waiting for
block confirmation" message. Updated to: "Lightning settles
in seconds; on-chain typically settles in 10-20 minutes (one
block confirmation)." Method-aware copy (parsed from the
provider's invoice payload) is a deeper fix but out of scope
here — this gets the operator-facing accuracy right today.
Test count unchanged; all 77 still passing.
271 lines
9.8 KiB
TypeScript
271 lines
9.8 KiB
TypeScript
// 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
|
|
webhook_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:\n` +
|
|
`1. Open app.zaprite.com → Settings → Webhooks → New webhook.\n` +
|
|
`2. Paste this URL exactly (full https://, not just the path):\n\n` +
|
|
` ${body.webhook_url}\n\n` +
|
|
`3. Subscribe to order events (payment_received, settled, refunded).\n\n` +
|
|
`Why webhooks: without them, Keysat falls back to polling Zaprite's ` +
|
|
`/v1/orders every 60 seconds, so license issuance can lag a settle ` +
|
|
`event by up to a minute. With webhooks, Keysat issues the license ` +
|
|
`within ~1 second of payment.\n\n` +
|
|
`Security: 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.\n\n` +
|
|
`Lost this message? Run "Show Zaprite webhook setup" to see the URL again.`,
|
|
result: null,
|
|
}
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Persistent surface for the webhook URL — operators who skipped the
|
|
* step on first connect, or who just want to verify which URL Zaprite
|
|
* should be posting to, run this and get the exact value to paste.
|
|
*/
|
|
export const showZapriteWebhookSetup = sdk.Action.withoutInput(
|
|
'show-zaprite-webhook-setup',
|
|
async () => ({
|
|
name: 'Show Zaprite webhook setup',
|
|
description:
|
|
"Display the full webhook URL to register in your Zaprite dashboard, " +
|
|
"plus an explanation of what webhooks do that polling doesn't. " +
|
|
"Useful if you skipped the step on first Connect.",
|
|
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(
|
|
`Could not read status: HTTP ${resp.status} — ${await resp.text()}`,
|
|
)
|
|
}
|
|
const body = (await resp.json()) as {
|
|
connected: boolean
|
|
webhook_url: string
|
|
webhook_explainer: string
|
|
}
|
|
if (!body.connected) {
|
|
return {
|
|
version: '1',
|
|
title: 'Zaprite not connected',
|
|
message:
|
|
'Connect Zaprite first via the Connect Zaprite action. ' +
|
|
'Once connected, re-run this action to see the webhook URL ' +
|
|
'to register in your Zaprite dashboard.',
|
|
result: null,
|
|
}
|
|
}
|
|
return {
|
|
version: '1',
|
|
title: 'Zaprite webhook setup',
|
|
message:
|
|
`Paste this URL exactly into Zaprite's webhook form ` +
|
|
`(app.zaprite.com → Settings → Webhooks → New webhook):\n\n` +
|
|
` ${body.webhook_url}\n\n` +
|
|
`Subscribe to order events (payment_received, settled, refunded).\n\n` +
|
|
body.webhook_explainer,
|
|
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,
|
|
}
|
|
},
|
|
)
|