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'),
}
},
)