@licenseseat/js 0.2.1 → 0.2.2

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 CHANGED
@@ -1,10 +1,10 @@
1
- # LicenseSeat JavaScript SDK
2
-
3
- Official JavaScript/TypeScript SDK for [LicenseSeat](https://licenseseat.com) – the simple, secure licensing platform for apps, games, and plugins.
1
+ # LicenseSeat - JavaScript SDK
4
2
 
5
3
  [![CI](https://github.com/licenseseat/licenseseat-js/actions/workflows/ci.yml/badge.svg)](https://github.com/licenseseat/licenseseat-js/actions/workflows/ci.yml)
6
4
  [![npm version](https://img.shields.io/npm/v/@licenseseat/js.svg)](https://www.npmjs.com/package/@licenseseat/js)
7
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Official JavaScript/TypeScript SDK for [LicenseSeat](https://licenseseat.com) – the simple, secure licensing platform for apps, games, and plugins.
8
8
 
9
9
  ---
10
10
 
@@ -148,18 +148,18 @@ const sdk = new LicenseSeat({
148
148
 
149
149
  ### Configuration Options
150
150
 
151
- | Option | Type | Default | Description |
152
- |--------|------|---------|-------------|
153
- | `apiKey` | `string` | `null` | API key for authentication (required for most operations) |
154
- | `apiBaseUrl` | `string` | `'https://licenseseat.com/api'` | API base URL |
155
- | `storagePrefix` | `string` | `'licenseseat_'` | Prefix for localStorage keys |
156
- | `autoValidateInterval` | `number` | `3600000` | Auto-validation interval in ms (1 hour) |
157
- | `autoInitialize` | `boolean` | `true` | Auto-initialize and validate cached license |
158
- | `offlineFallbackEnabled` | `boolean` | `false` | Enable offline validation on network errors |
159
- | `maxOfflineDays` | `number` | `0` | Maximum days license works offline (0 = disabled) |
160
- | `maxRetries` | `number` | `3` | Max retry attempts for failed API calls |
161
- | `retryDelay` | `number` | `1000` | Initial retry delay in ms (exponential backoff) |
162
- | `debug` | `boolean` | `false` | Enable debug logging to console |
151
+ | Option | Type | Default | Description |
152
+ | ------------------------ | --------- | ------------------------------- | --------------------------------------------------------- |
153
+ | `apiKey` | `string` | `null` | API key for authentication (required for most operations) |
154
+ | `apiBaseUrl` | `string` | `'https://licenseseat.com/api'` | API base URL |
155
+ | `storagePrefix` | `string` | `'licenseseat_'` | Prefix for localStorage keys |
156
+ | `autoValidateInterval` | `number` | `3600000` | Auto-validation interval in ms (1 hour) |
157
+ | `autoInitialize` | `boolean` | `true` | Auto-initialize and validate cached license |
158
+ | `offlineFallbackEnabled` | `boolean` | `false` | Enable offline validation on network errors |
159
+ | `maxOfflineDays` | `number` | `0` | Maximum days license works offline (0 = disabled) |
160
+ | `maxRetries` | `number` | `3` | Max retry attempts for failed API calls |
161
+ | `retryDelay` | `number` | `1000` | Initial retry delay in ms (exponential backoff) |
162
+ | `debug` | `boolean` | `false` | Enable debug logging to console |
163
163
 
164
164
  ---
165
165
 
@@ -339,42 +339,42 @@ sdk.off('activation:success', handler);
339
339
 
340
340
  ### Available Events
341
341
 
342
- | Event | Description | Data |
343
- |-------|-------------|------|
344
- | **Lifecycle** | | |
345
- | `license:loaded` | Cached license loaded on init | `CachedLicense` |
346
- | `sdk:reset` | SDK was reset | – |
347
- | `sdk:destroyed` | SDK was destroyed | – |
348
- | `sdk:error` | General SDK error | `{ message, error? }` |
349
- | **Activation** | | |
350
- | `activation:start` | Activation started | `{ licenseKey, deviceId }` |
351
- | `activation:success` | Activation succeeded | `CachedLicense` |
352
- | `activation:error` | Activation failed | `{ licenseKey, error }` |
353
- | **Deactivation** | | |
354
- | `deactivation:start` | Deactivation started | `CachedLicense` |
355
- | `deactivation:success` | Deactivation succeeded | `Object` |
356
- | `deactivation:error` | Deactivation failed | `{ error, license }` |
357
- | **Validation** | | |
358
- | `validation:start` | Validation started | `{ licenseKey }` |
359
- | `validation:success` | Online validation succeeded | `ValidationResult` |
360
- | `validation:failed` | Validation failed (invalid license) | `ValidationResult` |
361
- | `validation:error` | Validation error (network, etc.) | `{ licenseKey, error }` |
362
- | `validation:offline-success` | Offline validation succeeded | `ValidationResult` |
363
- | `validation:offline-failed` | Offline validation failed | `ValidationResult` |
364
- | `validation:auth-failed` | Auth failed during validation | `{ licenseKey, error, cached }` |
365
- | **Auto-Validation** | | |
366
- | `autovalidation:cycle` | Auto-validation scheduled | `{ nextRunAt: Date }` |
367
- | `autovalidation:stopped` | Auto-validation stopped | – |
368
- | **Network** | | |
369
- | `network:online` | Network connectivity restored | – |
370
- | `network:offline` | Network connectivity lost | `{ error }` |
371
- | **Offline License** | | |
372
- | `offlineLicense:fetching` | Fetching offline license | `{ licenseKey }` |
373
- | `offlineLicense:fetched` | Offline license fetched | `{ licenseKey, data }` |
374
- | `offlineLicense:fetchError` | Offline license fetch failed | `{ licenseKey, error }` |
375
- | `offlineLicense:ready` | Offline assets synced | `{ kid, exp_at }` |
376
- | `offlineLicense:verified` | Offline signature verified | `{ payload }` |
377
- | `offlineLicense:verificationFailed` | Offline signature invalid | `{ payload }` |
342
+ | Event | Description | Data |
343
+ | ----------------------------------- | ----------------------------------- | ------------------------------- |
344
+ | **Lifecycle** | | |
345
+ | `license:loaded` | Cached license loaded on init | `CachedLicense` |
346
+ | `sdk:reset` | SDK was reset | – |
347
+ | `sdk:destroyed` | SDK was destroyed | – |
348
+ | `sdk:error` | General SDK error | `{ message, error? }` |
349
+ | **Activation** | | |
350
+ | `activation:start` | Activation started | `{ licenseKey, deviceId }` |
351
+ | `activation:success` | Activation succeeded | `CachedLicense` |
352
+ | `activation:error` | Activation failed | `{ licenseKey, error }` |
353
+ | **Deactivation** | | |
354
+ | `deactivation:start` | Deactivation started | `CachedLicense` |
355
+ | `deactivation:success` | Deactivation succeeded | `Object` |
356
+ | `deactivation:error` | Deactivation failed | `{ error, license }` |
357
+ | **Validation** | | |
358
+ | `validation:start` | Validation started | `{ licenseKey }` |
359
+ | `validation:success` | Online validation succeeded | `ValidationResult` |
360
+ | `validation:failed` | Validation failed (invalid license) | `ValidationResult` |
361
+ | `validation:error` | Validation error (network, etc.) | `{ licenseKey, error }` |
362
+ | `validation:offline-success` | Offline validation succeeded | `ValidationResult` |
363
+ | `validation:offline-failed` | Offline validation failed | `ValidationResult` |
364
+ | `validation:auth-failed` | Auth failed during validation | `{ licenseKey, error, cached }` |
365
+ | **Auto-Validation** | | |
366
+ | `autovalidation:cycle` | Auto-validation scheduled | `{ nextRunAt: Date }` |
367
+ | `autovalidation:stopped` | Auto-validation stopped | – |
368
+ | **Network** | | |
369
+ | `network:online` | Network connectivity restored | – |
370
+ | `network:offline` | Network connectivity lost | `{ error }` |
371
+ | **Offline License** | | |
372
+ | `offlineLicense:fetching` | Fetching offline license | `{ licenseKey }` |
373
+ | `offlineLicense:fetched` | Offline license fetched | `{ licenseKey, data }` |
374
+ | `offlineLicense:fetchError` | Offline license fetch failed | `{ licenseKey, error }` |
375
+ | `offlineLicense:ready` | Offline assets synced | `{ kid, exp_at }` |
376
+ | `offlineLicense:verified` | Offline signature verified | `{ payload }` |
377
+ | `offlineLicense:verificationFailed` | Offline signature invalid | `{ payload }` |
378
378
 
379
379
  ---
380
380
 
@@ -454,12 +454,12 @@ try {
454
454
 
455
455
  ### Error Types
456
456
 
457
- | Error | Description |
458
- |-------|-------------|
459
- | `APIError` | HTTP request failures (includes `status` and `data`) |
460
- | `LicenseError` | License operation failures (includes `code`) |
461
- | `ConfigurationError` | SDK misconfiguration |
462
- | `CryptoError` | Cryptographic operation failures |
457
+ | Error | Description |
458
+ | -------------------- | ---------------------------------------------------- |
459
+ | `APIError` | HTTP request failures (includes `status` and `data`) |
460
+ | `LicenseError` | License operation failures (includes `code`) |
461
+ | `ConfigurationError` | SDK misconfiguration |
462
+ | `CryptoError` | Cryptographic operation failures |
463
463
 
464
464
  ---
465
465
 
@@ -561,17 +561,17 @@ npm install
561
561
 
562
562
  ### Scripts
563
563
 
564
- | Command | Description |
565
- |---------|-------------|
566
- | `npm run build` | Build JS bundle + TypeScript declarations |
567
- | `npm run build:js` | Build JavaScript bundle only |
568
- | `npm run build:types` | Generate TypeScript declarations |
569
- | `npm run build:iife` | Build global/IIFE bundle |
570
- | `npm run dev` | Watch mode for development |
571
- | `npm test` | Run tests |
572
- | `npm run test:watch` | Run tests in watch mode |
573
- | `npm run test:coverage` | Run tests with coverage report |
574
- | `npm run typecheck` | Type-check without emitting |
564
+ | Command | Description |
565
+ | ----------------------- | ----------------------------------------- |
566
+ | `npm run build` | Build JS bundle + TypeScript declarations |
567
+ | `npm run build:js` | Build JavaScript bundle only |
568
+ | `npm run build:types` | Generate TypeScript declarations |
569
+ | `npm run build:iife` | Build global/IIFE bundle |
570
+ | `npm run dev` | Watch mode for development |
571
+ | `npm test` | Run tests |
572
+ | `npm run test:watch` | Run tests in watch mode |
573
+ | `npm run test:coverage` | Run tests with coverage report |
574
+ | `npm run typecheck` | Type-check without emitting |
575
575
 
576
576
  ### Project Structure
577
577
 
@@ -671,12 +671,12 @@ This ensures:
671
671
 
672
672
  Once published to npm, the package is automatically available on CDNs:
673
673
 
674
- | CDN | URL |
675
- |-----|-----|
676
- | **esm.sh** | `https://esm.sh/@licenseseat/js` |
677
- | **unpkg** | `https://unpkg.com/@licenseseat/js/dist/index.js` |
674
+ | CDN | URL |
675
+ | ------------ | ------------------------------------------------------------ |
676
+ | **esm.sh** | `https://esm.sh/@licenseseat/js` |
677
+ | **unpkg** | `https://unpkg.com/@licenseseat/js/dist/index.js` |
678
678
  | **jsDelivr** | `https://cdn.jsdelivr.net/npm/@licenseseat/js/dist/index.js` |
679
- | **Skypack** | `https://cdn.skypack.dev/@licenseseat/js` |
679
+ | **Skypack** | `https://cdn.skypack.dev/@licenseseat/js` |
680
680
 
681
681
  **Version pinning** (recommended for production):
682
682
  ```html
@@ -731,10 +731,10 @@ This project follows [Semantic Versioning](https://semver.org/):
731
731
 
732
732
  ### Breaking Changes in v0.2.0
733
733
 
734
- | Change | Before | After | Migration |
735
- |--------|--------|-------|-----------|
736
- | `apiBaseUrl` default | `/api` | `https://licenseseat.com/api` | Set `apiBaseUrl` explicitly if using a relative URL |
737
- | `offlineFallbackEnabled` default | `true` | `false` | Set `offlineFallbackEnabled: true` if you need offline fallback |
734
+ | Change | Before | After | Migration |
735
+ | -------------------------------- | ------ | ----------------------------- | --------------------------------------------------------------- |
736
+ | `apiBaseUrl` default | `/api` | `https://licenseseat.com/api` | Set `apiBaseUrl` explicitly if using a relative URL |
737
+ | `offlineFallbackEnabled` default | `true` | `false` | Set `offlineFallbackEnabled: true` if you need offline fallback |
738
738
 
739
739
  ### New Features in v0.2.0
740
740
 
package/dist/index.js CHANGED
@@ -260,7 +260,14 @@ function base64UrlDecode(base64UrlString) {
260
260
  while (base64.length % 4) {
261
261
  base64 += "=";
262
262
  }
263
- const raw = window.atob(base64);
263
+ let raw;
264
+ if (typeof atob === "function") {
265
+ raw = atob(base64);
266
+ } else if (typeof Buffer !== "undefined") {
267
+ raw = Buffer.from(base64, "base64").toString("binary");
268
+ } else {
269
+ throw new Error("No base64 decoder available (neither atob nor Buffer found)");
270
+ }
264
271
  const outputArray = new Uint8Array(raw.length);
265
272
  for (let i = 0; i < raw.length; ++i) {
266
273
  outputArray[i] = raw.charCodeAt(i);
@@ -289,6 +296,11 @@ function getCanvasFingerprint() {
289
296
  }
290
297
  }
291
298
  function generateDeviceId() {
299
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
300
+ const os = typeof process !== "undefined" ? process.platform : "unknown";
301
+ const arch = typeof process !== "undefined" ? process.arch : "unknown";
302
+ return `node-${hashCode(os + "|" + arch)}`;
303
+ }
292
304
  const nav = window.navigator;
293
305
  const screen = window.screen;
294
306
  const data = [
@@ -300,7 +312,7 @@ function generateDeviceId() {
300
312
  nav.hardwareConcurrency,
301
313
  getCanvasFingerprint()
302
314
  ].join("|");
303
- return `web-${hashCode(data)}-${Date.now().toString(36)}`;
315
+ return `web-${hashCode(data)}`;
304
316
  }
305
317
  function sleep(ms) {
306
318
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -748,12 +760,12 @@ var LicenseSeatSDK = class {
748
760
  * Test server authentication
749
761
  * Useful for verifying API key/session is valid.
750
762
  * @returns {Promise<Object>} Result from the server
751
- * @throws {Error} When API key is not configured
763
+ * @throws {ConfigurationError} When API key is not configured
752
764
  * @throws {APIError} When authentication fails
753
765
  */
754
766
  async testAuth() {
755
767
  if (!this.config.apiKey) {
756
- const err = new Error("API key is required for auth test");
768
+ const err = new ConfigurationError("API key is required for auth test");
757
769
  this.emit("auth_test:error", { error: err });
758
770
  throw err;
759
771
  }
@@ -57,7 +57,7 @@ export class LicenseSeatSDK {
57
57
  private eventListeners;
58
58
  /**
59
59
  * Auto-validation timer ID
60
- * @type {number|null}
60
+ * @type {ReturnType<typeof setInterval>|null}
61
61
  * @private
62
62
  */
63
63
  private validationTimer;
@@ -81,13 +81,13 @@ export class LicenseSeatSDK {
81
81
  private currentAutoLicenseKey;
82
82
  /**
83
83
  * Connectivity polling timer ID
84
- * @type {number|null}
84
+ * @type {ReturnType<typeof setInterval>|null}
85
85
  * @private
86
86
  */
87
87
  private connectivityTimer;
88
88
  /**
89
89
  * Offline license refresh timer ID
90
- * @type {number|null}
90
+ * @type {ReturnType<typeof setInterval>|null}
91
91
  * @private
92
92
  */
93
93
  private offlineRefreshTimer;
@@ -185,7 +185,7 @@ export class LicenseSeatSDK {
185
185
  * Test server authentication
186
186
  * Useful for verifying API key/session is valid.
187
187
  * @returns {Promise<Object>} Result from the server
188
- * @throws {Error} When API key is not configured
188
+ * @throws {ConfigurationError} When API key is not configured
189
189
  * @throws {APIError} When authentication fails
190
190
  */
191
191
  testAuth(): Promise<any>;
@@ -20,6 +20,7 @@ export function constantTimeEqual(a?: string, b?: string): boolean;
20
20
  export function canonicalJsonStringify(obj: any): string;
21
21
  /**
22
22
  * Decode a Base64URL string to a Uint8Array
23
+ * Works in both browser and Node.js environments.
23
24
  * @param {string} base64UrlString - The Base64URL encoded string
24
25
  * @returns {Uint8Array} Decoded bytes
25
26
  */
@@ -36,8 +37,9 @@ export function hashCode(str: string): string;
36
37
  */
37
38
  export function getCanvasFingerprint(): string;
38
39
  /**
39
- * Generate a unique device identifier based on browser characteristics
40
- * @returns {string} Unique device identifier
40
+ * Generate a stable device identifier based on browser characteristics.
41
+ * The ID is deterministic - same device will produce the same ID across calls.
42
+ * @returns {string} Stable device identifier
41
43
  */
42
44
  export function generateDeviceId(): string;
43
45
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.js"],"names":[],"mappings":"AAQA;;;;GAIG;AACH,wDAFa,OAAO,YAAY,EAAE,WAAW,EAAE,CAS9C;AAED;;;;;GAKG;AACH,sCAJW,MAAM,MACN,MAAM,GACJ,OAAO,CASnB;AAED;;;;;GAKG;AACH,kDAFa,MAAM,CAuBlB;AAED;;;;GAIG;AACH,iDAHW,MAAM,GACJ,UAAU,CAatB;AAED;;;;GAIG;AACH,8BAHW,MAAM,GACJ,MAAM,CAUlB;AAED;;;GAGG;AACH,wCAFa,MAAM,CAalB;AAED;;;GAGG;AACH,oCAFa,MAAM,CAgBlB;AAED;;;;GAIG;AACH,0BAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAIzB;AAED;;;GAGG;AACH,gCAFa,MAAM,CAMlB"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.js"],"names":[],"mappings":"AAQA;;;;GAIG;AACH,wDAFa,OAAO,YAAY,EAAE,WAAW,EAAE,CAS9C;AAED;;;;;GAKG;AACH,sCAJW,MAAM,MACN,MAAM,GACJ,OAAO,CASnB;AAED;;;;;GAKG;AACH,kDAFa,MAAM,CAuBlB;AAED;;;;;GAKG;AACH,iDAHW,MAAM,GACJ,UAAU,CA0BtB;AAED;;;;GAIG;AACH,8BAHW,MAAM,GACJ,MAAM,CAUlB;AAED;;;GAGG;AACH,wCAFa,MAAM,CAalB;AAED;;;;GAIG;AACH,oCAFa,MAAM,CAyBlB;AAED;;;;GAIG;AACH,0BAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAIzB;AAED;;;GAGG;AACH,gCAFa,MAAM,CAMlB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@licenseseat/js",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Official JavaScript SDK for LicenseSeat – simple, secure software licensing.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -62,6 +62,7 @@
62
62
  "node": ">=18.0.0"
63
63
  },
64
64
  "devDependencies": {
65
+ "@types/node": "^25.0.9",
65
66
  "@vitest/coverage-v8": "^1.6.0",
66
67
  "esbuild": "^0.20.2",
67
68
  "jsdom": "^24.1.3",
@@ -21,7 +21,7 @@ import * as ed from "@noble/ed25519";
21
21
  import { sha512 } from "@noble/hashes/sha512";
22
22
 
23
23
  import { LicenseCache } from "./cache.js";
24
- import { APIError, LicenseError, CryptoError } from "./errors.js";
24
+ import { APIError, ConfigurationError, LicenseError, CryptoError } from "./errors.js";
25
25
  import {
26
26
  parseActiveEntitlements,
27
27
  constantTimeEqual,
@@ -98,7 +98,7 @@ export class LicenseSeatSDK {
98
98
 
99
99
  /**
100
100
  * Auto-validation timer ID
101
- * @type {number|null}
101
+ * @type {ReturnType<typeof setInterval>|null}
102
102
  * @private
103
103
  */
104
104
  this.validationTimer = null;
@@ -126,14 +126,14 @@ export class LicenseSeatSDK {
126
126
 
127
127
  /**
128
128
  * Connectivity polling timer ID
129
- * @type {number|null}
129
+ * @type {ReturnType<typeof setInterval>|null}
130
130
  * @private
131
131
  */
132
132
  this.connectivityTimer = null;
133
133
 
134
134
  /**
135
135
  * Offline license refresh timer ID
136
- * @type {number|null}
136
+ * @type {ReturnType<typeof setInterval>|null}
137
137
  * @private
138
138
  */
139
139
  this.offlineRefreshTimer = null;
@@ -637,12 +637,12 @@ export class LicenseSeatSDK {
637
637
  * Test server authentication
638
638
  * Useful for verifying API key/session is valid.
639
639
  * @returns {Promise<Object>} Result from the server
640
- * @throws {Error} When API key is not configured
640
+ * @throws {ConfigurationError} When API key is not configured
641
641
  * @throws {APIError} When authentication fails
642
642
  */
643
643
  async testAuth() {
644
644
  if (!this.config.apiKey) {
645
- const err = new Error("API key is required for auth test");
645
+ const err = new ConfigurationError("API key is required for auth test");
646
646
  this.emit("auth_test:error", { error: err });
647
647
  throw err;
648
648
  }
package/src/utils.js CHANGED
@@ -66,6 +66,7 @@ export function canonicalJsonStringify(obj) {
66
66
 
67
67
  /**
68
68
  * Decode a Base64URL string to a Uint8Array
69
+ * Works in both browser and Node.js environments.
69
70
  * @param {string} base64UrlString - The Base64URL encoded string
70
71
  * @returns {Uint8Array} Decoded bytes
71
72
  */
@@ -74,7 +75,20 @@ export function base64UrlDecode(base64UrlString) {
74
75
  while (base64.length % 4) {
75
76
  base64 += "=";
76
77
  }
77
- const raw = window.atob(base64);
78
+
79
+ // Cross-platform base64 decoding
80
+ /** @type {string} */
81
+ let raw;
82
+ if (typeof atob === "function") {
83
+ // Browser environment (or Node.js 16+ with global atob)
84
+ raw = atob(base64);
85
+ } else if (typeof Buffer !== "undefined") {
86
+ // Node.js environment
87
+ raw = Buffer.from(base64, "base64").toString("binary");
88
+ } else {
89
+ throw new Error("No base64 decoder available (neither atob nor Buffer found)");
90
+ }
91
+
78
92
  const outputArray = new Uint8Array(raw.length);
79
93
  for (let i = 0; i < raw.length; ++i) {
80
94
  outputArray[i] = raw.charCodeAt(i);
@@ -115,10 +129,19 @@ export function getCanvasFingerprint() {
115
129
  }
116
130
 
117
131
  /**
118
- * Generate a unique device identifier based on browser characteristics
119
- * @returns {string} Unique device identifier
132
+ * Generate a stable device identifier based on browser characteristics.
133
+ * The ID is deterministic - same device will produce the same ID across calls.
134
+ * @returns {string} Stable device identifier
120
135
  */
121
136
  export function generateDeviceId() {
137
+ // Check if we're in a browser environment
138
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
139
+ // Node.js or non-browser environment - use a fallback
140
+ const os = typeof process !== "undefined" ? process.platform : "unknown";
141
+ const arch = typeof process !== "undefined" ? process.arch : "unknown";
142
+ return `node-${hashCode(os + "|" + arch)}`;
143
+ }
144
+
122
145
  const nav = window.navigator;
123
146
  const screen = window.screen;
124
147
  const data = [
@@ -131,7 +154,8 @@ export function generateDeviceId() {
131
154
  getCanvasFingerprint(),
132
155
  ].join("|");
133
156
 
134
- return `web-${hashCode(data)}-${Date.now().toString(36)}`;
157
+ // Stable ID without timestamp - same device produces same ID
158
+ return `web-${hashCode(data)}`;
135
159
  }
136
160
 
137
161
  /**