@antondziuin/talos-sdk 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/dist/index.cjs ADDED
@@ -0,0 +1,1512 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ LicenseState: () => LicenseState,
34
+ StateStore: () => StateStore,
35
+ TalosApiError: () => TalosApiError,
36
+ TalosClient: () => TalosClient,
37
+ TalosError: () => TalosError,
38
+ TalosSignatureError: () => TalosSignatureError,
39
+ collectFingerprint: () => collectFingerprint,
40
+ defaultStatePath: () => defaultStatePath,
41
+ hashLicenseKey: () => hashLicenseKey,
42
+ publicKeyFromBase64: () => publicKeyFromBase64,
43
+ verifyAppliedUpdate: () => verifyAppliedUpdate,
44
+ verifyKeyset: () => verifyKeyset,
45
+ verifyManifest: () => verifyManifest,
46
+ verifyToken: () => verifyToken
47
+ });
48
+ module.exports = __toCommonJS(index_exports);
49
+
50
+ // src/client.ts
51
+ var import_node_crypto5 = require("crypto");
52
+ var import_node_fs3 = require("fs");
53
+ var import_promises = require("fs/promises");
54
+ var import_node_os3 = __toESM(require("os"), 1);
55
+ var import_node_crypto6 = require("crypto");
56
+
57
+ // src/errors.ts
58
+ var TalosError = class extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = "TalosError";
62
+ }
63
+ };
64
+ var TalosSignatureError = class extends TalosError {
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = "TalosSignatureError";
68
+ }
69
+ };
70
+ var TalosApiError = class extends TalosError {
71
+ statusCode;
72
+ errorCode;
73
+ constructor(statusCode, errorCode) {
74
+ super(`talos api error ${statusCode}${errorCode ? `: ${errorCode}` : ""}`);
75
+ this.name = "TalosApiError";
76
+ this.statusCode = statusCode;
77
+ this.errorCode = errorCode;
78
+ }
79
+ };
80
+
81
+ // src/fingerprint.ts
82
+ var import_node_crypto = require("crypto");
83
+ var import_node_fs = require("fs");
84
+ var import_node_os = __toESM(require("os"), 1);
85
+ function collectFingerprint(productToken, extra) {
86
+ const key = (0, import_node_crypto.createHash)("sha256").update("talos-fp|" + productToken).digest();
87
+ const raw = {
88
+ machine_guid: machineGuid(),
89
+ smbios_uuid: readFile("/sys/class/dmi/id/product_uuid"),
90
+ disk_serial: readFile("/sys/class/dmi/id/board_serial"),
91
+ mac_set: macSet(),
92
+ cpu: cpu(),
93
+ mem_bucket: memBucket(),
94
+ os_install: osInstall(),
95
+ hostname: safe(() => import_node_os.default.hostname()),
96
+ ...extra
97
+ };
98
+ const components = {};
99
+ for (const id of Object.keys(raw).sort()) {
100
+ const value = raw[id];
101
+ if (value === void 0 || value === null || value.trim() === "") continue;
102
+ components[id] = (0, import_node_crypto.createHmac)("sha256", key).update(id + "=" + value.trim()).digest("hex").slice(0, 32);
103
+ }
104
+ const canonical = Object.entries(components).map(([k, v]) => `${k}=${v}`).join("\n");
105
+ return {
106
+ composite: "fp2:" + (0, import_node_crypto.createHash)("sha256").update(canonical).digest("hex"),
107
+ components
108
+ };
109
+ }
110
+ function machineGuid() {
111
+ return readFile("/etc/machine-id") ?? readFile("/var/lib/dbus/machine-id");
112
+ }
113
+ function macSet() {
114
+ return safe(() => {
115
+ const macs = Object.values(import_node_os.default.networkInterfaces()).flat().filter((i) => Boolean(i) && !i.internal).map((i) => i.mac).filter((m) => m && m !== "00:00:00:00:00:00").filter((m, i, all) => all.indexOf(m) === i).sort();
116
+ return macs.length === 0 ? void 0 : macs.join(",");
117
+ });
118
+ }
119
+ function cpu() {
120
+ return safe(() => {
121
+ const cpus = import_node_os.default.cpus();
122
+ if (!cpus || cpus.length === 0) return void 0;
123
+ return `${cpus[0].model}|${cpus.length}`;
124
+ });
125
+ }
126
+ function memBucket() {
127
+ return safe(() => {
128
+ const gib = Math.floor(import_node_os.default.totalmem() / 1024 ** 3);
129
+ if (gib < 1) return void 0;
130
+ let bucket = 1;
131
+ while (bucket * 2 <= gib) bucket *= 2;
132
+ return String(bucket);
133
+ });
134
+ }
135
+ function osInstall() {
136
+ const path = import_node_os.default.platform() === "win32" ? process.env.SystemRoot ?? "C:\\Windows" : "/etc";
137
+ return safe(() => {
138
+ const birth = (0, import_node_fs.statSync)(path).birthtimeMs;
139
+ return birth > 0 ? String(Math.floor(birth / 1e3)) : void 0;
140
+ });
141
+ }
142
+ function readFile(path) {
143
+ return safe(() => {
144
+ const text = (0, import_node_fs.readFileSync)(path, "utf8").trim();
145
+ return text.length === 0 ? void 0 : text;
146
+ });
147
+ }
148
+ function safe(f) {
149
+ try {
150
+ return f();
151
+ } catch {
152
+ return void 0;
153
+ }
154
+ }
155
+
156
+ // src/state.ts
157
+ var import_node_crypto2 = require("crypto");
158
+ var import_node_crypto3 = require("crypto");
159
+ var import_node_fs2 = require("fs");
160
+ var import_node_path = require("path");
161
+ var import_node_os2 = __toESM(require("os"), 1);
162
+ var MAGIC = Buffer.from("TLS1");
163
+ var IV_LEN = 12;
164
+ var TAG_LEN = 16;
165
+ var SALT = Buffer.from("talos-state-v1");
166
+ function defaultStatePath(productToken) {
167
+ return statePathFor(productToken, import_node_os2.default.platform(), import_node_os2.default.homedir(), process.env);
168
+ }
169
+ function statePathFor(productToken, platform, home, env) {
170
+ const slot = (0, import_node_crypto2.createHash)("sha256").update(productToken).digest("hex").slice(0, 16);
171
+ switch (platform) {
172
+ case "win32":
173
+ return (0, import_node_path.join)(env["LOCALAPPDATA"] || (0, import_node_path.join)(home, "AppData", "Local"), "Talos", slot, "state.bin");
174
+ case "darwin":
175
+ return (0, import_node_path.join)(home, "Library", "Application Support", "Talos", slot, "state.bin");
176
+ default:
177
+ return (0, import_node_path.join)(env["XDG_DATA_HOME"] || (0, import_node_path.join)(home, ".local", "share"), "talos", slot, "state.bin");
178
+ }
179
+ }
180
+ function stateKeyMaterial(fp) {
181
+ return fp.components["machine_guid"] ?? fp.components["smbios_uuid"] ?? fp.components["os_install"] ?? "talos-unbound";
182
+ }
183
+ function hashLicenseKey(key) {
184
+ return (0, import_node_crypto2.createHash)("sha256").update(key, "utf8").digest("hex");
185
+ }
186
+ var StateStore = class {
187
+ constructor(path, productToken, keyMaterial) {
188
+ this.path = path;
189
+ this.productToken = productToken;
190
+ this.key = Buffer.from(
191
+ (0, import_node_crypto2.hkdfSync)("sha256", Buffer.from(keyMaterial), SALT, Buffer.from(productToken), 32)
192
+ );
193
+ }
194
+ path;
195
+ productToken;
196
+ key;
197
+ /**
198
+ * Load the binding, or null if there is none.
199
+ *
200
+ * Never throws. A state file that is missing, truncated, from another
201
+ * product, written by a future version, or simply corrupt must degrade to
202
+ * "not activated yet" — the app then activates again. Throwing here would
203
+ * turn a damaged cache into a launch failure.
204
+ */
205
+ load() {
206
+ let raw;
207
+ try {
208
+ raw = (0, import_node_fs2.readFileSync)(this.path);
209
+ } catch {
210
+ return null;
211
+ }
212
+ try {
213
+ if (raw.length < MAGIC.length + IV_LEN + TAG_LEN) return null;
214
+ if (!raw.subarray(0, MAGIC.length).equals(MAGIC)) return null;
215
+ const iv = raw.subarray(MAGIC.length, MAGIC.length + IV_LEN);
216
+ const tag = raw.subarray(MAGIC.length + IV_LEN, MAGIC.length + IV_LEN + TAG_LEN);
217
+ const body = raw.subarray(MAGIC.length + IV_LEN + TAG_LEN);
218
+ const d = (0, import_node_crypto2.createDecipheriv)("aes-256-gcm", this.key, iv);
219
+ d.setAAD(Buffer.from(this.productToken));
220
+ d.setAuthTag(tag);
221
+ const plain = Buffer.concat([d.update(body), d.final()]).toString("utf8");
222
+ const o = JSON.parse(plain);
223
+ if (o["v"] !== 1) return null;
224
+ const legacyKey = typeof o["licenseKey"] === "string" ? o["licenseKey"] : "";
225
+ const b = {
226
+ licenseKeyHash: typeof o["licenseKeyHash"] === "string" && o["licenseKeyHash"] ? o["licenseKeyHash"] : legacyKey ? hashLicenseKey(legacyKey) : "",
227
+ activationSecret: String(o["activationSecret"] ?? ""),
228
+ machineId: String(o["machineId"] ?? ""),
229
+ fingerprint: String(o["fingerprint"] ?? "")
230
+ };
231
+ if (typeof o["keyset"] === "string") b.keyset = o["keyset"];
232
+ if (typeof o["validation"] === "string") b.validation = o["validation"];
233
+ if (typeof o["timeHighWater"] === "number") b.timeHighWater = o["timeHighWater"];
234
+ if (typeof o["releaseSeqHighWater"] === "number") {
235
+ b.releaseSeqHighWater = o["releaseSeqHighWater"];
236
+ }
237
+ const pending = o["pendingRelease"];
238
+ if (pending && typeof pending["version"] === "string" && typeof pending["releaseSeq"] === "number") {
239
+ b.pendingRelease = { version: pending["version"], releaseSeq: pending["releaseSeq"] };
240
+ }
241
+ return b.activationSecret ? b : null;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+ /**
247
+ * Persist the binding. Best-effort by design: a read-only or full disk costs
248
+ * the user a re-activation next launch, and must not fail the activation
249
+ * that just succeeded.
250
+ */
251
+ save(b) {
252
+ try {
253
+ const plain = Buffer.from(JSON.stringify({ v: 1, ...b }), "utf8");
254
+ const iv = (0, import_node_crypto3.randomBytes)(IV_LEN);
255
+ const c = (0, import_node_crypto2.createCipheriv)("aes-256-gcm", this.key, iv);
256
+ c.setAAD(Buffer.from(this.productToken));
257
+ const body = Buffer.concat([c.update(plain), c.final()]);
258
+ const out = Buffer.concat([MAGIC, iv, c.getAuthTag(), body]);
259
+ (0, import_node_fs2.mkdirSync)((0, import_node_path.dirname)(this.path), { recursive: true, mode: 448 });
260
+ const tmp = `${this.path}.tmp`;
261
+ (0, import_node_fs2.writeFileSync)(tmp, out, { mode: 384 });
262
+ try {
263
+ (0, import_node_fs2.chmodSync)(tmp, 384);
264
+ } catch {
265
+ }
266
+ (0, import_node_fs2.renameSync)(tmp, this.path);
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+ /** Forget the binding (deactivation). */
273
+ clear() {
274
+ try {
275
+ (0, import_node_fs2.rmSync)(this.path, { force: true });
276
+ } catch {
277
+ }
278
+ }
279
+ };
280
+
281
+ // src/version.ts
282
+ var SDK_VERSION = "0.1.0";
283
+ var SDK_ID = `node/${SDK_VERSION}`;
284
+
285
+ // src/talos-compact.ts
286
+ var import_node_crypto4 = require("crypto");
287
+ var SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
288
+ var CLOCK_SKEW_SECONDS = 60;
289
+ function publicKeyFromBase64(b64) {
290
+ const raw = Buffer.from(b64, "base64");
291
+ if (raw.length !== 32) {
292
+ throw new Error("public key must decode to 32 bytes");
293
+ }
294
+ const der = Buffer.concat([SPKI_PREFIX, raw]);
295
+ return (0, import_node_crypto4.createPublicKey)({ key: der, format: "der", type: "spki" });
296
+ }
297
+ function b64urlDecode(s) {
298
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
299
+ }
300
+ function b64urlEncode(buf) {
301
+ return buf.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
302
+ }
303
+ function parseAndVerify(keys, token, expectedTyp) {
304
+ const list = Array.isArray(keys) ? keys : [keys];
305
+ const parts = token.split(".");
306
+ if (parts.length !== 5) throw new TalosSignatureError("malformed token");
307
+ if (parts[0] !== "talos") throw new TalosSignatureError("unexpected scheme");
308
+ if (parts[1] !== "1") throw new TalosSignatureError("unsupported wire version");
309
+ if (parts[2] !== expectedTyp) throw new TalosSignatureError("token type mismatch");
310
+ if (list.length === 0) throw new TalosSignatureError("no verification key");
311
+ const lastDot = token.lastIndexOf(".");
312
+ const prefix = Buffer.from(token.slice(0, lastDot), "ascii");
313
+ const sig = b64urlDecode(parts[4]);
314
+ const ok = list.some((k) => (0, import_node_crypto4.verify)(null, prefix, k, sig));
315
+ if (!ok) throw new TalosSignatureError("signature verification failed");
316
+ return JSON.parse(b64urlDecode(parts[3]).toString("utf8"));
317
+ }
318
+ function verifyToken(keys, expectedTyp, expectedAud, expectedNonce, token) {
319
+ const claims = parseAndVerify(keys, token, expectedTyp);
320
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
321
+ if (claims["non"] !== expectedNonce) {
322
+ throw new TalosSignatureError("nonce mismatch (possible replay)");
323
+ }
324
+ const now = Math.floor(Date.now() / 1e3);
325
+ const nbf = claims["nbf"];
326
+ if (typeof nbf !== "number") throw new TalosSignatureError("token has no nbf");
327
+ if (now + CLOCK_SKEW_SECONDS < nbf) {
328
+ throw new TalosSignatureError("token not yet valid");
329
+ }
330
+ const exp = claims["exp"];
331
+ if (typeof exp !== "number") throw new TalosSignatureError("token has no exp");
332
+ if (now - CLOCK_SKEW_SECONDS > exp) {
333
+ throw new TalosSignatureError("token expired");
334
+ }
335
+ return claims;
336
+ }
337
+ function verifyCachedToken(keys, expectedTyp, expectedAud, token) {
338
+ const claims = parseAndVerify(keys, token, expectedTyp);
339
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
340
+ const nbf = claims["nbf"];
341
+ if (typeof nbf === "number" && Math.floor(Date.now() / 1e3) + CLOCK_SKEW_SECONDS < nbf) {
342
+ throw new TalosSignatureError("cached token not yet valid");
343
+ }
344
+ return claims;
345
+ }
346
+ function verifyManifest(releaseKeys, expectedAud, token) {
347
+ const claims = parseAndVerify(releaseKeys, token, "manifest");
348
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
349
+ const notBefore = claims["not_before"];
350
+ if (typeof notBefore === "number") {
351
+ const now = Math.floor(Date.now() / 1e3);
352
+ if (now + CLOCK_SKEW_SECONDS < notBefore) {
353
+ throw new TalosSignatureError("manifest not yet released");
354
+ }
355
+ }
356
+ return claims;
357
+ }
358
+ function verifyKeyset(anchorKey, expectedAud, token) {
359
+ const claims = parseAndVerify(anchorKey, token, "keyset");
360
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
361
+ return claims;
362
+ }
363
+
364
+ // src/client.ts
365
+ var CLOCK_SKEW_SECONDS2 = 60;
366
+ var DEFAULT_HEARTBEAT_SECONDS = 600;
367
+ var DEFAULT_HTTP_TIMEOUT_MS = 1e4;
368
+ var LicenseState = class {
369
+ constructor(decision, features, licenseExpiresAt, isOffline = false, revalidateAfter = null, graceUntil = null) {
370
+ this.decision = decision;
371
+ this.features = features;
372
+ this.licenseExpiresAt = licenseExpiresAt;
373
+ this.isOffline = isOffline;
374
+ this.revalidateAfter = revalidateAfter;
375
+ this.graceUntil = graceUntil;
376
+ }
377
+ decision;
378
+ features;
379
+ licenseExpiresAt;
380
+ isOffline;
381
+ revalidateAfter;
382
+ graceUntil;
383
+ get isValid() {
384
+ return this.decision === "valid";
385
+ }
386
+ isFeatureEnabled(name) {
387
+ const v = this.features[name];
388
+ if (typeof v === "boolean") return v;
389
+ if (typeof v === "number") return v !== 0;
390
+ if (typeof v === "string") return v.length > 0;
391
+ return false;
392
+ }
393
+ getEntitlement(name) {
394
+ return this.features[name];
395
+ }
396
+ };
397
+ var KEYSET_MAX_AGE_SECONDS = 24 * 60 * 60;
398
+ var TalosClient = class {
399
+ options;
400
+ anchorKey;
401
+ publicKey;
402
+ releaseKey;
403
+ fingerprint;
404
+ fp;
405
+ doFetch;
406
+ // Learned from the anchor-signed keyset; take precedence over the direct pins.
407
+ keysetLicenseKeys = [];
408
+ keysetReleaseKeys = [];
409
+ /**
410
+ * SHA-256 of the key this machine activated with, or null.
411
+ *
412
+ * The key itself is deliberately not held: after activation the server
413
+ * resolves the licence from the activation secret, so keeping the key would
414
+ * mean a plaintext credential in memory and on disk for the sake of one
415
+ * string comparison in `activate`.
416
+ */
417
+ licenseKeyHash = null;
418
+ activationSecret = null;
419
+ machineId = null;
420
+ store;
421
+ /** Everything persisted for this product; written back as one file. */
422
+ binding = null;
423
+ /**
424
+ * True while the adopted keyset came off disk. One refresh per process
425
+ * upgrades it, so a rotation is picked up, but a client that cannot reach the
426
+ * server keeps verifying with what it already trusts.
427
+ */
428
+ keysetFromCache = false;
429
+ /**
430
+ * The `iat` of the adopted keyset, in unix seconds, or 0 if unknown.
431
+ *
432
+ * Kept so a keyset can go stale. Without it the refresh happens once per
433
+ * process, which is fine for a desktop app that restarts and useless for the
434
+ * process this SDK is most often *in* — a server or an Electron main that runs
435
+ * for weeks. A key revoked in the portal would never reach it.
436
+ */
437
+ keysetIssuedAt = 0;
438
+ /** The policy's heartbeat cadence, from the last heartbeat's signed claims. */
439
+ heartbeatInterval = null;
440
+ checkInTimer = null;
441
+ /** Bumped on stop so an in-flight tick cannot re-arm a cancelled loop. */
442
+ checkInGeneration = 0;
443
+ lease = null;
444
+ renewTimer = null;
445
+ /** Bumped on release so an in-flight renewal cannot resurrect the seat. */
446
+ leaseGeneration = 0;
447
+ /** Default instance id for floating leases: one per client object. */
448
+ instanceId = b64urlEncode((0, import_node_crypto5.randomBytes)(9));
449
+ current = null;
450
+ listeners = /* @__PURE__ */ new Map();
451
+ /** The newest release this client has already announced. */
452
+ announcedReleaseSeq = 0;
453
+ constructor(options) {
454
+ if (!options.productToken) throw new TalosError("productToken required");
455
+ if (!options.serverUrl) throw new TalosError("serverUrl required");
456
+ if (!options.anchorPublicKey && !options.publicKey) {
457
+ throw new TalosError("anchorPublicKey or publicKey required");
458
+ }
459
+ this.options = options;
460
+ this.anchorKey = options.anchorPublicKey ? publicKeyFromBase64(options.anchorPublicKey) : null;
461
+ this.publicKey = options.publicKey ? publicKeyFromBase64(options.publicKey) : null;
462
+ this.releaseKey = options.releasePublicKey ? publicKeyFromBase64(options.releasePublicKey) : this.publicKey;
463
+ this.fp = collectFingerprint(options.productToken, options.fingerprintComponents);
464
+ this.fingerprint = this.fp.composite;
465
+ this.doFetch = options.fetch ?? globalThis.fetch;
466
+ if (!this.doFetch) throw new TalosError("no fetch available (need Node 18+ or options.fetch)");
467
+ this.store = options.persistState === false ? null : new StateStore(
468
+ options.statePath ?? defaultStatePath(options.productToken),
469
+ options.productToken,
470
+ stateKeyMaterial(this.fp)
471
+ );
472
+ const stored = this.store?.load() ?? null;
473
+ if (stored) {
474
+ this.binding = stored;
475
+ this.licenseKeyHash = stored.licenseKeyHash;
476
+ this.activationSecret = stored.activationSecret;
477
+ this.machineId = stored.machineId;
478
+ if (stored.keyset) {
479
+ try {
480
+ this.adoptKeyset(stored.keyset);
481
+ this.keysetFromCache = this.keysetLicenseKeys.length > 0;
482
+ } catch {
483
+ }
484
+ }
485
+ this.promoteDownloadedRelease();
486
+ }
487
+ }
488
+ /**
489
+ * A release that was downloaded and is now the version running: promote its
490
+ * `release_seq` to the anti-downgrade mark.
491
+ *
492
+ * This is the whole of "the SDK knows what is installed". The SDK downloads
493
+ * and verifies bytes; the host installs them, its own way, usually across a
494
+ * restart — so installation is not an event the SDK can observe. What it can
495
+ * observe is the result, because `appVersion` is the host saying which
496
+ * version it is. When that equals the version whose bytes were fetched, the
497
+ * download became an install.
498
+ *
499
+ * Only forward, and that is the point: yanking a release or lowering its
500
+ * rollout stops it being *offered*, and must not walk an install that already
501
+ * applied it back onto an older build.
502
+ */
503
+ promoteDownloadedRelease() {
504
+ const pending = this.binding?.pendingRelease;
505
+ if (!pending || pending.version !== this.options.appVersion) return;
506
+ const mark = Math.max(this.binding?.releaseSeqHighWater ?? 0, pending.releaseSeq);
507
+ this.persist({ releaseSeqHighWater: mark, pendingRelease: void 0 });
508
+ }
509
+ /**
510
+ * The `release_seq` no update may go at or below.
511
+ *
512
+ * The host's explicit value wins when it gives one — it knows things the SDK
513
+ * cannot, such as a build installed by a package manager — and the persisted
514
+ * mark is the default that makes anti-downgrade work for the hosts that give
515
+ * nothing, which is every example and most applications.
516
+ */
517
+ releaseFloor(explicit) {
518
+ return explicit ?? this.binding?.releaseSeqHighWater;
519
+ }
520
+ /**
521
+ * Whether this machine already holds an activation — restored from disk on
522
+ * construction, so this is the startup question: prompt for a licence key, or
523
+ * go straight to {@link validate}?
524
+ */
525
+ get isActivated() {
526
+ return this.activationSecret !== null;
527
+ }
528
+ /** The server's id for this machine, once activated. */
529
+ get currentMachineId() {
530
+ return this.machineId;
531
+ }
532
+ isFeatureEnabled(name) {
533
+ return this.current?.isFeatureEnabled(name) ?? false;
534
+ }
535
+ /**
536
+ * Fetch and verify the product's keyset (signed by the pinned anchor) and
537
+ * adopt its current license/release keys. Called automatically during
538
+ * {@link activate}; call it again after a server-side key rotation to keep
539
+ * verifying without any config change. No-op if no `anchorPublicKey` is pinned.
540
+ */
541
+ async refreshKeyset() {
542
+ if (!this.anchorKey) return;
543
+ const body = await this.post("/v1/client/keyset", {
544
+ product_token: this.options.productToken
545
+ });
546
+ this.adoptKeyset(body["keyset"]);
547
+ this.keysetFromCache = false;
548
+ if (typeof body["keyset"] === "string") this.persist({ keyset: body["keyset"] });
549
+ }
550
+ /**
551
+ * Make sure a verification key exists before a response has to be verified.
552
+ *
553
+ * A restored binding goes straight to `validate`, skipping `activate` — which
554
+ * is the only place the keyset used to be adopted. An anchor-only client (the
555
+ * recommended pinning, and the one that survives key rotation) therefore had
556
+ * nothing to verify with on the app's *second* launch, and every call failed
557
+ * with "keyset unavailable". Only persistence made that reachable: before it,
558
+ * `activate` always ran first.
559
+ *
560
+ * The keyset is also cached on disk, so the *first* call of a launch with no
561
+ * network can still verify a cached decision. A cached one is refreshed once
562
+ * per process so a rotation is picked up, but a client that cannot reach the
563
+ * server keeps verifying with what it already trusts.
564
+ */
565
+ async ensureKeyset() {
566
+ if (!this.anchorKey) return;
567
+ if (this.keysetLicenseKeys.length > 0 && !this.keysetFromCache && !this.keysetIsStale()) {
568
+ return;
569
+ }
570
+ try {
571
+ await this.refreshKeyset();
572
+ } catch (e) {
573
+ if (this.keysetLicenseKeys.length === 0) throw e;
574
+ }
575
+ }
576
+ /**
577
+ * Whether the adopted keyset is old enough to be worth re-fetching.
578
+ *
579
+ * This is what makes revoking a compromised signing key mean anything to an
580
+ * install: a revoked key is omitted from the keyset, so a client only stops
581
+ * trusting it once it fetches a newer one. Refreshing only on the first call
582
+ * of a process bounds that by "next restart", which for a long-running host is
583
+ * not a bound at all.
584
+ *
585
+ * A day, not an hour: the endpoint is public and unauthenticated, and every
586
+ * install on earth hitting it hourly is a self-inflicted load pattern for a
587
+ * document that changes about once a year. The honest description of the
588
+ * guarantee is in `docs/SECURITY.md` — revocation is bounded, not instant.
589
+ */
590
+ keysetIsStale() {
591
+ if (!this.keysetIssuedAt) return true;
592
+ return Date.now() / 1e3 - this.keysetIssuedAt > KEYSET_MAX_AGE_SECONDS;
593
+ }
594
+ /** Verify a keyset token against the anchor and adopt its keys. */
595
+ adoptKeyset(token) {
596
+ if (!this.anchorKey || typeof token !== "string") return;
597
+ const claims = verifyKeyset(this.anchorKey, this.options.productToken, token);
598
+ const iat = claims["iat"];
599
+ this.keysetIssuedAt = typeof iat === "number" ? iat : 0;
600
+ const licenseKeys = [];
601
+ const releaseKeys = [];
602
+ for (const entry of asArray(claims["keys"])) {
603
+ const o = entry;
604
+ if (typeof o["pk"] !== "string") continue;
605
+ const key = publicKeyFromBase64(o["pk"]);
606
+ if (o["purpose"] === "license") licenseKeys.push(key);
607
+ else if (o["purpose"] === "release") releaseKeys.push(key);
608
+ }
609
+ this.keysetLicenseKeys = licenseKeys;
610
+ this.keysetReleaseKeys = releaseKeys;
611
+ }
612
+ /** Keys to verify license tokens with: the keyset if loaded, else the direct pin. */
613
+ licenseVerifyKeys() {
614
+ if (this.keysetLicenseKeys.length) return this.keysetLicenseKeys;
615
+ if (this.publicKey) return [this.publicKey];
616
+ throw new TalosSignatureError("no license verification key (keyset unavailable)");
617
+ }
618
+ /** Keys to verify manifests with: the keyset if loaded, else the direct pin. */
619
+ releaseVerifyKeys() {
620
+ if (this.keysetReleaseKeys.length) return this.keysetReleaseKeys;
621
+ if (this.releaseKey) return [this.releaseKey];
622
+ throw new TalosSignatureError("no release verification key (keyset unavailable)");
623
+ }
624
+ /**
625
+ * Ensure this machine is activated on `licenseKey`, and return the verified
626
+ * state. Safe to call on every start.
627
+ *
628
+ * When a binding for the same key is already held, this **re-validates**
629
+ * instead of re-activating. Same one round trip, three different outcomes:
630
+ * the activation secret is not rotated (so a second copy of the app on the
631
+ * same machine does not knock the first one out), no seat accounting is
632
+ * touched, and — because `activate` is the only client route refused for a
633
+ * suspended tenant — the developer's own paying users keep working while
634
+ * their vendor's account is suspended, which is what "graduated suspension"
635
+ * was supposed to mean.
636
+ *
637
+ * It falls back to a real activation when the stored binding is no longer
638
+ * usable: the seat was released from the portal, or the secret was rotated
639
+ * elsewhere.
640
+ */
641
+ async activate(licenseKey) {
642
+ if (this.isActivated && this.licenseKeyHash === hashLicenseKey(licenseKey)) {
643
+ try {
644
+ const state = await this.validate();
645
+ if (state.decision !== "deactivated") return state;
646
+ } catch (e) {
647
+ const stale = e instanceof TalosApiError && (e.statusCode === 401 || e.statusCode === 404);
648
+ if (!stale) throw e;
649
+ }
650
+ this.forgetBinding();
651
+ }
652
+ const nonce = newNonce();
653
+ const body = await this.post("/v1/client/activate", {
654
+ product_token: this.options.productToken,
655
+ license_key: licenseKey,
656
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
657
+ nonce,
658
+ hostname: this.reportedHostname(),
659
+ os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
660
+ arch: import_node_os3.default.arch(),
661
+ app_version: this.options.appVersion,
662
+ sdk_version: SDK_ID
663
+ });
664
+ this.adoptKeyset(body["keyset"]);
665
+ const claims = verifyToken(
666
+ this.licenseVerifyKeys(),
667
+ "activation",
668
+ this.options.productToken,
669
+ nonce,
670
+ body["activation_token"]
671
+ );
672
+ this.licenseKeyHash = hashLicenseKey(licenseKey);
673
+ this.activationSecret = body["activation_secret"];
674
+ this.machineId = body["machine_id"] ?? null;
675
+ this.binding = {
676
+ licenseKeyHash: this.licenseKeyHash,
677
+ activationSecret: this.activationSecret,
678
+ machineId: this.machineId ?? "",
679
+ fingerprint: this.fingerprint,
680
+ // A fresh activation invalidates any cached verdict for the previous
681
+ // binding; keep only the keyset, which is per product, not per machine.
682
+ keyset: typeof body["keyset"] === "string" ? body["keyset"] : this.binding?.keyset,
683
+ timeHighWater: this.binding?.timeHighWater
684
+ };
685
+ this.advanceClock(claims);
686
+ this.store?.save(this.binding);
687
+ return this.adopt(stateFromClaims(claims, "valid", false));
688
+ }
689
+ /**
690
+ * The heartbeat cadence the server's policy asks for, in seconds, taken from
691
+ * the last heartbeat's **signed** claims — so it cannot be lengthened in
692
+ * transit by anyone wanting a fleet to check in less often.
693
+ *
694
+ * `null` until the first heartbeat of this process.
695
+ */
696
+ get heartbeatIntervalSeconds() {
697
+ return this.heartbeatInterval;
698
+ }
699
+ /**
700
+ * Subscribe to a licence transition. Returns a function that unsubscribes.
701
+ *
702
+ * ```ts
703
+ * const off = talos.on("gracePeriodStarted", (s) => showOfflineBanner(s.graceUntil));
704
+ * ```
705
+ *
706
+ * Fires from every path that adopts a verdict — `activate`, `validate`,
707
+ * `heartbeat`, and the offline fallback — not only from the background loop,
708
+ * so a host that runs its own timer gets the same transitions. The listener
709
+ * runs synchronously inside that call; a listener that throws is ignored (see
710
+ * {@link emit}).
711
+ *
712
+ * `updateAvailable` is the one event that is not a licence transition: it
713
+ * carries an {@link UpdateInfo}, and subscribing to it is what makes the
714
+ * heartbeat ask the server whether a release is waiting.
715
+ */
716
+ on(event, listener) {
717
+ let set = this.listeners.get(event);
718
+ if (!set) {
719
+ set = /* @__PURE__ */ new Set();
720
+ this.listeners.set(event, set);
721
+ }
722
+ const fn = listener;
723
+ set.add(fn);
724
+ return () => {
725
+ set.delete(fn);
726
+ };
727
+ }
728
+ /**
729
+ * Adopt a verdict as the current one, announcing the transition it makes.
730
+ *
731
+ * The single funnel every path goes through, which is what makes the events
732
+ * trustworthy: a verdict reached anywhere else would be a transition nobody
733
+ * hears about.
734
+ */
735
+ adopt(state) {
736
+ const event = transitionEvent(this.current, state);
737
+ this.current = state;
738
+ if (event) this.emit(event, state);
739
+ return state;
740
+ }
741
+ emit(event, payload) {
742
+ for (const listener of this.listeners.get(event) ?? []) {
743
+ try {
744
+ listener(payload);
745
+ } catch {
746
+ }
747
+ }
748
+ }
749
+ /**
750
+ * Run the check-ins the server's policy asks for, in the background.
751
+ *
752
+ * Two knobs have existed on the wire since Phase 1 and reached no client:
753
+ * `heartbeat_interval_s` (how often an install reports in — the telemetry
754
+ * rate, and the upper bound on how long a revocation takes to arrive) and
755
+ * `reval_after` (when the entitlement bag and the offline cache should be
756
+ * refreshed). Both are signed. This loop is what makes them a lever the
757
+ * developer can actually pull: change the policy, and deployed apps follow.
758
+ *
759
+ * One timer serves both. Each tick re-validates if the signed `reval_after`
760
+ * has passed, and otherwise sends a heartbeat — which is also what renews the
761
+ * cached verdict that {@link validate} falls back on, so an app that runs
762
+ * this stays inside its grace window without the host writing any scheduling
763
+ * of its own.
764
+ *
765
+ * Opt-in, and idempotent: calling it again replaces the running loop.
766
+ */
767
+ startCheckIns(opts = {}) {
768
+ this.stopCheckIns();
769
+ const generation = ++this.checkInGeneration;
770
+ const tick = async () => {
771
+ try {
772
+ const due = this.current?.revalidateAfter;
773
+ const state = due === null || due === void 0 || nowSeconds() >= due ? await this.validate() : await this.heartbeat();
774
+ if (generation !== this.checkInGeneration) return;
775
+ opts.onState?.(state);
776
+ } catch (e) {
777
+ if (generation !== this.checkInGeneration) return;
778
+ opts.onError?.(e);
779
+ }
780
+ if (generation === this.checkInGeneration) schedule();
781
+ };
782
+ const schedule = () => {
783
+ const secs = Math.max(60, this.heartbeatInterval ?? DEFAULT_HEARTBEAT_SECONDS);
784
+ this.checkInTimer = setTimeout(() => void tick(), secs * 1e3);
785
+ this.checkInTimer.unref?.();
786
+ };
787
+ schedule();
788
+ }
789
+ /** Stop the background check-ins started by {@link startCheckIns}. */
790
+ stopCheckIns() {
791
+ this.checkInGeneration += 1;
792
+ if (this.checkInTimer) {
793
+ clearTimeout(this.checkInTimer);
794
+ this.checkInTimer = null;
795
+ }
796
+ }
797
+ /** Drop the machine binding, in memory and on disk. */
798
+ forgetBinding() {
799
+ this.licenseKeyHash = null;
800
+ this.activationSecret = null;
801
+ this.machineId = null;
802
+ this.current = null;
803
+ this.binding = null;
804
+ this.store?.clear();
805
+ }
806
+ /** Merge fields into the persisted binding and write it back. */
807
+ persist(patch) {
808
+ if (!this.store || !this.binding) return;
809
+ this.binding = { ...this.binding, ...patch };
810
+ this.store.save(this.binding);
811
+ }
812
+ /**
813
+ * Advance the server-time high-water mark from a signed claim.
814
+ *
815
+ * Only ever forward: this is the mark that keeps offline grace bounded when
816
+ * the user owns the clock. `iat` is used when a token carries no explicit
817
+ * `server_time` (activation does not) — both are the server's clock, signed.
818
+ */
819
+ advanceClock(claims) {
820
+ const seen = num(claims["server_time"]) ?? num(claims["iat"]);
821
+ if (seen === null) return;
822
+ const current = this.binding?.timeHighWater ?? 0;
823
+ if (seen > current) {
824
+ if (this.binding) this.binding.timeHighWater = seen;
825
+ }
826
+ }
827
+ async validate() {
828
+ this.requireActivated();
829
+ try {
830
+ await this.ensureKeyset();
831
+ const nonce = newNonce();
832
+ const body = await this.post("/v1/client/validate", {
833
+ product_token: this.options.productToken,
834
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
835
+ activation_secret: this.activationSecret,
836
+ nonce,
837
+ // Sent here as well as on activation and heartbeat, because check-ins
838
+ // are opt-in and this call is not: an application that validates on
839
+ // launch and never calls `startCheckIns` would otherwise show, forever,
840
+ // whatever version it activated on.
841
+ app_version: this.options.appVersion,
842
+ sdk_version: SDK_ID
843
+ });
844
+ const token = body["validation_token"];
845
+ const claims = verifyToken(
846
+ this.licenseVerifyKeys(),
847
+ "validation",
848
+ this.options.productToken,
849
+ nonce,
850
+ token
851
+ );
852
+ const decision = typeof claims["decision"] === "string" ? claims["decision"] : "invalid";
853
+ this.advanceClock(claims);
854
+ this.persist({ validation: token, timeHighWater: this.binding?.timeHighWater });
855
+ return this.adopt(stateFromClaims(claims, decision, false));
856
+ } catch (e) {
857
+ if (e instanceof TalosApiError) throw e;
858
+ const offline = this.offlineDecision();
859
+ if (!offline) throw e;
860
+ return this.adopt(offline);
861
+ }
862
+ }
863
+ /**
864
+ * The verdict from the local cache, or null when there is nothing usable.
865
+ *
866
+ * This is the whole offline story, and every check here is load-bearing:
867
+ *
868
+ * - the token is re-verified under the pinned key, so a cache edited on disk
869
+ * is worthless;
870
+ * - `fph` must be this machine, so copying a state file to a second computer
871
+ * and pulling the network cable does not licence it;
872
+ * - `grace_until` — the policy's `offline_grace_hours`, signed — bounds how
873
+ * long a verdict may outlive its issuance;
874
+ * - the licence's own expiry is re-checked, because a cached "valid" must not
875
+ * outlive the subscription it was issued under;
876
+ * - and the clock is checked against a mark that only moves forward, since
877
+ * every deadline above is evaluated against a clock the user controls.
878
+ */
879
+ offlineDecision() {
880
+ const token = this.binding?.validation;
881
+ if (!token) return null;
882
+ const now = nowSeconds();
883
+ const highWater = this.binding?.timeHighWater ?? 0;
884
+ if (highWater > 0 && now + CLOCK_SKEW_SECONDS2 < highWater) {
885
+ return new LicenseState("clock_tampered", {}, null, true, null, null);
886
+ }
887
+ let claims;
888
+ try {
889
+ claims = verifyCachedToken(
890
+ this.licenseVerifyKeys(),
891
+ "validation",
892
+ this.options.productToken,
893
+ token
894
+ );
895
+ } catch {
896
+ return null;
897
+ }
898
+ if (claims["fph"] !== this.fingerprint) return null;
899
+ const graceUntil = num(claims["grace_until"]);
900
+ const cached = stateFromClaims(
901
+ claims,
902
+ typeof claims["decision"] === "string" ? claims["decision"] : "invalid",
903
+ true
904
+ );
905
+ if (graceUntil === null || now > graceUntil) {
906
+ return new LicenseState(
907
+ "grace_expired",
908
+ cached.features,
909
+ cached.licenseExpiresAt,
910
+ true,
911
+ cached.revalidateAfter,
912
+ graceUntil
913
+ );
914
+ }
915
+ if (cached.licenseExpiresAt !== null && now > cached.licenseExpiresAt) {
916
+ return new LicenseState(
917
+ "expired",
918
+ cached.features,
919
+ cached.licenseExpiresAt,
920
+ true,
921
+ cached.revalidateAfter,
922
+ graceUntil
923
+ );
924
+ }
925
+ return cached;
926
+ }
927
+ async deactivate() {
928
+ this.requireActivated();
929
+ await this.post("/v1/client/deactivate", {
930
+ product_token: this.options.productToken,
931
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
932
+ activation_secret: this.activationSecret
933
+ });
934
+ this.forgetBinding();
935
+ }
936
+ /**
937
+ * Send a heartbeat: refreshes this machine's last-seen state, feeds the
938
+ * developer's monitoring (version adoption / active installs), and returns the
939
+ * current signed decision — so a revocation reaches the app within one
940
+ * heartbeat interval. Call it periodically while the app runs.
941
+ *
942
+ * The response is a signed `typ=heartbeat` token, so a MITM cannot forge the
943
+ * status or lengthen the interval.
944
+ */
945
+ /**
946
+ * The machine name to report, or `undefined` to report none.
947
+ *
948
+ * An explicit `""` means "do not send": omitting the field leaves whatever
949
+ * the server already holds, because the server coalesces, so sending an empty
950
+ * string would not clear it either — suppression has to happen here.
951
+ */
952
+ reportedHostname() {
953
+ const h = this.options.hostname ?? import_node_os3.default.hostname();
954
+ return h === "" ? void 0 : h;
955
+ }
956
+ async heartbeat() {
957
+ this.requireActivated();
958
+ await this.ensureKeyset();
959
+ const nonce = newNonce();
960
+ const body = await this.post("/v1/client/heartbeat", {
961
+ product_token: this.options.productToken,
962
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
963
+ activation_secret: this.activationSecret,
964
+ nonce,
965
+ app_version: this.options.appVersion,
966
+ os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
967
+ arch: import_node_os3.default.arch(),
968
+ sdk_version: SDK_ID,
969
+ // Naming the channel is what asks the server for the update hint, and it
970
+ // costs two queries there — so it is asked for only when somebody is
971
+ // listening for the answer.
972
+ update_channel: this.listeners.get("updateAvailable")?.size ? this.updateChannel() : void 0
973
+ });
974
+ const claims = verifyToken(
975
+ this.licenseVerifyKeys(),
976
+ "heartbeat",
977
+ this.options.productToken,
978
+ nonce,
979
+ body["heartbeat_token"]
980
+ );
981
+ const decision = typeof claims["decision"] === "string" ? claims["decision"] : "invalid";
982
+ const interval = num(claims["heartbeat_interval_s"]);
983
+ if (interval !== null && interval > 0) this.heartbeatInterval = interval;
984
+ this.advanceClock(claims);
985
+ this.persist({ timeHighWater: this.binding?.timeHighWater });
986
+ const state = this.adopt(
987
+ new LicenseState(
988
+ decision,
989
+ this.current?.features ?? {},
990
+ this.current?.licenseExpiresAt ?? null,
991
+ false,
992
+ this.current?.revalidateAfter ?? null,
993
+ this.current?.graceUntil ?? null
994
+ )
995
+ );
996
+ await this.announceUpdate(claims);
997
+ return state;
998
+ }
999
+ /** The channel this install follows; `stable` unless set. */
1000
+ updateChannel() {
1001
+ return this.options.updateChannel || "stable";
1002
+ }
1003
+ /**
1004
+ * Act on the heartbeat's `upd` hint: confirm it with a real update check and
1005
+ * emit `updateAvailable` for a release not announced yet.
1006
+ *
1007
+ * The hint is read from the **signed** claims, not the response envelope, and
1008
+ * it is only ever a prompt to go and look: the check applies this install's
1009
+ * anti-downgrade floor and verifies a manifest, neither of which the hint
1010
+ * does. A hint that turns out to be wrong costs one request and emits
1011
+ * nothing.
1012
+ *
1013
+ * A failing check is swallowed. The heartbeat it rode in on has already
1014
+ * succeeded, and a licensing call must not start failing because the update
1015
+ * service is having a bad day.
1016
+ */
1017
+ async announceUpdate(claims) {
1018
+ if (!this.listeners.get("updateAvailable")?.size) return;
1019
+ if (claims["upd"] !== true) return;
1020
+ let info;
1021
+ try {
1022
+ info = await this.checkForUpdate();
1023
+ } catch {
1024
+ return;
1025
+ }
1026
+ if (!info || info.releaseSeq <= this.announcedReleaseSeq) return;
1027
+ this.announcedReleaseSeq = info.releaseSeq;
1028
+ this.emit("updateAvailable", info);
1029
+ }
1030
+ /**
1031
+ * Acquire a floating seat (concurrent-use license). The server arbitrates the
1032
+ * cap atomically, so this either returns a seat or throws
1033
+ * {@link TalosApiError} with `seat_limit_reached` (HTTP 409).
1034
+ *
1035
+ * By default the seat is auto-renewed in the background at the server-chosen
1036
+ * interval; a lost renewal invokes `onLeaseLost`. Always
1037
+ * {@link releaseLease} on clean exit — a crash simply lets the seat expire.
1038
+ */
1039
+ async acquireLease(opts = {}) {
1040
+ this.requireActivated();
1041
+ await this.ensureKeyset();
1042
+ const instanceId = opts.instanceId ?? this.instanceId;
1043
+ const generation = this.leaseGeneration;
1044
+ const nonce = newNonce();
1045
+ const body = await this.post("/v1/client/leases", {
1046
+ product_token: this.options.productToken,
1047
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
1048
+ activation_secret: this.activationSecret,
1049
+ instance_id: instanceId,
1050
+ nonce
1051
+ });
1052
+ const claims = verifyToken(
1053
+ this.licenseVerifyKeys(),
1054
+ "lease",
1055
+ this.options.productToken,
1056
+ nonce,
1057
+ body["lease_token"]
1058
+ );
1059
+ if (claims["inst"] !== instanceId || claims["fph"] !== this.fingerprint) {
1060
+ throw new TalosSignatureError("lease token does not match this instance");
1061
+ }
1062
+ const expiresAt = typeof claims["exp"] === "number" ? claims["exp"] : 0;
1063
+ const serverNow = typeof claims["server_time"] === "number" ? claims["server_time"] : Math.floor(Date.now() / 1e3);
1064
+ const budget = Math.max(1, expiresAt - serverNow);
1065
+ const hinted = Number(body["renew_after_s"] ?? 0);
1066
+ const lease = {
1067
+ id: String(claims["lease"] ?? ""),
1068
+ instanceId,
1069
+ expiresAt,
1070
+ renewAfterSeconds: hinted > 0 && hinted <= budget ? hinted : Math.max(1, Math.floor(budget / 3)),
1071
+ seatsInUse: Number(claims["seats_in_use"] ?? 0),
1072
+ maxSeats: Number(claims["max_seats"] ?? 0)
1073
+ };
1074
+ if (generation !== this.leaseGeneration) {
1075
+ void this.releaseInstance(instanceId).catch(() => {
1076
+ });
1077
+ return lease;
1078
+ }
1079
+ this.lease = lease;
1080
+ if (opts.autoRenew !== false) this.scheduleRenew(opts);
1081
+ return lease;
1082
+ }
1083
+ /** Best-effort DELETE of one instance's seat (used for stale-renewal cleanup). */
1084
+ async releaseInstance(instanceId) {
1085
+ if (!this.activationSecret) return;
1086
+ await this.request("DELETE", "/v1/client/leases", {
1087
+ product_token: this.options.productToken,
1088
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
1089
+ activation_secret: this.activationSecret,
1090
+ instance_id: instanceId
1091
+ });
1092
+ }
1093
+ /** The currently held floating seat, if any. */
1094
+ get currentLease() {
1095
+ return this.lease;
1096
+ }
1097
+ /** Release the floating seat (clean exit) and stop auto-renewal. */
1098
+ async releaseLease() {
1099
+ this.leaseGeneration += 1;
1100
+ this.stopRenew();
1101
+ const lease = this.lease;
1102
+ this.lease = null;
1103
+ if (!lease) return;
1104
+ await this.releaseInstance(lease.instanceId);
1105
+ }
1106
+ scheduleRenew(opts) {
1107
+ this.stopRenew();
1108
+ const generation = this.leaseGeneration;
1109
+ const instanceId = this.lease?.instanceId;
1110
+ const delay = Math.max(1, this.lease?.renewAfterSeconds ?? 200) * 1e3;
1111
+ this.renewTimer = setTimeout(() => {
1112
+ void (async () => {
1113
+ try {
1114
+ await this.acquireLease({ ...opts, instanceId, autoRenew: false });
1115
+ if (generation !== this.leaseGeneration) return;
1116
+ this.scheduleRenew(opts);
1117
+ } catch (e) {
1118
+ if (generation !== this.leaseGeneration) return;
1119
+ this.lease = null;
1120
+ opts.onLeaseLost?.(e instanceof Error ? e.message : "lease renewal failed");
1121
+ }
1122
+ })();
1123
+ }, delay);
1124
+ this.renewTimer.unref?.();
1125
+ }
1126
+ stopRenew() {
1127
+ if (this.renewTimer) {
1128
+ clearTimeout(this.renewTimer);
1129
+ this.renewTimer = null;
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Ask whether a newer release is available. Returns a verified {@link UpdateInfo}
1134
+ * (all metadata comes from the manifest signed by the pinned release key), or
1135
+ * `null` when up to date.
1136
+ *
1137
+ * Enforces anti-downgrade: a manifest whose `release_seq` is at or below the
1138
+ * floor is rejected as no update. The floor is the persisted high-water mark
1139
+ * — the highest `release_seq` this install has been seen running — unless
1140
+ * {@link CheckForUpdateOptions.currentReleaseSeq} overrides it.
1141
+ */
1142
+ async checkForUpdate(opts = {}) {
1143
+ this.requireActivated();
1144
+ await this.ensureKeyset();
1145
+ const body = await this.post("/v1/client/updates/check", {
1146
+ product_token: this.options.productToken,
1147
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
1148
+ activation_secret: this.activationSecret,
1149
+ current_version: opts.currentVersion ?? this.options.appVersion,
1150
+ current_release_seq: this.releaseFloor(opts.currentReleaseSeq),
1151
+ channel: opts.channel ?? this.updateChannel()
1152
+ });
1153
+ if (body["update_available"] !== true) return null;
1154
+ const manifestToken = body["manifest"];
1155
+ if (typeof manifestToken !== "string") {
1156
+ throw new TalosSignatureError("update available but no manifest");
1157
+ }
1158
+ const m = verifyManifest(this.releaseVerifyKeys(), this.options.productToken, manifestToken);
1159
+ const releaseSeq = typeof m["release_seq"] === "number" ? m["release_seq"] : 0;
1160
+ const floor = this.releaseFloor(opts.currentReleaseSeq);
1161
+ if (floor !== void 0 && releaseSeq <= floor) {
1162
+ return null;
1163
+ }
1164
+ const downloads = /* @__PURE__ */ new Map();
1165
+ for (const d of asArray(body["artifacts"])) {
1166
+ const o = d;
1167
+ if (typeof o["download_path"] === "string") {
1168
+ downloads.set(selKey(o["platform"], o["arch"], o["kind"]), o["download_path"]);
1169
+ }
1170
+ }
1171
+ const artifacts = asArray(m["artifacts"]).map((a) => {
1172
+ const o = a;
1173
+ const platform = String(o["platform"] ?? "");
1174
+ const arch = String(o["arch"] ?? "");
1175
+ const kind = String(o["kind"] ?? "");
1176
+ return {
1177
+ platform,
1178
+ arch,
1179
+ kind,
1180
+ filename: String(o["filename"] ?? ""),
1181
+ size: typeof o["size"] === "number" ? o["size"] : 0,
1182
+ sha256: String(o["sha256"] ?? ""),
1183
+ downloadPath: downloads.get(selKey(platform, arch, kind))
1184
+ };
1185
+ });
1186
+ const deltaPaths = /* @__PURE__ */ new Map();
1187
+ for (const d of asArray(body["deltas"])) {
1188
+ const o = d;
1189
+ if (typeof o["download_path"] === "string") {
1190
+ deltaPaths.set(
1191
+ deltaKey(o["platform"], o["arch"], o["from"], o["target_kind"]),
1192
+ o["download_path"]
1193
+ );
1194
+ }
1195
+ }
1196
+ const deltas = asArray(m["deltas"]).map((d) => {
1197
+ const o = d;
1198
+ const t = o["target"] ?? {};
1199
+ const platform = String(o["platform"] ?? "");
1200
+ const arch = String(o["arch"] ?? "");
1201
+ const from = String(o["from"] ?? "");
1202
+ const targetKind = String(t["kind"] ?? "");
1203
+ return {
1204
+ platform,
1205
+ arch,
1206
+ from,
1207
+ fromSeq: typeof o["from_seq"] === "number" ? o["from_seq"] : 0,
1208
+ filename: String(o["filename"] ?? ""),
1209
+ size: typeof o["size"] === "number" ? o["size"] : 0,
1210
+ sha256: String(o["sha256"] ?? ""),
1211
+ target: {
1212
+ kind: targetKind,
1213
+ filename: String(t["filename"] ?? ""),
1214
+ size: typeof t["size"] === "number" ? t["size"] : 0,
1215
+ sha256: String(t["sha256"] ?? "")
1216
+ },
1217
+ downloadPath: deltaPaths.get(deltaKey(platform, arch, from, targetKind))
1218
+ };
1219
+ });
1220
+ return {
1221
+ version: String(m["version"] ?? ""),
1222
+ releaseSeq,
1223
+ mandatory: m["mandatory"] === true,
1224
+ notesUrl: typeof m["notes_url"] === "string" ? m["notes_url"] : null,
1225
+ artifacts,
1226
+ deltas
1227
+ };
1228
+ }
1229
+ /**
1230
+ * The patch that upgrades this machine, or `null` if there is none.
1231
+ *
1232
+ * Matches on the *exact* published version string, not on version ordering: a
1233
+ * patch applies to the bytes it was built against, and "close enough" produces
1234
+ * a corrupt file rather than an error. So a machine whose installed version is
1235
+ * not one the release ships a patch from simply downloads the full build.
1236
+ */
1237
+ selectDelta(info, opts = {}) {
1238
+ const from = opts.from ?? this.options.appVersion;
1239
+ if (!from) return null;
1240
+ const { platform, arch } = hostTarget();
1241
+ const matches = info.deltas.filter(
1242
+ (d) => d.platform === platform && d.arch === arch && d.from === from
1243
+ );
1244
+ if (opts.targetKind) return matches.find((d) => d.target.kind === opts.targetKind) ?? null;
1245
+ return matches.length === 1 ? matches[0] : null;
1246
+ }
1247
+ /**
1248
+ * Download a patch and verify its bytes against the manifest's SHA-256, then
1249
+ * write it to `destPath`. Returns the patch, whose `target.sha256` is what the
1250
+ * rebuilt file must hash to.
1251
+ *
1252
+ * Does NOT apply it. Applying is the host's job — and so is checking the
1253
+ * result, which {@link verifyAppliedUpdate} does. A patch verified on the way
1254
+ * in and a result nobody checks is a signed chain that stops one step short of
1255
+ * the binary that runs.
1256
+ */
1257
+ async downloadDelta(info, destPath, opts = {}) {
1258
+ const chosen = opts.delta ?? this.selectDelta(info, opts);
1259
+ if (!chosen) throw new TalosError("no patch applies to this install");
1260
+ await this.fetchVerified(
1261
+ chosen.downloadPath,
1262
+ chosen.size,
1263
+ chosen.sha256,
1264
+ destPath,
1265
+ opts.onProgress
1266
+ );
1267
+ this.rememberDownloaded(info);
1268
+ return chosen;
1269
+ }
1270
+ /**
1271
+ * Download one artifact of an update and verify its bytes against the manifest
1272
+ * SHA-256, then write it to `destPath`. Returns the chosen artifact. Does NOT
1273
+ * apply or execute anything — verification and installation are the host app's
1274
+ * responsibility.
1275
+ *
1276
+ * `onProgress` is called as the bytes arrive, which is the only way a host can
1277
+ * show a bar: an installer takes minutes on a domestic connection, and a
1278
+ * promise that says nothing until it settles is indistinguishable from one that
1279
+ * has hung. Totals come from the signed manifest — see {@link DownloadProgress}.
1280
+ */
1281
+ async downloadUpdate(info, destPath, artifact, onProgress) {
1282
+ this.requireActivated();
1283
+ const chosen = artifact ?? pickArtifact(info.artifacts);
1284
+ if (!chosen) throw new TalosError("no artifact matches this platform");
1285
+ await this.fetchVerified(chosen.downloadPath, chosen.size, chosen.sha256, destPath, onProgress);
1286
+ this.rememberDownloaded(info);
1287
+ return chosen;
1288
+ }
1289
+ /**
1290
+ * Note which release's bytes are now on disk, so the next start can tell
1291
+ * whether they were installed.
1292
+ *
1293
+ * Written after the hash check, never before: a download that failed
1294
+ * verification is not a release this install is about to be running, and
1295
+ * recording it would let a corrupt or substituted file raise the
1296
+ * anti-downgrade floor past a good build.
1297
+ */
1298
+ rememberDownloaded(info) {
1299
+ if (info.releaseSeq <= (this.binding?.releaseSeqHighWater ?? 0)) return;
1300
+ this.persist({ pendingRelease: { version: info.version, releaseSeq: info.releaseSeq } });
1301
+ }
1302
+ /**
1303
+ * Fetch one file over the entitled download route and put it at `destPath`
1304
+ * only once its bytes match the size and hash the signed manifest names.
1305
+ * Shared by full builds and patches, which differ in what they are for and not
1306
+ * at all in how their bytes are checked.
1307
+ *
1308
+ * The bytes go straight to a `.talos-part` file beside the destination, hashed
1309
+ * as they arrive, and are renamed into place only once the hash matches. Two
1310
+ * things follow. A multi-gigabyte installer is never held in memory — the whole
1311
+ * body used to be buffered before it was checked, so the largest artifact a
1312
+ * product could ship was bounded by the smallest machine it ran on. And
1313
+ * `destPath` never holds unverified bytes, not even briefly: a download that
1314
+ * fails leaves whatever was there before rather than a truncated file a host
1315
+ * might run.
1316
+ */
1317
+ async fetchVerified(downloadPath, size, sha256, destPath, onProgress) {
1318
+ if (!downloadPath) throw new TalosError("artifact has no download path");
1319
+ const url = this.options.serverUrl.replace(/\/+$/, "") + downloadPath;
1320
+ const res = await this.doFetch(url, {
1321
+ method: "GET",
1322
+ headers: {
1323
+ "x-talos-product-token": this.options.productToken,
1324
+ "x-talos-activation-secret": this.activationSecret,
1325
+ "x-talos-fingerprint": this.fingerprint
1326
+ }
1327
+ });
1328
+ if (res.status < 200 || res.status >= 300) throw new TalosApiError(res.status, void 0);
1329
+ const contentLength = res.headers?.get("content-length");
1330
+ if (contentLength && Number(contentLength) > size) {
1331
+ throw new TalosSignatureError("artifact exceeds manifest size");
1332
+ }
1333
+ const partPath = destPath + ".talos-part";
1334
+ const handle = await (0, import_promises.open)(partPath, "w");
1335
+ const hash = (0, import_node_crypto5.createHash)("sha256");
1336
+ let received = 0;
1337
+ let closed = false;
1338
+ const take = async (chunk) => {
1339
+ received += chunk.byteLength;
1340
+ if (received > size) throw new TalosSignatureError("artifact exceeds manifest size");
1341
+ hash.update(chunk);
1342
+ await handle.write(chunk);
1343
+ onProgress?.({ bytesReceived: received, totalBytes: size });
1344
+ };
1345
+ try {
1346
+ const body = res.body;
1347
+ if (body) {
1348
+ const reader = body.getReader();
1349
+ try {
1350
+ for (; ; ) {
1351
+ const { done, value } = await reader.read();
1352
+ if (done || !value) break;
1353
+ await take(value);
1354
+ }
1355
+ } catch (e) {
1356
+ await reader.cancel().catch(() => {
1357
+ });
1358
+ throw e;
1359
+ }
1360
+ } else {
1361
+ await take(new Uint8Array(await res.arrayBuffer()));
1362
+ }
1363
+ if (hash.digest("hex") !== sha256) {
1364
+ throw new TalosSignatureError("artifact hash mismatch (tampered or wrong bytes)");
1365
+ }
1366
+ await handle.close();
1367
+ closed = true;
1368
+ await (0, import_promises.rename)(partPath, destPath);
1369
+ } catch (e) {
1370
+ if (!closed) await handle.close().catch(() => {
1371
+ });
1372
+ await (0, import_promises.rm)(partPath, { force: true }).catch(() => {
1373
+ });
1374
+ throw e;
1375
+ }
1376
+ }
1377
+ requireActivated() {
1378
+ if (!this.activationSecret) {
1379
+ throw new TalosError("not activated: call activate() first");
1380
+ }
1381
+ }
1382
+ post(path, body) {
1383
+ return this.request("POST", path, body);
1384
+ }
1385
+ /**
1386
+ * The abort signal for one licensing call, or `undefined` when disabled.
1387
+ *
1388
+ * A fresh one per request rather than one shared: `AbortSignal.timeout`
1389
+ * starts counting when it is created, so a signal made once in the
1390
+ * constructor would already have fired by the second call.
1391
+ */
1392
+ timeoutSignal() {
1393
+ const ms = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
1394
+ if (!ms || ms <= 0) return void 0;
1395
+ return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
1396
+ }
1397
+ async request(method, path, body) {
1398
+ let res;
1399
+ try {
1400
+ res = await this.doFetch(this.options.serverUrl.replace(/\/+$/, "") + path, {
1401
+ method,
1402
+ headers: { "content-type": "application/json" },
1403
+ body: JSON.stringify(body),
1404
+ signal: this.timeoutSignal()
1405
+ });
1406
+ } catch (e) {
1407
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
1408
+ throw new TalosError(
1409
+ `request timed out after ${this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS}ms`
1410
+ );
1411
+ }
1412
+ throw e;
1413
+ }
1414
+ const text = await res.text();
1415
+ if (res.status < 200 || res.status >= 300) {
1416
+ let errorCode;
1417
+ try {
1418
+ errorCode = JSON.parse(text).error;
1419
+ } catch {
1420
+ errorCode = void 0;
1421
+ }
1422
+ throw new TalosApiError(res.status, errorCode);
1423
+ }
1424
+ return text ? JSON.parse(text) : {};
1425
+ }
1426
+ };
1427
+ function stateFromClaims(claims, decision, isOffline) {
1428
+ const lic = claims["lic"] ?? {};
1429
+ const features = lic["features"] ?? {};
1430
+ const exp = num(lic["lic_exp"]);
1431
+ const iat = num(claims["iat"]) ?? nowSeconds();
1432
+ const revalAfter = num(claims["reval_after"]) ?? hoursAfter(iat, lic["reval_h"]);
1433
+ const graceUntil = num(claims["grace_until"]) ?? hoursAfter(iat, lic["grace_h"]);
1434
+ return new LicenseState(decision, features, exp, isOffline, revalAfter, graceUntil);
1435
+ }
1436
+ function hoursAfter(base, hours) {
1437
+ const h = num(hours);
1438
+ return h === null ? null : base + h * 3600;
1439
+ }
1440
+ function num(v) {
1441
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
1442
+ }
1443
+ function nowSeconds() {
1444
+ return Math.floor(Date.now() / 1e3);
1445
+ }
1446
+ function transitionEvent(prev, next) {
1447
+ if (next.decision === "clock_tampered") {
1448
+ return prev?.decision === "clock_tampered" ? null : "clockTamperDetected";
1449
+ }
1450
+ if (next.decision === "grace_expired") {
1451
+ return prev?.decision === "grace_expired" ? null : "gracePeriodExpired";
1452
+ }
1453
+ if (next.isValid) {
1454
+ if (!next.isOffline) return null;
1455
+ return prev?.isOffline && prev.isValid ? null : "gracePeriodStarted";
1456
+ }
1457
+ return prev?.decision === next.decision ? null : "licenseInvalid";
1458
+ }
1459
+ function newNonce() {
1460
+ return b64urlEncode((0, import_node_crypto5.randomBytes)(16));
1461
+ }
1462
+ function asArray(v) {
1463
+ return Array.isArray(v) ? v : [];
1464
+ }
1465
+ function selKey(platform, arch, kind) {
1466
+ return `${String(platform)}/${String(arch)}/${String(kind)}`;
1467
+ }
1468
+ function deltaKey(platform, arch, from, targetKind) {
1469
+ return `${String(platform)}/${String(arch)}/${String(from)}/${String(targetKind)}`;
1470
+ }
1471
+ function hostTarget() {
1472
+ const map = { win32: "windows", darwin: "macos", linux: "linux" };
1473
+ return { platform: map[import_node_os3.default.platform()] ?? import_node_os3.default.platform(), arch: import_node_os3.default.arch() };
1474
+ }
1475
+ function pickArtifact(artifacts) {
1476
+ const { platform, arch } = hostTarget();
1477
+ const matches = artifacts.filter((a) => a.platform === platform && a.arch === arch);
1478
+ return matches.find((a) => a.kind === "installer") ?? matches[0];
1479
+ }
1480
+ async function verifyAppliedUpdate(filePath, delta) {
1481
+ const hash = (0, import_node_crypto5.createHash)("sha256");
1482
+ const stream = (0, import_node_fs3.createReadStream)(filePath);
1483
+ await new Promise((resolve, reject) => {
1484
+ stream.on("data", (chunk) => hash.update(chunk));
1485
+ stream.on("error", reject);
1486
+ stream.on("end", () => resolve());
1487
+ });
1488
+ const actual = hash.digest("hex");
1489
+ if (actual !== delta.target.sha256) {
1490
+ throw new TalosSignatureError(
1491
+ `applied update does not match the signed manifest: expected ${delta.target.sha256}, got ${actual}`
1492
+ );
1493
+ }
1494
+ }
1495
+ // Annotate the CommonJS export names for ESM import in node:
1496
+ 0 && (module.exports = {
1497
+ LicenseState,
1498
+ StateStore,
1499
+ TalosApiError,
1500
+ TalosClient,
1501
+ TalosError,
1502
+ TalosSignatureError,
1503
+ collectFingerprint,
1504
+ defaultStatePath,
1505
+ hashLicenseKey,
1506
+ publicKeyFromBase64,
1507
+ verifyAppliedUpdate,
1508
+ verifyKeyset,
1509
+ verifyManifest,
1510
+ verifyToken
1511
+ });
1512
+ //# sourceMappingURL=index.cjs.map