v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// Action: activate the Keysat package's own self-license.
|
||||
//
|
||||
// The daemon embeds the keysat.xyz master public key at compile time
|
||||
// (see licensing-service/src/license_self.rs). The operator pastes a
|
||||
// LIC1-… key here; the daemon verifies it against the master pubkey,
|
||||
// writes it to /data/keysat-license.txt, and swaps its runtime tier
|
||||
// to Licensed without a restart.
|
||||
//
|
||||
// In permissive builds (the default for local `make x86`) the daemon
|
||||
// will start regardless and this action just records the tier. In
|
||||
// enforce builds (compiled with KEYSAT_LICENSE_ENFORCE=1, used for
|
||||
// the marketplace .s9pk) the daemon refuses to start without a valid
|
||||
// license, and this action is the bootstrap path: install Keysat,
|
||||
// run this action with your activation key, then start the service.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_key: Value.text({
|
||||
name: 'License key',
|
||||
description:
|
||||
'Paste the LIC1-… license key issued for your Keysat install. ' +
|
||||
'Buy or redeem one at registry.keysat.xyz.',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: 'LIC1-XXXXXXXXXXXX-XXXXXXXXXXXX',
|
||||
}),
|
||||
})
|
||||
|
||||
export const activateLicense = sdk.Action.withInput(
|
||||
'activate-license',
|
||||
async () => ({
|
||||
name: 'Activate Keysat license',
|
||||
description:
|
||||
'Activate this Keysat install. Required for marketplace builds; ' +
|
||||
'optional but recommended for source-built dev installs (signals support, ' +
|
||||
'and lets the admin UI show your tier).',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'License',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const key = formInput.license_key.trim()
|
||||
if (!key) {
|
||||
throw new Error('License key is required.')
|
||||
}
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/self-license',
|
||||
{ method: 'POST', body: JSON.stringify({ license_key: key }) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text()
|
||||
let detail = body
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
if (parsed.detail) detail = parsed.detail
|
||||
else if (parsed.error) detail = parsed.error
|
||||
} catch (_) {}
|
||||
throw new Error(`Activation rejected (HTTP ${resp.status}): ${detail}`)
|
||||
}
|
||||
|
||||
const result = (await resp.json()) as {
|
||||
ok: boolean
|
||||
tier: {
|
||||
tier: 'licensed' | 'unlicensed'
|
||||
license_id?: string
|
||||
product_id?: string
|
||||
expires_at?: number
|
||||
entitlements?: string[]
|
||||
mode: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Keysat license activated',
|
||||
message:
|
||||
result.message +
|
||||
' The license is stored at /data/keysat-license.txt and survives upgrades and reinstalls (it is part of your StartOS backup set).',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Companion read-only action: surface the current self-license tier.
|
||||
// Useful both as a sanity check after activation and as a way for the
|
||||
// operator to see "am I running licensed or unlicensed?" without
|
||||
// digging into logs.
|
||||
export const showLicenseStatus = sdk.Action.withoutInput(
|
||||
'show-license-status',
|
||||
async () => ({
|
||||
name: 'Show Keysat license status',
|
||||
description:
|
||||
'Reports whether this Keysat install is running licensed or unlicensed, ' +
|
||||
'and which entitlements are active.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'License',
|
||||
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/self-license',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Could not read license status: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const j = (await resp.json()) as {
|
||||
tier: 'licensed' | 'unlicensed'
|
||||
license_id?: string
|
||||
product_id?: string
|
||||
expires_at?: number
|
||||
entitlements?: string[]
|
||||
reason?: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
if (j.tier === 'licensed') {
|
||||
const exp = !j.expires_at
|
||||
? 'perpetual'
|
||||
: new Date(j.expires_at * 1000).toISOString().slice(0, 10)
|
||||
const ents = (j.entitlements || []).length === 0 ? '(none)' : (j.entitlements || []).join(', ')
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Licensed',
|
||||
message:
|
||||
`License id: ${j.license_id}\n` +
|
||||
`Expires: ${exp}\n` +
|
||||
`Entitlements: ${ents}\n` +
|
||||
`Build mode: ${j.mode}`,
|
||||
result: null,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Unlicensed',
|
||||
message:
|
||||
`Reason: ${j.reason || 'no license configured'}\n` +
|
||||
`Build mode: ${j.mode}\n\n` +
|
||||
(j.mode === 'enforce'
|
||||
? 'This is a marketplace build that requires a valid license to run. Use the "Activate Keysat license" action to bootstrap.'
|
||||
: 'This is a permissive (dev) build. The daemon will keep running. Activate a license to see your tier reflected here.'),
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -14,11 +14,12 @@
|
||||
// The operator never sees or types an API key, store id, or webhook secret.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
export const configureBtcpay = sdk.Action.withoutInput(
|
||||
'configureBtcpay',
|
||||
async ({ effects }) => ({
|
||||
'configure-btcpay',
|
||||
async () => ({
|
||||
name: 'Connect BTCPay',
|
||||
description:
|
||||
"One-click connect to your BTCPay Server. Opens a consent page in " +
|
||||
@@ -29,11 +30,12 @@ export const configureBtcpay = sdk.Action.withoutInput(
|
||||
group: 'BTCPay',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/connect',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
@@ -43,22 +45,102 @@ export const configureBtcpay = sdk.Action.withoutInput(
|
||||
const body = (await resp.json()) as { authorize_url: string }
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Approve on BTCPay to finish connecting',
|
||||
message:
|
||||
'Open the URL below in your browser. You will be taken to your ' +
|
||||
'BTCPay Server, where you click "Authorize". After that BTCPay ' +
|
||||
'sends the API key back to Keysat automatically — you do not ' +
|
||||
'need to copy anything.\n\n' +
|
||||
body.authorize_url +
|
||||
'\n\nYou can confirm the connection succeeded with the "Check BTCPay ' +
|
||||
'connection" action once approval is complete.',
|
||||
'need to copy anything.\n\nYou can confirm the connection succeeded ' +
|
||||
'with the "Check BTCPay connection" action once approval is complete.',
|
||||
result: {
|
||||
type: 'single',
|
||||
value: body.authorize_url,
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Replay id used by init/index.ts when surfacing the BTCPay setup task. */
|
||||
const BTCPAY_SETUP_TASK_ID = 'btcpay-initial-setup'
|
||||
|
||||
/** Disconnect BTCPay — clean revocation path for re-authorize cases. */
|
||||
export const disconnectBtcpay = sdk.Action.withoutInput(
|
||||
'disconnect-btcpay',
|
||||
async () => ({
|
||||
name: 'Disconnect BTCPay',
|
||||
description:
|
||||
'Disconnect Keysat from your BTCPay Server: revoke the API key, ' +
|
||||
'delete the registered webhook, and clear local connection state. ' +
|
||||
"Run this before 'Connect BTCPay' if you want to re-authorize " +
|
||||
'(e.g., to switch stores or rotate the API key). Existing license ' +
|
||||
'keys, products, and policies are unaffected.',
|
||||
warning:
|
||||
'Until you re-run "Connect BTCPay" after this, new purchases will ' +
|
||||
'return 503 (BTCPay not configured). Already-issued license keys ' +
|
||||
'continue to validate normally.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'BTCPay',
|
||||
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/btcpay/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
|
||||
store_id: string | null
|
||||
webhook_id: string | null
|
||||
warnings: string[]
|
||||
}
|
||||
if ('noop' in body && body.noop) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Already disconnected',
|
||||
message: body.message,
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const b = body as {
|
||||
ok: true
|
||||
noop: false
|
||||
store_id: string | null
|
||||
webhook_id: string | null
|
||||
warnings: string[]
|
||||
}
|
||||
const warningsBlock = b.warnings.length > 0
|
||||
? `\n\nWarnings:\n${b.warnings.map((w) => `• ${w}`).join('\n')}`
|
||||
: ''
|
||||
return {
|
||||
version: '1',
|
||||
title: 'BTCPay disconnected',
|
||||
message:
|
||||
`Local BTCPay connection cleared. ` +
|
||||
`Store id was ${b.store_id ?? '(unknown)'}, webhook id was ${b.webhook_id ?? '(none)'}. ` +
|
||||
`You can now run "Connect BTCPay" again to re-authorize.${warningsBlock}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Optional companion action: show current BTCPay connection state. */
|
||||
export const btcpayStatus = sdk.Action.withoutInput(
|
||||
'btcpayStatus',
|
||||
async ({ effects }) => ({
|
||||
'btcpay-status',
|
||||
async () => ({
|
||||
name: 'Check BTCPay connection',
|
||||
description: 'Shows whether BTCPay is currently connected, and the store id.',
|
||||
warning: null,
|
||||
@@ -67,10 +149,11 @@ export const btcpayStatus = sdk.Action.withoutInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/status',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -83,15 +166,62 @@ export const btcpayStatus = sdk.Action.withoutInput(
|
||||
|
||||
if (!body.connected) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Not connected',
|
||||
message: 'BTCPay is not connected yet. Run the "Connect BTCPay" action to authorize.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
|
||||
// BTCPay is connected — clear the install-time setup task so it
|
||||
// disappears from the dashboard. clearTask is idempotent and
|
||||
// tolerates being called when no such task exists, so this is safe
|
||||
// every time btcpayStatus is run.
|
||||
try {
|
||||
await sdk.action.clearTask(effects, BTCPAY_SETUP_TASK_ID)
|
||||
} catch (_) {
|
||||
// Non-fatal — we still report status.
|
||||
}
|
||||
|
||||
// Also check whether BTCPay's store has any payment methods (wallet
|
||||
// / Lightning) configured. A connected store with zero payment
|
||||
// methods can't actually issue invoices — that's the trap that
|
||||
// surfaces as "BTC-CHAIN: Payment method unavailable" when buyers
|
||||
// try to purchase. Surface the situation here so the operator
|
||||
// discovers it BEFORE a customer hits a broken purchase flow.
|
||||
let walletNote = ''
|
||||
try {
|
||||
const pmResp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/btcpay/payment-methods',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (pmResp.ok) {
|
||||
const pmBody = (await pmResp.json()) as { count: number }
|
||||
if (pmBody.count === 0) {
|
||||
walletNote =
|
||||
`\n\n⚠ NO WALLET CONFIGURED on this BTCPay store. Buyers won't ` +
|
||||
`be able to pay until you set one up.\n` +
|
||||
`Open your BTCPay store settings (${body.base_url.replace(/^http:\/\//, 'http://').replace(/^https:\/\//, 'https://')}/stores/${body.store_id}) ` +
|
||||
`→ Wallets / Lightning, then come back and re-run "Check BTCPay connection".`
|
||||
} else {
|
||||
walletNote = `\n\n✓ ${pmBody.count} payment method(s) configured.`
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal: payment-method check is informational.
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'BTCPay is connected',
|
||||
message:
|
||||
`BTCPay is connected.\n` +
|
||||
`Store id: ${body.store_id}\n` +
|
||||
`Webhook id: ${body.webhook_id ?? '(not registered — check BTCPay manually)'}\n` +
|
||||
`Base URL: ${body.base_url}`,
|
||||
`Base URL: ${body.base_url}` +
|
||||
walletNote,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Action: create a discount / referral code.
|
||||
//
|
||||
// A code can be percentage-off or fixed-sats-off. It can target a specific
|
||||
// product (or apply universally), have an optional expiry, an optional
|
||||
// usage cap, and a free-form referrer label for tracking ("twitter-launch",
|
||||
// "alice@example.com"). Codes are case-insensitive and normalized to
|
||||
// uppercase on create.
|
||||
//
|
||||
// Buyers redeem codes by passing `?code=FOUNDERS50` to the public purchase
|
||||
// flow. The discount is reserved atomically at purchase time and finalized
|
||||
// when the BTCPay invoice settles.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
code: Value.text({
|
||||
name: 'Code',
|
||||
description:
|
||||
'The redeemable string. Case-insensitive (will be uppercased). ' +
|
||||
'ASCII letters, digits, "-", and "_" only. E.g., "FOUNDERS50".',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [
|
||||
{
|
||||
regex: '^[A-Za-z0-9_-]{2,40}$',
|
||||
description:
|
||||
'letters, digits, dashes, underscores; 2 to 40 characters',
|
||||
},
|
||||
],
|
||||
}),
|
||||
kind: Value.select({
|
||||
name: 'Discount kind',
|
||||
description:
|
||||
'"Percent off" reduces the price by N%. ' +
|
||||
'"Fixed sats off" subtracts a fixed number of sats. ' +
|
||||
'"Free license" issues a license outright with no payment, ' +
|
||||
'redeemed via the public /v1/redeem endpoint.',
|
||||
default: 'percent',
|
||||
values: {
|
||||
percent: 'Percent off',
|
||||
fixed_sats: 'Fixed sats off',
|
||||
free_license: 'Free license (no payment)',
|
||||
},
|
||||
}),
|
||||
amount: Value.number({
|
||||
name: 'Amount',
|
||||
description:
|
||||
'For percent: 1..=100 (integer percentage). E.g., 50 = 50% off. ' +
|
||||
'For fixed sats off: any positive integer (sats). ' +
|
||||
'For free license: ignored (set to 0). ' +
|
||||
'Note: percent is converted to basis points server-side.',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
}),
|
||||
max_uses: Value.number({
|
||||
name: 'Max uses',
|
||||
description: '0 = unlimited. Otherwise, max number of redemptions.',
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
}),
|
||||
expires_at: Value.text({
|
||||
name: 'Expires at (ISO 8601)',
|
||||
description:
|
||||
'Optional cutoff date. RFC3339 / ISO 8601 UTC, e.g. ' +
|
||||
'"2026-12-31T23:59:59Z". Leave blank for no expiry.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug (optional)',
|
||||
description:
|
||||
'Restrict the code to one specific product. Leave blank to apply ' +
|
||||
'the discount to any product.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
policy_slug: Value.text({
|
||||
name: 'Policy slug (optional)',
|
||||
description:
|
||||
'Further restrict to a single policy of the chosen product. ' +
|
||||
'Requires "Product slug" to be set if used.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
referrer_label: Value.text({
|
||||
name: 'Referrer / campaign label (optional)',
|
||||
description:
|
||||
'Free-form tracking string. E.g., "twitter-launch", ' +
|
||||
'"partner-alice@example.com", "podcast-XYZ-spring-2026". Shown in ' +
|
||||
'usage reports; never visible to buyers.',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
description: Value.textarea({
|
||||
name: 'Description (optional)',
|
||||
description: 'Internal note. E.g., "Founders rate, expires May 31."',
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
})
|
||||
|
||||
export const createDiscountCode = sdk.Action.withInput(
|
||||
'create-discount-code',
|
||||
async () => ({
|
||||
name: 'Create discount code',
|
||||
description:
|
||||
'Add a redeemable discount / referral code. Buyers append ' +
|
||||
'?code=YOUR_CODE to the purchase URL to apply it.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
if (formInput.policy_slug && !formInput.product_slug) {
|
||||
throw new Error('Policy slug requires Product slug to also be set.')
|
||||
}
|
||||
|
||||
// Convert UI percent (1..=100) to basis points (100..=10000).
|
||||
let amount = formInput.amount
|
||||
if (formInput.kind === 'percent') {
|
||||
if (amount < 1 || amount > 100) {
|
||||
throw new Error('Percent amount must be between 1 and 100.')
|
||||
}
|
||||
amount = amount * 100
|
||||
} else if (formInput.kind === 'fixed_sats') {
|
||||
if (amount < 1) {
|
||||
throw new Error('Fixed sats amount must be at least 1.')
|
||||
}
|
||||
} else if (formInput.kind === 'free_license') {
|
||||
// Amount is unused for free licenses; force to 0 so the server
|
||||
// accepts it.
|
||||
amount = 0
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
code: formInput.code,
|
||||
kind: formInput.kind,
|
||||
amount,
|
||||
description: formInput.description ?? '',
|
||||
}
|
||||
if (formInput.max_uses > 0) body.max_uses = formInput.max_uses
|
||||
if (formInput.expires_at) body.expires_at = formInput.expires_at
|
||||
if (formInput.product_slug) body.product_slug = formInput.product_slug
|
||||
if (formInput.policy_slug) body.policy_slug = formInput.policy_slug
|
||||
if (formInput.referrer_label) body.referrer_label = formInput.referrer_label
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/discount-codes',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create discount code failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const code = (await resp.json()) as { id: string; code: string; kind: string; amount: number }
|
||||
const humanAmount =
|
||||
code.kind === 'percent'
|
||||
? `${code.amount / 100}% off`
|
||||
: code.kind === 'fixed_sats'
|
||||
? `${code.amount} sats off`
|
||||
: 'free license (no payment)'
|
||||
const redemptionHint =
|
||||
code.kind === 'free_license'
|
||||
? `Buyers redeem this code via the public /v1/redeem endpoint or via ` +
|
||||
`the "Redeem free license" buyer-side action — they receive the key ` +
|
||||
`directly, no BTCPay invoice.`
|
||||
: `Buyers can redeem this code by appending ?code=${code.code} to the ` +
|
||||
`public purchase URL.`
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Discount code created',
|
||||
message:
|
||||
`Created code "${code.code}" — ${humanAmount}.\n` +
|
||||
redemptionHint +
|
||||
`\n\nInternal id: ${code.id}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -6,35 +6,36 @@
|
||||
// normal purchase flow.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
product_slug: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug',
|
||||
description: 'The product this policy applies to.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
slug: {
|
||||
type: 'text',
|
||||
}),
|
||||
slug: Value.text({
|
||||
name: 'Policy slug',
|
||||
description:
|
||||
'URL-safe name, e.g., "default", "annual", "trial". ' +
|
||||
'Use "default" for the one consumed by the public purchase flow.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }],
|
||||
},
|
||||
name: {
|
||||
type: 'text',
|
||||
patterns: [
|
||||
{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' },
|
||||
],
|
||||
}),
|
||||
name: Value.text({
|
||||
name: 'Display name',
|
||||
description: 'Shown in admin listings. E.g., "Annual subscription".',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
duration_seconds: {
|
||||
type: 'number',
|
||||
}),
|
||||
duration_seconds: Value.number({
|
||||
name: 'Duration (seconds)',
|
||||
description: '0 = perpetual. 31536000 = one year. 7776000 = 90 days.',
|
||||
required: true,
|
||||
@@ -42,9 +43,8 @@ const input = sdk.InputSpec.of({
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
grace_seconds: {
|
||||
type: 'number',
|
||||
}),
|
||||
grace_seconds: Value.number({
|
||||
name: 'Grace period (seconds)',
|
||||
description:
|
||||
'After expiry, how long a cached validation remains honoured ' +
|
||||
@@ -54,9 +54,8 @@ const input = sdk.InputSpec.of({
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
max_machines: {
|
||||
type: 'number',
|
||||
}),
|
||||
max_machines: Value.number({
|
||||
name: 'Max machines',
|
||||
description: '0 = unlimited, 1 = single-seat, n>1 = multi-seat cap.',
|
||||
required: true,
|
||||
@@ -64,24 +63,21 @@ const input = sdk.InputSpec.of({
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
is_trial: {
|
||||
type: 'toggle',
|
||||
}),
|
||||
is_trial: Value.toggle({
|
||||
name: 'Trial policy',
|
||||
description: 'Mark issued keys as trial (sets the TRIAL flag in the payload).',
|
||||
default: false,
|
||||
},
|
||||
entitlements: {
|
||||
type: 'text',
|
||||
}),
|
||||
entitlements: Value.text({
|
||||
name: 'Entitlements',
|
||||
description:
|
||||
'Comma-separated list of feature slugs embedded in the license key. ' +
|
||||
'E.g., "pro,multi-device". Leave blank for none.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
price_sats_override: {
|
||||
type: 'number',
|
||||
}),
|
||||
price_sats_override: Value.number({
|
||||
name: 'Price override (sats, optional)',
|
||||
description:
|
||||
"Override the product's default price for licenses issued under this " +
|
||||
@@ -91,12 +87,12 @@ const input = sdk.InputSpec.of({
|
||||
min: -1,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const createPolicy = sdk.Action.withInput(
|
||||
'createPolicy',
|
||||
async ({ effects }) => ({
|
||||
'create-policy',
|
||||
async () => ({
|
||||
name: 'Create policy',
|
||||
description:
|
||||
'Add a reusable license template to a product. The public purchase ' +
|
||||
@@ -108,8 +104,11 @@ export const createPolicy = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const entitlements = (formInput.entitlements ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
@@ -129,7 +128,7 @@ export const createPolicy = sdk.Action.withInput(
|
||||
body.price_sats_override = formInput.price_sats_override
|
||||
}
|
||||
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/policies', {
|
||||
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/policies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -138,11 +137,14 @@ export const createPolicy = sdk.Action.withInput(
|
||||
}
|
||||
const policy = (await resp.json()) as { id: string; slug: string; name: string }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Policy created',
|
||||
message:
|
||||
`Created policy '${policy.slug}' (id ${policy.id}).\n` +
|
||||
(formInput.slug === 'default'
|
||||
? 'Because the slug is "default", this policy will be used by the public purchase flow.'
|
||||
: 'Use this slug when calling "Issue license manually".'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,33 +4,34 @@
|
||||
// key. No need for the operator to touch curl or handle tokens.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
slug: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
slug: Value.text({
|
||||
name: 'Slug',
|
||||
description: 'URL-safe short name, e.g., "my-app". Used in product links.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }],
|
||||
},
|
||||
name: {
|
||||
type: 'text',
|
||||
patterns: [
|
||||
{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' },
|
||||
],
|
||||
}),
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'Display name shown to buyers.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: 'textarea',
|
||||
}),
|
||||
description: Value.textarea({
|
||||
name: 'Description',
|
||||
description: 'Public description of what the buyer is getting.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
price_sats: {
|
||||
type: 'number',
|
||||
}),
|
||||
price_sats: Value.number({
|
||||
name: 'Price (sats)',
|
||||
description: 'Price per license in satoshis. 100,000,000 sats = 1 BTC.',
|
||||
required: true,
|
||||
@@ -38,12 +39,12 @@ const input = sdk.InputSpec.of({
|
||||
min: 1,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const createProduct = sdk.Action.withInput(
|
||||
'createProduct',
|
||||
async ({ effects }) => ({
|
||||
'create-product',
|
||||
async () => ({
|
||||
name: 'Create product',
|
||||
description: 'Add a new product that can be purchased through this service.',
|
||||
warning: null,
|
||||
@@ -52,9 +53,11 @@ export const createProduct = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/products', {
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
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/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
slug: formInput.slug,
|
||||
@@ -67,13 +70,16 @@ export const createProduct = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create product failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = await resp.json()
|
||||
const body = (await resp.json()) as { id: string; slug: string; price_sats: number }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Product created',
|
||||
message:
|
||||
`Created product '${body.slug}' (id ${body.id}).\n` +
|
||||
`Priced at ${body.price_sats} sats.\n\n` +
|
||||
`Buyers can purchase by POSTing to your Keysat URL:\n` +
|
||||
`<your Keysat URL>/v1/purchase with body: {"product":"${body.slug}"}`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,28 +4,29 @@
|
||||
// with `not_activated`, freeing up a seat for another install.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
machine_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
machine_id: Value.text({
|
||||
name: 'Machine ID',
|
||||
description: 'UUID of the machine to deactivate. Find via list-machines.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: 'text',
|
||||
}),
|
||||
reason: Value.text({
|
||||
name: 'Reason',
|
||||
description: 'Stored for audit. E.g., "laptop stolen", "support request".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const deactivateMachine = sdk.Action.withInput(
|
||||
'deactivateMachine',
|
||||
async ({ effects }) => ({
|
||||
'deactivate-machine',
|
||||
async () => ({
|
||||
name: 'Deactivate machine',
|
||||
description:
|
||||
'Force an install off a license. Frees up a seat and causes that ' +
|
||||
@@ -38,11 +39,13 @@ export const deactivateMachine = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/machines/${encodeURIComponent(formInput.machine_id)}/deactivate`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -52,6 +55,11 @@ export const deactivateMachine = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Deactivate failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Deactivated machine ${formInput.machine_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Machine deactivated',
|
||||
message: `Deactivated machine ${formInput.machine_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Action: disable (or re-enable) a discount code.
|
||||
//
|
||||
// Disabling is reversible — the code's redemption history is preserved
|
||||
// either way. Disabled codes simply won't redeem on new purchases.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
code: Value.text({
|
||||
name: 'Code',
|
||||
description:
|
||||
'The redeemable string (e.g. "FOUNDERS50"). Case-insensitive — ' +
|
||||
'will be uppercased before lookup.',
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
active: Value.toggle({
|
||||
name: 'Active',
|
||||
description:
|
||||
'Toggle off to disable this code. Toggle on to re-enable a ' +
|
||||
'previously disabled code.',
|
||||
default: false,
|
||||
}),
|
||||
})
|
||||
|
||||
export const disableDiscountCode = sdk.Action.withInput(
|
||||
'disable-discount-code',
|
||||
async () => ({
|
||||
name: 'Disable / enable discount code',
|
||||
description:
|
||||
'Disable a code so it stops accepting new redemptions. Existing ' +
|
||||
'redemptions and the underlying license are unaffected. Re-enable ' +
|
||||
'by running again with "Active" toggled on.',
|
||||
warning:
|
||||
'Disabling does not refund or revoke previously-issued licenses. ' +
|
||||
'Use "Revoke license" for that.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
// Look the code up by string to discover its id.
|
||||
const lookup = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/discount-codes',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!lookup.ok) {
|
||||
throw new Error(`Lookup failed: HTTP ${lookup.status} — ${await lookup.text()}`)
|
||||
}
|
||||
const body = (await lookup.json()) as { codes: Array<{ id: string; code: string }> }
|
||||
const target = body.codes.find(
|
||||
(c) => c.code.toUpperCase() === formInput.code.trim().toUpperCase(),
|
||||
)
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`No discount code found matching "${formInput.code}". ` +
|
||||
'Use "List discount codes" with "Include disabled codes" toggled on to see all codes.',
|
||||
)
|
||||
}
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/discount-codes/${target.id}/active`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ active: formInput.active }) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Update failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: formInput.active ? 'Code re-enabled' : 'Code disabled',
|
||||
message: formInput.active
|
||||
? `Code "${target.code}" is now active and will accept new redemptions.`
|
||||
: `Code "${target.code}" is now disabled. New purchases that try ` +
|
||||
'to redeem it will be rejected. Existing redemptions and licenses are unaffected.',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
+26
-32
@@ -1,44 +1,38 @@
|
||||
// Register every action with StartOS.
|
||||
// Register actions with StartOS.
|
||||
//
|
||||
// As of v0.1.0:11 the StartOS Actions tab is intentionally minimal —
|
||||
// only setup-time operations live here:
|
||||
//
|
||||
// - General → Set operator name
|
||||
// - BTCPay → Connect / Check / Disconnect
|
||||
// - License → Activate Keysat license / Show license status
|
||||
// - Credentials → Show admin API key
|
||||
//
|
||||
// Everything else (products, policies, discount codes, licenses,
|
||||
// machines, webhooks, audit log) lives in the embedded admin web UI
|
||||
// at /admin/. The action source files remain in this directory for
|
||||
// reference — and the underlying admin HTTP API is unchanged — but
|
||||
// they're no longer registered as StartOS UI buttons. This keeps the
|
||||
// dashboard from feeling like an undifferentiated wall of buttons.
|
||||
//
|
||||
// The web UI uses the same /v1/admin/* endpoints those actions used to
|
||||
// call, so functionality is identical; only the UI surface changed.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { btcpayStatus, configureBtcpay } from './configureBtcpay'
|
||||
import { createPolicy } from './createPolicy'
|
||||
import { createProduct } from './createProduct'
|
||||
import { deactivateMachine } from './deactivateMachine'
|
||||
import { issueLicense } from './issueLicense'
|
||||
import { listMachines } from './listMachines'
|
||||
import { listWebhooks } from './listWebhooks'
|
||||
import { registerWebhook } from './registerWebhook'
|
||||
import { revokeLicense } from './revokeLicense'
|
||||
import { searchLicenses } from './searchLicenses'
|
||||
import { activateLicense, showLicenseStatus } from './activateLicense'
|
||||
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
|
||||
import { setOperatorName } from './setOperatorName'
|
||||
import { showCredentials } from './showCredentials'
|
||||
import { suspendLicense } from './suspendLicense'
|
||||
import { unsuspendLicense } from './unsuspendLicense'
|
||||
import { viewAuditLog } from './viewAuditLog'
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
// General
|
||||
.addAction(setOperatorName)
|
||||
// BTCPay
|
||||
// BTCPay setup
|
||||
.addAction(configureBtcpay)
|
||||
.addAction(btcpayStatus)
|
||||
.addAction(disconnectBtcpay)
|
||||
// Keysat self-license (Keysat-licenses-Keysat)
|
||||
.addAction(activateLicense)
|
||||
.addAction(showLicenseStatus)
|
||||
// Credentials
|
||||
.addAction(showCredentials)
|
||||
// Products + Policies
|
||||
.addAction(createProduct)
|
||||
.addAction(createPolicy)
|
||||
// Licenses
|
||||
.addAction(issueLicense)
|
||||
.addAction(searchLicenses)
|
||||
.addAction(suspendLicense)
|
||||
.addAction(unsuspendLicense)
|
||||
.addAction(revokeLicense)
|
||||
// Machines
|
||||
.addAction(listMachines)
|
||||
.addAction(deactivateMachine)
|
||||
// Webhooks
|
||||
.addAction(registerWebhook)
|
||||
.addAction(listWebhooks)
|
||||
// Diagnostics
|
||||
.addAction(viewAuditLog)
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
// Action: manually issue a license for a product (comp, press, dev).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
product_slug: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
product_slug: Value.text({
|
||||
name: 'Product slug',
|
||||
description: 'Which product to issue a license for.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
note: {
|
||||
type: 'text',
|
||||
}),
|
||||
note: Value.text({
|
||||
name: 'Note (optional)',
|
||||
description: 'Audit trail — e.g., "comp for @alice", "press key".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const issueLicense = sdk.Action.withInput(
|
||||
'issueLicense',
|
||||
async ({ effects }) => ({
|
||||
'issue-license',
|
||||
async () => ({
|
||||
name: 'Issue license manually',
|
||||
description: 'Generate a license key outside the purchase flow. Useful for comps and press.',
|
||||
warning: null,
|
||||
@@ -31,9 +32,11 @@ export const issueLicense = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/licenses', {
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
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/licenses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_slug: formInput.product_slug,
|
||||
@@ -43,11 +46,18 @@ export const issueLicense = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Issue failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = await resp.json()
|
||||
const body = (await resp.json()) as { license_id: string; license_key: string }
|
||||
return {
|
||||
message:
|
||||
`License issued.\nID: ${body.license_id}\n\n` +
|
||||
`Key (give this to the recipient):\n${body.license_key}`,
|
||||
version: '1',
|
||||
title: 'License issued',
|
||||
message: `License ID: ${body.license_id}\n\nGive this key to the recipient.`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: body.license_key,
|
||||
copyable: true,
|
||||
qr: true,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Action: list discount / referral codes with usage stats.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
include_inactive: Value.toggle({
|
||||
name: 'Include disabled codes',
|
||||
description: 'Show codes that have been disabled.',
|
||||
default: false,
|
||||
}),
|
||||
})
|
||||
|
||||
interface DiscountCode {
|
||||
id: string
|
||||
code: string
|
||||
kind: string
|
||||
amount: number
|
||||
max_uses: number | null
|
||||
used_count: number
|
||||
expires_at: string | null
|
||||
applies_to_product_id: string | null
|
||||
applies_to_policy_id: string | null
|
||||
referrer_label: string | null
|
||||
description: string
|
||||
active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const listDiscountCodes = sdk.Action.withInput(
|
||||
'list-discount-codes',
|
||||
async () => ({
|
||||
name: 'List discount codes',
|
||||
description: 'View every discount / referral code with usage stats.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Discount codes',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (formInput.include_inactive) params.set('include_inactive', 'true')
|
||||
const path =
|
||||
'/v1/admin/discount-codes' +
|
||||
(params.toString() ? `?${params.toString()}` : '')
|
||||
|
||||
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, path, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`List failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as { codes: DiscountCode[] }
|
||||
if (body.codes.length === 0) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No discount codes',
|
||||
message:
|
||||
formInput.include_inactive
|
||||
? 'No discount codes exist yet.'
|
||||
: 'No active discount codes. Toggle "Include disabled codes" to also see disabled ones.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.codes.map((c) => {
|
||||
const off =
|
||||
c.kind === 'percent'
|
||||
? `${(c.amount / 100).toFixed(c.amount % 100 === 0 ? 0 : 2)}% off`
|
||||
: c.kind === 'fixed_sats'
|
||||
? `${c.amount} sats off`
|
||||
: 'free license'
|
||||
const usage = c.max_uses
|
||||
? `${c.used_count}/${c.max_uses}`
|
||||
: `${c.used_count}/∞`
|
||||
const status = c.active ? 'active' : 'DISABLED'
|
||||
const exp = c.expires_at ? `expires ${c.expires_at}` : 'no expiry'
|
||||
const target = c.applies_to_product_id
|
||||
? c.applies_to_policy_id
|
||||
? '(product+policy scoped)'
|
||||
: '(product scoped)'
|
||||
: '(any product)'
|
||||
const ref = c.referrer_label ? ` — ref:${c.referrer_label}` : ''
|
||||
return `• ${c.code} [${status}] ${off} uses ${usage} ${exp} ${target}${ref}`
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.codes.length} discount code(s)`,
|
||||
message: lines.join('\n'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -4,27 +4,28 @@
|
||||
// troubleshooting a multi-seat cap ("can't activate, too many machines").
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to inspect.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
include_inactive: {
|
||||
type: 'toggle',
|
||||
}),
|
||||
include_inactive: Value.toggle({
|
||||
name: 'Include deactivated machines',
|
||||
description: 'Show rows for machines that were previously deactivated.',
|
||||
default: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const listMachines = sdk.Action.withInput(
|
||||
'listMachines',
|
||||
async ({ effects }) => ({
|
||||
'list-machines',
|
||||
async () => ({
|
||||
name: 'List machines',
|
||||
description: 'Show installs currently bound to a license.',
|
||||
warning: null,
|
||||
@@ -33,15 +34,17 @@ export const listMachines = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const params = new URLSearchParams()
|
||||
params.set('license_id', formInput.license_id)
|
||||
if (formInput.include_inactive) params.set('include_inactive', 'true')
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/machines?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -60,7 +63,12 @@ export const listMachines = sdk.Action.withInput(
|
||||
}>
|
||||
}
|
||||
if (body.machines.length === 0) {
|
||||
return { message: 'No machines bound to this license.' }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No machines',
|
||||
message: 'No machines bound to this license.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.machines.map((m) => {
|
||||
const activeStr =
|
||||
@@ -76,10 +84,13 @@ export const listMachines = sdk.Action.withInput(
|
||||
return '• ' + bits.filter(Boolean).join(' ')
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.machines.length} machine(s)`,
|
||||
message:
|
||||
`${body.machines.length} machine(s) on license ${formInput.license_id}:\n\n` +
|
||||
lines.join('\n') +
|
||||
'\n\nTo free a seat, use the "Deactivate machine" action with the machine id.',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// masked — rotate by deleting and recreating an endpoint.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
export const listWebhooks = sdk.Action.withoutInput(
|
||||
'listWebhooks',
|
||||
async ({ effects }) => ({
|
||||
'list-webhooks',
|
||||
async () => ({
|
||||
name: 'List webhook endpoints',
|
||||
description: 'Show all currently-registered outbound webhook subscribers.',
|
||||
warning: null,
|
||||
@@ -16,11 +17,12 @@ export const listWebhooks = sdk.Action.withoutInput(
|
||||
group: 'Webhooks',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
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,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/webhook-endpoints',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -38,9 +40,12 @@ export const listWebhooks = sdk.Action.withoutInput(
|
||||
}
|
||||
if (body.endpoints.length === 0) {
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No webhooks',
|
||||
message:
|
||||
'No webhook endpoints registered. Use "Register webhook endpoint" ' +
|
||||
'to add one.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.endpoints.map((ep) => {
|
||||
@@ -49,8 +54,11 @@ export const listWebhooks = sdk.Action.withoutInput(
|
||||
(ep.description ? ` ("${ep.description}")` : '')
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.endpoints.length} endpoint(s)`,
|
||||
message:
|
||||
`${body.endpoints.length} endpoint(s):\n\n` + lines.join('\n'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,19 +7,20 @@
|
||||
// `sha256=<hex>` — same shape as BTCPay's outbound hooks.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
url: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
url: Value.text({
|
||||
name: 'Webhook URL',
|
||||
description: 'HTTPS endpoint that will receive POSTed event bodies.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [{ regex: '^https?://', description: 'must be an HTTP(S) URL' }],
|
||||
},
|
||||
event_types: {
|
||||
type: 'text',
|
||||
}),
|
||||
event_types: Value.text({
|
||||
name: 'Event types',
|
||||
description:
|
||||
'Comma-separated list of events to subscribe to, or "*" for all. ' +
|
||||
@@ -28,19 +29,18 @@ const input = sdk.InputSpec.of({
|
||||
'machine.activated, machine.deactivated, invoice.settled.',
|
||||
required: true,
|
||||
default: '*',
|
||||
},
|
||||
description: {
|
||||
type: 'text',
|
||||
}),
|
||||
description: Value.text({
|
||||
name: 'Description',
|
||||
description: 'Free-form label, shown in the admin list.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const registerWebhook = sdk.Action.withInput(
|
||||
'registerWebhook',
|
||||
async ({ effects }) => ({
|
||||
'register-webhook',
|
||||
async () => ({
|
||||
name: 'Register webhook endpoint',
|
||||
description:
|
||||
'Tell Keysat to POST signed event notifications to an HTTPS URL you ' +
|
||||
@@ -53,8 +53,10 @@ export const registerWebhook = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const eventTypes = formInput.event_types
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
@@ -64,7 +66,7 @@ export const registerWebhook = sdk.Action.withInput(
|
||||
}
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/webhook-endpoints',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -85,13 +87,22 @@ export const registerWebhook = sdk.Action.withInput(
|
||||
event_types: string[]
|
||||
}
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Webhook registered',
|
||||
message:
|
||||
`Registered webhook endpoint (id ${ep.id}).\n` +
|
||||
`URL: ${ep.url}\n` +
|
||||
`Events: ${ep.event_types.join(', ')}\n\n` +
|
||||
`HMAC secret (save this now — will not be shown again):\n${ep.secret}\n\n` +
|
||||
`Save the HMAC secret shown below — it will not be displayed again.\n\n` +
|
||||
`Verify incoming requests with header X-Keysat-Signature: sha256=<hex> ` +
|
||||
`(HMAC-SHA256 of the raw request body using this secret).`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: ep.secret,
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,44 +1,49 @@
|
||||
// Action: revoke an existing license.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to revoke. Find via list-licenses action.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: 'text',
|
||||
}),
|
||||
reason: Value.text({
|
||||
name: 'Reason',
|
||||
description: 'Stored for audit. E.g., "chargeback", "key leaked".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const revokeLicense = sdk.Action.withInput(
|
||||
'revokeLicense',
|
||||
async ({ effects }) => ({
|
||||
'revoke-license',
|
||||
async () => ({
|
||||
name: 'Revoke license',
|
||||
description:
|
||||
'Mark a license as revoked (one-way; use "Suspend license" for a ' +
|
||||
'reversible lockout). The next time downstream software checks ' +
|
||||
'revocation, it will be denied.',
|
||||
warning: 'Revocation takes effect on the next online validation. Clients with cached results may continue running until their cache expires.',
|
||||
warning:
|
||||
'Revocation takes effect on the next online validation. Clients with ' +
|
||||
'cached results may continue running until their cache expires.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Licenses',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -48,6 +53,11 @@ export const revokeLicense = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Revoke failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Revoked license ${formInput.license_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'License revoked',
|
||||
message: `Revoked license ${formInput.license_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,35 +5,35 @@
|
||||
// matching licenses with IDs, product slugs, and current status.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
buyer_email: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
buyer_email: Value.text({
|
||||
name: 'Buyer email',
|
||||
description: 'Exact-match email address (leave blank if searching by another field).',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
nostr_npub: {
|
||||
type: 'text',
|
||||
}),
|
||||
nostr_npub: Value.text({
|
||||
name: 'Nostr npub',
|
||||
description: 'Nostr public key (npub…). Optional.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
invoice_id: {
|
||||
type: 'text',
|
||||
}),
|
||||
invoice_id: Value.text({
|
||||
name: 'BTCPay invoice ID',
|
||||
description: 'The BTCPay invoice ID associated with a purchase. Optional.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const searchLicenses = sdk.Action.withInput(
|
||||
'searchLicenses',
|
||||
async ({ effects }) => ({
|
||||
'search-licenses',
|
||||
async () => ({
|
||||
name: 'Search licenses',
|
||||
description:
|
||||
"Look up a buyer's licenses by email, Nostr npub, or BTCPay " +
|
||||
@@ -44,8 +44,10 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (formInput.buyer_email) params.set('buyer_email', formInput.buyer_email)
|
||||
@@ -57,7 +59,7 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/search?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -75,7 +77,12 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
}>
|
||||
}
|
||||
if (body.licenses.length === 0) {
|
||||
return { message: 'No licenses matched.' }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No matches',
|
||||
message: 'No licenses matched.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.licenses.map(
|
||||
(l) =>
|
||||
@@ -85,11 +92,14 @@ export const searchLicenses = sdk.Action.withInput(
|
||||
(l.expires_at ? ` expires=${l.expires_at}` : ''),
|
||||
)
|
||||
return {
|
||||
version: '1',
|
||||
title: `Found ${body.licenses.length} license(s)`,
|
||||
message:
|
||||
`Found ${body.licenses.length} license(s):\n\n` +
|
||||
lines.join('\n') +
|
||||
'\n\nTo reissue the key to the buyer, look up the license details ' +
|
||||
'via /v1/admin/licenses with the admin API key.',
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,36 +1,75 @@
|
||||
// Action: set the operator display name shown on the service homepage.
|
||||
//
|
||||
// As of v0.1.0:7+ this writes to the daemon's runtime settings table via
|
||||
// the admin API, so changes take effect immediately without a daemon
|
||||
// restart. We also mirror the value to the wrapper's package store so
|
||||
// the StartOS prefill / future env-var handoff remains consistent.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
operator_name: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
operator_name: Value.text({
|
||||
name: 'Operator name',
|
||||
description:
|
||||
'Displayed on the service homepage so buyers know whose Keysat ' +
|
||||
'instance they are interacting with. E.g., your name or business name.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const setOperatorName = sdk.Action.withInput(
|
||||
'setOperatorName',
|
||||
async ({ effects }) => ({
|
||||
'set-operator-name',
|
||||
async () => ({
|
||||
name: 'Set operator name',
|
||||
description: 'Edit the operator name shown publicly.',
|
||||
description: 'Edit the operator name shown publicly. Takes effect immediately — no restart required.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'General',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
// Pre-fill the form with the current value.
|
||||
async ({ effects: _effects }) => {
|
||||
const current = await store.read().once()
|
||||
return current?.operator_name ? { operator_name: current.operator_name } : null
|
||||
},
|
||||
async ({ effects, input: formInput }) => {
|
||||
const current = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
await sdk.store.setOwn(effects, sdk.StorePath, {
|
||||
...current,
|
||||
operator_name: formInput.operator_name,
|
||||
})
|
||||
return { message: `Operator name set to ${formInput.operator_name}. Restart the service to apply.` }
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const trimmed = formInput.operator_name.trim()
|
||||
|
||||
// Live-update the daemon via admin endpoint. This stores the value
|
||||
// in the daemon's settings table and the very next request to / or
|
||||
// /thank-you uses it. No restart needed.
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
storeData.admin_api_key,
|
||||
'/v1/admin/settings/operator-name',
|
||||
{ method: 'POST', body: JSON.stringify({ name: trimmed }) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(
|
||||
`Operator-name update failed: HTTP ${resp.status} — ${await resp.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror to the wrapper store. This isn't strictly required (the
|
||||
// daemon owns the live value), but it keeps the prefill working
|
||||
// and gives us a fallback path during package upgrades.
|
||||
await store.merge(effects, { operator_name: trimmed })
|
||||
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Operator name updated',
|
||||
message:
|
||||
`Operator name set to "${trimmed}". The change is live immediately — ` +
|
||||
`no restart needed. Anyone visiting your service homepage from now on will see the new name.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
// The BTCPay webhook secret used to live in the StartOS store; it now lives
|
||||
// inside the daemon's own SQLite database, generated automatically during
|
||||
// the "Connect BTCPay" authorize flow. Operators don't need to know it.
|
||||
//
|
||||
// SDK 0.4.0 shape: `Action.withoutInput(id, metadata, run)` — the run fn is
|
||||
// the third positional arg, not a chained `.withoutRunner(...)` method.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
|
||||
export const showCredentials = sdk.Action.withoutInput(
|
||||
'showCredentials',
|
||||
async ({ effects }) => ({
|
||||
'show-credentials',
|
||||
async () => ({
|
||||
name: 'Show admin API key',
|
||||
description:
|
||||
'Display the auto-generated admin API key. Treat it like a password — ' +
|
||||
@@ -24,13 +28,23 @@ export const showCredentials = sdk.Action.withoutInput(
|
||||
group: 'Credentials',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
).withoutRunner(async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
return {
|
||||
message:
|
||||
`Admin API key:\n${store.admin_api_key}\n\n` +
|
||||
`Used as 'Authorization: Bearer <key>' against /v1/admin/*. All ` +
|
||||
`StartOS actions already supply this for you — only export it if ` +
|
||||
`you intend to script against the admin API from outside the box.`,
|
||||
}
|
||||
})
|
||||
async () => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
return {
|
||||
version: '1',
|
||||
title: 'Admin API key',
|
||||
message:
|
||||
`Used as 'Authorization: Bearer <key>' against /v1/admin/*. All ` +
|
||||
`StartOS actions already supply this for you — only export it if ` +
|
||||
`you intend to script against the admin API from outside the box.`,
|
||||
result: {
|
||||
type: 'single',
|
||||
value: storeData.admin_api_key,
|
||||
copyable: true,
|
||||
qr: true,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,28 +5,29 @@
|
||||
// disputes where the outcome isn't yet known.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to suspend. Find via search-licenses action.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: 'text',
|
||||
}),
|
||||
reason: Value.text({
|
||||
name: 'Reason',
|
||||
description: 'Stored for audit. E.g., "payment dispute pending".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const suspendLicense = sdk.Action.withInput(
|
||||
'suspendLicense',
|
||||
async ({ effects }) => ({
|
||||
'suspend-license',
|
||||
async () => ({
|
||||
name: 'Suspend license',
|
||||
description:
|
||||
'Temporarily disable a license. Validation calls will fail with a ' +
|
||||
@@ -40,11 +41,13 @@ export const suspendLicense = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/suspend`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -54,6 +57,11 @@ export const suspendLicense = sdk.Action.withInput(
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Suspend failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Suspended license ${formInput.license_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'License suspended',
|
||||
message: `Suspended license ${formInput.license_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
// Action: clear a previously-applied suspension.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
license_id: Value.text({
|
||||
name: 'License ID',
|
||||
description: 'UUID of the suspended license to re-enable.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const unsuspendLicense = sdk.Action.withInput(
|
||||
'unsuspendLicense',
|
||||
async ({ effects }) => ({
|
||||
'unsuspend-license',
|
||||
async () => ({
|
||||
name: 'Unsuspend license',
|
||||
description:
|
||||
'Lift a previous suspension. Validation will succeed again on the ' +
|
||||
@@ -27,17 +29,24 @@ export const unsuspendLicense = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/unsuspend`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Unsuspend failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Unsuspended license ${formInput.license_id}.` }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'License unsuspended',
|
||||
message: `Unsuspended license ${formInput.license_id}.`,
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
// operator can skim without curl.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
limit: {
|
||||
type: 'number',
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const input = InputSpec.of({
|
||||
limit: Value.number({
|
||||
name: 'Limit',
|
||||
description: 'Number of most recent entries to return (1–1000).',
|
||||
required: true,
|
||||
@@ -18,21 +20,20 @@ const input = sdk.InputSpec.of({
|
||||
min: 1,
|
||||
max: 1000,
|
||||
integer: true,
|
||||
},
|
||||
action: {
|
||||
type: 'text',
|
||||
}),
|
||||
action: Value.text({
|
||||
name: 'Filter action',
|
||||
description:
|
||||
'Optional action slug to filter on. E.g., "license.revoke", ' +
|
||||
'"license.suspend", "policy.create", "webhook_endpoint.create".',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const viewAuditLog = sdk.Action.withInput(
|
||||
'viewAuditLog',
|
||||
async ({ effects }) => ({
|
||||
'view-audit-log',
|
||||
async () => ({
|
||||
name: 'View audit log',
|
||||
description:
|
||||
'Show the most recent admin mutations recorded by the service — ' +
|
||||
@@ -44,15 +45,17 @@ export const viewAuditLog = sdk.Action.withInput(
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
async () => null,
|
||||
async ({ effects: _effects, input: formInput }) => {
|
||||
const storeData = await store.read().once()
|
||||
if (!storeData) throw new Error('Store not initialized — restart the service.')
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(formInput.limit))
|
||||
if (formInput.action) params.set('action', formInput.action)
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
storeData.admin_api_key,
|
||||
`/v1/admin/audit?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
@@ -72,7 +75,12 @@ export const viewAuditLog = sdk.Action.withInput(
|
||||
}>
|
||||
}
|
||||
if (body.entries.length === 0) {
|
||||
return { message: 'No audit entries match the filter.' }
|
||||
return {
|
||||
version: '1',
|
||||
title: 'No entries',
|
||||
message: 'No audit entries match the filter.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
const lines = body.entries.map((e) => {
|
||||
const target = e.target_type && e.target_id ? `${e.target_type}:${e.target_id}` : '(no target)'
|
||||
@@ -81,8 +89,11 @@ export const viewAuditLog = sdk.Action.withInput(
|
||||
return `• ${e.created_at} ${e.action} ${target} ${actor} ${ip}`
|
||||
})
|
||||
return {
|
||||
version: '1',
|
||||
title: `${body.entries.length} entry(ies)`,
|
||||
message:
|
||||
`${body.entries.length} entry(ies):\n\n` + lines.join('\n'),
|
||||
result: null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+11
-3
@@ -3,9 +3,17 @@
|
||||
// Everything important lives in the `main` volume (SQLite DB, which in turn
|
||||
// contains the signing key). StartOS's default backup mechanism captures
|
||||
// the whole volume, so we don't need custom backup logic — we just opt in.
|
||||
//
|
||||
// `setupBackups` returns `{ createBackup, restoreInit }`. `createBackup` is
|
||||
// the package-level backup export; `restoreInit` is an InitScript we chain
|
||||
// into `sdk.setupInit(...)` so that a restore triggers the right init
|
||||
// sequence after the volume is repopulated.
|
||||
//
|
||||
// NOTE: The JSDoc example in 0.4.0 shows `sdk.Backups.volumes('main')`, but
|
||||
// the actual runtime/type name is `ofVolumes`. The example is stale.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const { createBackup, restoreBackup } = sdk.setupBackups(async ({ effects }) => [
|
||||
sdk.Backups.volumes('main'),
|
||||
])
|
||||
export const { createBackup, restoreInit } = sdk.setupBackups(
|
||||
async () => sdk.Backups.ofVolumes('main'),
|
||||
)
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
// - prevent starting if BTCPay isn't installed,
|
||||
// - gate our service's health status on BTCPay's,
|
||||
// - provide the `btcpayserver.startos` hostname inside our container.
|
||||
//
|
||||
// versionRange uses ExVer (StartOS's Extended Versioning). The ':0' suffix
|
||||
// is the downstream revision; ':0' is the conventional value meaning "any
|
||||
// downstream revision of upstream version 1.11.0 or later".
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
|
||||
export const setDependencies = sdk.setupDependencies(async ({ effects: _effects }) => {
|
||||
return {
|
||||
btcpayserver: {
|
||||
kind: 'running',
|
||||
versionRange: '>=1.11.0',
|
||||
versionRange: '>=1.11.0:0',
|
||||
healthChecks: [],
|
||||
},
|
||||
}
|
||||
|
||||
+20
-16
@@ -3,27 +3,31 @@
|
||||
// between service starts (e.g., the generated admin API key so we don't
|
||||
// regenerate it on every restart).
|
||||
//
|
||||
// StartOS persists this JSON through upgrades and backs it up automatically.
|
||||
// StartOS persists this JSON through upgrades and backs it up automatically
|
||||
// (the file lives alongside the package data dir).
|
||||
//
|
||||
// In 0.4.0.x we model this with `FileHelper.json` + a Zod schema. Consumers
|
||||
// read via `store.read().once()` (fire-and-forget) or `store.read().const(effects)`
|
||||
// (re-runs the calling context if the file changes), and write with
|
||||
// `store.write(effects, data)` or `store.merge(effects, partial)`.
|
||||
|
||||
import { matches } from '@start9labs/start-sdk'
|
||||
import { FileHelper } from '@start9labs/start-sdk'
|
||||
import { z } from 'zod'
|
||||
|
||||
const { arr, num, obj, oneOf, literal, string } = matches
|
||||
|
||||
export const storeShape = obj({
|
||||
export const storeShape = z.object({
|
||||
// Admin API key for /v1/admin/* endpoints. Auto-generated on first init.
|
||||
admin_api_key: string,
|
||||
// Shared webhook secret configured on both sides (BTCPay + our service).
|
||||
btcpay_webhook_secret: string,
|
||||
admin_api_key: z.string(),
|
||||
// Shared webhook secret historically configured on both sides (BTCPay +
|
||||
// our service). Kept in the shape for backwards compatibility with
|
||||
// installs made before the one-click "Connect BTCPay" authorize flow; the
|
||||
// daemon now generates and persists its own webhook secret.
|
||||
btcpay_webhook_secret: z.string(),
|
||||
// Operator display name shown on the service homepage.
|
||||
operator_name: string,
|
||||
operator_name: z.string(),
|
||||
// Tracks which version's init has already been applied.
|
||||
schema_version: num,
|
||||
schema_version: z.number(),
|
||||
})
|
||||
|
||||
export type Store = typeof storeShape._TYPE
|
||||
export type Store = z.infer<typeof storeShape>
|
||||
|
||||
export const store = {
|
||||
shape: storeShape,
|
||||
// Defaults. Populated for real during init.
|
||||
path: 'store.json' as const,
|
||||
}
|
||||
export const store = FileHelper.json('store.json', storeShape)
|
||||
|
||||
+43
-16
@@ -1,29 +1,56 @@
|
||||
// StartOS entry point. Glues every module together so `start-cli` can pack
|
||||
// the package.
|
||||
// StartOS entry point. Composes every module together so `start-cli` can
|
||||
// pack the package and so StartOS can find the expected exports.
|
||||
//
|
||||
// The ABI StartOS expects (see ExpectedExports in the SDK):
|
||||
// - manifest
|
||||
// - main
|
||||
// - init
|
||||
// - uninit
|
||||
// - createBackup
|
||||
// - actions
|
||||
//
|
||||
// In SDK 0.4.0 `setupInit(...inits)` / `setupUninit(...uninits)` are variadic
|
||||
// — each argument is either an InitScript/UninitScript or an
|
||||
// InitFn/UninitFn. They run in the order provided.
|
||||
//
|
||||
// Ordering of init scripts matters:
|
||||
// 1. restoreInit — repopulates the main volume from backup if applicable
|
||||
// 2. versions — runs any pending migrations from the version graph
|
||||
// 3. initFn — our own first-boot key generation
|
||||
// 4. setDependencies — publishes our declared dependency on BTCPay
|
||||
// 5. setInterfaces — publishes the public-facing API + webhook URL
|
||||
// 6. actions — registers the admin actions with StartOS
|
||||
|
||||
import { buildManifest } from '@start9labs/start-sdk'
|
||||
import { sdk } from './sdk'
|
||||
|
||||
import { actions } from './actions'
|
||||
import { createBackup, restoreBackup } from './backups'
|
||||
import { createBackup, restoreInit } from './backups'
|
||||
import { setDependencies } from './dependencies'
|
||||
import { initFn, uninitFn } from './init'
|
||||
import { setInterfaces } from './interfaces'
|
||||
import { main } from './main'
|
||||
import { manifest } from './manifest'
|
||||
import { manifest as sdkManifest } from './manifest'
|
||||
import { versions } from './versions'
|
||||
|
||||
export const { packageInit, packageUninit, containerInit } = sdk.setupPackageInit({
|
||||
init: initFn,
|
||||
uninit: uninitFn,
|
||||
})
|
||||
// `setupManifest(...)` in `./manifest` produces the raw SDKManifest.
|
||||
// `buildManifest(versions, sdkManifest)` injects `version`, `sdkVersion`,
|
||||
// `releaseNotes`, `canMigrateTo/From`, normalized `alerts`, `images`
|
||||
// defaults, etc — producing the final T.Manifest that `start-cli s9pk pack`
|
||||
// serializes. Exporting the raw SDKManifest here (without buildManifest)
|
||||
// causes start-cli to fail with: `Deserialization Error: missing field
|
||||
// `version``.
|
||||
export const manifest = buildManifest(versions, sdkManifest)
|
||||
|
||||
export {
|
||||
manifest,
|
||||
main,
|
||||
actions,
|
||||
export const init = sdk.setupInit(
|
||||
restoreInit,
|
||||
versions,
|
||||
initFn,
|
||||
setDependencies,
|
||||
setInterfaces,
|
||||
createBackup,
|
||||
restoreBackup,
|
||||
versions,
|
||||
}
|
||||
actions,
|
||||
)
|
||||
|
||||
export const uninit = sdk.setupUninit(versions, uninitFn)
|
||||
|
||||
export { main, actions, createBackup }
|
||||
|
||||
+50
-7
@@ -1,24 +1,38 @@
|
||||
// First-boot initialization.
|
||||
//
|
||||
// On fresh install:
|
||||
// - Generate an admin API key (stored in the StartOS store; user can
|
||||
// retrieve it via an action if they need to script against the API).
|
||||
// - Generate an admin API key (stored in the StartOS package-local store;
|
||||
// user can retrieve it via the `showCredentials` action if they need to
|
||||
// script against the API).
|
||||
// - Surface "Connect BTCPay" as a critical task so the operator sees a
|
||||
// clear "do this next" prompt in the StartOS dashboard. Cleared by the
|
||||
// btcpayStatus action once BTCPay reports connected (see
|
||||
// ../actions/configureBtcpay.ts).
|
||||
//
|
||||
// The BTCPay webhook secret is no longer stored here — the daemon generates
|
||||
// and persists it in its own DB during the one-click "Connect BTCPay" flow.
|
||||
// The field is kept in the store shape for backward compatibility with
|
||||
// installs made before v0.1.0; it is not used.
|
||||
// installs made before the authorize flow; it is not authoritative.
|
||||
//
|
||||
// On subsequent boots this is a no-op (keys already exist).
|
||||
//
|
||||
// SDK 0.4.0 note: InitFn signature is `(effects, kind)` positional — NOT the
|
||||
// 0.3.x `({effects})` object destructure. `setupOnInit` wraps the function
|
||||
// into an InitScript so it can be composed with `setupInit(...)`.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { store } from '../fileModels/store'
|
||||
import { generateSecret } from '../utils'
|
||||
import { configureBtcpay } from '../actions/configureBtcpay'
|
||||
|
||||
export const initFn = sdk.setupOnInit(async ({ effects }) => {
|
||||
const current = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
/** Replay id used to dedupe + later-clear the BTCPay setup task. */
|
||||
export const BTCPAY_SETUP_TASK_ID = 'btcpay-initial-setup'
|
||||
|
||||
export const initFn = sdk.setupOnInit(async (effects, kind) => {
|
||||
const current = await store.read().once()
|
||||
|
||||
if (!current || current.schema_version === 0 || current.schema_version === undefined) {
|
||||
await sdk.store.setOwn(effects, sdk.StorePath, {
|
||||
await store.write(effects, {
|
||||
admin_api_key: current?.admin_api_key || generateSecret(32),
|
||||
// Kept in the shape for backcompat; no longer authoritative.
|
||||
btcpay_webhook_secret: current?.btcpay_webhook_secret || '',
|
||||
@@ -26,9 +40,38 @@ export const initFn = sdk.setupOnInit(async ({ effects }) => {
|
||||
schema_version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// Surface BTCPay setup as a prominent task on first install and on
|
||||
// restore (a backup older than the BTCPay-authorize flow may not have a
|
||||
// valid BTCPay config). On regular updates / container rebuilds we
|
||||
// skip — BTCPay should already be connected by then. createOwnTask is
|
||||
// idempotent on the same replayId, so a re-run won't duplicate.
|
||||
//
|
||||
// Severity is 'important', not 'critical', because 'critical' blocks
|
||||
// the service from STARTING until the task is completed — but the
|
||||
// configureBtcpay action requires the service to BE running (it makes
|
||||
// an HTTP call to the local daemon to kick off the authorize flow).
|
||||
// 'critical' would deadlock: task blocks start, action needs running.
|
||||
// 'important' shows the task prominently without blocking startup.
|
||||
if (kind === 'install' || kind === 'restore') {
|
||||
try {
|
||||
await sdk.action.createOwnTask(effects, configureBtcpay, 'important', {
|
||||
replayId: BTCPAY_SETUP_TASK_ID,
|
||||
reason:
|
||||
'Connect Keysat to your BTCPay Server to start selling licenses. ' +
|
||||
'Your BTCPay instance on this Start9 is already a declared ' +
|
||||
'dependency — Keysat just needs to authorize against it.',
|
||||
})
|
||||
} catch (e) {
|
||||
// Don't block init on a task-create failure. Operators can still
|
||||
// run "Connect BTCPay" manually.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('createOwnTask(configureBtcpay) failed:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const uninitFn = sdk.setupOnUninit(async ({ effects }) => {
|
||||
export const uninitFn = sdk.setupOnUninit(async (_effects, _target) => {
|
||||
// Nothing to tear down at the StartOS level — the DB volume is handled by
|
||||
// StartOS directly when the package is uninstalled.
|
||||
})
|
||||
|
||||
+54
-14
@@ -1,22 +1,30 @@
|
||||
// Network interfaces exposed by the service.
|
||||
//
|
||||
// Two logical interfaces:
|
||||
// - `api` — the REST API that buyers (purchase flow) and licensed
|
||||
// software (validate flow) hit. Must be reachable from
|
||||
// outside the StartOS host if you're selling to the public,
|
||||
// so we expose it on LAN + Tor + optional clearnet.
|
||||
// - `webhook` — the BTCPay webhook landing endpoint. Only BTCPay needs to
|
||||
// reach it; same-host LAN is sufficient.
|
||||
// Three logical interfaces, all sharing the same internal port (8080).
|
||||
// The Rust daemon routes by path, and StartOS uses the interface
|
||||
// concept for *access surfaces* and *display grouping*.
|
||||
//
|
||||
// In practice both live on the same HTTP port (8080) because the service
|
||||
// routes by path. StartOS's interface concept is about *access surfaces*
|
||||
// and *display grouping*, not separate ports.
|
||||
// - `api` — the REST API that buyers (purchase flow) and licensed
|
||||
// software (validate flow) hit. Must be reachable from
|
||||
// outside the host if you're selling to the public.
|
||||
// - `webhook` — the BTCPay webhook landing endpoint. Only BTCPay needs
|
||||
// to reach it; same-host LAN is sufficient.
|
||||
// - `admin-ui` — the embedded admin web UI (rust-embed at /admin/).
|
||||
// type: 'ui' so StartOS surfaces a "Launch UI" button.
|
||||
// Operator should restrict this interface's exposure
|
||||
// to LAN-only or Tor-only — the public clearnet
|
||||
// doesn't need to see it. (For v0.2 follow-up: split
|
||||
// onto a separate port so it can be fully isolated
|
||||
// from the public api.)
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
const apiMulti = sdk.MultiHost.of(effects, 'api-multi')
|
||||
await apiMulti.bindPort(8080, { protocol: 'http', preferredExternalPort: 443 })
|
||||
const multi = sdk.MultiHost.of(effects, 'api-multi')
|
||||
const origin = await multi.bindPort(8080, {
|
||||
protocol: 'http',
|
||||
preferredExternalPort: 443,
|
||||
})
|
||||
|
||||
const api = sdk.createInterface(effects, {
|
||||
name: 'Licensing API',
|
||||
@@ -26,7 +34,6 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
'the URL you share with customers and bake into your own software ' +
|
||||
'builds as the licensing endpoint.',
|
||||
type: 'api',
|
||||
hasPrimary: true,
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
@@ -34,5 +41,38 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
query: {},
|
||||
})
|
||||
|
||||
return [await api.export([apiMulti])]
|
||||
const webhook = sdk.createInterface(effects, {
|
||||
name: 'BTCPay webhook endpoint',
|
||||
id: 'webhook',
|
||||
description:
|
||||
'The landing URL for BTCPay webhook callbacks. Not intended for ' +
|
||||
'human use — Keysat registers this URL with BTCPay automatically ' +
|
||||
'during the one-click "Connect BTCPay" flow.',
|
||||
type: 'api',
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
path: '/btcpay',
|
||||
query: {},
|
||||
})
|
||||
|
||||
const adminUi = sdk.createInterface(effects, {
|
||||
name: 'Admin Web UI',
|
||||
id: 'admin-ui',
|
||||
description:
|
||||
'Embedded admin dashboard — manage products, policies, discount ' +
|
||||
'codes, licenses, machines, webhooks, and audit log without ' +
|
||||
'leaving the browser. Login is gated by your Keysat admin API key. ' +
|
||||
'Recommended: restrict this interface to LAN or Tor only; the ' +
|
||||
'public clearnet does not need to reach the admin UI.',
|
||||
type: 'ui',
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
path: '/admin',
|
||||
query: {},
|
||||
})
|
||||
|
||||
const receipt = await origin.export([api, webhook, adminUi])
|
||||
return [receipt]
|
||||
})
|
||||
|
||||
+202
-15
@@ -1,41 +1,228 @@
|
||||
// Daemon definition — the thing that actually runs when the service is
|
||||
// started. Passes configuration into the Rust binary via environment
|
||||
// variables, same interface as `.env.example` in the upstream project.
|
||||
// variables (same interface as `.env.example` in the upstream project).
|
||||
//
|
||||
// SDK 0.4.0 shape:
|
||||
// - `setupMain(async ({ effects }) => Daemons)` — no `started` any more.
|
||||
// - Mounts are built via `sdk.Mounts.of().mountVolume(...)` (immutable
|
||||
// builder) and passed as a single object to `sdk.SubContainer.of`.
|
||||
// - Daemons are created via `sdk.Daemons.of(effects)` (effects directly).
|
||||
// - Store reads use the FileHelper reactive API: `.read().const(effects)`
|
||||
// so the daemon re-runs if the store changes at runtime.
|
||||
// - The public URL is read from our own `api` service interface via
|
||||
// `sdk.serviceInterface.getOwn(...).const()` + `.addressInfo.nonLocal.format()`.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
import { store } from './fileModels/store'
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects, started }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
/**
|
||||
* Pick a URL from a service interface's address list that's actually
|
||||
* reachable from the operator's normal-LAN browser.
|
||||
*
|
||||
* StartOS hands us a list of URLs the service is reachable on, but they
|
||||
* vary in who-can-reach-them:
|
||||
* - mDNS `.local` hostname → reachable on the operator's LAN
|
||||
* - LAN RFC1918 IP (192.168, etc) → reachable on the operator's LAN
|
||||
* - public clearnet URL → reachable from anywhere
|
||||
* - StartTunnel local IP (10.59) → only reachable inside StartOS
|
||||
* - .startos bridge hostname → only reachable inside containers
|
||||
* - localhost / 127.x → only reachable inside the container
|
||||
*
|
||||
* Naively picking `addressInfo.nonLocal.format()[0]` can land on the
|
||||
* StartTunnel-local IP, which breaks any flow where the operator's
|
||||
* browser actually has to follow the URL. This helper ranks URLs by
|
||||
* realistic browser reachability instead.
|
||||
*/
|
||||
// Shared URL filters used by both pickers below.
|
||||
function isLocalhost(u: string): boolean {
|
||||
return (
|
||||
u.startsWith('http://localhost') ||
|
||||
u.startsWith('https://localhost') ||
|
||||
u.startsWith('http://127.') ||
|
||||
u.startsWith('https://127.')
|
||||
)
|
||||
}
|
||||
function isBridge(u: string): boolean {
|
||||
return u.includes('.startos:')
|
||||
}
|
||||
function isMdns(u: string): boolean {
|
||||
return /\/\/[^/:]+\.local(:|\/)/.test(u)
|
||||
}
|
||||
function isRfc1918(u: string): boolean {
|
||||
return (
|
||||
/\/\/192\.168\.\d+\.\d+(:|\/)/.test(u) ||
|
||||
/\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:|\/)/.test(u) ||
|
||||
// Real RFC1918 10.0.0.0/8 — but exclude StartTunnel's 10.59.x.x range
|
||||
// which is StartOS-internal and not reachable from a normal browser.
|
||||
(/\/\/10\.\d+\.\d+\.\d+(:|\/)/.test(u) && !/\/\/10\.59\./.test(u))
|
||||
)
|
||||
}
|
||||
function isStarttunnelLocal(u: string): boolean {
|
||||
return /\/\/10\.59\./.test(u)
|
||||
}
|
||||
function isIpv4(u: string): boolean {
|
||||
return /\/\/\d+\.\d+\.\d+\.\d+(:|\/)/.test(u)
|
||||
}
|
||||
function isIpv6Bracketed(u: string): boolean {
|
||||
return /\/\/\[/.test(u)
|
||||
}
|
||||
|
||||
// Public URL the service advertises to buyers / referenced in webhooks.
|
||||
// We read our own primary interface address from StartOS at runtime so
|
||||
// this works whether the operator exposes on Tor, LAN, or clearnet.
|
||||
const publicUrl = await sdk.serviceInterface
|
||||
.getOwn(effects, 'api')
|
||||
/// Pick a URL the OPERATOR's browser can reach during one-time setup
|
||||
/// flows (OAuth authorize, etc.). Operator is typically on the same
|
||||
/// LAN as the Start9, so mDNS / RFC1918 LAN URLs are preferred —
|
||||
/// they're faster and don't depend on Cloudflare being up.
|
||||
function pickBrowserUrl(
|
||||
allUrls: string[],
|
||||
addrInfo?: { nonLocal: { format(): string[] } } | null | undefined,
|
||||
): string | undefined {
|
||||
const browserUsable = (u: string) =>
|
||||
!isLocalhost(u) && !isBridge(u) && !isStarttunnelLocal(u)
|
||||
|
||||
const mdnsUrls = allUrls.filter((u) => isMdns(u) && browserUsable(u))
|
||||
if (mdnsUrls.length > 0) return mdnsUrls[0]
|
||||
const lanUrls = allUrls.filter((u) => isRfc1918(u) && browserUsable(u))
|
||||
if (lanUrls.length > 0) return lanUrls[0]
|
||||
const nonLocalUrls = (addrInfo?.nonLocal.format() ?? []).filter(browserUsable)
|
||||
if (nonLocalUrls.length > 0) return nonLocalUrls[0]
|
||||
const anyUsable = allUrls.filter(browserUsable)
|
||||
if (anyUsable.length > 0) return anyUsable[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
/// Pick a URL that BUYERS on the public internet can reach. Used to
|
||||
/// rewrite checkout URLs so they're browser-reachable from anywhere.
|
||||
/// Prefers domain-named URLs (clearnet via StartTunnel) over IP/mDNS
|
||||
/// addresses. Falls back to LAN/mDNS only if no public domain is set
|
||||
/// up — useful for local testing but won't work for real customers.
|
||||
function pickPublicUrl(allUrls: string[]): string | undefined {
|
||||
const usable = allUrls.filter(
|
||||
(u) => !isLocalhost(u) && !isBridge(u) && !isStarttunnelLocal(u),
|
||||
)
|
||||
// Prefer URLs with a real domain name (no IP, no .local).
|
||||
const clearnet = usable.filter(
|
||||
(u) => !isIpv4(u) && !isIpv6Bracketed(u) && !isMdns(u),
|
||||
)
|
||||
if (clearnet.length > 0) return clearnet[0]
|
||||
// Fall back to LAN (still browser-reachable for testing on the same network).
|
||||
const mdns = usable.filter(isMdns)
|
||||
if (mdns.length > 0) return mdns[0]
|
||||
const lan = usable.filter(isRfc1918)
|
||||
if (lan.length > 0) return lan[0]
|
||||
return usable[0]
|
||||
}
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects }) => {
|
||||
const storeData = await store.read().const(effects)
|
||||
if (!storeData) {
|
||||
// Init should always run before main, so this is a real error.
|
||||
throw new Error(
|
||||
'Keysat store.json is missing — init did not run. Try restarting the service.',
|
||||
)
|
||||
}
|
||||
|
||||
// Public URL advertised to buyers / baked into webhook payloads. We read
|
||||
// our own `api` interface from StartOS at runtime so this works whether the
|
||||
// operator exposes on Tor, LAN, or clearnet. `.nonLocal` filters out
|
||||
// localhost/link-local; we pick the first resulting URL, falling back to
|
||||
// localhost only if StartOS hasn't filled in the interface yet.
|
||||
// Pick a browser-reachable URL for ourselves. This is what we hand to
|
||||
// BTCPay as the OAuth redirect_uri (the operator's browser follows it
|
||||
// after clicking Authorize), and it's the URL buyers later use to
|
||||
// poll purchase status. Same ranking logic as for BTCPay's URL —
|
||||
// prefer mDNS .local and RFC1918 LAN IPs, deprioritize StartTunnel
|
||||
// local addresses (10.59.x.x), avoid localhost / bridge.
|
||||
const iface = await sdk.serviceInterface.getOwn(effects, 'api').const()
|
||||
const ownAllUrls = iface?.addressInfo?.format() ?? []
|
||||
// Use the PUBLIC-preferred picker for our own URL — buyers redirected
|
||||
// back from BTCPay after payment hit this URL with their browser; it
|
||||
// needs to be clearnet-resolvable. Falls back to the operator-facing
|
||||
// mDNS/LAN URL if no clearnet domain is set up.
|
||||
const publicUrl =
|
||||
pickPublicUrl(ownAllUrls) ??
|
||||
pickBrowserUrl(ownAllUrls, iface?.addressInfo) ??
|
||||
'http://localhost:8080'
|
||||
|
||||
// BTCPay's PUBLIC web UI URL — distinct from the internal-network
|
||||
// hostname we use for daemon-to-daemon API calls. The operator's
|
||||
// browser is redirected here to authorize Keysat against BTCPay; that
|
||||
// means the URL must be resolvable from a normal browser.
|
||||
//
|
||||
// We can't hardcode BTCPay's interface ID because it's package-
|
||||
// specific (and the previous version of this code guessed wrong by
|
||||
// assuming `'ui'`). Instead, fetch ALL interfaces BTCPay exposes,
|
||||
// pick the one whose TYPE is `'ui'`, and read its address list.
|
||||
// Within that, prefer non-local URLs but accept LAN URLs as a
|
||||
// fallback (they're perfectly browser-reachable for the operator).
|
||||
const btcpayIfaces = await sdk.serviceInterface
|
||||
.getAll(effects, { packageId: 'btcpayserver' })
|
||||
.const()
|
||||
.then((i) => i?.addressInfo?.urls?.[0] ?? 'http://localhost:8080')
|
||||
const ifaceList = btcpayIfaces ?? []
|
||||
const uiIface = ifaceList.find((i) => i.type === 'ui') ?? null
|
||||
const btcpayAllUrls = uiIface?.addressInfo?.format() ?? []
|
||||
const btcpayBrowserUrl = pickBrowserUrl(btcpayAllUrls, uiIface?.addressInfo) ?? ''
|
||||
// PUBLIC URL preference is different — for buyer-facing checkout
|
||||
// URLs we want a clearnet domain that random internet customers
|
||||
// can resolve. Falls back to the operator-facing browser URL (mDNS/
|
||||
// LAN) if no clearnet domain is set up; that's only useful for
|
||||
// local testing but won't break production.
|
||||
const btcpayPublicUrl = pickPublicUrl(btcpayAllUrls) ?? btcpayBrowserUrl
|
||||
console.info(
|
||||
`Keysat BTCPay lookup: ${ifaceList.length} interface(s) declared by btcpayserver. ` +
|
||||
`Types found: [${ifaceList.map((i) => `${i.id}:${i.type}`).join(', ')}]. ` +
|
||||
`Selected ui interface id="${uiIface?.id ?? '(none)'}". ` +
|
||||
`Picked browser URL "${btcpayBrowserUrl || '(none)'}". ` +
|
||||
`Picked public URL "${btcpayPublicUrl || '(none — falling back to internal URL)'}".`,
|
||||
)
|
||||
|
||||
const mounts = sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
mountpoint: '/data',
|
||||
subpath: null,
|
||||
readonly: false,
|
||||
})
|
||||
|
||||
const sub = await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
[{ mountpoint: '/data', volumeId: 'main', subpath: null, readonly: false }],
|
||||
mounts,
|
||||
'keysat',
|
||||
)
|
||||
|
||||
return sdk.Daemons.of({ effects, started, healthReceipts: [] }).addDaemon('primary', {
|
||||
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||
subcontainer: sub,
|
||||
exec: {
|
||||
// Use the Dockerfile's ENTRYPOINT / CMD instead of hardcoding a command
|
||||
// here; the image is the source of truth for how to launch the binary.
|
||||
command: sdk.useEntrypoint(),
|
||||
env: {
|
||||
KEYSAT_BIND: '0.0.0.0:8080',
|
||||
KEYSAT_DB_PATH: '/data/keysat.db',
|
||||
KEYSAT_PUBLIC_URL: publicUrl,
|
||||
KEYSAT_ADMIN_API_KEY: store.admin_api_key,
|
||||
KEYSAT_OPERATOR_NAME: store.operator_name,
|
||||
// Reachable because of our dependency on btcpayserver.
|
||||
KEYSAT_ADMIN_API_KEY: storeData.admin_api_key,
|
||||
KEYSAT_OPERATOR_NAME: storeData.operator_name,
|
||||
// Reachable because of our dependency on btcpayserver. This is
|
||||
// the INTERNAL hostname used for daemon-to-daemon API calls.
|
||||
// Keysat's container can't reliably reach the public StartTunnel
|
||||
// URL from outside (egress is restricted), so all BTCPay API
|
||||
// traffic stays on the local Docker network — fast + always
|
||||
// reachable. The downside (BTCPay returns checkout URLs with
|
||||
// this internal hostname) is mitigated in the daemon: we
|
||||
// rewrite the host of every checkout URL to the public
|
||||
// BTCPAY_BROWSER_URL before handing it back to a buyer.
|
||||
BTCPAY_URL: 'http://btcpayserver.startos:23000',
|
||||
// BTCPay's web UI URL for OPERATOR-facing browser redirects
|
||||
// (the OAuth-style authorize flow). Operator is on the same
|
||||
// LAN as the Start9 typically, so this prefers mDNS / LAN.
|
||||
BTCPAY_BROWSER_URL: btcpayBrowserUrl,
|
||||
// BTCPay's PUBLIC URL for BUYER-facing redirects. Used by the
|
||||
// daemon to rewrite checkout URLs returned by BTCPay so they
|
||||
// resolve from random internet browsers. Prefers clearnet
|
||||
// domain names (e.g. `https://btcpay.your-domain.com`); falls
|
||||
// back to LAN/mDNS only if no public domain is set up. If
|
||||
// empty, daemon won't rewrite (only useful for local testing).
|
||||
BTCPAY_PUBLIC_URL: btcpayPublicUrl,
|
||||
// The three credentials below are left empty in the normal case —
|
||||
// the daemon now persists them in its own DB after the one-click
|
||||
// the daemon persists them in its own DB after the one-click
|
||||
// "Connect BTCPay" action completes. Only seed them here if you are
|
||||
// migrating from a pre-authorize-flow install.
|
||||
BTCPAY_API_KEY: '',
|
||||
|
||||
+29
-22
@@ -1,9 +1,12 @@
|
||||
// StartOS package manifest. Run through `setupManifest()` from the SDK.
|
||||
//
|
||||
// NOTE: This service's source code is source-available but not open source
|
||||
// (see ../../../licensing-service/LICENSE). The `license` field here is
|
||||
// set to 'Proprietary' accordingly — StartOS displays this on the install
|
||||
// page so users know what they're installing.
|
||||
// NOTE: This service's source code is source-available but not open source.
|
||||
// The `license` field takes an SPDX identifier, and the actual license text
|
||||
// must live in a file named `LICENSE` at the package root (start-cli bundles
|
||||
// it as an ingredient). Since this project ships under a custom license, we
|
||||
// use the SPDX `LicenseRef-` prefix per the SPDX spec for non-standard
|
||||
// licenses. The `LICENSE` file at the package root is a copy of
|
||||
// `../licensing-service/LICENSE`.
|
||||
|
||||
import { setupManifest } from '@start9labs/start-sdk'
|
||||
import { short, long } from './i18n'
|
||||
@@ -11,14 +14,14 @@ import { short, long } from './i18n'
|
||||
export const manifest = setupManifest({
|
||||
id: 'keysat',
|
||||
title: 'Keysat',
|
||||
license: 'Proprietary',
|
||||
packageRepo: 'https://github.com/ten31/keysat-startos',
|
||||
upstreamRepo: 'https://github.com/ten31/keysat',
|
||||
marketingUrl: 'https://ten31.xyz/keysat',
|
||||
license: 'LicenseRef-Proprietary',
|
||||
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
|
||||
upstreamRepo: 'https://github.com/keysat-xyz/keysat',
|
||||
marketingUrl: 'https://keysat.xyz',
|
||||
donationUrl: null,
|
||||
docsUrls: [
|
||||
'https://github.com/ten31/keysat/blob/main/README.md',
|
||||
'https://github.com/ten31/keysat/blob/main/docs/INTEGRATION.md',
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/README.md',
|
||||
'https://github.com/keysat-xyz/keysat/blob/main/docs/INTEGRATION.md',
|
||||
],
|
||||
description: { short, long },
|
||||
// A single data volume holds the SQLite database (which in turn holds the
|
||||
@@ -26,15 +29,14 @@ export const manifest = setupManifest({
|
||||
volumes: ['main'],
|
||||
images: {
|
||||
main: {
|
||||
// Built from the project's Dockerfile. The build context is the parent
|
||||
// `Licensing/` directory so the Dockerfile can COPY from the sibling
|
||||
// `licensing-service/` Rust source; a top-level .dockerignore keeps the
|
||||
// uploaded context small.
|
||||
// Built from the project's Dockerfile. Build context is this package
|
||||
// directory itself (the start-cli default). The Rust source is
|
||||
// exposed inside the package dir as `licensing-service/`, which is
|
||||
// a symlink to the sibling `../licensing-service/` repo so the
|
||||
// upstream sources stay in their natural location while the build
|
||||
// context stays self-contained.
|
||||
source: {
|
||||
dockerBuild: {
|
||||
workdir: '..',
|
||||
dockerfile: 'licensing-service-startos/Dockerfile',
|
||||
},
|
||||
dockerBuild: {},
|
||||
},
|
||||
arch: ['x86_64', 'aarch64'],
|
||||
},
|
||||
@@ -53,12 +55,17 @@ export const manifest = setupManifest({
|
||||
stop: null,
|
||||
},
|
||||
dependencies: {
|
||||
// DepInfo = { description, optional } & ({ metadata: {title, icon} } | { s9pk })
|
||||
// We use the s9pk form with `null` since we don't want to bundle a copy of
|
||||
// BTCPay's s9pk into our package just to extract its metadata at build time
|
||||
// — StartOS will pull the metadata from the installed instance at runtime.
|
||||
btcpayserver: {
|
||||
description: 'Required to receive Bitcoin payments and confirm settlement via webhook.',
|
||||
optional: false,
|
||||
metadata: {
|
||||
title: 'BTCPay Server',
|
||||
description: {
|
||||
en_US:
|
||||
'Required to receive Bitcoin payments and confirm settlement via webhook.',
|
||||
},
|
||||
optional: false,
|
||||
s9pk: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+8
-5
@@ -1,9 +1,12 @@
|
||||
// Re-export of the SDK pre-bound to our manifest and file models. Import
|
||||
// `sdk` from here everywhere else in `startos/` so every call benefits from
|
||||
// the typed narrowing of our package-specific store shape.
|
||||
// Re-export of the SDK pre-bound to our manifest. Import `sdk` from here
|
||||
// everywhere else in `startos/` so every call benefits from the typed
|
||||
// narrowing of our package-specific manifest.
|
||||
//
|
||||
// NOTE: In 0.4.0.x the SDK builder does not take a store — package-local
|
||||
// persistent state is now expressed through `FileHelper` (see
|
||||
// `./fileModels/store.ts`). We just bind the manifest here.
|
||||
|
||||
import { StartSdk } from '@start9labs/start-sdk'
|
||||
import { manifest } from './manifest'
|
||||
import { store } from './fileModels/store'
|
||||
|
||||
export const sdk = StartSdk.of().withManifest(manifest).withStore(store).build(true)
|
||||
export const sdk = StartSdk.of().withManifest(manifest).build(true)
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
// Version graph. The current version must be listed first; older versions
|
||||
// we can migrate from go in `other: [...]`. Passed as an InitScript into
|
||||
// `sdk.setupInit(...)` and `sdk.setupUninit(...)` so StartOS can run the
|
||||
// correct migrations on install / update / downgrade / restore.
|
||||
|
||||
import { VersionGraph } from '@start9labs/start-sdk'
|
||||
import { v0_1_0 } from './v0.1.0'
|
||||
|
||||
export const versions = { 'v0.1.0:0': v0_1_0 }
|
||||
export const versions = VersionGraph.of({
|
||||
current: v0_1_0,
|
||||
other: [],
|
||||
})
|
||||
|
||||
+135
-13
@@ -1,18 +1,140 @@
|
||||
// First version of the package. Migrations get added here as versions
|
||||
// increment. For v0.1.0 there's nothing to migrate because nothing exists
|
||||
// yet.
|
||||
// Current version of the package. Migrations get added here as versions
|
||||
// increment.
|
||||
//
|
||||
// Version-string format is ExVer: `<upstream>:<downstream>`. Downstream
|
||||
// revision is bumped for wrapper-only or daemon-only changes that don't
|
||||
// alter on-disk data shape (we use SQLite migrations for schema changes
|
||||
// rather than ExVer-level migrations).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v0_1_0 = sdk.Version.of({
|
||||
version: '0.1.0:0',
|
||||
releaseNotes: `Initial release:\n` +
|
||||
`- Core licensing API: products, purchase, validate, revoke.\n` +
|
||||
`- BTCPay Server integration with HMAC-verified webhooks.\n` +
|
||||
`- Ed25519-signed license keys (offline-verifiable).\n` +
|
||||
`- Admin actions in StartOS UI.\n`,
|
||||
export const v0_1_0 = VersionInfo.of({
|
||||
version: '0.1.0:24',
|
||||
releaseNotes: [
|
||||
`Alpha-iteration revision 24 of v0.1.0 — Apply-discount button on the buy page + delete discount codes from the admin UI.`,
|
||||
``,
|
||||
`Buy page (/buy/<slug>) — buyers can now click an "Apply" button next to the discount code input to preview the discount before committing. The price card updates with strikethrough on the original price, the new price, and a green tag showing the percent or sats off. If the code is a free_license type, the primary CTA flips from "Pay with Bitcoin" to "Redeem license" and skips the BTCPay path entirely on submit. Validation happens against a new public endpoint GET /v1/discount-codes/preview which checks existence/active/expiry/product/exhaustion and computes the discounted price WITHOUT consuming a redemption slot. Editing the code after Apply resets the price card.`,
|
||||
``,
|
||||
`Admin UI — discount codes table now has a Delete button next to Disable/Enable. Hard-deletes the code with a confirmation prompt. Backed by a new endpoint DELETE /v1/admin/discount-codes/:id that refuses with 409 Conflict if any redemptions reference the code (preserves the audit trail). Operators should keep using Disable for redeemed codes; Delete is for cleaning up codes that were created but never used.`,
|
||||
``,
|
||||
`New public endpoint: GET /v1/discount-codes/preview?code=…&product=… — used by the buy page Apply button. Returns {valid, code, kind, is_free, base_price_sats, discount_applied_sats, final_price_sats, amount_pct, message}. Same pricing math as /v1/purchase, kept in sync.`,
|
||||
``,
|
||||
`New admin endpoint: DELETE /v1/admin/discount-codes/:id — audit-logged as discount_code.delete; returns 409 Conflict with a clear message if the code has been redeemed.`,
|
||||
``,
|
||||
`Net effect: the buy page is now a single-form purchase flow that handles paid + free + discount-coded purchases without surprises, and the admin can prune mistakenly-created codes.`,
|
||||
``,
|
||||
`No DB schema changes since :23.`,
|
||||
``,
|
||||
`Alpha-iteration revision 23 of v0.1.0 — buyers actually receive their license after paying.`,
|
||||
``,
|
||||
`Three coordinated fixes:`,
|
||||
`1. KEYSAT_PUBLIC_URL is now picked using pickPublicUrl (clearnet preferred) instead of pickBrowserUrl (mDNS preferred). The daemon's own public URL needs to resolve from random buyer browsers, not just the operator's LAN.`,
|
||||
`2. purchase.rs now defaults BTCPay's redirect_url to {public_base_url}/thank-you?invoice_id=<internal-id> so BTCPay sends the buyer back to a Keysat page after payment. Internal invoice id is also used as the local row id (was previously a fresh UUID), so /v1/purchase/<internal_id> and /thank-you?invoice_id=<id> both resolve to the same row.`,
|
||||
`3. /thank-you completely rewritten as a buyer-facing license-display page. Reads ?invoice_id from the URL, polls /v1/purchase/<id> every 3 seconds, renders the license in a certificate-style card with a Copy button when issued. Polls for up to 12 minutes before giving up. Falls back to a friendly error if the invoice id is missing/invalid.`,
|
||||
``,
|
||||
`Net effect: after paying via BTCPay, buyers land on a Keysat-branded thank-you page that auto-displays their license key as soon as the BTCPay webhook fires and the daemon issues the license. No StartOS dashboard required — this is a pure end-buyer flow.`,
|
||||
``,
|
||||
`Database change: repo::create_invoice now takes the invoice id as a parameter (was previously self-generated). Backwards-compatible at the schema level.`,
|
||||
``,
|
||||
`Alpha-iteration revision 22 of v0.1.0 — buy page auto-handles free-license discount codes.`,
|
||||
``,
|
||||
`Before: pasting a discount code of kind 'free_license' on /buy/<slug> still tried to create a BTCPay invoice for the post-discount sat amount, which BTCPay rejected with "amount below dust threshold" for tiny amounts. Buyers had to manually curl /v1/redeem to actually use free codes.`,
|
||||
``,
|
||||
`Now: when a code is provided, the buy page tries POST /v1/redeem first. If the code is free_license type, the daemon issues a license directly with no payment leg and the page renders the license key inline in a certificate-style success card with a Copy button. If the code is percent or fixed_sats type, /v1/redeem returns "this code requires payment" and the page falls through to the standard BTCPay purchase flow with the code applied. Real code errors (unknown, expired, wrong product) surface to the buyer cleanly.`,
|
||||
``,
|
||||
`Net effect: free-license codes now Just Work via the normal buyer UI. Useful for press, beta testers, partners, the early-100-users plan, etc.`,
|
||||
``,
|
||||
`Alpha-iteration revision 21 of v0.1.0 — actually fix the buyer-facing checkout URL.`,
|
||||
``,
|
||||
`Bug found via :20 diagnostic logs: BtcpayProvider::create_invoice (the trait method) had the rewrite logic, but purchase.rs uses the compat shim state.btcpay_client() which returns the raw BtcpayClient and bypasses the trait entirely. Result: the rewrite was never reached, and buyers always got the internal Docker hostname.`,
|
||||
``,
|
||||
`Fix: apply the same rewrite_to_public helper inline in purchase.rs after BtcpayClient::create_invoice returns. Same diagnostic log lines now fire from the purchase code path. Eventually purchase.rs (and reconcile.rs, tipping.rs) will migrate fully to the PaymentProvider trait — that's a v0.3 cleanup. For now the rewrite happens in both places so the urgent buyer-facing bug is fixed.`,
|
||||
``,
|
||||
`Operator action: install, then make a fresh purchase. The new log line "purchase: checkout URL rewritten for buyer" with original/rewritten URLs should appear, and the Pay-with-Bitcoin redirect should land on \`https://btcpay.<your-domain>/i/...\`.`,
|
||||
``,
|
||||
`Alpha-iteration revision 20 of v0.1.0 — diagnostic logging on the BTCPay checkout-URL rewrite path.`,
|
||||
``,
|
||||
`On startup the daemon now logs the resolved \`btcpay_url\`, \`btcpay_browser_url\`, and \`btcpay_public_url\` so it's clear what the wrapper handed in. On every checkout-URL rewrite, BtcpayProvider logs the original URL, the rewritten URL, and the public_base used. If public_base is None (no rewrite), it logs a loud warning explaining what to check.`,
|
||||
``,
|
||||
`Use these logs to diagnose any remaining "buyer gets the internal .startos URL" issue: tail Keysat logs, kick a purchase, look for "checkout URL rewritten" (good) or "checkout URL NOT rewritten" (misconfig — wrapper or env var problem).`,
|
||||
``,
|
||||
`No code-flow changes since :19; pure observability bump.`,
|
||||
``,
|
||||
`Alpha-iteration revision 19 of v0.1.0 — buyer-facing checkout URLs now use clearnet domain instead of mDNS.`,
|
||||
``,
|
||||
`:18 added a checkout-URL host rewrite, but used the same URL-picker as the operator OAuth redirect — which prefers mDNS/LAN URLs (good for the operator on the same LAN as the Start9, useless for buyers on the public internet). The rewrite produced URLs like \`https://immense-voyage.local:49347/i/...\` that random buyers couldn't resolve.`,
|
||||
``,
|
||||
`:19 splits the pickers. New \`pickPublicUrl\` prefers domain-named clearnet URLs (e.g. \`https://btcpay.your-domain.com\`) over IP/mDNS, used specifically for buyer-facing checkout URL rewrites. \`pickBrowserUrl\` (operator OAuth flow) keeps preferring LAN/mDNS — operator is local, faster path. New env var \`BTCPAY_PUBLIC_URL\` plumbs the public-preferred URL into the daemon, and the BtcpayProvider's host-rewrite uses it instead of \`BTCPAY_BROWSER_URL\`.`,
|
||||
``,
|
||||
`Operator action: install, then Disconnect → Connect BTCPay one more time to refresh the active provider with the new public URL. After that, /buy/<slug> should produce checkout URLs at your clearnet BTCPay domain (e.g. \`https://btcpay.keysat.xyz/i/...\`) which buyers can actually open.`,
|
||||
``,
|
||||
`Falls back to the old behaviour (BTCPAY_BROWSER_URL = mDNS) only if no clearnet URL is configured for BTCPay — useful for local-only testing but won't produce working URLs for real customers.`,
|
||||
``,
|
||||
`Alpha-iteration revision 18 of v0.1.0 — proper fix for BTCPay checkout URLs (revert :17, rewrite at the boundary instead).`,
|
||||
``,
|
||||
`:17 changed BTCPAY_URL to BTCPay's public StartTunnel URL for API calls. That broke the OAuth Connect flow because the Keysat container can't reliably reach the public URL from outside (StartOS egress routing). Reverted that.`,
|
||||
``,
|
||||
`Better fix: keep API calls on the internal \`btcpayserver.startos:23000\` hostname (fast, always reachable). Then in the BtcpayProvider's create_invoice path, rewrite the checkout URL's host (scheme + host + port) from the internal one to BTCPay's public URL before returning to the buyer. Path/query/fragment are preserved. Buyers now get a working public URL; daemon-to-daemon API calls stay internal.`,
|
||||
``,
|
||||
`Operator action: install this version, then run Disconnect BTCPay → Connect BTCPay once to refresh stored connection state. After that, /buy/<slug> purchases should produce a checkout URL like \`https://btcpay.<your-domain>/i/...\` instead of \`btcpayserver.startos:23000/i/...\`.`,
|
||||
``,
|
||||
`New cargo dep: \`url = "2"\` (already transitively present via reqwest; now declared directly for the host-rewrite helper).`,
|
||||
``,
|
||||
`Alpha-iteration revision 17 of v0.1.0 — fix BTCPay URL handed to daemon (checkout URLs were broken for buyers).`,
|
||||
``,
|
||||
`Bug: BTCPAY_URL was hard-coded to the internal Docker hostname \`btcpayserver.startos:23000\`. When Keysat created an invoice via BTCPay's API at that URL, BTCPay generated a checkout URL using the same internal hostname — and any buyer hitting that checkout URL got a "Server Not Found" error because \`.startos\` only resolves on the local Start9.`,
|
||||
``,
|
||||
`Fix: BTCPAY_URL now defaults to BTCPay's PUBLIC URL (the same URL used for browser redirects during the authorize flow). API calls cost a small out-and-back through StartTunnel per invoice — invoice creation is rare and the URL correctness wins. Falls back to the internal URL if the public URL hasn't been enumerated yet.`,
|
||||
``,
|
||||
`After installing this version, run Disconnect BTCPay → Connect BTCPay once to refresh the stored connection state, then test with a fresh /buy/<slug> purchase. The checkout URL should now be \`https://btcpay.<your-domain>/i/...\` instead of \`btcpayserver.startos:23000/i/...\`.`,
|
||||
``,
|
||||
`Alpha-iteration revision 16 of v0.1.0 — public buyer-facing purchase page.`,
|
||||
``,
|
||||
`New route: GET /buy/:slug. Server-renders a Keysat-branded HTML page for a given product slug — name, description, price-in-sats, optional email + discount code form, "Pay with Bitcoin" button. The button POSTs via JS to /v1/purchase, gets the BTCPay checkout URL, redirects the buyer there. After payment BTCPay returns them to /thank-you (existing handler).`,
|
||||
``,
|
||||
`Inlined navy/cream/gold styling matches the rest of the Keysat brand. Self-contained — no asset hosting required. 404 for inactive or missing slugs with a friendly explanation page.`,
|
||||
``,
|
||||
`Operator's "buy URL to share with customers" is now: https://<keysat-host>/buy/<product-slug>. Update marketing copy / install docs to point at this URL.`,
|
||||
``,
|
||||
`Alpha-iteration revision 15 of v0.1.0 — admin-only issuer-key import endpoint for master-Keysat bootstrap.`,
|
||||
``,
|
||||
`New endpoint: POST /v1/admin/import-issuer-key. Accepts a PEM-encoded Ed25519 private key in the request body, validates it, and upserts into the server_keys table replacing the auto-generated keypair. Refuses if any licenses have already been issued (safety guard against accidentally invalidating customer keys). Audit-logged. Restart the service after a successful import for the new keypair to take effect.`,
|
||||
``,
|
||||
`Why this isn't a StartOS Action: it'd clutter every operator's UI to serve a one-time setup for the single master operator. Documented in MASTER_KEYPAIR_PROCEDURE.md as the canonical bootstrap path. Curl during master-Keysat setup, never touched by the 95% of operators selling their own software.`,
|
||||
``,
|
||||
`No DB schema changes. No new dependencies.`,
|
||||
``,
|
||||
`Alpha-iteration revision 14 of v0.1.0 — Marketplace icon updated to the new Keysat brand mark (gold key on a navy-bordered certificate). Cosmetic only — no code or schema changes since :13.`,
|
||||
``,
|
||||
`Alpha-iteration revision 13 of v0.1.0 — PaymentProvider abstraction (Phase 1 of multi-provider work).`,
|
||||
``,
|
||||
`Refactor only — no user-visible behavior change. Sets up v0.3 to add Zaprite as a second payment provider alongside BTCPay without parallel code paths.`,
|
||||
``,
|
||||
`New module 'src/payment/' defines a PaymentProvider trait with create_invoice / get_invoice_status / validate_webhook / pay_lightning_invoice methods. BtcpayProvider is the first impl, wrapping the existing BtcpayClient and HMAC webhook secret. The webhook handler now dispatches through the trait — same BTCPay flow, but the abstraction is exercised end-to-end so we know the design holds before Zaprite arrives.`,
|
||||
``,
|
||||
`AppState replaces its 'btcpay' field with 'payment: Arc<RwLock<Option<Arc<dyn PaymentProvider>>>>'. Existing BTCPay-specific call sites (purchase, reconcile, tipping) unchanged; they go through compat accessors that downcast the trait object back to BtcpayProvider. Those compat accessors retire in v0.3 as the call sites migrate.`,
|
||||
``,
|
||||
`New cargo dep: async-trait (for object-safe async methods on the new trait).`,
|
||||
``,
|
||||
`No DB schema changes vs :12.`,
|
||||
``,
|
||||
`Earlier in the v0.1.0 line:`,
|
||||
`:12 — Tip-recipient on policy + Support development footer link.`,
|
||||
`:11 — Keysat-licenses-Keysat dogfooded; daemon embeds master pubkey, verifies /data/keysat-license.txt at boot; new "Activate Keysat license" + "Show license status" StartOS actions.`,
|
||||
`:10 — admin web UI restyled in Keysat brand (navy/cream/gold).`,
|
||||
`:9 — admin web UI made functional; Actions trimmed to setup-only.`,
|
||||
`:8 — embedded admin SPA scaffolding (placeholder).`,
|
||||
`:7 — operator-name live-reload; idempotent Connect BTCPay; Disconnect action; payment-method check.`,
|
||||
`:6 — CSRF state encoded inside redirect URL.`,
|
||||
`:5 — URL ranking applied to our own public URL.`,
|
||||
`:4 — URL ranking by browser-reachability for BTCPay's URL.`,
|
||||
`:3 — getAll() over BTCPay interfaces, filter by type='ui'.`,
|
||||
`:2 — broader BTCPay URL filter for LAN-only setups.`,
|
||||
`:1 — kebab-case action IDs; task severity 'important'; root in container; BTCPAY_BROWSER_URL plumbing.`,
|
||||
`:0 — initial release.`,
|
||||
].join('\n'),
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
up: async () => {},
|
||||
down: async () => {},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user