initial
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// Action: one-click "Connect BTCPay".
|
||||
//
|
||||
// Instead of asking the operator to generate and paste an API key, we use
|
||||
// BTCPay's built-in authorize flow:
|
||||
// 1. Action calls POST /v1/admin/btcpay/connect on the local daemon.
|
||||
// 2. Daemon returns an authorize URL pointing at the sibling BTCPay
|
||||
// instance, with the permissions we need pre-filled.
|
||||
// 3. Operator opens that URL in their browser, approves on BTCPay's
|
||||
// consent page, and BTCPay calls back into /v1/btcpay/authorize/callback
|
||||
// with the freshly-minted API key.
|
||||
// 4. Daemon auto-detects the store, registers the webhook, and persists
|
||||
// everything.
|
||||
//
|
||||
// The operator never sees or types an API key, store id, or webhook secret.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
export const configureBtcpay = sdk.Action.withoutInput(
|
||||
'configureBtcpay',
|
||||
async ({ effects }) => ({
|
||||
name: 'Connect BTCPay',
|
||||
description:
|
||||
"One-click connect to your BTCPay Server. Opens a consent page in " +
|
||||
"your browser where you click 'Authorize'; Keysat then auto-detects " +
|
||||
"your store and registers the webhook.",
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'BTCPay',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
'/v1/admin/btcpay/connect',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Connect initialisation failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as { authorize_url: string }
|
||||
|
||||
return {
|
||||
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.',
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Optional companion action: show current BTCPay connection state. */
|
||||
export const btcpayStatus = sdk.Action.withoutInput(
|
||||
'btcpayStatus',
|
||||
async ({ effects }) => ({
|
||||
name: 'Check BTCPay connection',
|
||||
description: 'Shows whether BTCPay is currently connected, and the store id.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'BTCPay',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
'/v1/admin/btcpay/status',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Status check failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as
|
||||
| { connected: false }
|
||||
| { connected: true; store_id: string; webhook_id: string | null; base_url: string }
|
||||
|
||||
if (!body.connected) {
|
||||
return {
|
||||
message: 'BTCPay is not connected yet. Run the "Connect BTCPay" action to authorize.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
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}`,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
// Action: create a license policy (reusable template) for a product.
|
||||
//
|
||||
// Policies let the operator capture "when someone buys this product, issue a
|
||||
// license with these defaults" (duration, grace period, entitlements, seat
|
||||
// cap, trial flag). A policy slugged `default` is used automatically by the
|
||||
// normal purchase flow.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
product_slug: {
|
||||
type: 'text',
|
||||
name: 'Product slug',
|
||||
description: 'The product this policy applies to.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
slug: {
|
||||
type: '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',
|
||||
name: 'Display name',
|
||||
description: 'Shown in admin listings. E.g., "Annual subscription".',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
duration_seconds: {
|
||||
type: 'number',
|
||||
name: 'Duration (seconds)',
|
||||
description: '0 = perpetual. 31536000 = one year. 7776000 = 90 days.',
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
grace_seconds: {
|
||||
type: 'number',
|
||||
name: 'Grace period (seconds)',
|
||||
description:
|
||||
'After expiry, how long a cached validation remains honoured ' +
|
||||
'before the client must reach the server again. 0 = no grace.',
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
max_machines: {
|
||||
type: 'number',
|
||||
name: 'Max machines',
|
||||
description: '0 = unlimited, 1 = single-seat, n>1 = multi-seat cap.',
|
||||
required: true,
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
is_trial: {
|
||||
type: 'toggle',
|
||||
name: 'Trial policy',
|
||||
description: 'Mark issued keys as trial (sets the TRIAL flag in the payload).',
|
||||
default: false,
|
||||
},
|
||||
entitlements: {
|
||||
type: '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',
|
||||
name: 'Price override (sats, optional)',
|
||||
description:
|
||||
"Override the product's default price for licenses issued under this " +
|
||||
'policy. Leave at -1 to use the product price.',
|
||||
required: true,
|
||||
default: -1,
|
||||
min: -1,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
})
|
||||
|
||||
export const createPolicy = sdk.Action.withInput(
|
||||
'createPolicy',
|
||||
async ({ effects }) => ({
|
||||
name: 'Create policy',
|
||||
description:
|
||||
'Add a reusable license template to a product. The public purchase ' +
|
||||
'flow picks up the policy slugged "default"; other policies are used ' +
|
||||
'by the admin "Issue license manually" action.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Policies',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const entitlements = (formInput.entitlements ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
const body: Record<string, unknown> = {
|
||||
product_slug: formInput.product_slug,
|
||||
name: formInput.name,
|
||||
slug: formInput.slug,
|
||||
duration_seconds: formInput.duration_seconds,
|
||||
grace_seconds: formInput.grace_seconds,
|
||||
max_machines: formInput.max_machines,
|
||||
is_trial: formInput.is_trial,
|
||||
entitlements,
|
||||
metadata: {},
|
||||
}
|
||||
if (formInput.price_sats_override >= 0) {
|
||||
body.price_sats_override = formInput.price_sats_override
|
||||
}
|
||||
|
||||
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/policies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create policy failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const policy = (await resp.json()) as { id: string; slug: string; name: string }
|
||||
return {
|
||||
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".'),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
// Action: create a new product for sale.
|
||||
//
|
||||
// Hits the service's admin API through localhost using the in-store admin
|
||||
// key. No need for the operator to touch curl or handle tokens.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
slug: {
|
||||
type: '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',
|
||||
name: 'Name',
|
||||
description: 'Display name shown to buyers.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: 'textarea',
|
||||
name: 'Description',
|
||||
description: 'Public description of what the buyer is getting.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
price_sats: {
|
||||
type: 'number',
|
||||
name: 'Price (sats)',
|
||||
description: 'Price per license in satoshis. 100,000,000 sats = 1 BTC.',
|
||||
required: true,
|
||||
default: null,
|
||||
min: 1,
|
||||
max: null,
|
||||
integer: true,
|
||||
},
|
||||
})
|
||||
|
||||
export const createProduct = sdk.Action.withInput(
|
||||
'createProduct',
|
||||
async ({ effects }) => ({
|
||||
name: 'Create product',
|
||||
description: 'Add a new product that can be purchased through this service.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Products',
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
slug: formInput.slug,
|
||||
name: formInput.name,
|
||||
description: formInput.description ?? '',
|
||||
price_sats: formInput.price_sats,
|
||||
metadata: {},
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Create product failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = await resp.json()
|
||||
return {
|
||||
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}"}`,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
// Action: force-kick an install off a license.
|
||||
//
|
||||
// The buyer's copy on that device will fail its next online validation
|
||||
// with `not_activated`, freeing up a seat for another install.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
machine_id: {
|
||||
type: 'text',
|
||||
name: 'Machine ID',
|
||||
description: 'UUID of the machine to deactivate. Find via list-machines.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: '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 }) => ({
|
||||
name: 'Deactivate machine',
|
||||
description:
|
||||
'Force an install off a license. Frees up a seat and causes that ' +
|
||||
"install's next online validation to fail.",
|
||||
warning:
|
||||
'The affected client may continue running from cache until its grace ' +
|
||||
'window expires.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Machines',
|
||||
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/machines/${encodeURIComponent(formInput.machine_id)}/deactivate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: formInput.reason ?? '' }),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Deactivate failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Deactivated machine ${formInput.machine_id}.` }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
// Register every action with StartOS.
|
||||
|
||||
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 { 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
|
||||
.addAction(configureBtcpay)
|
||||
.addAction(btcpayStatus)
|
||||
// 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)
|
||||
@@ -0,0 +1,53 @@
|
||||
// Action: manually issue a license for a product (comp, press, dev).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
product_slug: {
|
||||
type: 'text',
|
||||
name: 'Product slug',
|
||||
description: 'Which product to issue a license for.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
note: {
|
||||
type: '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 }) => ({
|
||||
name: 'Issue license manually',
|
||||
description: 'Generate a license key outside the purchase flow. Useful for comps and press.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Licenses',
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_slug: formInput.product_slug,
|
||||
note: formInput.note ?? null,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Issue failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = await resp.json()
|
||||
return {
|
||||
message:
|
||||
`License issued.\nID: ${body.license_id}\n\n` +
|
||||
`Key (give this to the recipient):\n${body.license_key}`,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
// Action: list machines (installs) currently bound to a license.
|
||||
//
|
||||
// Useful when a buyer asks "which devices am I active on?" or when
|
||||
// troubleshooting a multi-seat cap ("can't activate, too many machines").
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to inspect.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
include_inactive: {
|
||||
type: '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 }) => ({
|
||||
name: 'List machines',
|
||||
description: 'Show installs currently bound to a license.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Machines',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
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,
|
||||
`/v1/admin/machines?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`List machines failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
machines: Array<{
|
||||
id: string
|
||||
active: number | boolean
|
||||
hostname: string | null
|
||||
platform: string | null
|
||||
last_heartbeat_at: string | null
|
||||
activated_at: string
|
||||
fingerprint_hash: string
|
||||
}>
|
||||
}
|
||||
if (body.machines.length === 0) {
|
||||
return { message: 'No machines bound to this license.' }
|
||||
}
|
||||
const lines = body.machines.map((m) => {
|
||||
const activeStr =
|
||||
m.active === true || m.active === 1 ? 'ACTIVE' : 'deactivated'
|
||||
const bits = [
|
||||
m.id,
|
||||
activeStr,
|
||||
m.hostname ?? 'unknown host',
|
||||
m.platform ?? '',
|
||||
`fp=${m.fingerprint_hash.slice(0, 12)}…`,
|
||||
`last_hb=${m.last_heartbeat_at ?? 'never'}`,
|
||||
]
|
||||
return '• ' + bits.filter(Boolean).join(' ')
|
||||
})
|
||||
return {
|
||||
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.',
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
// Action: list currently registered outbound webhook endpoints.
|
||||
//
|
||||
// Shows each endpoint's id, URL, event list, and active flag. Secrets are
|
||||
// masked — rotate by deleting and recreating an endpoint.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
export const listWebhooks = sdk.Action.withoutInput(
|
||||
'listWebhooks',
|
||||
async ({ effects }) => ({
|
||||
name: 'List webhook endpoints',
|
||||
description: 'Show all currently-registered outbound webhook subscribers.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Webhooks',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
async ({ effects }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
'/v1/admin/webhook-endpoints',
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`List webhooks failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
endpoints: Array<{
|
||||
id: string
|
||||
url: string
|
||||
event_types: string[]
|
||||
active: number | boolean
|
||||
description: string
|
||||
}>
|
||||
}
|
||||
if (body.endpoints.length === 0) {
|
||||
return {
|
||||
message:
|
||||
'No webhook endpoints registered. Use "Register webhook endpoint" ' +
|
||||
'to add one.',
|
||||
}
|
||||
}
|
||||
const lines = body.endpoints.map((ep) => {
|
||||
const activeStr = ep.active === true || ep.active === 1 ? 'active' : 'disabled'
|
||||
return `• ${ep.id} [${activeStr}] ${ep.url} events=${ep.event_types.join(',')}` +
|
||||
(ep.description ? ` ("${ep.description}")` : '')
|
||||
})
|
||||
return {
|
||||
message:
|
||||
`${body.endpoints.length} endpoint(s):\n\n` + lines.join('\n'),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
// Action: register an outbound webhook subscriber.
|
||||
//
|
||||
// After registration, Keysat will POST signed JSON bodies to the URL when
|
||||
// relevant events fire (license.issued, license.revoked, machine.activated,
|
||||
// machine.deactivated, invoice.settled, etc.). Signatures use HMAC-SHA256
|
||||
// over the body, carried in the `X-Keysat-Signature` header as
|
||||
// `sha256=<hex>` — same shape as BTCPay's outbound hooks.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
url: {
|
||||
type: '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',
|
||||
name: 'Event types',
|
||||
description:
|
||||
'Comma-separated list of events to subscribe to, or "*" for all. ' +
|
||||
'E.g., "license.issued,license.revoked". Known events: license.issued, ' +
|
||||
'license.revoked, license.suspended, license.unsuspended, ' +
|
||||
'machine.activated, machine.deactivated, invoice.settled.',
|
||||
required: true,
|
||||
default: '*',
|
||||
},
|
||||
description: {
|
||||
type: '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 }) => ({
|
||||
name: 'Register webhook endpoint',
|
||||
description:
|
||||
'Tell Keysat to POST signed event notifications to an HTTPS URL you ' +
|
||||
'control. A fresh HMAC secret is generated and shown once — save it.',
|
||||
warning:
|
||||
'The HMAC secret is returned in plaintext exactly once, on creation. ' +
|
||||
'If you lose it, you will need to delete and recreate the endpoint.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Webhooks',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
const eventTypes = formInput.event_types
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
if (eventTypes.length === 0) {
|
||||
throw new Error('Provide at least one event type (or "*" for all).')
|
||||
}
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
'/v1/admin/webhook-endpoints',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: formInput.url,
|
||||
event_types: eventTypes,
|
||||
description: formInput.description ?? '',
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Register webhook failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const ep = (await resp.json()) as {
|
||||
id: string
|
||||
url: string
|
||||
secret: string
|
||||
event_types: string[]
|
||||
}
|
||||
return {
|
||||
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` +
|
||||
`Verify incoming requests with header X-Keysat-Signature: sha256=<hex> ` +
|
||||
`(HMAC-SHA256 of the raw request body using this secret).`,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
// Action: revoke an existing license.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to revoke. Find via list-licenses action.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: '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 }) => ({
|
||||
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.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Licenses',
|
||||
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/${encodeURIComponent(formInput.license_id)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: formInput.reason ?? 'admin revoke' }),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Revoke failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Revoked license ${formInput.license_id}.` }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
// Action: search licenses by buyer email / Nostr npub / invoice id.
|
||||
//
|
||||
// The typical use case is "a buyer emailed me saying they lost their key."
|
||||
// Operator runs this with the buyer's email and gets back up to 100
|
||||
// matching licenses with IDs, product slugs, and current status.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
buyer_email: {
|
||||
type: 'text',
|
||||
name: 'Buyer email',
|
||||
description: 'Exact-match email address (leave blank if searching by another field).',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
nostr_npub: {
|
||||
type: 'text',
|
||||
name: 'Nostr npub',
|
||||
description: 'Nostr public key (npub…). Optional.',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
invoice_id: {
|
||||
type: '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 }) => ({
|
||||
name: 'Search licenses',
|
||||
description:
|
||||
"Look up a buyer's licenses by email, Nostr npub, or BTCPay " +
|
||||
'invoice ID. Intended for "lost key recovery" support requests.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Licenses',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (formInput.buyer_email) params.set('buyer_email', formInput.buyer_email)
|
||||
if (formInput.nostr_npub) params.set('nostr_npub', formInput.nostr_npub)
|
||||
if (formInput.invoice_id) params.set('invoice_id', formInput.invoice_id)
|
||||
if ([...params.keys()].length === 0) {
|
||||
throw new Error('Provide at least one search field (email, npub, or invoice).')
|
||||
}
|
||||
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
`/v1/admin/licenses/search?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Search failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
licenses: Array<{
|
||||
id: string
|
||||
product_id: string
|
||||
status: string
|
||||
buyer_email: string | null
|
||||
issued_at: string
|
||||
expires_at: string | null
|
||||
}>
|
||||
}
|
||||
if (body.licenses.length === 0) {
|
||||
return { message: 'No licenses matched.' }
|
||||
}
|
||||
const lines = body.licenses.map(
|
||||
(l) =>
|
||||
`• ${l.id} (${l.status}) product=${l.product_id}` +
|
||||
(l.buyer_email ? ` buyer=${l.buyer_email}` : '') +
|
||||
` issued=${l.issued_at}` +
|
||||
(l.expires_at ? ` expires=${l.expires_at}` : ''),
|
||||
)
|
||||
return {
|
||||
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.',
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
// Action: set the operator display name shown on the service homepage.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
operator_name: {
|
||||
type: '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 }) => ({
|
||||
name: 'Set operator name',
|
||||
description: 'Edit the operator name shown publicly.',
|
||||
warning: null,
|
||||
allowedStatuses: 'any',
|
||||
group: 'General',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
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.` }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
// Action: reveal the auto-generated admin API key.
|
||||
//
|
||||
// The operator rarely needs this — every other action in StartOS already
|
||||
// carries the key for them — but it's useful if they want to script against
|
||||
// the admin HTTP API directly.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
|
||||
export const showCredentials = sdk.Action.withoutInput(
|
||||
'showCredentials',
|
||||
async ({ effects }) => ({
|
||||
name: 'Show admin API key',
|
||||
description:
|
||||
'Display the auto-generated admin API key. Treat it like a password — ' +
|
||||
'anyone with this key can mint and revoke licenses on this server.',
|
||||
warning:
|
||||
'Anyone with this value has full control of your Keysat server. ' +
|
||||
'Do not share it.',
|
||||
allowedStatuses: 'any',
|
||||
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.`,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// Action: suspend an existing license (reversible).
|
||||
//
|
||||
// Unlike revoke (which is one-way), suspend temporarily blocks validation
|
||||
// and can be cleared with the "Unsuspend" action. Useful for payment
|
||||
// disputes where the outcome isn't yet known.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: 'text',
|
||||
name: 'License ID',
|
||||
description: 'UUID of the license to suspend. Find via search-licenses action.',
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
reason: {
|
||||
type: '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 }) => ({
|
||||
name: 'Suspend license',
|
||||
description:
|
||||
'Temporarily disable a license. Validation calls will fail with a ' +
|
||||
'`suspended` status until you unsuspend. Use this for reversible ' +
|
||||
'situations (e.g., payment disputes) instead of revoke.',
|
||||
warning:
|
||||
'Suspension 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()
|
||||
const resp = await adminCall(
|
||||
LICENSING_URL,
|
||||
store.admin_api_key,
|
||||
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/suspend`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: formInput.reason ?? 'admin suspend' }),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Suspend failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
return { message: `Suspended license ${formInput.license_id}.` }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
// Action: clear a previously-applied suspension.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
license_id: {
|
||||
type: '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 }) => ({
|
||||
name: 'Unsuspend license',
|
||||
description:
|
||||
'Lift a previous suspension. Validation will succeed again on the ' +
|
||||
'next call. Has no effect if the license is already active or if it ' +
|
||||
'has been revoked.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Licenses',
|
||||
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/${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}.` }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
// Action: view recent admin audit log entries.
|
||||
//
|
||||
// Every admin mutation writes an audit row recording: who (hashed bearer
|
||||
// token), what (action slug), target id, client IP, user agent, and a
|
||||
// free-form JSON detail blob. This action surfaces them in StartOS so the
|
||||
// operator can skim without curl.
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { adminCall, LICENSING_URL } from '../utils'
|
||||
|
||||
const input = sdk.InputSpec.of({
|
||||
limit: {
|
||||
type: 'number',
|
||||
name: 'Limit',
|
||||
description: 'Number of most recent entries to return (1–1000).',
|
||||
required: true,
|
||||
default: 50,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
integer: true,
|
||||
},
|
||||
action: {
|
||||
type: '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 }) => ({
|
||||
name: 'View audit log',
|
||||
description:
|
||||
'Show the most recent admin mutations recorded by the service — ' +
|
||||
'useful for compliance, debugging, or checking what an API-key holder ' +
|
||||
'has been up to.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: 'Diagnostics',
|
||||
visibility: 'enabled',
|
||||
}),
|
||||
input,
|
||||
async ({ effects, input: formInput }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
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,
|
||||
`/v1/admin/audit?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Audit fetch failed: HTTP ${resp.status} — ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as {
|
||||
entries: Array<{
|
||||
id: string
|
||||
created_at: string
|
||||
action: string
|
||||
target_type: string | null
|
||||
target_id: string | null
|
||||
actor_hash: string | null
|
||||
client_ip: string | null
|
||||
detail: unknown
|
||||
}>
|
||||
}
|
||||
if (body.entries.length === 0) {
|
||||
return { message: 'No audit entries match the filter.' }
|
||||
}
|
||||
const lines = body.entries.map((e) => {
|
||||
const target = e.target_type && e.target_id ? `${e.target_type}:${e.target_id}` : '(no target)'
|
||||
const actor = e.actor_hash ? `actor=${e.actor_hash.slice(0, 10)}…` : 'actor=?'
|
||||
const ip = e.client_ip ? `ip=${e.client_ip}` : ''
|
||||
return `• ${e.created_at} ${e.action} ${target} ${actor} ${ip}`
|
||||
})
|
||||
return {
|
||||
message:
|
||||
`${body.entries.length} entry(ies):\n\n` + lines.join('\n'),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
// Backup & restore.
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const { createBackup, restoreBackup } = sdk.setupBackups(async ({ effects }) => [
|
||||
sdk.Backups.volumes('main'),
|
||||
])
|
||||
@@ -0,0 +1,16 @@
|
||||
// Declare our dependency on BTCPay Server. StartOS uses this to:
|
||||
// - prevent starting if BTCPay isn't installed,
|
||||
// - gate our service's health status on BTCPay's,
|
||||
// - provide the `btcpayserver.startos` hostname inside our container.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
|
||||
return {
|
||||
btcpayserver: {
|
||||
kind: 'running',
|
||||
versionRange: '>=1.11.0',
|
||||
healthChecks: [],
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package-local persistent state. This is separate from the SQLite database
|
||||
// inside the container — it's metadata the StartOS wrapper needs to remember
|
||||
// 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.
|
||||
|
||||
import { matches } from '@start9labs/start-sdk'
|
||||
|
||||
const { arr, num, obj, oneOf, literal, string } = matches
|
||||
|
||||
export const storeShape = obj({
|
||||
// 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,
|
||||
// Operator display name shown on the service homepage.
|
||||
operator_name: string,
|
||||
// Tracks which version's init has already been applied.
|
||||
schema_version: num,
|
||||
})
|
||||
|
||||
export type Store = typeof storeShape._TYPE
|
||||
|
||||
export const store = {
|
||||
shape: storeShape,
|
||||
// Defaults. Populated for real during init.
|
||||
path: 'store.json' as const,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// StartOS entry point. Glues every module together so `start-cli` can pack
|
||||
// the package.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
import { actions } from './actions'
|
||||
import { createBackup, restoreBackup } from './backups'
|
||||
import { setDependencies } from './dependencies'
|
||||
import { initFn, uninitFn } from './init'
|
||||
import { setInterfaces } from './interfaces'
|
||||
import { main } from './main'
|
||||
import { manifest } from './manifest'
|
||||
import { versions } from './versions'
|
||||
|
||||
export const { packageInit, packageUninit, containerInit } = sdk.setupPackageInit({
|
||||
init: initFn,
|
||||
uninit: uninitFn,
|
||||
})
|
||||
|
||||
export {
|
||||
manifest,
|
||||
main,
|
||||
actions,
|
||||
setDependencies,
|
||||
setInterfaces,
|
||||
createBackup,
|
||||
restoreBackup,
|
||||
versions,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// On subsequent boots this is a no-op (keys already exist).
|
||||
|
||||
import { sdk } from '../sdk'
|
||||
import { generateSecret } from '../utils'
|
||||
|
||||
export const initFn = sdk.setupOnInit(async ({ effects }) => {
|
||||
const current = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
|
||||
if (!current || current.schema_version === 0 || current.schema_version === undefined) {
|
||||
await sdk.store.setOwn(effects, sdk.StorePath, {
|
||||
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 || '',
|
||||
operator_name: current?.operator_name || '',
|
||||
schema_version: 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const uninitFn = sdk.setupOnUninit(async ({ effects }) => {
|
||||
// Nothing to tear down at the StartOS level — the DB volume is handled by
|
||||
// StartOS directly when the package is uninstalled.
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
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 api = sdk.createInterface(effects, {
|
||||
name: 'Licensing API',
|
||||
id: 'api',
|
||||
description:
|
||||
'REST API for buyers and licensed software. Public-facing: this is ' +
|
||||
'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,
|
||||
path: '',
|
||||
query: {},
|
||||
})
|
||||
|
||||
return [await api.export([apiMulti])]
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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.
|
||||
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects, started }) => {
|
||||
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
|
||||
|
||||
// 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')
|
||||
.const()
|
||||
.then((i) => i?.addressInfo?.urls?.[0] ?? 'http://localhost:8080')
|
||||
|
||||
const sub = await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
[{ mountpoint: '/data', volumeId: 'main', subpath: null, readonly: false }],
|
||||
'keysat',
|
||||
)
|
||||
|
||||
return sdk.Daemons.of({ effects, started, healthReceipts: [] }).addDaemon('primary', {
|
||||
subcontainer: sub,
|
||||
exec: {
|
||||
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.
|
||||
BTCPAY_URL: 'http://btcpayserver.startos:23000',
|
||||
// The three credentials below are left empty in the normal case —
|
||||
// the daemon now 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: '',
|
||||
BTCPAY_STORE_ID: '',
|
||||
BTCPAY_WEBHOOK_SECRET: '',
|
||||
RUST_LOG: 'info,sqlx=warn,hyper=warn',
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
display: 'API',
|
||||
fn: () =>
|
||||
sdk.healthCheck.checkPortListening(effects, 8080, {
|
||||
successMessage: 'Keysat API is accepting requests',
|
||||
errorMessage: 'Keysat API is not responding on port 8080',
|
||||
}),
|
||||
},
|
||||
requires: [],
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// Human-readable description strings, separated so they can be translated
|
||||
// later. Only English is filled in here; add more locales as needed.
|
||||
|
||||
export const short = {
|
||||
en_US: 'Keysat — self-hosted Bitcoin-paid software license server.',
|
||||
}
|
||||
|
||||
export const long = {
|
||||
en_US: `Keysat lets you sell licenses to your own software products using
|
||||
Bitcoin payments via BTCPay Server. Every instance runs on the operator's own
|
||||
StartOS — there is no central authority. The service issues Ed25519-signed
|
||||
license keys that downstream software can verify offline, with optional
|
||||
expiry, entitlements, fingerprint binding, and per-seat activation caps.
|
||||
Supports multiple products per instance and closed-source, open-core, and
|
||||
open-source distribution models.`,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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.
|
||||
|
||||
import { setupManifest } from '@start9labs/start-sdk'
|
||||
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',
|
||||
donationUrl: null,
|
||||
docsUrls: [
|
||||
'https://github.com/ten31/keysat/blob/main/README.md',
|
||||
'https://github.com/ten31/keysat/blob/main/docs/INTEGRATION.md',
|
||||
],
|
||||
description: { short, long },
|
||||
// A single data volume holds the SQLite database (which in turn holds the
|
||||
// server signing key). StartOS encrypts and backs this up automatically.
|
||||
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.
|
||||
source: {
|
||||
dockerBuild: {
|
||||
workdir: '..',
|
||||
dockerfile: 'licensing-service-startos/Dockerfile',
|
||||
},
|
||||
},
|
||||
arch: ['x86_64', 'aarch64'],
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
install: null,
|
||||
update: null,
|
||||
uninstall: {
|
||||
en_US:
|
||||
'Uninstalling will delete your server signing key and all license ' +
|
||||
'records. Previously-issued license keys will no longer validate ' +
|
||||
'against this server. Back up first if you plan to reinstall.',
|
||||
},
|
||||
restore: null,
|
||||
start: null,
|
||||
stop: null,
|
||||
},
|
||||
dependencies: {
|
||||
btcpayserver: {
|
||||
description: 'Required to receive Bitcoin payments and confirm settlement via webhook.',
|
||||
optional: false,
|
||||
metadata: {
|
||||
title: 'BTCPay Server',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
// 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.
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,28 @@
|
||||
// Small helpers used across actions and init.
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
/** Generate a hex string secret (default 32 bytes = 64 hex chars). */
|
||||
export function generateSecret(bytes = 32): string {
|
||||
return randomBytes(bytes).toString('hex')
|
||||
}
|
||||
|
||||
/** Thin wrapper around fetch that attaches the admin bearer token. */
|
||||
export async function adminCall(
|
||||
baseUrl: string,
|
||||
adminKey: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${adminKey}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve the in-container licensing API URL from inside action scripts. */
|
||||
export const LICENSING_URL = 'http://localhost:8080'
|
||||
@@ -0,0 +1,3 @@
|
||||
import { v0_1_0 } from './v0.1.0'
|
||||
|
||||
export const versions = { 'v0.1.0:0': v0_1_0 }
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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.
|
||||
|
||||
import { sdk } from '../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`,
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user