v0.1.0:24 — Keysat licensing service end-to-end

Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
This commit is contained in:
Grant
2026-05-07 10:33:39 -05:00
parent 432250bffc
commit 6ac118ae70
90 changed files with 14896 additions and 524 deletions
+167
View File
@@ -0,0 +1,167 @@
// Action: activate the Keysat package's own self-license.
//
// The daemon embeds the keysat.xyz master public key at compile time
// (see licensing-service/src/license_self.rs). The operator pastes a
// LIC1-… key here; the daemon verifies it against the master pubkey,
// writes it to /data/keysat-license.txt, and swaps its runtime tier
// to Licensed without a restart.
//
// In permissive builds (the default for local `make x86`) the daemon
// will start regardless and this action just records the tier. In
// enforce builds (compiled with KEYSAT_LICENSE_ENFORCE=1, used for
// the marketplace .s9pk) the daemon refuses to start without a valid
// license, and this action is the bootstrap path: install Keysat,
// run this action with your activation key, then start the service.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const input = InputSpec.of({
license_key: Value.text({
name: 'License key',
description:
'Paste the LIC1-… license key issued for your Keysat install. ' +
'Buy or redeem one at registry.keysat.xyz.',
required: true,
default: null,
placeholder: 'LIC1-XXXXXXXXXXXX-XXXXXXXXXXXX',
}),
})
export const activateLicense = sdk.Action.withInput(
'activate-license',
async () => ({
name: 'Activate Keysat license',
description:
'Activate this Keysat install. Required for marketplace builds; ' +
'optional but recommended for source-built dev installs (signals support, ' +
'and lets the admin UI show your tier).',
warning: null,
allowedStatuses: 'only-running',
group: 'License',
visibility: 'enabled',
}),
input,
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const key = formInput.license_key.trim()
if (!key) {
throw new Error('License key is required.')
}
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/self-license',
{ method: 'POST', body: JSON.stringify({ license_key: key }) },
)
if (!resp.ok) {
const body = await resp.text()
let detail = body
try {
const parsed = JSON.parse(body)
if (parsed.detail) detail = parsed.detail
else if (parsed.error) detail = parsed.error
} catch (_) {}
throw new Error(`Activation rejected (HTTP ${resp.status}): ${detail}`)
}
const result = (await resp.json()) as {
ok: boolean
tier: {
tier: 'licensed' | 'unlicensed'
license_id?: string
product_id?: string
expires_at?: number
entitlements?: string[]
mode: string
}
message: string
}
return {
version: '1',
title: 'Keysat license activated',
message:
result.message +
' The license is stored at /data/keysat-license.txt and survives upgrades and reinstalls (it is part of your StartOS backup set).',
result: null,
}
},
)
// Companion read-only action: surface the current self-license tier.
// Useful both as a sanity check after activation and as a way for the
// operator to see "am I running licensed or unlicensed?" without
// digging into logs.
export const showLicenseStatus = sdk.Action.withoutInput(
'show-license-status',
async () => ({
name: 'Show Keysat license status',
description:
'Reports whether this Keysat install is running licensed or unlicensed, ' +
'and which entitlements are active.',
warning: null,
allowedStatuses: 'only-running',
group: 'License',
visibility: 'enabled',
}),
async ({ effects: _effects }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/self-license',
{ method: 'GET' },
)
if (!resp.ok) {
throw new Error(`Could not read license status: HTTP ${resp.status}${await resp.text()}`)
}
const j = (await resp.json()) as {
tier: 'licensed' | 'unlicensed'
license_id?: string
product_id?: string
expires_at?: number
entitlements?: string[]
reason?: string
mode: string
}
if (j.tier === 'licensed') {
const exp = !j.expires_at
? 'perpetual'
: new Date(j.expires_at * 1000).toISOString().slice(0, 10)
const ents = (j.entitlements || []).length === 0 ? '(none)' : (j.entitlements || []).join(', ')
return {
version: '1',
title: 'Licensed',
message:
`License id: ${j.license_id}\n` +
`Expires: ${exp}\n` +
`Entitlements: ${ents}\n` +
`Build mode: ${j.mode}`,
result: null,
}
} else {
return {
version: '1',
title: 'Unlicensed',
message:
`Reason: ${j.reason || 'no license configured'}\n` +
`Build mode: ${j.mode}\n\n` +
(j.mode === 'enforce'
? 'This is a marketplace build that requires a valid license to run. Use the "Activate Keysat license" action to bootstrap.'
: 'This is a permissive (dev) build. The daemon will keep running. Activate a license to see your tier reflected here.'),
result: null,
}
}
},
)
+145 -15
View File
@@ -14,11 +14,12 @@
// The operator never sees or types an API key, store id, or webhook secret.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
export const configureBtcpay = sdk.Action.withoutInput(
'configureBtcpay',
async ({ effects }) => ({
'configure-btcpay',
async () => ({
name: 'Connect BTCPay',
description:
"One-click connect to your BTCPay Server. Opens a consent page in " +
@@ -29,11 +30,12 @@ export const configureBtcpay = sdk.Action.withoutInput(
group: 'BTCPay',
visibility: 'enabled',
}),
async ({ effects }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
'/v1/admin/btcpay/connect',
{ method: 'POST' },
)
@@ -43,22 +45,102 @@ export const configureBtcpay = sdk.Action.withoutInput(
const body = (await resp.json()) as { authorize_url: string }
return {
version: '1',
title: 'Approve on BTCPay to finish connecting',
message:
'Open the URL below in your browser. You will be taken to your ' +
'BTCPay Server, where you click "Authorize". After that BTCPay ' +
'sends the API key back to Keysat automatically — you do not ' +
'need to copy anything.\n\n' +
body.authorize_url +
'\n\nYou can confirm the connection succeeded with the "Check BTCPay ' +
'connection" action once approval is complete.',
'need to copy anything.\n\nYou can confirm the connection succeeded ' +
'with the "Check BTCPay connection" action once approval is complete.',
result: {
type: 'single',
value: body.authorize_url,
copyable: true,
qr: false,
masked: false,
},
}
},
)
/** Replay id used by init/index.ts when surfacing the BTCPay setup task. */
const BTCPAY_SETUP_TASK_ID = 'btcpay-initial-setup'
/** Disconnect BTCPay — clean revocation path for re-authorize cases. */
export const disconnectBtcpay = sdk.Action.withoutInput(
'disconnect-btcpay',
async () => ({
name: 'Disconnect BTCPay',
description:
'Disconnect Keysat from your BTCPay Server: revoke the API key, ' +
'delete the registered webhook, and clear local connection state. ' +
"Run this before 'Connect BTCPay' if you want to re-authorize " +
'(e.g., to switch stores or rotate the API key). Existing license ' +
'keys, products, and policies are unaffected.',
warning:
'Until you re-run "Connect BTCPay" after this, new purchases will ' +
'return 503 (BTCPay not configured). Already-issued license keys ' +
'continue to validate normally.',
allowedStatuses: 'only-running',
group: 'BTCPay',
visibility: 'enabled',
}),
async ({ effects: _effects }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/btcpay/disconnect',
{ method: 'POST' },
)
if (!resp.ok) {
throw new Error(`Disconnect failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = (await resp.json()) as
| { ok: true; noop: true; message: string }
| {
ok: true
noop: false
store_id: string | null
webhook_id: string | null
warnings: string[]
}
if ('noop' in body && body.noop) {
return {
version: '1',
title: 'Already disconnected',
message: body.message,
result: null,
}
}
const b = body as {
ok: true
noop: false
store_id: string | null
webhook_id: string | null
warnings: string[]
}
const warningsBlock = b.warnings.length > 0
? `\n\nWarnings:\n${b.warnings.map((w) => `${w}`).join('\n')}`
: ''
return {
version: '1',
title: 'BTCPay disconnected',
message:
`Local BTCPay connection cleared. ` +
`Store id was ${b.store_id ?? '(unknown)'}, webhook id was ${b.webhook_id ?? '(none)'}. ` +
`You can now run "Connect BTCPay" again to re-authorize.${warningsBlock}`,
result: null,
}
},
)
/** Optional companion action: show current BTCPay connection state. */
export const btcpayStatus = sdk.Action.withoutInput(
'btcpayStatus',
async ({ effects }) => ({
'btcpay-status',
async () => ({
name: 'Check BTCPay connection',
description: 'Shows whether BTCPay is currently connected, and the store id.',
warning: null,
@@ -67,10 +149,11 @@ export const btcpayStatus = sdk.Action.withoutInput(
visibility: 'enabled',
}),
async ({ effects }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
'/v1/admin/btcpay/status',
{ method: 'GET' },
)
@@ -83,15 +166,62 @@ export const btcpayStatus = sdk.Action.withoutInput(
if (!body.connected) {
return {
version: '1',
title: 'Not connected',
message: 'BTCPay is not connected yet. Run the "Connect BTCPay" action to authorize.',
result: null,
}
}
// BTCPay is connected — clear the install-time setup task so it
// disappears from the dashboard. clearTask is idempotent and
// tolerates being called when no such task exists, so this is safe
// every time btcpayStatus is run.
try {
await sdk.action.clearTask(effects, BTCPAY_SETUP_TASK_ID)
} catch (_) {
// Non-fatal — we still report status.
}
// Also check whether BTCPay's store has any payment methods (wallet
// / Lightning) configured. A connected store with zero payment
// methods can't actually issue invoices — that's the trap that
// surfaces as "BTC-CHAIN: Payment method unavailable" when buyers
// try to purchase. Surface the situation here so the operator
// discovers it BEFORE a customer hits a broken purchase flow.
let walletNote = ''
try {
const pmResp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/btcpay/payment-methods',
{ method: 'GET' },
)
if (pmResp.ok) {
const pmBody = (await pmResp.json()) as { count: number }
if (pmBody.count === 0) {
walletNote =
`\n\n⚠ NO WALLET CONFIGURED on this BTCPay store. Buyers won't ` +
`be able to pay until you set one up.\n` +
`Open your BTCPay store settings (${body.base_url.replace(/^http:\/\//, 'http://').replace(/^https:\/\//, 'https://')}/stores/${body.store_id}) ` +
`→ Wallets / Lightning, then come back and re-run "Check BTCPay connection".`
} else {
walletNote = `\n\n✓ ${pmBody.count} payment method(s) configured.`
}
}
} catch (_) {
// Non-fatal: payment-method check is informational.
}
return {
version: '1',
title: 'BTCPay is connected',
message:
`BTCPay is connected.\n` +
`Store id: ${body.store_id}\n` +
`Webhook id: ${body.webhook_id ?? '(not registered — check BTCPay manually)'}\n` +
`Base URL: ${body.base_url}`,
`Base URL: ${body.base_url}` +
walletNote,
result: null,
}
},
)
+196
View File
@@ -0,0 +1,196 @@
// Action: create a discount / referral code.
//
// A code can be percentage-off or fixed-sats-off. It can target a specific
// product (or apply universally), have an optional expiry, an optional
// usage cap, and a free-form referrer label for tracking ("twitter-launch",
// "alice@example.com"). Codes are case-insensitive and normalized to
// uppercase on create.
//
// Buyers redeem codes by passing `?code=FOUNDERS50` to the public purchase
// flow. The discount is reserved atomically at purchase time and finalized
// when the BTCPay invoice settles.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const input = InputSpec.of({
code: Value.text({
name: 'Code',
description:
'The redeemable string. Case-insensitive (will be uppercased). ' +
'ASCII letters, digits, "-", and "_" only. E.g., "FOUNDERS50".',
required: true,
default: null,
patterns: [
{
regex: '^[A-Za-z0-9_-]{2,40}$',
description:
'letters, digits, dashes, underscores; 2 to 40 characters',
},
],
}),
kind: Value.select({
name: 'Discount kind',
description:
'"Percent off" reduces the price by N%. ' +
'"Fixed sats off" subtracts a fixed number of sats. ' +
'"Free license" issues a license outright with no payment, ' +
'redeemed via the public /v1/redeem endpoint.',
default: 'percent',
values: {
percent: 'Percent off',
fixed_sats: 'Fixed sats off',
free_license: 'Free license (no payment)',
},
}),
amount: Value.number({
name: 'Amount',
description:
'For percent: 1..=100 (integer percentage). E.g., 50 = 50% off. ' +
'For fixed sats off: any positive integer (sats). ' +
'For free license: ignored (set to 0). ' +
'Note: percent is converted to basis points server-side.',
required: true,
default: 50,
min: 0,
max: null,
integer: true,
}),
max_uses: Value.number({
name: 'Max uses',
description: '0 = unlimited. Otherwise, max number of redemptions.',
required: true,
default: 0,
min: 0,
max: null,
integer: true,
}),
expires_at: Value.text({
name: 'Expires at (ISO 8601)',
description:
'Optional cutoff date. RFC3339 / ISO 8601 UTC, e.g. ' +
'"2026-12-31T23:59:59Z". Leave blank for no expiry.',
required: false,
default: null,
}),
product_slug: Value.text({
name: 'Product slug (optional)',
description:
'Restrict the code to one specific product. Leave blank to apply ' +
'the discount to any product.',
required: false,
default: null,
}),
policy_slug: Value.text({
name: 'Policy slug (optional)',
description:
'Further restrict to a single policy of the chosen product. ' +
'Requires "Product slug" to be set if used.',
required: false,
default: null,
}),
referrer_label: Value.text({
name: 'Referrer / campaign label (optional)',
description:
'Free-form tracking string. E.g., "twitter-launch", ' +
'"partner-alice@example.com", "podcast-XYZ-spring-2026". Shown in ' +
'usage reports; never visible to buyers.',
required: false,
default: null,
}),
description: Value.textarea({
name: 'Description (optional)',
description: 'Internal note. E.g., "Founders rate, expires May 31."',
required: false,
default: null,
}),
})
export const createDiscountCode = sdk.Action.withInput(
'create-discount-code',
async () => ({
name: 'Create discount code',
description:
'Add a redeemable discount / referral code. Buyers append ' +
'?code=YOUR_CODE to the purchase URL to apply it.',
warning: null,
allowedStatuses: 'only-running',
group: 'Discount codes',
visibility: 'enabled',
}),
input,
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
if (formInput.policy_slug && !formInput.product_slug) {
throw new Error('Policy slug requires Product slug to also be set.')
}
// Convert UI percent (1..=100) to basis points (100..=10000).
let amount = formInput.amount
if (formInput.kind === 'percent') {
if (amount < 1 || amount > 100) {
throw new Error('Percent amount must be between 1 and 100.')
}
amount = amount * 100
} else if (formInput.kind === 'fixed_sats') {
if (amount < 1) {
throw new Error('Fixed sats amount must be at least 1.')
}
} else if (formInput.kind === 'free_license') {
// Amount is unused for free licenses; force to 0 so the server
// accepts it.
amount = 0
}
const body: Record<string, unknown> = {
code: formInput.code,
kind: formInput.kind,
amount,
description: formInput.description ?? '',
}
if (formInput.max_uses > 0) body.max_uses = formInput.max_uses
if (formInput.expires_at) body.expires_at = formInput.expires_at
if (formInput.product_slug) body.product_slug = formInput.product_slug
if (formInput.policy_slug) body.policy_slug = formInput.policy_slug
if (formInput.referrer_label) body.referrer_label = formInput.referrer_label
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/discount-codes',
{ method: 'POST', body: JSON.stringify(body) },
)
if (!resp.ok) {
throw new Error(`Create discount code failed: HTTP ${resp.status}${await resp.text()}`)
}
const code = (await resp.json()) as { id: string; code: string; kind: string; amount: number }
const humanAmount =
code.kind === 'percent'
? `${code.amount / 100}% off`
: code.kind === 'fixed_sats'
? `${code.amount} sats off`
: 'free license (no payment)'
const redemptionHint =
code.kind === 'free_license'
? `Buyers redeem this code via the public /v1/redeem endpoint or via ` +
`the "Redeem free license" buyer-side action — they receive the key ` +
`directly, no BTCPay invoice.`
: `Buyers can redeem this code by appending ?code=${code.code} to the ` +
`public purchase URL.`
return {
version: '1',
title: 'Discount code created',
message:
`Created code "${code.code}" — ${humanAmount}.\n` +
redemptionHint +
`\n\nInternal id: ${code.id}`,
result: null,
}
},
)
+36 -34
View File
@@ -6,35 +6,36 @@
// normal purchase flow.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
product_slug: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
product_slug: Value.text({
name: 'Product slug',
description: 'The product this policy applies to.',
required: true,
default: null,
},
slug: {
type: 'text',
}),
slug: Value.text({
name: 'Policy slug',
description:
'URL-safe name, e.g., "default", "annual", "trial". ' +
'Use "default" for the one consumed by the public purchase flow.',
required: true,
default: null,
patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }],
},
name: {
type: 'text',
patterns: [
{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' },
],
}),
name: Value.text({
name: 'Display name',
description: 'Shown in admin listings. E.g., "Annual subscription".',
required: true,
default: null,
},
duration_seconds: {
type: 'number',
}),
duration_seconds: Value.number({
name: 'Duration (seconds)',
description: '0 = perpetual. 31536000 = one year. 7776000 = 90 days.',
required: true,
@@ -42,9 +43,8 @@ const input = sdk.InputSpec.of({
min: 0,
max: null,
integer: true,
},
grace_seconds: {
type: 'number',
}),
grace_seconds: Value.number({
name: 'Grace period (seconds)',
description:
'After expiry, how long a cached validation remains honoured ' +
@@ -54,9 +54,8 @@ const input = sdk.InputSpec.of({
min: 0,
max: null,
integer: true,
},
max_machines: {
type: 'number',
}),
max_machines: Value.number({
name: 'Max machines',
description: '0 = unlimited, 1 = single-seat, n>1 = multi-seat cap.',
required: true,
@@ -64,24 +63,21 @@ const input = sdk.InputSpec.of({
min: 0,
max: null,
integer: true,
},
is_trial: {
type: 'toggle',
}),
is_trial: Value.toggle({
name: 'Trial policy',
description: 'Mark issued keys as trial (sets the TRIAL flag in the payload).',
default: false,
},
entitlements: {
type: 'text',
}),
entitlements: Value.text({
name: 'Entitlements',
description:
'Comma-separated list of feature slugs embedded in the license key. ' +
'E.g., "pro,multi-device". Leave blank for none.',
required: false,
default: null,
},
price_sats_override: {
type: 'number',
}),
price_sats_override: Value.number({
name: 'Price override (sats, optional)',
description:
"Override the product's default price for licenses issued under this " +
@@ -91,12 +87,12 @@ const input = sdk.InputSpec.of({
min: -1,
max: null,
integer: true,
},
}),
})
export const createPolicy = sdk.Action.withInput(
'createPolicy',
async ({ effects }) => ({
'create-policy',
async () => ({
name: 'Create policy',
description:
'Add a reusable license template to a product. The public purchase ' +
@@ -108,8 +104,11 @@ export const createPolicy = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const entitlements = (formInput.entitlements ?? '')
.split(',')
.map((s) => s.trim())
@@ -129,7 +128,7 @@ export const createPolicy = sdk.Action.withInput(
body.price_sats_override = formInput.price_sats_override
}
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/policies', {
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/policies', {
method: 'POST',
body: JSON.stringify(body),
})
@@ -138,11 +137,14 @@ export const createPolicy = sdk.Action.withInput(
}
const policy = (await resp.json()) as { id: string; slug: string; name: string }
return {
version: '1',
title: 'Policy created',
message:
`Created policy '${policy.slug}' (id ${policy.id}).\n` +
(formInput.slug === 'default'
? 'Because the slug is "default", this policy will be used by the public purchase flow.'
: 'Use this slug when calling "Issue license manually".'),
result: null,
}
},
)
+26 -20
View File
@@ -4,33 +4,34 @@
// key. No need for the operator to touch curl or handle tokens.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
slug: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
slug: Value.text({
name: 'Slug',
description: 'URL-safe short name, e.g., "my-app". Used in product links.',
required: true,
default: null,
patterns: [{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' }],
},
name: {
type: 'text',
patterns: [
{ regex: '^[a-z0-9-]{2,40}$', description: 'lowercase letters, digits, and dashes' },
],
}),
name: Value.text({
name: 'Name',
description: 'Display name shown to buyers.',
required: true,
default: null,
},
description: {
type: 'textarea',
}),
description: Value.textarea({
name: 'Description',
description: 'Public description of what the buyer is getting.',
required: false,
default: null,
},
price_sats: {
type: 'number',
}),
price_sats: Value.number({
name: 'Price (sats)',
description: 'Price per license in satoshis. 100,000,000 sats = 1 BTC.',
required: true,
@@ -38,12 +39,12 @@ const input = sdk.InputSpec.of({
min: 1,
max: null,
integer: true,
},
}),
})
export const createProduct = sdk.Action.withInput(
'createProduct',
async ({ effects }) => ({
'create-product',
async () => ({
name: 'Create product',
description: 'Add a new product that can be purchased through this service.',
warning: null,
@@ -52,9 +53,11 @@ export const createProduct = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/products', {
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/products', {
method: 'POST',
body: JSON.stringify({
slug: formInput.slug,
@@ -67,13 +70,16 @@ export const createProduct = sdk.Action.withInput(
if (!resp.ok) {
throw new Error(`Create product failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = await resp.json()
const body = (await resp.json()) as { id: string; slug: string; price_sats: number }
return {
version: '1',
title: 'Product created',
message:
`Created product '${body.slug}' (id ${body.id}).\n` +
`Priced at ${body.price_sats} sats.\n\n` +
`Buyers can purchase by POSTing to your Keysat URL:\n` +
`<your Keysat URL>/v1/purchase with body: {"product":"${body.slug}"}`,
result: null,
}
},
)
+21 -13
View File
@@ -4,28 +4,29 @@
// with `not_activated`, freeing up a seat for another install.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
machine_id: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
machine_id: Value.text({
name: 'Machine ID',
description: 'UUID of the machine to deactivate. Find via list-machines.',
required: true,
default: null,
},
reason: {
type: 'text',
}),
reason: Value.text({
name: 'Reason',
description: 'Stored for audit. E.g., "laptop stolen", "support request".',
required: false,
default: null,
},
}),
})
export const deactivateMachine = sdk.Action.withInput(
'deactivateMachine',
async ({ effects }) => ({
'deactivate-machine',
async () => ({
name: 'Deactivate machine',
description:
'Force an install off a license. Frees up a seat and causes that ' +
@@ -38,11 +39,13 @@ export const deactivateMachine = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/machines/${encodeURIComponent(formInput.machine_id)}/deactivate`,
{
method: 'POST',
@@ -52,6 +55,11 @@ export const deactivateMachine = sdk.Action.withInput(
if (!resp.ok) {
throw new Error(`Deactivate failed: HTTP ${resp.status}${await resp.text()}`)
}
return { message: `Deactivated machine ${formInput.machine_id}.` }
return {
version: '1',
title: 'Machine deactivated',
message: `Deactivated machine ${formInput.machine_id}.`,
result: null,
}
},
)
+91
View File
@@ -0,0 +1,91 @@
// Action: disable (or re-enable) a discount code.
//
// Disabling is reversible — the code's redemption history is preserved
// either way. Disabled codes simply won't redeem on new purchases.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const input = InputSpec.of({
code: Value.text({
name: 'Code',
description:
'The redeemable string (e.g. "FOUNDERS50"). Case-insensitive — ' +
'will be uppercased before lookup.',
required: true,
default: null,
}),
active: Value.toggle({
name: 'Active',
description:
'Toggle off to disable this code. Toggle on to re-enable a ' +
'previously disabled code.',
default: false,
}),
})
export const disableDiscountCode = sdk.Action.withInput(
'disable-discount-code',
async () => ({
name: 'Disable / enable discount code',
description:
'Disable a code so it stops accepting new redemptions. Existing ' +
'redemptions and the underlying license are unaffected. Re-enable ' +
'by running again with "Active" toggled on.',
warning:
'Disabling does not refund or revoke previously-issued licenses. ' +
'Use "Revoke license" for that.',
allowedStatuses: 'only-running',
group: 'Discount codes',
visibility: 'enabled',
}),
input,
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
// Look the code up by string to discover its id.
const lookup = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/discount-codes',
{ method: 'GET' },
)
if (!lookup.ok) {
throw new Error(`Lookup failed: HTTP ${lookup.status}${await lookup.text()}`)
}
const body = (await lookup.json()) as { codes: Array<{ id: string; code: string }> }
const target = body.codes.find(
(c) => c.code.toUpperCase() === formInput.code.trim().toUpperCase(),
)
if (!target) {
throw new Error(
`No discount code found matching "${formInput.code}". ` +
'Use "List discount codes" with "Include disabled codes" toggled on to see all codes.',
)
}
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
`/v1/admin/discount-codes/${target.id}/active`,
{ method: 'PATCH', body: JSON.stringify({ active: formInput.active }) },
)
if (!resp.ok) {
throw new Error(`Update failed: HTTP ${resp.status}${await resp.text()}`)
}
return {
version: '1',
title: formInput.active ? 'Code re-enabled' : 'Code disabled',
message: formInput.active
? `Code "${target.code}" is now active and will accept new redemptions.`
: `Code "${target.code}" is now disabled. New purchases that try ` +
'to redeem it will be rejected. Existing redemptions and licenses are unaffected.',
result: null,
}
},
)
+26 -32
View File
@@ -1,44 +1,38 @@
// Register every action with StartOS.
// Register actions with StartOS.
//
// As of v0.1.0:11 the StartOS Actions tab is intentionally minimal —
// only setup-time operations live here:
//
// - General → Set operator name
// - BTCPay → Connect / Check / Disconnect
// - License → Activate Keysat license / Show license status
// - Credentials → Show admin API key
//
// Everything else (products, policies, discount codes, licenses,
// machines, webhooks, audit log) lives in the embedded admin web UI
// at /admin/. The action source files remain in this directory for
// reference — and the underlying admin HTTP API is unchanged — but
// they're no longer registered as StartOS UI buttons. This keeps the
// dashboard from feeling like an undifferentiated wall of buttons.
//
// The web UI uses the same /v1/admin/* endpoints those actions used to
// call, so functionality is identical; only the UI surface changed.
import { sdk } from '../sdk'
import { btcpayStatus, configureBtcpay } from './configureBtcpay'
import { createPolicy } from './createPolicy'
import { createProduct } from './createProduct'
import { deactivateMachine } from './deactivateMachine'
import { issueLicense } from './issueLicense'
import { listMachines } from './listMachines'
import { listWebhooks } from './listWebhooks'
import { registerWebhook } from './registerWebhook'
import { revokeLicense } from './revokeLicense'
import { searchLicenses } from './searchLicenses'
import { activateLicense, showLicenseStatus } from './activateLicense'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import { setOperatorName } from './setOperatorName'
import { showCredentials } from './showCredentials'
import { suspendLicense } from './suspendLicense'
import { unsuspendLicense } from './unsuspendLicense'
import { viewAuditLog } from './viewAuditLog'
export const actions = sdk.Actions.of()
// General
.addAction(setOperatorName)
// BTCPay
// BTCPay setup
.addAction(configureBtcpay)
.addAction(btcpayStatus)
.addAction(disconnectBtcpay)
// Keysat self-license (Keysat-licenses-Keysat)
.addAction(activateLicense)
.addAction(showLicenseStatus)
// Credentials
.addAction(showCredentials)
// Products + Policies
.addAction(createProduct)
.addAction(createPolicy)
// Licenses
.addAction(issueLicense)
.addAction(searchLicenses)
.addAction(suspendLicense)
.addAction(unsuspendLicense)
.addAction(revokeLicense)
// Machines
.addAction(listMachines)
.addAction(deactivateMachine)
// Webhooks
.addAction(registerWebhook)
.addAction(listWebhooks)
// Diagnostics
.addAction(viewAuditLog)
+26 -16
View File
@@ -1,28 +1,29 @@
// Action: manually issue a license for a product (comp, press, dev).
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
product_slug: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
product_slug: Value.text({
name: 'Product slug',
description: 'Which product to issue a license for.',
required: true,
default: null,
},
note: {
type: 'text',
}),
note: Value.text({
name: 'Note (optional)',
description: 'Audit trail — e.g., "comp for @alice", "press key".',
required: false,
default: null,
},
}),
})
export const issueLicense = sdk.Action.withInput(
'issueLicense',
async ({ effects }) => ({
'issue-license',
async () => ({
name: 'Issue license manually',
description: 'Generate a license key outside the purchase flow. Useful for comps and press.',
warning: null,
@@ -31,9 +32,11 @@ export const issueLicense = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
const resp = await adminCall(LICENSING_URL, store.admin_api_key, '/v1/admin/licenses', {
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, '/v1/admin/licenses', {
method: 'POST',
body: JSON.stringify({
product_slug: formInput.product_slug,
@@ -43,11 +46,18 @@ export const issueLicense = sdk.Action.withInput(
if (!resp.ok) {
throw new Error(`Issue failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = await resp.json()
const body = (await resp.json()) as { license_id: string; license_key: string }
return {
message:
`License issued.\nID: ${body.license_id}\n\n` +
`Key (give this to the recipient):\n${body.license_key}`,
version: '1',
title: 'License issued',
message: `License ID: ${body.license_id}\n\nGive this key to the recipient.`,
result: {
type: 'single',
value: body.license_key,
copyable: true,
qr: true,
masked: true,
},
}
},
)
+100
View File
@@ -0,0 +1,100 @@
// Action: list discount / referral codes with usage stats.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const input = InputSpec.of({
include_inactive: Value.toggle({
name: 'Include disabled codes',
description: 'Show codes that have been disabled.',
default: false,
}),
})
interface DiscountCode {
id: string
code: string
kind: string
amount: number
max_uses: number | null
used_count: number
expires_at: string | null
applies_to_product_id: string | null
applies_to_policy_id: string | null
referrer_label: string | null
description: string
active: boolean
created_at: string
}
export const listDiscountCodes = sdk.Action.withInput(
'list-discount-codes',
async () => ({
name: 'List discount codes',
description: 'View every discount / referral code with usage stats.',
warning: null,
allowedStatuses: 'only-running',
group: 'Discount codes',
visibility: 'enabled',
}),
input,
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const params = new URLSearchParams()
if (formInput.include_inactive) params.set('include_inactive', 'true')
const path =
'/v1/admin/discount-codes' +
(params.toString() ? `?${params.toString()}` : '')
const resp = await adminCall(LICENSING_URL, storeData.admin_api_key, path, {
method: 'GET',
})
if (!resp.ok) {
throw new Error(`List failed: HTTP ${resp.status}${await resp.text()}`)
}
const body = (await resp.json()) as { codes: DiscountCode[] }
if (body.codes.length === 0) {
return {
version: '1',
title: 'No discount codes',
message:
formInput.include_inactive
? 'No discount codes exist yet.'
: 'No active discount codes. Toggle "Include disabled codes" to also see disabled ones.',
result: null,
}
}
const lines = body.codes.map((c) => {
const off =
c.kind === 'percent'
? `${(c.amount / 100).toFixed(c.amount % 100 === 0 ? 0 : 2)}% off`
: c.kind === 'fixed_sats'
? `${c.amount} sats off`
: 'free license'
const usage = c.max_uses
? `${c.used_count}/${c.max_uses}`
: `${c.used_count}/∞`
const status = c.active ? 'active' : 'DISABLED'
const exp = c.expires_at ? `expires ${c.expires_at}` : 'no expiry'
const target = c.applies_to_product_id
? c.applies_to_policy_id
? '(product+policy scoped)'
: '(product scoped)'
: '(any product)'
const ref = c.referrer_label ? ` — ref:${c.referrer_label}` : ''
return `${c.code} [${status}] ${off} uses ${usage} ${exp} ${target}${ref}`
})
return {
version: '1',
title: `${body.codes.length} discount code(s)`,
message: lines.join('\n'),
result: null,
}
},
)
+24 -13
View File
@@ -4,27 +4,28 @@
// troubleshooting a multi-seat cap ("can't activate, too many machines").
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
license_id: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
license_id: Value.text({
name: 'License ID',
description: 'UUID of the license to inspect.',
required: true,
default: null,
},
include_inactive: {
type: 'toggle',
}),
include_inactive: Value.toggle({
name: 'Include deactivated machines',
description: 'Show rows for machines that were previously deactivated.',
default: false,
},
}),
})
export const listMachines = sdk.Action.withInput(
'listMachines',
async ({ effects }) => ({
'list-machines',
async () => ({
name: 'List machines',
description: 'Show installs currently bound to a license.',
warning: null,
@@ -33,15 +34,17 @@ export const listMachines = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const params = new URLSearchParams()
params.set('license_id', formInput.license_id)
if (formInput.include_inactive) params.set('include_inactive', 'true')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/machines?${params.toString()}`,
{ method: 'GET' },
)
@@ -60,7 +63,12 @@ export const listMachines = sdk.Action.withInput(
}>
}
if (body.machines.length === 0) {
return { message: 'No machines bound to this license.' }
return {
version: '1',
title: 'No machines',
message: 'No machines bound to this license.',
result: null,
}
}
const lines = body.machines.map((m) => {
const activeStr =
@@ -76,10 +84,13 @@ export const listMachines = sdk.Action.withInput(
return '• ' + bits.filter(Boolean).join(' ')
})
return {
version: '1',
title: `${body.machines.length} machine(s)`,
message:
`${body.machines.length} machine(s) on license ${formInput.license_id}:\n\n` +
lines.join('\n') +
'\n\nTo free a seat, use the "Deactivate machine" action with the machine id.',
result: null,
}
},
)
+13 -5
View File
@@ -4,11 +4,12 @@
// masked — rotate by deleting and recreating an endpoint.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
export const listWebhooks = sdk.Action.withoutInput(
'listWebhooks',
async ({ effects }) => ({
'list-webhooks',
async () => ({
name: 'List webhook endpoints',
description: 'Show all currently-registered outbound webhook subscribers.',
warning: null,
@@ -16,11 +17,12 @@ export const listWebhooks = sdk.Action.withoutInput(
group: 'Webhooks',
visibility: 'enabled',
}),
async ({ effects }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async ({ effects: _effects }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
'/v1/admin/webhook-endpoints',
{ method: 'GET' },
)
@@ -38,9 +40,12 @@ export const listWebhooks = sdk.Action.withoutInput(
}
if (body.endpoints.length === 0) {
return {
version: '1',
title: 'No webhooks',
message:
'No webhook endpoints registered. Use "Register webhook endpoint" ' +
'to add one.',
result: null,
}
}
const lines = body.endpoints.map((ep) => {
@@ -49,8 +54,11 @@ export const listWebhooks = sdk.Action.withoutInput(
(ep.description ? ` ("${ep.description}")` : '')
})
return {
version: '1',
title: `${body.endpoints.length} endpoint(s)`,
message:
`${body.endpoints.length} endpoint(s):\n\n` + lines.join('\n'),
result: null,
}
},
)
+27 -16
View File
@@ -7,19 +7,20 @@
// `sha256=<hex>` — same shape as BTCPay's outbound hooks.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
url: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
url: Value.text({
name: 'Webhook URL',
description: 'HTTPS endpoint that will receive POSTed event bodies.',
required: true,
default: null,
patterns: [{ regex: '^https?://', description: 'must be an HTTP(S) URL' }],
},
event_types: {
type: 'text',
}),
event_types: Value.text({
name: 'Event types',
description:
'Comma-separated list of events to subscribe to, or "*" for all. ' +
@@ -28,19 +29,18 @@ const input = sdk.InputSpec.of({
'machine.activated, machine.deactivated, invoice.settled.',
required: true,
default: '*',
},
description: {
type: 'text',
}),
description: Value.text({
name: 'Description',
description: 'Free-form label, shown in the admin list.',
required: false,
default: null,
},
}),
})
export const registerWebhook = sdk.Action.withInput(
'registerWebhook',
async ({ effects }) => ({
'register-webhook',
async () => ({
name: 'Register webhook endpoint',
description:
'Tell Keysat to POST signed event notifications to an HTTPS URL you ' +
@@ -53,8 +53,10 @@ export const registerWebhook = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const eventTypes = formInput.event_types
.split(',')
.map((s) => s.trim())
@@ -64,7 +66,7 @@ export const registerWebhook = sdk.Action.withInput(
}
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
'/v1/admin/webhook-endpoints',
{
method: 'POST',
@@ -85,13 +87,22 @@ export const registerWebhook = sdk.Action.withInput(
event_types: string[]
}
return {
version: '1',
title: 'Webhook registered',
message:
`Registered webhook endpoint (id ${ep.id}).\n` +
`URL: ${ep.url}\n` +
`Events: ${ep.event_types.join(', ')}\n\n` +
`HMAC secret (save this now — will not be shown again):\n${ep.secret}\n\n` +
`Save the HMAC secret shown below — it will not be displayed again.\n\n` +
`Verify incoming requests with header X-Keysat-Signature: sha256=<hex> ` +
`(HMAC-SHA256 of the raw request body using this secret).`,
result: {
type: 'single',
value: ep.secret,
copyable: true,
qr: false,
masked: true,
},
}
},
)
+24 -14
View File
@@ -1,44 +1,49 @@
// Action: revoke an existing license.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
license_id: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
license_id: Value.text({
name: 'License ID',
description: 'UUID of the license to revoke. Find via list-licenses action.',
required: true,
default: null,
},
reason: {
type: 'text',
}),
reason: Value.text({
name: 'Reason',
description: 'Stored for audit. E.g., "chargeback", "key leaked".',
required: false,
default: null,
},
}),
})
export const revokeLicense = sdk.Action.withInput(
'revokeLicense',
async ({ effects }) => ({
'revoke-license',
async () => ({
name: 'Revoke license',
description:
'Mark a license as revoked (one-way; use "Suspend license" for a ' +
'reversible lockout). The next time downstream software checks ' +
'revocation, it will be denied.',
warning: 'Revocation takes effect on the next online validation. Clients with cached results may continue running until their cache expires.',
warning:
'Revocation takes effect on the next online validation. Clients with ' +
'cached results may continue running until their cache expires.',
allowedStatuses: 'only-running',
group: 'Licenses',
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/revoke`,
{
method: 'POST',
@@ -48,6 +53,11 @@ export const revokeLicense = sdk.Action.withInput(
if (!resp.ok) {
throw new Error(`Revoke failed: HTTP ${resp.status}${await resp.text()}`)
}
return { message: `Revoked license ${formInput.license_id}.` }
return {
version: '1',
title: 'License revoked',
message: `Revoked license ${formInput.license_id}.`,
result: null,
}
},
)
+26 -16
View File
@@ -5,35 +5,35 @@
// matching licenses with IDs, product slugs, and current status.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
buyer_email: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
buyer_email: Value.text({
name: 'Buyer email',
description: 'Exact-match email address (leave blank if searching by another field).',
required: false,
default: null,
},
nostr_npub: {
type: 'text',
}),
nostr_npub: Value.text({
name: 'Nostr npub',
description: 'Nostr public key (npub…). Optional.',
required: false,
default: null,
},
invoice_id: {
type: 'text',
}),
invoice_id: Value.text({
name: 'BTCPay invoice ID',
description: 'The BTCPay invoice ID associated with a purchase. Optional.',
required: false,
default: null,
},
}),
})
export const searchLicenses = sdk.Action.withInput(
'searchLicenses',
async ({ effects }) => ({
'search-licenses',
async () => ({
name: 'Search licenses',
description:
"Look up a buyer's licenses by email, Nostr npub, or BTCPay " +
@@ -44,8 +44,10 @@ export const searchLicenses = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const params = new URLSearchParams()
if (formInput.buyer_email) params.set('buyer_email', formInput.buyer_email)
@@ -57,7 +59,7 @@ export const searchLicenses = sdk.Action.withInput(
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/licenses/search?${params.toString()}`,
{ method: 'GET' },
)
@@ -75,7 +77,12 @@ export const searchLicenses = sdk.Action.withInput(
}>
}
if (body.licenses.length === 0) {
return { message: 'No licenses matched.' }
return {
version: '1',
title: 'No matches',
message: 'No licenses matched.',
result: null,
}
}
const lines = body.licenses.map(
(l) =>
@@ -85,11 +92,14 @@ export const searchLicenses = sdk.Action.withInput(
(l.expires_at ? ` expires=${l.expires_at}` : ''),
)
return {
version: '1',
title: `Found ${body.licenses.length} license(s)`,
message:
`Found ${body.licenses.length} license(s):\n\n` +
lines.join('\n') +
'\n\nTo reissue the key to the buyer, look up the license details ' +
'via /v1/admin/licenses with the admin API key.',
result: null,
}
},
)
+53 -14
View File
@@ -1,36 +1,75 @@
// Action: set the operator display name shown on the service homepage.
//
// As of v0.1.0:7+ this writes to the daemon's runtime settings table via
// the admin API, so changes take effect immediately without a daemon
// restart. We also mirror the value to the wrapper's package store so
// the StartOS prefill / future env-var handoff remains consistent.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
operator_name: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
operator_name: Value.text({
name: 'Operator name',
description:
'Displayed on the service homepage so buyers know whose Keysat ' +
'instance they are interacting with. E.g., your name or business name.',
required: true,
default: null,
},
}),
})
export const setOperatorName = sdk.Action.withInput(
'setOperatorName',
async ({ effects }) => ({
'set-operator-name',
async () => ({
name: 'Set operator name',
description: 'Edit the operator name shown publicly.',
description: 'Edit the operator name shown publicly. Takes effect immediately — no restart required.',
warning: null,
allowedStatuses: 'any',
allowedStatuses: 'only-running',
group: 'General',
visibility: 'enabled',
}),
input,
// Pre-fill the form with the current value.
async ({ effects: _effects }) => {
const current = await store.read().once()
return current?.operator_name ? { operator_name: current.operator_name } : null
},
async ({ effects, input: formInput }) => {
const current = await sdk.store.getOwn(effects, sdk.StorePath).const()
await sdk.store.setOwn(effects, sdk.StorePath, {
...current,
operator_name: formInput.operator_name,
})
return { message: `Operator name set to ${formInput.operator_name}. Restart the service to apply.` }
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const trimmed = formInput.operator_name.trim()
// Live-update the daemon via admin endpoint. This stores the value
// in the daemon's settings table and the very next request to / or
// /thank-you uses it. No restart needed.
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/settings/operator-name',
{ method: 'POST', body: JSON.stringify({ name: trimmed }) },
)
if (!resp.ok) {
throw new Error(
`Operator-name update failed: HTTP ${resp.status}${await resp.text()}`,
)
}
// Mirror to the wrapper store. This isn't strictly required (the
// daemon owns the live value), but it keeps the prefill working
// and gives us a fallback path during package upgrades.
await store.merge(effects, { operator_name: trimmed })
return {
version: '1',
title: 'Operator name updated',
message:
`Operator name set to "${trimmed}". The change is live immediately — ` +
`no restart needed. Anyone visiting your service homepage from now on will see the new name.`,
result: null,
}
},
)
+26 -12
View File
@@ -7,12 +7,16 @@
// The BTCPay webhook secret used to live in the StartOS store; it now lives
// inside the daemon's own SQLite database, generated automatically during
// the "Connect BTCPay" authorize flow. Operators don't need to know it.
//
// SDK 0.4.0 shape: `Action.withoutInput(id, metadata, run)` — the run fn is
// the third positional arg, not a chained `.withoutRunner(...)` method.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
export const showCredentials = sdk.Action.withoutInput(
'showCredentials',
async ({ effects }) => ({
'show-credentials',
async () => ({
name: 'Show admin API key',
description:
'Display the auto-generated admin API key. Treat it like a password — ' +
@@ -24,13 +28,23 @@ export const showCredentials = sdk.Action.withoutInput(
group: 'Credentials',
visibility: 'enabled',
}),
).withoutRunner(async ({ effects }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
return {
message:
`Admin API key:\n${store.admin_api_key}\n\n` +
`Used as 'Authorization: Bearer <key>' against /v1/admin/*. All ` +
`StartOS actions already supply this for you — only export it if ` +
`you intend to script against the admin API from outside the box.`,
}
})
async () => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
return {
version: '1',
title: 'Admin API key',
message:
`Used as 'Authorization: Bearer <key>' against /v1/admin/*. All ` +
`StartOS actions already supply this for you — only export it if ` +
`you intend to script against the admin API from outside the box.`,
result: {
type: 'single',
value: storeData.admin_api_key,
copyable: true,
qr: true,
masked: true,
},
}
},
)
+21 -13
View File
@@ -5,28 +5,29 @@
// disputes where the outcome isn't yet known.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
license_id: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
license_id: Value.text({
name: 'License ID',
description: 'UUID of the license to suspend. Find via search-licenses action.',
required: true,
default: null,
},
reason: {
type: 'text',
}),
reason: Value.text({
name: 'Reason',
description: 'Stored for audit. E.g., "payment dispute pending".',
required: false,
default: null,
},
}),
})
export const suspendLicense = sdk.Action.withInput(
'suspendLicense',
async ({ effects }) => ({
'suspend-license',
async () => ({
name: 'Suspend license',
description:
'Temporarily disable a license. Validation calls will fail with a ' +
@@ -40,11 +41,13 @@ export const suspendLicense = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/suspend`,
{
method: 'POST',
@@ -54,6 +57,11 @@ export const suspendLicense = sdk.Action.withInput(
if (!resp.ok) {
throw new Error(`Suspend failed: HTTP ${resp.status}${await resp.text()}`)
}
return { message: `Suspended license ${formInput.license_id}.` }
return {
version: '1',
title: 'License suspended',
message: `Suspended license ${formInput.license_id}.`,
result: null,
}
},
)
+19 -10
View File
@@ -1,21 +1,23 @@
// Action: clear a previously-applied suspension.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
license_id: {
type: 'text',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
license_id: Value.text({
name: 'License ID',
description: 'UUID of the suspended license to re-enable.',
required: true,
default: null,
},
}),
})
export const unsuspendLicense = sdk.Action.withInput(
'unsuspendLicense',
async ({ effects }) => ({
'unsuspend-license',
async () => ({
name: 'Unsuspend license',
description:
'Lift a previous suspension. Validation will succeed again on the ' +
@@ -27,17 +29,24 @@ export const unsuspendLicense = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/licenses/${encodeURIComponent(formInput.license_id)}/unsuspend`,
{ method: 'POST', body: JSON.stringify({}) },
)
if (!resp.ok) {
throw new Error(`Unsuspend failed: HTTP ${resp.status}${await resp.text()}`)
}
return { message: `Unsuspended license ${formInput.license_id}.` }
return {
version: '1',
title: 'License unsuspended',
message: `Unsuspended license ${formInput.license_id}.`,
result: null,
}
},
)
+24 -13
View File
@@ -6,11 +6,13 @@
// operator can skim without curl.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const input = sdk.InputSpec.of({
limit: {
type: 'number',
const { InputSpec, Value } = sdk
const input = InputSpec.of({
limit: Value.number({
name: 'Limit',
description: 'Number of most recent entries to return (11000).',
required: true,
@@ -18,21 +20,20 @@ const input = sdk.InputSpec.of({
min: 1,
max: 1000,
integer: true,
},
action: {
type: 'text',
}),
action: Value.text({
name: 'Filter action',
description:
'Optional action slug to filter on. E.g., "license.revoke", ' +
'"license.suspend", "policy.create", "webhook_endpoint.create".',
required: false,
default: null,
},
}),
})
export const viewAuditLog = sdk.Action.withInput(
'viewAuditLog',
async ({ effects }) => ({
'view-audit-log',
async () => ({
name: 'View audit log',
description:
'Show the most recent admin mutations recorded by the service — ' +
@@ -44,15 +45,17 @@ export const viewAuditLog = sdk.Action.withInput(
visibility: 'enabled',
}),
input,
async ({ effects, input: formInput }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).const()
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
const params = new URLSearchParams()
params.set('limit', String(formInput.limit))
if (formInput.action) params.set('action', formInput.action)
const resp = await adminCall(
LICENSING_URL,
store.admin_api_key,
storeData.admin_api_key,
`/v1/admin/audit?${params.toString()}`,
{ method: 'GET' },
)
@@ -72,7 +75,12 @@ export const viewAuditLog = sdk.Action.withInput(
}>
}
if (body.entries.length === 0) {
return { message: 'No audit entries match the filter.' }
return {
version: '1',
title: 'No entries',
message: 'No audit entries match the filter.',
result: null,
}
}
const lines = body.entries.map((e) => {
const target = e.target_type && e.target_id ? `${e.target_type}:${e.target_id}` : '(no target)'
@@ -81,8 +89,11 @@ export const viewAuditLog = sdk.Action.withInput(
return `${e.created_at} ${e.action} ${target} ${actor} ${ip}`
})
return {
version: '1',
title: `${body.entries.length} entry(ies)`,
message:
`${body.entries.length} entry(ies):\n\n` + lines.join('\n'),
result: null,
}
},
)