@nietzsci/clavis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/index.d.mts +291 -0
- package/dist/index.d.ts +291 -0
- package/dist/index.js +662 -0
- package/dist/index.mjs +617 -0
- package/examples/browser-example.html +87 -0
- package/examples/node-example.ts +67 -0
- package/package.json +28 -0
- package/src/client.ts +594 -0
- package/src/crypto.ts +84 -0
- package/src/errors.ts +46 -0
- package/src/fingerprint.ts +46 -0
- package/src/hmac.ts +67 -0
- package/src/index.ts +33 -0
- package/src/storage.ts +90 -0
- package/src/types.ts +114 -0
- package/tests/sdk.test.ts +548 -0
- package/tsconfig.json +19 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ClavisError = class extends Error {
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ClavisError";
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var ErrorCodes = {
|
|
10
|
+
// 网络
|
|
11
|
+
NETWORK_ERROR: "NETWORK_ERROR",
|
|
12
|
+
API_ERROR: "API_ERROR",
|
|
13
|
+
// 签名
|
|
14
|
+
INVALID_SIGNATURE: "INVALID_SIGNATURE",
|
|
15
|
+
MISSING_AUTH_HEADERS: "MISSING_AUTH_HEADERS",
|
|
16
|
+
TIMESTAMP_EXPIRED: "TIMESTAMP_EXPIRED",
|
|
17
|
+
// 授权码
|
|
18
|
+
LICENSE_NOT_FOUND: "LICENSE_NOT_FOUND",
|
|
19
|
+
LICENSE_EXPIRED: "LICENSE_EXPIRED",
|
|
20
|
+
LICENSE_REVOKED: "LICENSE_REVOKED",
|
|
21
|
+
ACTIVATION_LIMIT: "ACTIVATION_LIMIT",
|
|
22
|
+
ACTIVATION_NOT_FOUND: "ACTIVATION_NOT_FOUND",
|
|
23
|
+
// 离线验证
|
|
24
|
+
NO_CACHED_TOKEN: "NO_CACHED_TOKEN",
|
|
25
|
+
TOKEN_SIGNATURE_INVALID: "TOKEN_SIGNATURE_INVALID",
|
|
26
|
+
TOKEN_EXPIRED: "TOKEN_EXPIRED",
|
|
27
|
+
TOKEN_GRACE_EXPIRED: "TOKEN_GRACE_EXPIRED",
|
|
28
|
+
IDENTITY_MISMATCH: "IDENTITY_MISMATCH",
|
|
29
|
+
CLOCK_DRIFT_DETECTED: "CLOCK_DRIFT_DETECTED",
|
|
30
|
+
// 配置
|
|
31
|
+
MISSING_CONFIG: "MISSING_CONFIG",
|
|
32
|
+
INVALID_CONFIG: "INVALID_CONFIG"
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/crypto.ts
|
|
36
|
+
import nacl from "tweetnacl";
|
|
37
|
+
import { decodeBase64 } from "tweetnacl-util";
|
|
38
|
+
function fromBase64Url(b64url) {
|
|
39
|
+
let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
40
|
+
while (b64.length % 4 !== 0) b64 += "=";
|
|
41
|
+
return b64;
|
|
42
|
+
}
|
|
43
|
+
function verifyOfflineToken(token, publicKeyBase64) {
|
|
44
|
+
try {
|
|
45
|
+
const parts = token.split(".");
|
|
46
|
+
if (parts.length !== 2) return null;
|
|
47
|
+
const [payloadBase64Url, signatureBase64Url] = parts;
|
|
48
|
+
const payloadB64 = fromBase64Url(payloadBase64Url);
|
|
49
|
+
const payloadBytes = Uint8Array.from(
|
|
50
|
+
atob(payloadB64),
|
|
51
|
+
(c) => c.charCodeAt(0)
|
|
52
|
+
);
|
|
53
|
+
const payloadJson = new TextDecoder().decode(payloadBytes);
|
|
54
|
+
const signatureB64 = fromBase64Url(signatureBase64Url);
|
|
55
|
+
const signatureBytes = decodeBase64(signatureB64);
|
|
56
|
+
const publicKeyBytes = decodeBase64(publicKeyBase64);
|
|
57
|
+
const messageBytes = new TextEncoder().encode(payloadJson);
|
|
58
|
+
const signedMessage = new Uint8Array(
|
|
59
|
+
signatureBytes.length + messageBytes.length
|
|
60
|
+
);
|
|
61
|
+
signedMessage.set(signatureBytes);
|
|
62
|
+
signedMessage.set(messageBytes, signatureBytes.length);
|
|
63
|
+
const verified = nacl.sign.open(signedMessage, publicKeyBytes);
|
|
64
|
+
if (!verified) return null;
|
|
65
|
+
return JSON.parse(payloadJson);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/hmac.ts
|
|
72
|
+
import crypto from "crypto";
|
|
73
|
+
function signRequest(params) {
|
|
74
|
+
const timestamp = Math.floor(Date.now() / 1e3).toString();
|
|
75
|
+
const nonce = crypto.randomUUID();
|
|
76
|
+
const bodyHash = crypto.createHash("sha256").update(params.body || "").digest("hex");
|
|
77
|
+
const stringToSign = `${params.method.toUpperCase()}
|
|
78
|
+
${params.path}
|
|
79
|
+
${timestamp}
|
|
80
|
+
${nonce}
|
|
81
|
+
${bodyHash}`;
|
|
82
|
+
const signature = crypto.createHmac("sha256", params.appSecret).update(stringToSign).digest("hex");
|
|
83
|
+
return {
|
|
84
|
+
"x-app-id": params.appId,
|
|
85
|
+
"x-timestamp": timestamp,
|
|
86
|
+
"x-nonce": nonce,
|
|
87
|
+
"x-signature": signature
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function hashIdentity(identity, appSecret) {
|
|
91
|
+
return crypto.createHmac("sha256", appSecret).update(identity).digest("hex");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/fingerprint.ts
|
|
95
|
+
import crypto2 from "crypto";
|
|
96
|
+
import os from "os";
|
|
97
|
+
function getMachineCode(appSecret) {
|
|
98
|
+
const cpus = os.cpus();
|
|
99
|
+
const data = [
|
|
100
|
+
os.hostname(),
|
|
101
|
+
os.platform(),
|
|
102
|
+
os.arch(),
|
|
103
|
+
cpus[0]?.model || "",
|
|
104
|
+
cpus.length.toString(),
|
|
105
|
+
os.totalmem().toString()
|
|
106
|
+
].join("|");
|
|
107
|
+
const key = appSecret || "clavis-default-key";
|
|
108
|
+
const hash = crypto2.createHmac("sha256", key).update(data).digest("hex");
|
|
109
|
+
return `MC-${hash.substring(0, 4).toUpperCase()}-${hash.substring(4, 8).toUpperCase()}-${hash.substring(8, 12).toUpperCase()}-${hash.substring(12, 16).toUpperCase()}`;
|
|
110
|
+
}
|
|
111
|
+
function getDeviceInfo() {
|
|
112
|
+
const cpus = os.cpus();
|
|
113
|
+
return {
|
|
114
|
+
os: os.platform(),
|
|
115
|
+
osVersion: os.release(),
|
|
116
|
+
hostname: os.hostname(),
|
|
117
|
+
arch: os.arch(),
|
|
118
|
+
cpuModel: cpus[0]?.model || "unknown",
|
|
119
|
+
totalMemory: os.totalmem()
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/storage.ts
|
|
124
|
+
import fs from "fs";
|
|
125
|
+
import path from "path";
|
|
126
|
+
import crypto3 from "crypto";
|
|
127
|
+
var FileStorage = class {
|
|
128
|
+
constructor(storagePath, appId) {
|
|
129
|
+
const safeName = appId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
130
|
+
this.filePath = path.join(storagePath, `.clavis_${safeName}`);
|
|
131
|
+
this.encryptionKey = crypto3.createHash("sha256").update(appId).digest();
|
|
132
|
+
}
|
|
133
|
+
/** 保存缓存数据(AES-256-GCM 加密) */
|
|
134
|
+
save(data) {
|
|
135
|
+
const json = JSON.stringify(data);
|
|
136
|
+
const iv = crypto3.randomBytes(12);
|
|
137
|
+
const cipher = crypto3.createCipheriv("aes-256-gcm", this.encryptionKey, iv);
|
|
138
|
+
const encrypted = Buffer.concat([
|
|
139
|
+
cipher.update(json, "utf8"),
|
|
140
|
+
cipher.final()
|
|
141
|
+
]);
|
|
142
|
+
const tag = cipher.getAuthTag();
|
|
143
|
+
const combined = Buffer.concat([iv, tag, encrypted]);
|
|
144
|
+
const dir = path.dirname(this.filePath);
|
|
145
|
+
if (!fs.existsSync(dir)) {
|
|
146
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
fs.writeFileSync(this.filePath, combined);
|
|
149
|
+
}
|
|
150
|
+
/** 读取缓存数据(解密) */
|
|
151
|
+
load() {
|
|
152
|
+
try {
|
|
153
|
+
if (!fs.existsSync(this.filePath)) return null;
|
|
154
|
+
const data = fs.readFileSync(this.filePath);
|
|
155
|
+
if (data.length < 28) return null;
|
|
156
|
+
const iv = data.subarray(0, 12);
|
|
157
|
+
const tag = data.subarray(12, 28);
|
|
158
|
+
const encrypted = data.subarray(28);
|
|
159
|
+
const decipher = crypto3.createDecipheriv(
|
|
160
|
+
"aes-256-gcm",
|
|
161
|
+
this.encryptionKey,
|
|
162
|
+
iv
|
|
163
|
+
);
|
|
164
|
+
decipher.setAuthTag(tag);
|
|
165
|
+
const decrypted = decipher.update(encrypted, void 0, "utf8") + decipher.final("utf8");
|
|
166
|
+
return JSON.parse(decrypted);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** 清除缓存文件 */
|
|
172
|
+
clear() {
|
|
173
|
+
try {
|
|
174
|
+
if (fs.existsSync(this.filePath)) {
|
|
175
|
+
fs.unlinkSync(this.filePath);
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// src/client.ts
|
|
183
|
+
var Clavis = class {
|
|
184
|
+
constructor(config) {
|
|
185
|
+
this.statusListeners = [];
|
|
186
|
+
this.expiryListeners = [];
|
|
187
|
+
this.lastStatus = null;
|
|
188
|
+
this.currentLicenseKey = null;
|
|
189
|
+
this.currentIdentityHash = null;
|
|
190
|
+
if (!config.appId || !config.appSecret || !config.publicKey) {
|
|
191
|
+
throw new ClavisError(
|
|
192
|
+
ErrorCodes.MISSING_CONFIG,
|
|
193
|
+
"appId, appSecret, publicKey \u4E3A\u5FC5\u586B\u9879"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
this.config = {
|
|
197
|
+
appId: config.appId,
|
|
198
|
+
appSecret: config.appSecret,
|
|
199
|
+
publicKey: config.publicKey,
|
|
200
|
+
baseUrl: (config.baseUrl || "https://c.nietzsci.com").replace(/\/+$/, ""),
|
|
201
|
+
storagePath: config.storagePath || process.cwd(),
|
|
202
|
+
offlineGraceDays: config.offlineGraceDays ?? 7,
|
|
203
|
+
heartbeatInterval: config.heartbeatInterval ?? 3600,
|
|
204
|
+
autoHeartbeat: config.autoHeartbeat ?? true
|
|
205
|
+
};
|
|
206
|
+
this.storage = new FileStorage(this.config.storagePath, this.config.appId);
|
|
207
|
+
}
|
|
208
|
+
// ===== 静态工具方法 =====
|
|
209
|
+
/** 生成设备机器码 */
|
|
210
|
+
static getMachineCode(appSecret) {
|
|
211
|
+
return getMachineCode(appSecret);
|
|
212
|
+
}
|
|
213
|
+
/** 获取设备信息 */
|
|
214
|
+
static getDeviceInfo() {
|
|
215
|
+
return getDeviceInfo();
|
|
216
|
+
}
|
|
217
|
+
// ===== 激活 =====
|
|
218
|
+
/**
|
|
219
|
+
* 激活授权码(需要联网)
|
|
220
|
+
*
|
|
221
|
+
* @param licenseKey 授权码,格式 XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
|
222
|
+
* @param identity 设备标识(机器码或自定义字符串),SDK 会自动 HMAC 哈希
|
|
223
|
+
*/
|
|
224
|
+
async activate(licenseKey, identity) {
|
|
225
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
226
|
+
const body = JSON.stringify({
|
|
227
|
+
license_key: licenseKey,
|
|
228
|
+
identity_hash: identityHash
|
|
229
|
+
});
|
|
230
|
+
const data = await this.apiPost("/api/v1/sdk/activate", body);
|
|
231
|
+
this.storage.save({
|
|
232
|
+
offlineToken: data.offline_token,
|
|
233
|
+
lastVerifiedAt: Math.floor(Date.now() / 1e3),
|
|
234
|
+
tokenVersion: 0,
|
|
235
|
+
// 激活时不知道具体版本,从 token 解析
|
|
236
|
+
licenseKey
|
|
237
|
+
});
|
|
238
|
+
this.currentLicenseKey = licenseKey;
|
|
239
|
+
this.currentIdentityHash = identityHash;
|
|
240
|
+
if (this.config.autoHeartbeat) {
|
|
241
|
+
this.startHeartbeat(licenseKey, identity);
|
|
242
|
+
}
|
|
243
|
+
const result = {
|
|
244
|
+
success: true,
|
|
245
|
+
tier: data.tier,
|
|
246
|
+
features: data.features,
|
|
247
|
+
expiresAt: data.expires_at,
|
|
248
|
+
offlineToken: data.offline_token
|
|
249
|
+
};
|
|
250
|
+
this.notifyStatusChange(this.buildStatusFromActivate(result, false));
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
// ===== 验证 =====
|
|
254
|
+
/**
|
|
255
|
+
* 检查授权状态(优先离线,失败则在线)
|
|
256
|
+
*/
|
|
257
|
+
async checkLicense(identity) {
|
|
258
|
+
const offlineResult = this.verifyOffline(identity);
|
|
259
|
+
if (offlineResult.valid) return offlineResult;
|
|
260
|
+
const cached = this.storage.load();
|
|
261
|
+
if (!cached?.licenseKey) {
|
|
262
|
+
return offlineResult;
|
|
263
|
+
}
|
|
264
|
+
return this.verifyOnline(cached.licenseKey, identity);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 纯离线验证(零网络请求)
|
|
268
|
+
*
|
|
269
|
+
* @param identity 可选的设备标识,用于 identity_hash 匹配校验
|
|
270
|
+
*/
|
|
271
|
+
verifyOffline(identity) {
|
|
272
|
+
const invalid = {
|
|
273
|
+
valid: false,
|
|
274
|
+
tier: "",
|
|
275
|
+
features: [],
|
|
276
|
+
expiresAt: null,
|
|
277
|
+
daysRemaining: null,
|
|
278
|
+
isOffline: true,
|
|
279
|
+
isTrial: false,
|
|
280
|
+
inGracePeriod: false
|
|
281
|
+
};
|
|
282
|
+
const cached = this.storage.load();
|
|
283
|
+
if (!cached?.offlineToken) {
|
|
284
|
+
return { ...invalid, tier: "none" };
|
|
285
|
+
}
|
|
286
|
+
const payload = verifyOfflineToken(
|
|
287
|
+
cached.offlineToken,
|
|
288
|
+
this.config.publicKey
|
|
289
|
+
);
|
|
290
|
+
if (!payload) {
|
|
291
|
+
return { ...invalid, tier: "invalid_signature" };
|
|
292
|
+
}
|
|
293
|
+
if (payload.app_id !== this.config.appId) {
|
|
294
|
+
return { ...invalid, tier: "app_mismatch" };
|
|
295
|
+
}
|
|
296
|
+
if (identity) {
|
|
297
|
+
const expectedHash = hashIdentity(identity, this.config.appSecret);
|
|
298
|
+
if (payload.identity_hash !== expectedHash) {
|
|
299
|
+
return { ...invalid, tier: "identity_mismatch" };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
303
|
+
if (cached.lastVerifiedAt > now + 60) {
|
|
304
|
+
return { ...invalid, tier: "clock_drift" };
|
|
305
|
+
}
|
|
306
|
+
const isExpired = payload.expires_at !== null && payload.expires_at < now;
|
|
307
|
+
const inGracePeriod = isExpired && payload.grace_until > now;
|
|
308
|
+
const graceExpired = isExpired && payload.grace_until <= now;
|
|
309
|
+
if (graceExpired) {
|
|
310
|
+
return {
|
|
311
|
+
...invalid,
|
|
312
|
+
tier: payload.tier,
|
|
313
|
+
features: payload.features,
|
|
314
|
+
expiresAt: payload.expires_at ? new Date(payload.expires_at * 1e3).toISOString() : null,
|
|
315
|
+
daysRemaining: 0
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
let daysRemaining = null;
|
|
319
|
+
if (payload.expires_at !== null) {
|
|
320
|
+
daysRemaining = Math.ceil((payload.expires_at - now) / 86400);
|
|
321
|
+
if (daysRemaining < 0) daysRemaining = 0;
|
|
322
|
+
}
|
|
323
|
+
this.storage.save({
|
|
324
|
+
...cached,
|
|
325
|
+
lastVerifiedAt: now
|
|
326
|
+
});
|
|
327
|
+
const status = {
|
|
328
|
+
valid: true,
|
|
329
|
+
tier: payload.tier,
|
|
330
|
+
features: payload.features,
|
|
331
|
+
expiresAt: payload.expires_at ? new Date(payload.expires_at * 1e3).toISOString() : null,
|
|
332
|
+
daysRemaining,
|
|
333
|
+
isOffline: true,
|
|
334
|
+
isTrial: payload.tier === "trial",
|
|
335
|
+
inGracePeriod
|
|
336
|
+
};
|
|
337
|
+
this.notifyStatusChange(status);
|
|
338
|
+
this.checkExpiryReminders(daysRemaining);
|
|
339
|
+
return status;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 在线验证(备用)
|
|
343
|
+
*/
|
|
344
|
+
async verifyOnline(licenseKey, identity) {
|
|
345
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
346
|
+
const body = JSON.stringify({
|
|
347
|
+
license_key: licenseKey,
|
|
348
|
+
identity_hash: identityHash
|
|
349
|
+
});
|
|
350
|
+
try {
|
|
351
|
+
const data = await this.apiPost("/api/v1/sdk/verify", body);
|
|
352
|
+
if (data.offline_token) {
|
|
353
|
+
this.storage.save({
|
|
354
|
+
offlineToken: data.offline_token,
|
|
355
|
+
lastVerifiedAt: Math.floor(Date.now() / 1e3),
|
|
356
|
+
tokenVersion: 0,
|
|
357
|
+
licenseKey
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const status = {
|
|
361
|
+
valid: data.valid,
|
|
362
|
+
tier: data.tier,
|
|
363
|
+
features: data.features,
|
|
364
|
+
expiresAt: data.expires_at,
|
|
365
|
+
daysRemaining: data.days_remaining,
|
|
366
|
+
isOffline: false,
|
|
367
|
+
isTrial: data.tier === "trial",
|
|
368
|
+
inGracePeriod: false
|
|
369
|
+
};
|
|
370
|
+
this.notifyStatusChange(status);
|
|
371
|
+
return status;
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return {
|
|
374
|
+
valid: false,
|
|
375
|
+
tier: "",
|
|
376
|
+
features: [],
|
|
377
|
+
expiresAt: null,
|
|
378
|
+
daysRemaining: null,
|
|
379
|
+
isOffline: true,
|
|
380
|
+
isTrial: false,
|
|
381
|
+
inGracePeriod: false
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 检查是否有某个功能
|
|
387
|
+
*/
|
|
388
|
+
hasFeature(featureName) {
|
|
389
|
+
const cached = this.storage.load();
|
|
390
|
+
if (!cached?.offlineToken) return false;
|
|
391
|
+
const payload = verifyOfflineToken(
|
|
392
|
+
cached.offlineToken,
|
|
393
|
+
this.config.publicKey
|
|
394
|
+
);
|
|
395
|
+
if (!payload) return false;
|
|
396
|
+
return payload.features.includes(featureName);
|
|
397
|
+
}
|
|
398
|
+
// ===== 支付 =====
|
|
399
|
+
/**
|
|
400
|
+
* 获取定价方案列表
|
|
401
|
+
*/
|
|
402
|
+
async getPricingPlans() {
|
|
403
|
+
const url = `${this.config.baseUrl}/api/v1/public/app-info?app_id=${encodeURIComponent(this.config.appId)}`;
|
|
404
|
+
const response = await fetch(url);
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
throw new ClavisError(
|
|
407
|
+
ErrorCodes.API_ERROR,
|
|
408
|
+
`\u83B7\u53D6\u5B9A\u4EF7\u65B9\u6848\u5931\u8D25: ${response.status}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const json = await response.json();
|
|
412
|
+
if (!json.success || !json.data) {
|
|
413
|
+
throw new ClavisError(
|
|
414
|
+
ErrorCodes.API_ERROR,
|
|
415
|
+
json.error?.message || "\u83B7\u53D6\u5B9A\u4EF7\u65B9\u6848\u5931\u8D25"
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return json.data.pricing || [];
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 获取支付页面 URL
|
|
422
|
+
*/
|
|
423
|
+
getPaymentUrl(planId, identity) {
|
|
424
|
+
let url = `${this.config.baseUrl}/activate/${this.config.appId}?plan=${encodeURIComponent(planId)}`;
|
|
425
|
+
if (identity) {
|
|
426
|
+
url += `&identity=${encodeURIComponent(identity)}`;
|
|
427
|
+
}
|
|
428
|
+
return url;
|
|
429
|
+
}
|
|
430
|
+
// ===== 生命周期 =====
|
|
431
|
+
/**
|
|
432
|
+
* 解绑当前设备(需要联网)
|
|
433
|
+
*/
|
|
434
|
+
async deactivate(licenseKey, identity) {
|
|
435
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
436
|
+
const body = JSON.stringify({
|
|
437
|
+
license_key: licenseKey,
|
|
438
|
+
identity_hash: identityHash
|
|
439
|
+
});
|
|
440
|
+
await this.apiPost("/api/v1/sdk/deactivate", body);
|
|
441
|
+
this.storage.clear();
|
|
442
|
+
this.stopHeartbeat();
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* 心跳(通常自动调用)
|
|
446
|
+
*/
|
|
447
|
+
async heartbeat(licenseKey, identity) {
|
|
448
|
+
const identityHash = hashIdentity(identity, this.config.appSecret);
|
|
449
|
+
const body = JSON.stringify({
|
|
450
|
+
license_key: licenseKey,
|
|
451
|
+
identity_hash: identityHash
|
|
452
|
+
});
|
|
453
|
+
const data = await this.apiPost("/api/v1/sdk/heartbeat", body);
|
|
454
|
+
if (data.offline_token) {
|
|
455
|
+
this.storage.save({
|
|
456
|
+
offlineToken: data.offline_token,
|
|
457
|
+
lastVerifiedAt: Math.floor(Date.now() / 1e3),
|
|
458
|
+
tokenVersion: 0,
|
|
459
|
+
licenseKey
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
serverTimestamp: data.server_timestamp,
|
|
464
|
+
status: data.status,
|
|
465
|
+
offlineToken: data.offline_token
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* 监听授权状态变化
|
|
470
|
+
* @returns 取消监听函数
|
|
471
|
+
*/
|
|
472
|
+
onStatusChange(callback) {
|
|
473
|
+
this.statusListeners.push(callback);
|
|
474
|
+
return () => {
|
|
475
|
+
this.statusListeners = this.statusListeners.filter(
|
|
476
|
+
(cb) => cb !== callback
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* 到期前提醒
|
|
482
|
+
* @param daysBeforeExpiry 到期前多少天触发
|
|
483
|
+
* @returns 取消监听函数
|
|
484
|
+
*/
|
|
485
|
+
onExpiring(daysBeforeExpiry, callback) {
|
|
486
|
+
const listener = { days: daysBeforeExpiry, callback, fired: false };
|
|
487
|
+
this.expiryListeners.push(listener);
|
|
488
|
+
return () => {
|
|
489
|
+
this.expiryListeners = this.expiryListeners.filter(
|
|
490
|
+
(l) => l !== listener
|
|
491
|
+
);
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* 销毁(清理心跳定时器和监听器)
|
|
496
|
+
*/
|
|
497
|
+
destroy() {
|
|
498
|
+
this.stopHeartbeat();
|
|
499
|
+
this.statusListeners = [];
|
|
500
|
+
this.expiryListeners = [];
|
|
501
|
+
this.lastStatus = null;
|
|
502
|
+
}
|
|
503
|
+
// ===== 内部方法 =====
|
|
504
|
+
/** 发送带 HMAC 签名的 POST 请求 */
|
|
505
|
+
async apiPost(path2, body) {
|
|
506
|
+
const headers = signRequest({
|
|
507
|
+
method: "POST",
|
|
508
|
+
path: path2,
|
|
509
|
+
body,
|
|
510
|
+
appId: this.config.appId,
|
|
511
|
+
appSecret: this.config.appSecret
|
|
512
|
+
});
|
|
513
|
+
const url = `${this.config.baseUrl}${path2}`;
|
|
514
|
+
let response;
|
|
515
|
+
try {
|
|
516
|
+
response = await fetch(url, {
|
|
517
|
+
method: "POST",
|
|
518
|
+
headers: {
|
|
519
|
+
...headers,
|
|
520
|
+
"Content-Type": "application/json"
|
|
521
|
+
},
|
|
522
|
+
body
|
|
523
|
+
});
|
|
524
|
+
} catch (err) {
|
|
525
|
+
throw new ClavisError(
|
|
526
|
+
ErrorCodes.NETWORK_ERROR,
|
|
527
|
+
`\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const json = await response.json();
|
|
531
|
+
if (!response.ok || !json.success) {
|
|
532
|
+
const code = json.error?.code || ErrorCodes.API_ERROR;
|
|
533
|
+
const message = json.error?.message || `API \u8BF7\u6C42\u5931\u8D25: ${response.status}`;
|
|
534
|
+
throw new ClavisError(code, message);
|
|
535
|
+
}
|
|
536
|
+
return json.data;
|
|
537
|
+
}
|
|
538
|
+
/** 启动自动心跳 */
|
|
539
|
+
startHeartbeat(licenseKey, identity) {
|
|
540
|
+
this.stopHeartbeat();
|
|
541
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
542
|
+
try {
|
|
543
|
+
await this.heartbeat(licenseKey, identity);
|
|
544
|
+
} catch {
|
|
545
|
+
}
|
|
546
|
+
}, this.config.heartbeatInterval * 1e3);
|
|
547
|
+
if (this.heartbeatTimer && typeof this.heartbeatTimer === "object" && "unref" in this.heartbeatTimer) {
|
|
548
|
+
this.heartbeatTimer.unref();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** 停止心跳 */
|
|
552
|
+
stopHeartbeat() {
|
|
553
|
+
if (this.heartbeatTimer) {
|
|
554
|
+
clearInterval(this.heartbeatTimer);
|
|
555
|
+
this.heartbeatTimer = void 0;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** 通知状态变化 */
|
|
559
|
+
notifyStatusChange(status) {
|
|
560
|
+
if (this.lastStatus === null || this.lastStatus.valid !== status.valid || this.lastStatus.tier !== status.tier) {
|
|
561
|
+
this.lastStatus = status;
|
|
562
|
+
for (const cb of this.statusListeners) {
|
|
563
|
+
try {
|
|
564
|
+
cb(status);
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** 检查到期提醒 */
|
|
571
|
+
checkExpiryReminders(daysRemaining) {
|
|
572
|
+
if (daysRemaining === null) return;
|
|
573
|
+
for (const listener of this.expiryListeners) {
|
|
574
|
+
if (!listener.fired && daysRemaining <= listener.days) {
|
|
575
|
+
listener.fired = true;
|
|
576
|
+
try {
|
|
577
|
+
listener.callback();
|
|
578
|
+
} catch {
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** 从激活结果构造 LicenseStatus */
|
|
584
|
+
buildStatusFromActivate(result, isOffline) {
|
|
585
|
+
let daysRemaining = null;
|
|
586
|
+
if (result.expiresAt) {
|
|
587
|
+
const expiresMs = new Date(result.expiresAt).getTime();
|
|
588
|
+
daysRemaining = Math.ceil((expiresMs - Date.now()) / (86400 * 1e3));
|
|
589
|
+
if (daysRemaining < 0) daysRemaining = 0;
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
valid: result.success,
|
|
593
|
+
tier: result.tier,
|
|
594
|
+
features: result.features,
|
|
595
|
+
expiresAt: result.expiresAt,
|
|
596
|
+
daysRemaining,
|
|
597
|
+
isOffline,
|
|
598
|
+
isTrial: result.tier === "trial",
|
|
599
|
+
inGracePeriod: false
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
// src/index.ts
|
|
605
|
+
var index_default = Clavis;
|
|
606
|
+
export {
|
|
607
|
+
Clavis,
|
|
608
|
+
ClavisError,
|
|
609
|
+
ErrorCodes,
|
|
610
|
+
FileStorage,
|
|
611
|
+
index_default as default,
|
|
612
|
+
getDeviceInfo,
|
|
613
|
+
getMachineCode,
|
|
614
|
+
hashIdentity,
|
|
615
|
+
signRequest,
|
|
616
|
+
verifyOfflineToken
|
|
617
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Clavis SDK - 浏览器示例</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
|
|
9
|
+
h1 { font-size: 1.5rem; }
|
|
10
|
+
.card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 16px 0; }
|
|
11
|
+
.status { padding: 8px 16px; border-radius: 4px; font-weight: bold; }
|
|
12
|
+
.status.valid { background: #d4edda; color: #155724; }
|
|
13
|
+
.status.invalid { background: #f8d7da; color: #721c24; }
|
|
14
|
+
input { width: 100%; padding: 8px; margin: 8px 0; box-sizing: border-box; }
|
|
15
|
+
button { padding: 8px 16px; background: #0066ff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
16
|
+
button:hover { background: #0052cc; }
|
|
17
|
+
pre { background: #f5f5f5; padding: 12px; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; }
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<h1>Clavis SDK 浏览器示例</h1>
|
|
22
|
+
|
|
23
|
+
<div class="card">
|
|
24
|
+
<h3>配置</h3>
|
|
25
|
+
<input id="appId" placeholder="App ID (nc_xxx)" value="" />
|
|
26
|
+
<input id="publicKey" placeholder="Ed25519 公钥 (base64)" value="" />
|
|
27
|
+
<input id="baseUrl" placeholder="API 地址" value="https://c.nietzsci.com" />
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="card">
|
|
31
|
+
<h3>离线验证</h3>
|
|
32
|
+
<p>粘贴 offline_token 进行纯离线验证(不发送网络请求):</p>
|
|
33
|
+
<textarea id="offlineToken" rows="4" style="width:100%;box-sizing:border-box;" placeholder="base64url(payload).base64url(signature)"></textarea>
|
|
34
|
+
<br /><br />
|
|
35
|
+
<button onclick="verifyToken()">验证 Token</button>
|
|
36
|
+
<div id="result"></div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="card">
|
|
40
|
+
<h3>购买授权</h3>
|
|
41
|
+
<button onclick="openPayment()">打开购买页面</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- 说明:浏览器环境不能使用 HMAC 签名(appSecret 不能暴露在前端) -->
|
|
45
|
+
<!-- 浏览器端只能做:离线验证(公钥验签)、打开支付页面 -->
|
|
46
|
+
<!-- 激活/心跳/解绑 等需要 appSecret 的操作必须放在后端 -->
|
|
47
|
+
|
|
48
|
+
<script type="module">
|
|
49
|
+
// 浏览器环境用 ES module 导入构建产物
|
|
50
|
+
// import { verifyOfflineToken } from '@clavis/sdk';
|
|
51
|
+
|
|
52
|
+
// 简化版:直接内联 tweetnacl 的验签逻辑用于演示
|
|
53
|
+
// 生产环境请使用构建后的 @clavis/sdk
|
|
54
|
+
|
|
55
|
+
window.verifyToken = function () {
|
|
56
|
+
const token = document.getElementById('offlineToken').value.trim();
|
|
57
|
+
const publicKey = document.getElementById('publicKey').value.trim();
|
|
58
|
+
const resultDiv = document.getElementById('result');
|
|
59
|
+
|
|
60
|
+
if (!token || !publicKey) {
|
|
61
|
+
resultDiv.innerHTML = '<p class="status invalid">请填写 Token 和公钥</p>';
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
resultDiv.innerHTML = '<pre>验证中... (生产环境使用 @clavis/sdk 的 verifyOfflineToken)</pre>';
|
|
66
|
+
|
|
67
|
+
// 实际使用:
|
|
68
|
+
// const payload = verifyOfflineToken(token, publicKey);
|
|
69
|
+
// if (payload) {
|
|
70
|
+
// resultDiv.innerHTML = `<p class="status valid">授权有效</p><pre>${JSON.stringify(payload, null, 2)}</pre>`;
|
|
71
|
+
// } else {
|
|
72
|
+
// resultDiv.innerHTML = '<p class="status invalid">签名验证失败</p>';
|
|
73
|
+
// }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
window.openPayment = function () {
|
|
77
|
+
const appId = document.getElementById('appId').value.trim();
|
|
78
|
+
const baseUrl = document.getElementById('baseUrl').value.trim();
|
|
79
|
+
if (!appId || !baseUrl) {
|
|
80
|
+
alert('请填写 App ID 和 API 地址');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
window.open(`${baseUrl}/activate/${appId}`, '_blank');
|
|
84
|
+
};
|
|
85
|
+
</script>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|