This commit is contained in:
MacPro
2026-04-22 17:46:43 -05:00
commit 432250bffc
41 changed files with 2223 additions and 0 deletions
+97
View File
@@ -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}`,
}
},
)
+148
View File
@@ -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".'),
}
},
)
+79
View File
@@ -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}"}`,
}
},
)
+57
View File
@@ -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}.` }
},
)
+44
View File
@@ -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)
+53
View File
@@ -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}`,
}
},
)
+85
View File
@@ -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.',
}
},
)
+56
View File
@@ -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'),
}
},
)
+97
View File
@@ -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).`,
}
},
)
+53
View File
@@ -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}.` }
},
)
+95
View File
@@ -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.',
}
},
)
+36
View File
@@ -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.` }
},
)
+36
View File
@@ -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.`,
}
})
+59
View File
@@ -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}.` }
},
)
+43
View File
@@ -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}.` }
},
)
+88
View File
@@ -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 (11000).',
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'),
}
},
)
+11
View File
@@ -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'),
])
+16
View File
@@ -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: [],
},
}
})
+29
View File
@@ -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,
}
+29
View File
@@ -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,
}
+34
View File
@@ -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.
})
+38
View File
@@ -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])]
})
+57
View File
@@ -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: [],
})
})
+16
View File
@@ -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.`,
}
+64
View File
@@ -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',
},
},
},
})
+9
View File
@@ -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)
+28
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
import { v0_1_0 } from './v0.1.0'
export const versions = { 'v0.1.0:0': v0_1_0 }
+18
View File
@@ -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 }) => {},
},
})