feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
101d2164b4
commit
fea6d8db04
72 files changed
+3307
-396
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Waffo Pancake (NEVER commit real keys)
|
||||
WAFFO_MERCHANT_ID=MER_586CtDwoHUbrnFJyudo3t4
|
||||
WAFFO_PRIVATE_KEY=
|
||||
WAFFO_ENVIRONMENT=test
|
||||
|
||||
# Store / product (from Pancake Dashboard)
|
||||
WAFFO_STORE_ID=
|
||||
WAFFO_STORE_SLUG=opsxshell-pwsi6m4n
|
||||
WAFFO_PRODUCT_ID=PROD_0pcnzzT4nalEkkcdyIUoD5
|
||||
|
||||
# Optional: permanent hosted checkout URL (preferred when set)
|
||||
WAFFO_CHECKOUT_URL=https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_0pcnzzT4nalEkkcdyIUoD5?type=onetime¤cy=USD&test=true
|
||||
|
||||
# One-time Pro price used by bootstrap (USD)
|
||||
WAFFO_PRODUCT_PRICE=29.00
|
||||
|
||||
# Must match desktop verify key (src-tauri license service DEFAULT / OPSX_LICENSE_HMAC)
|
||||
OPSX_LICENSE_HMAC=opsxshell-dev-license-hmac-v1-change-in-prod
|
||||
|
||||
PORT=8787
|
||||
SUCCESS_URL=opsxshell://pro/activated
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8787
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
*.log
|
||||
@@ -0,0 +1,43 @@
|
||||
# OpsXShell License Server
|
||||
|
||||
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发。
|
||||
|
||||
**私钥只放本机环境变量,不要写入仓库或桌面端。**
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd license-server
|
||||
cp .env.example .env
|
||||
# 填入 WAFFO_PRIVATE_KEY(Dashboard 测试密钥)
|
||||
npm install
|
||||
npm run bootstrap # 首次:创建 Store + 一次性商品 Pro,写回 .env
|
||||
npm run start
|
||||
```
|
||||
|
||||
桌面端默认请求 `http://127.0.0.1:8787`。可用 `VITE_LICENSE_SERVER_URL` 覆盖。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 健康检查 |
|
||||
| POST | `/checkout` | `{ deviceId }` → `{ checkoutUrl }`(onetime) |
|
||||
| POST | `/webhooks` | Waffo webhook(`order.completed`) |
|
||||
| POST | `/activate` | `{ deviceId }` 取回已签发许可证 |
|
||||
| POST | `/issue-test-license` | 开发用:直接签发终身 Pro token |
|
||||
|
||||
## 测试许可证(无需支付)
|
||||
|
||||
```bash
|
||||
npm run issue-dev-license -- --deviceId '*'
|
||||
# 临时授权可加 --days 30
|
||||
```
|
||||
|
||||
将返回的 `token` 粘贴到 App「已有许可证?激活」。
|
||||
|
||||
## 安全
|
||||
|
||||
- `@waffo/pancake-ts` 仅在本服务使用
|
||||
- `OPSX_LICENSE_HMAC` 与桌面端校验密钥一致(生产请轮换)
|
||||
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "opsxshell-license-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opsxshell-license-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@waffo/pancake-ts": "^0.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@waffo/pancake-ts": {
|
||||
"version": "0.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@waffo/pancake-ts/-/pancake-ts-0.1.9.tgz",
|
||||
"integrity": "sha512-Gbb/Bbe6JqF4m1L+NKaAcGEA9RY4+kceVKgIA3uwZqdP8skTmi7PIUDXdPTAeRYmx9uZUqkKLhoHFJ1H2K88JA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "opsxshell-license-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "OpsXShell Pro license + Waffo Pancake checkout (server-side only)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"start": "node src/server.mjs",
|
||||
"bootstrap": "node scripts/bootstrap-products.mjs",
|
||||
"issue-dev-license": "node scripts/issue-dev-license.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waffo/pancake-ts": "^0.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* First-time bootstrap: create Store "OpsXShell" and one-time Pro product,
|
||||
* then write IDs into .env (never logs the private key).
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const ENV_PATH = join(ROOT, '.env')
|
||||
|
||||
function loadEnv() {
|
||||
if (!existsSync(ENV_PATH)) {
|
||||
console.error('缺少 .env — 请先 cp .env.example .env 并填入 WAFFO_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
const raw = readFileSync(ENV_PATH, 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEnv(key, value) {
|
||||
let raw = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : ''
|
||||
const line = `${key}=${value}`
|
||||
const re = new RegExp(`^${key}=.*$`, 'm')
|
||||
if (re.test(raw)) raw = raw.replace(re, line)
|
||||
else raw = `${raw.trimEnd()}\n${line}\n`
|
||||
writeFileSync(ENV_PATH, raw)
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const merchantId = process.env.WAFFO_MERCHANT_ID
|
||||
const privateKey = process.env.WAFFO_PRIVATE_KEY
|
||||
if (!merchantId || !privateKey) {
|
||||
console.error('需要 WAFFO_MERCHANT_ID 与 WAFFO_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { WaffoPancake } = require('@waffo/pancake-ts')
|
||||
const client = new WaffoPancake({
|
||||
merchantId,
|
||||
privateKey,
|
||||
...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}),
|
||||
})
|
||||
|
||||
async function main() {
|
||||
let storeId = process.env.WAFFO_STORE_ID
|
||||
if (!storeId) {
|
||||
console.log('Creating store OpsXShell…')
|
||||
const store = await client.stores.create({
|
||||
name: 'OpsXShell',
|
||||
slug: `opsxshell-${Date.now().toString(36)}`,
|
||||
})
|
||||
storeId = store.id || store.storeId
|
||||
if (!storeId) throw new Error(`stores.create 未返回 id: ${JSON.stringify(store)}`)
|
||||
upsertEnv('WAFFO_STORE_ID', storeId)
|
||||
console.log('Store:', storeId)
|
||||
} else {
|
||||
console.log('Using existing store:', storeId)
|
||||
}
|
||||
|
||||
if (process.env.WAFFO_PRODUCT_ID) {
|
||||
console.log(`Using WAFFO_PRODUCT_ID=${process.env.WAFFO_PRODUCT_ID}`)
|
||||
console.log('\nBootstrap complete.')
|
||||
return
|
||||
}
|
||||
|
||||
const price = process.env.WAFFO_PRODUCT_PRICE || '29.00'
|
||||
console.log(`Creating one-time product OpsXShell Pro (USD ${price})…`)
|
||||
const { product } = await client.onetimeProducts.create({
|
||||
storeId,
|
||||
name: 'OpsXShell Pro',
|
||||
description: 'One-time unlock for OpsXShell professional features',
|
||||
prices: {
|
||||
USD: { amount: price, taxCategory: 'software' },
|
||||
},
|
||||
successUrl: process.env.SUCCESS_URL || 'opsxshell://pro/activated',
|
||||
metadata: { app: 'opsxshell', license: 'lifetime' },
|
||||
})
|
||||
const id = product?.id
|
||||
if (!id) throw new Error(`create onetime product failed: ${JSON.stringify(product)}`)
|
||||
|
||||
if (client.onetimeProducts.publish) {
|
||||
await client.onetimeProducts.publish({ id }).catch(() => undefined)
|
||||
}
|
||||
upsertEnv('WAFFO_PRODUCT_ID', id)
|
||||
console.log('WAFFO_PRODUCT_ID', id)
|
||||
console.log('\nBootstrap complete. Product ID written to .env')
|
||||
console.log('Next: npm start')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = join(ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function arg(name, fallback) {
|
||||
const idx = args.indexOf(`--${name}`)
|
||||
if (idx >= 0 && args[idx + 1]) return args[idx + 1]
|
||||
return fallback
|
||||
}
|
||||
|
||||
const deviceId = arg('deviceId', '*')
|
||||
const daysArg = arg('days', null)
|
||||
const secret =
|
||||
process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod'
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = {
|
||||
plan: 'pro',
|
||||
deviceId,
|
||||
iat: now,
|
||||
features: ['*'],
|
||||
}
|
||||
if (daysArg != null) {
|
||||
payload.exp = now + Number(daysArg) * 86400
|
||||
}
|
||||
const payloadB64 = b64url(JSON.stringify(payload))
|
||||
const sig = createHmac('sha256', secret).update(payloadB64).digest()
|
||||
const token = `opsx1.${payloadB64}.${b64url(sig)}`
|
||||
|
||||
console.log(JSON.stringify({ token, payload }, null, 2))
|
||||
@@ -0,0 +1,333 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const DATA_DIR = join(ROOT, 'data')
|
||||
const ENTITLEMENTS_PATH = join(DATA_DIR, 'entitlements.json')
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = join(ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
const raw = readFileSync(envPath, 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
let WaffoPancake
|
||||
let verifyWebhook
|
||||
try {
|
||||
const sdk = require('@waffo/pancake-ts')
|
||||
WaffoPancake = sdk.WaffoPancake
|
||||
verifyWebhook = sdk.verifyWebhook
|
||||
} catch (error) {
|
||||
console.warn('[opsxshell-license] @waffo/pancake-ts not installed yet:', error.message)
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787)
|
||||
const LICENSE_HMAC =
|
||||
process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod'
|
||||
const SUCCESS_URL = process.env.SUCCESS_URL || 'opsxshell://pro/activated'
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function issueLicense({ deviceId, days = null, features = ['*'] }) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = {
|
||||
plan: 'pro',
|
||||
deviceId: deviceId || '*',
|
||||
iat: now,
|
||||
features,
|
||||
}
|
||||
// Lifetime (one-time) licenses omit exp. Pass days for temporary/test tokens.
|
||||
if (days != null && Number(days) > 0) {
|
||||
payload.exp = now + Math.max(1, Number(days)) * 86400
|
||||
}
|
||||
const payloadB64 = b64url(JSON.stringify(payload))
|
||||
const sig = createHmac('sha256', LICENSE_HMAC).update(payloadB64).digest()
|
||||
return { token: `opsx1.${payloadB64}.${b64url(sig)}`, payload }
|
||||
}
|
||||
|
||||
async function loadEntitlements() {
|
||||
try {
|
||||
return JSON.parse(await readFile(ENTITLEMENTS_PATH, 'utf8'))
|
||||
} catch {
|
||||
return { byDevice: {}, byOrder: {}, pending: {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEntitlements(data) {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(ENTITLEMENTS_PATH, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
if (!WaffoPancake) throw new Error('请先在 license-server 目录 npm install @waffo/pancake-ts')
|
||||
const merchantId = process.env.WAFFO_MERCHANT_ID
|
||||
const privateKey = process.env.WAFFO_PRIVATE_KEY
|
||||
if (!merchantId || !privateKey) {
|
||||
throw new Error('缺少 WAFFO_MERCHANT_ID / WAFFO_PRIVATE_KEY(见 .env.example)')
|
||||
}
|
||||
return new WaffoPancake({
|
||||
merchantId,
|
||||
privateKey,
|
||||
...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function productId() {
|
||||
return process.env.WAFFO_PRODUCT_ID || ''
|
||||
}
|
||||
|
||||
function buildHostedCheckoutUrl(deviceId) {
|
||||
const success = `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`
|
||||
if (process.env.WAFFO_CHECKOUT_URL) {
|
||||
const url = new URL(process.env.WAFFO_CHECKOUT_URL)
|
||||
if (!url.searchParams.get('successUrl')) url.searchParams.set('successUrl', success)
|
||||
url.searchParams.set('deviceId', deviceId)
|
||||
return url.toString()
|
||||
}
|
||||
const slug = process.env.WAFFO_STORE_SLUG
|
||||
const pid = productId()
|
||||
if (slug && pid) {
|
||||
const url = new URL(`https://pancake.waffo.ai/store/${slug}/product/${pid}`)
|
||||
url.searchParams.set('type', 'onetime')
|
||||
url.searchParams.set('currency', 'USD')
|
||||
if ((process.env.WAFFO_ENVIRONMENT || 'test') === 'test') {
|
||||
url.searchParams.set('test', 'true')
|
||||
}
|
||||
url.searchParams.set('successUrl', success)
|
||||
url.searchParams.set('deviceId', deviceId)
|
||||
return url.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks)
|
||||
const text = raw.toString('utf8')
|
||||
let json = null
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
return { text, json }
|
||||
}
|
||||
|
||||
function send(res, status, body) {
|
||||
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Waffo-Signature',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
send(res, 204, '')
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`)
|
||||
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
send(res, 200, {
|
||||
ok: true,
|
||||
hasWaffoKey: Boolean(process.env.WAFFO_PRIVATE_KEY),
|
||||
sdk: Boolean(WaffoPancake),
|
||||
productId: Boolean(productId()),
|
||||
pricing: 'onetime',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/checkout') {
|
||||
const { json } = await readBody(req)
|
||||
const deviceId = String(json?.deviceId || '').trim()
|
||||
if (!deviceId) {
|
||||
send(res, 400, { error: 'deviceId 必填' })
|
||||
return
|
||||
}
|
||||
|
||||
const pid = productId()
|
||||
const storeId = process.env.WAFFO_STORE_ID
|
||||
let checkoutUrl = null
|
||||
let mode = 'hosted'
|
||||
|
||||
// Prefer API session when merchant key + store/product IDs are ready
|
||||
if (pid && storeId && process.env.WAFFO_PRIVATE_KEY) {
|
||||
try {
|
||||
const client = getClient()
|
||||
const session = await client.checkout.createSession({
|
||||
storeId,
|
||||
productId: pid,
|
||||
productType: 'onetime',
|
||||
currency: 'USD',
|
||||
successUrl: `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`,
|
||||
darkMode: true,
|
||||
metadata: { deviceId, app: 'opsxshell', license: 'lifetime' },
|
||||
})
|
||||
checkoutUrl = session.checkoutUrl || session.url
|
||||
mode = 'session'
|
||||
} catch (error) {
|
||||
console.warn('[checkout] createSession failed, falling back to hosted URL:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkoutUrl) {
|
||||
checkoutUrl = buildHostedCheckoutUrl(deviceId)
|
||||
}
|
||||
|
||||
if (!checkoutUrl) {
|
||||
send(res, 500, {
|
||||
error:
|
||||
'未配置结账信息。请在 license-server/.env 填写 WAFFO_PRODUCT_ID 与 WAFFO_STORE_SLUG(或 WAFFO_CHECKOUT_URL)',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const store = await loadEntitlements()
|
||||
store.pending = store.pending || {}
|
||||
store.pending[deviceId] = {
|
||||
productId: pid || null,
|
||||
createdAt: Date.now(),
|
||||
mode,
|
||||
}
|
||||
await saveEntitlements(store)
|
||||
send(res, 200, { checkoutUrl, deviceId, pricing: 'onetime', mode })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/webhooks') {
|
||||
const { text } = await readBody(req)
|
||||
if (!verifyWebhook) {
|
||||
send(res, 500, { error: 'SDK verifyWebhook 不可用' })
|
||||
return
|
||||
}
|
||||
const signature = String(req.headers['x-waffo-signature'] || '')
|
||||
let event
|
||||
try {
|
||||
event = verifyWebhook(text, signature)
|
||||
} catch (error) {
|
||||
send(res, 401, { error: 'Invalid signature', detail: String(error) })
|
||||
return
|
||||
}
|
||||
|
||||
const eventType = String(event.eventType || event.type || '')
|
||||
const data = event.data || {}
|
||||
const metadata = data.metadata || {}
|
||||
const deviceId = metadata.deviceId || data.deviceId
|
||||
const orderId = data.orderId || data.id
|
||||
const store = await loadEntitlements()
|
||||
|
||||
if (
|
||||
eventType.includes('order.completed') ||
|
||||
eventType.includes('OrderCompleted')
|
||||
) {
|
||||
if (deviceId) {
|
||||
const issued = issueLicense({ deviceId })
|
||||
store.byDevice[deviceId] = {
|
||||
status: 'active',
|
||||
orderId,
|
||||
token: issued.token,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
if (orderId) {
|
||||
store.byOrder = store.byOrder || {}
|
||||
store.byOrder[orderId] = {
|
||||
status: 'completed',
|
||||
deviceId,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await saveEntitlements(store)
|
||||
send(res, 200, 'OK')
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/activate') {
|
||||
const { json } = await readBody(req)
|
||||
const deviceId = String(json?.deviceId || '').trim()
|
||||
if (!deviceId) {
|
||||
send(res, 400, { error: 'deviceId 必填' })
|
||||
return
|
||||
}
|
||||
const store = await loadEntitlements()
|
||||
const existing = store.byDevice[deviceId]
|
||||
if (existing?.status === 'active' && existing.token) {
|
||||
send(res, 200, { token: existing.token, status: 'active' })
|
||||
return
|
||||
}
|
||||
if (json?.forceIssue) {
|
||||
const days = json.days != null ? Number(json.days) : null
|
||||
const issued = issueLicense({ deviceId, days })
|
||||
store.byDevice[deviceId] = {
|
||||
status: 'active',
|
||||
token: issued.token,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await saveEntitlements(store)
|
||||
send(res, 200, { token: issued.token, status: 'active' })
|
||||
return
|
||||
}
|
||||
send(res, 404, { error: '尚未收到付款完成事件,请完成支付或使用测试许可证' })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/issue-test-license') {
|
||||
const { json } = await readBody(req)
|
||||
const deviceId = String(json?.deviceId || '*').trim() || '*'
|
||||
// Default: lifetime. Pass days for temporary tokens.
|
||||
const days = json?.days != null ? Number(json.days) : null
|
||||
send(res, 200, issueLicense({ deviceId, days }))
|
||||
return
|
||||
}
|
||||
|
||||
send(res, 404, { error: 'not found' })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
send(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[opsxshell-license] http://127.0.0.1:${PORT}`)
|
||||
console.log(`[opsxshell-license] waffo key: ${Boolean(process.env.WAFFO_PRIVATE_KEY)}`)
|
||||
})
|
||||
Reference in new issue
Block a user