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