aios-core 4.2.4 → 4.2.5

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.
@@ -0,0 +1,617 @@
1
+ /**
2
+ * License API Client
3
+ *
4
+ * HTTP client for communicating with the license validation server.
5
+ * Supports activation, validation, deactivation, and offline sync.
6
+ *
7
+ * @module pro/license/license-api
8
+ * @see ADR-PRO-003 - Feature Gating & Licensing
9
+ * @see Story PRO-6 - License Key & Feature Gating System
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const https = require('https');
15
+ const http = require('http');
16
+ const { URL } = require('url');
17
+ const { LicenseActivationError, AuthError, BuyerValidationError } = require('./errors');
18
+ const { hasPendingDeactivation, clearPendingDeactivation } = require('./license-cache');
19
+
20
+ /**
21
+ * Default configuration.
22
+ */
23
+ const CONFIG = {
24
+ BASE_URL: process.env.AIOS_LICENSE_API_URL || 'https://aios-license-server.vercel.app',
25
+ TIMEOUT_MS: 10000,
26
+ MAX_RETRIES: 3,
27
+ RETRY_DELAY_MS: 1000,
28
+ USER_AGENT: 'AIOS-Pro-License-Client/1.0',
29
+ };
30
+
31
+ /**
32
+ * LicenseApiClient - HTTP client for license operations.
33
+ */
34
+ class LicenseApiClient {
35
+ /**
36
+ * Create a LicenseApiClient.
37
+ *
38
+ * @param {object} [options] - Client options
39
+ * @param {string} [options.baseUrl] - Base URL for API (default: api.synkra.ai)
40
+ * @param {number} [options.timeoutMs] - Request timeout in ms (default: 10000)
41
+ */
42
+ constructor(options = {}) {
43
+ this.baseUrl = options.baseUrl || CONFIG.BASE_URL;
44
+ this.timeoutMs = options.timeoutMs || CONFIG.TIMEOUT_MS;
45
+ }
46
+
47
+ /**
48
+ * Make an HTTP request with timeout and abort support.
49
+ *
50
+ * @private
51
+ * @param {string} method - HTTP method
52
+ * @param {string} path - API path
53
+ * @param {object} body - Request body
54
+ * @returns {Promise<object>} Response data
55
+ * @throws {LicenseActivationError} On network or server errors
56
+ */
57
+ async _request(method, path, body) {
58
+ const url = new URL(path, this.baseUrl);
59
+ const isHttps = url.protocol === 'https:';
60
+ const client = isHttps ? https : http;
61
+
62
+ const requestData = JSON.stringify(body);
63
+
64
+ const options = {
65
+ method,
66
+ hostname: url.hostname,
67
+ port: url.port || (isHttps ? 443 : 80),
68
+ path: url.pathname + url.search,
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'Content-Length': Buffer.byteLength(requestData),
72
+ 'User-Agent': CONFIG.USER_AGENT,
73
+ Accept: 'application/json',
74
+ },
75
+ timeout: this.timeoutMs,
76
+ };
77
+
78
+ return new Promise((resolve, reject) => {
79
+ const req = client.request(options, (res) => {
80
+ let data = '';
81
+
82
+ res.on('data', (chunk) => {
83
+ data += chunk;
84
+ });
85
+
86
+ res.on('end', () => {
87
+ try {
88
+ const response = data ? JSON.parse(data) : {};
89
+ this._handleResponse(res.statusCode, response, resolve, reject);
90
+ } catch {
91
+ reject(
92
+ new LicenseActivationError(
93
+ 'Invalid response from license server',
94
+ 'INVALID_RESPONSE',
95
+ { rawData: data.substring(0, 200) },
96
+ ),
97
+ );
98
+ }
99
+ });
100
+ });
101
+
102
+ // Handle request timeout
103
+ req.on('timeout', () => {
104
+ req.destroy();
105
+ reject(LicenseActivationError.networkError(new Error('Request timeout')));
106
+ });
107
+
108
+ // Handle request error
109
+ req.on('error', (error) => {
110
+ reject(LicenseActivationError.networkError(error));
111
+ });
112
+
113
+ // Send request body
114
+ req.write(requestData);
115
+ req.end();
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Handle HTTP response based on status code.
121
+ *
122
+ * @private
123
+ * @param {number} statusCode - HTTP status code
124
+ * @param {object} response - Parsed response body
125
+ * @param {Function} resolve - Promise resolve
126
+ * @param {Function} reject - Promise reject
127
+ */
128
+ _handleResponse(statusCode, response, resolve, reject) {
129
+ // Success
130
+ if (statusCode >= 200 && statusCode < 300) {
131
+ resolve(response);
132
+ return;
133
+ }
134
+
135
+ // Client errors
136
+ switch (statusCode) {
137
+ case 400:
138
+ reject(
139
+ new LicenseActivationError(
140
+ response.message || 'Invalid request',
141
+ response.code || 'BAD_REQUEST',
142
+ response.details,
143
+ ),
144
+ );
145
+ break;
146
+
147
+ case 401:
148
+ reject(LicenseActivationError.invalidKey());
149
+ break;
150
+
151
+ case 403:
152
+ if (response.code === 'EXPIRED_KEY') {
153
+ reject(LicenseActivationError.expiredKey());
154
+ } else if (response.code === 'SEAT_LIMIT_EXCEEDED') {
155
+ reject(
156
+ LicenseActivationError.seatLimitExceeded(
157
+ response.details?.used || 0,
158
+ response.details?.max || 0,
159
+ ),
160
+ );
161
+ } else {
162
+ reject(
163
+ new LicenseActivationError(
164
+ response.message || 'Access forbidden',
165
+ response.code || 'FORBIDDEN',
166
+ response.details,
167
+ ),
168
+ );
169
+ }
170
+ break;
171
+
172
+ case 429:
173
+ reject(LicenseActivationError.rateLimited(response.retryAfter));
174
+ break;
175
+
176
+ case 500:
177
+ case 502:
178
+ case 503:
179
+ case 504:
180
+ // Preserve server error code if provided (e.g., BUYER_SERVICE_UNAVAILABLE)
181
+ if (response.code) {
182
+ reject(
183
+ new LicenseActivationError(
184
+ response.message || 'Server error',
185
+ response.code,
186
+ response.details,
187
+ ),
188
+ );
189
+ } else {
190
+ reject(LicenseActivationError.serverError());
191
+ }
192
+ break;
193
+
194
+ default:
195
+ reject(
196
+ new LicenseActivationError(
197
+ `Unexpected response: ${statusCode}`,
198
+ 'UNEXPECTED_STATUS',
199
+ { statusCode, response },
200
+ ),
201
+ );
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Activate a license key.
207
+ *
208
+ * @param {string} key - License key (PRO-XXXX-XXXX-XXXX-XXXX)
209
+ * @param {string} machineId - Machine fingerprint
210
+ * @param {string} aiosCoreVersion - AIOS Core version
211
+ * @returns {Promise<object>} Activation result with features, seats, cache info
212
+ * @throws {LicenseActivationError} On activation failure
213
+ */
214
+ async activate(key, machineId, aiosCoreVersion) {
215
+ // First, sync any pending deactivations
216
+ await this.syncPendingDeactivation(machineId);
217
+
218
+ const response = await this._request('POST', '/v1/license/activate', {
219
+ key,
220
+ machineId,
221
+ aiosCoreVersion,
222
+ });
223
+
224
+ // Validate response structure
225
+ if (!response.features || !Array.isArray(response.features)) {
226
+ throw new LicenseActivationError(
227
+ 'Invalid activation response: missing features',
228
+ 'INVALID_RESPONSE',
229
+ );
230
+ }
231
+
232
+ return {
233
+ key: response.key || key,
234
+ features: response.features,
235
+ seats: response.seats || { used: 1, max: 1 },
236
+ expiresAt: response.expiresAt,
237
+ cacheValidDays: response.cacheValidDays || 30,
238
+ gracePeriodDays: response.gracePeriodDays || 7,
239
+ activatedAt: new Date().toISOString(),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Validate an existing license.
245
+ *
246
+ * @param {string} key - License key
247
+ * @param {string} machineId - Machine fingerprint
248
+ * @returns {Promise<object>} Validation result
249
+ * @throws {LicenseActivationError} On validation failure
250
+ */
251
+ async validate(key, machineId) {
252
+ // First, sync any pending deactivations
253
+ await this.syncPendingDeactivation(machineId);
254
+
255
+ const response = await this._request('POST', '/v1/license/validate', {
256
+ key,
257
+ machineId,
258
+ });
259
+
260
+ return {
261
+ valid: response.valid !== false,
262
+ features: response.features || [],
263
+ seats: response.seats || { used: 1, max: 1 },
264
+ expiresAt: response.expiresAt,
265
+ cacheValidDays: response.cacheValidDays || 30,
266
+ gracePeriodDays: response.gracePeriodDays || 7,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Deactivate a license from this machine.
272
+ *
273
+ * @param {string} key - License key
274
+ * @param {string} machineId - Machine fingerprint
275
+ * @returns {Promise<object>} Deactivation result
276
+ * @throws {LicenseActivationError} On deactivation failure
277
+ */
278
+ async deactivate(key, machineId) {
279
+ const response = await this._request('POST', '/v1/license/deactivate', {
280
+ key,
281
+ machineId,
282
+ });
283
+
284
+ return {
285
+ success: response.success !== false,
286
+ seatFreed: response.seatFreed !== false,
287
+ message: response.message || 'License deactivated successfully',
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Sync any pending offline deactivations with the server.
293
+ *
294
+ * This is called automatically before activate/validate operations.
295
+ *
296
+ * @param {string} machineId - Current machine ID for verification
297
+ * @param {string} [baseDir] - Base directory for cache (optional)
298
+ * @returns {Promise<boolean>} true if sync was performed
299
+ */
300
+ async syncPendingDeactivation(machineId, baseDir) {
301
+ try {
302
+ const pendingResult = hasPendingDeactivation(baseDir);
303
+
304
+ if (!pendingResult.pending) {
305
+ return false;
306
+ }
307
+
308
+ const pending = pendingResult.data;
309
+
310
+ if (!pending || !pending.licenseKey) {
311
+ // Invalid pending data, clear it
312
+ clearPendingDeactivation(baseDir);
313
+ return false;
314
+ }
315
+
316
+ // Attempt to deactivate on server
317
+ try {
318
+ await this._request('POST', '/v1/license/deactivate', {
319
+ key: pending.licenseKey,
320
+ machineId: pending.machineId || machineId,
321
+ offlineDeactivation: true,
322
+ offlineTimestamp: pending.deactivatedAt,
323
+ });
324
+
325
+ // Success - clear the pending flag
326
+ clearPendingDeactivation(baseDir);
327
+ return true;
328
+ } catch (error) {
329
+ // If the key is already deactivated or invalid, clear pending
330
+ if (error.code === 'INVALID_KEY' || error.code === 'NOT_ACTIVATED') {
331
+ clearPendingDeactivation(baseDir);
332
+ return true;
333
+ }
334
+
335
+ // For other errors (network, server), keep pending for next sync
336
+ // Don't throw - allow the main operation to continue
337
+ return false;
338
+ }
339
+ } catch {
340
+ // If we can't read pending state, continue anyway
341
+ return false;
342
+ }
343
+ }
344
+
345
+ // ────────────────────────────────────────────────────────────────
346
+ // Auth methods (Story PRO-11 - Email Authentication)
347
+ // ────────────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * Pre-flight check: verify buyer status and account existence for an email.
351
+ *
352
+ * @param {string} email - User email
353
+ * @returns {Promise<object>} Result with { isBuyer, hasAccount, email }
354
+ * @throws {AuthError} On check failure
355
+ */
356
+ async checkEmail(email) {
357
+ try {
358
+ const response = await this._request('POST', '/api/v1/auth/check-email', {
359
+ email,
360
+ });
361
+
362
+ return {
363
+ isBuyer: response.isBuyer === true,
364
+ hasAccount: response.hasAccount === true,
365
+ email: response.email || email,
366
+ };
367
+ } catch (error) {
368
+ if (error.code === 'RATE_LIMITED') {
369
+ throw AuthError.rateLimited(error.details?.retryAfter);
370
+ }
371
+ throw new AuthError(
372
+ error.message || 'Failed to check email',
373
+ error.code || 'CHECK_EMAIL_FAILED',
374
+ error.details,
375
+ );
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Register a new user with email and password.
381
+ *
382
+ * @param {string} email - User email
383
+ * @param {string} password - User password (min 8 characters)
384
+ * @returns {Promise<object>} Signup result with { userId, message }
385
+ * @throws {AuthError} On signup failure
386
+ */
387
+ async signup(email, password) {
388
+ try {
389
+ const response = await this._request('POST', '/api/v1/auth/signup', {
390
+ email,
391
+ password,
392
+ });
393
+
394
+ return {
395
+ userId: response.userId,
396
+ message: response.message || 'Verification email sent. Please check your inbox.',
397
+ };
398
+ } catch (error) {
399
+ if (error.code === 'BAD_REQUEST' && error.message.includes('already')) {
400
+ throw AuthError.emailAlreadyRegistered();
401
+ }
402
+ if (error.code === 'RATE_LIMITED') {
403
+ throw AuthError.rateLimited(error.details?.retryAfter);
404
+ }
405
+ throw new AuthError(
406
+ error.message || 'Signup failed',
407
+ error.code || 'SIGNUP_FAILED',
408
+ error.details,
409
+ );
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Login with email and password.
415
+ *
416
+ * @param {string} email - User email
417
+ * @param {string} password - User password
418
+ * @returns {Promise<object>} Login result with { sessionToken, userId, emailVerified }
419
+ * @throws {AuthError} On login failure
420
+ */
421
+ async login(email, password) {
422
+ try {
423
+ const response = await this._request('POST', '/api/v1/auth/login', {
424
+ email,
425
+ password,
426
+ });
427
+
428
+ return {
429
+ sessionToken: response.sessionToken,
430
+ userId: response.userId,
431
+ emailVerified: response.emailVerified !== false,
432
+ };
433
+ } catch (error) {
434
+ if (error.code === 'INVALID_KEY' || error.code === 'BAD_REQUEST') {
435
+ throw AuthError.invalidCredentials();
436
+ }
437
+ if (error.code === 'RATE_LIMITED') {
438
+ throw AuthError.rateLimited(error.details?.retryAfter);
439
+ }
440
+ throw new AuthError(
441
+ error.message || 'Login failed',
442
+ error.code || 'LOGIN_FAILED',
443
+ error.details,
444
+ );
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Check if user's email has been verified.
450
+ *
451
+ * @param {string} sessionToken - Session token from login/signup
452
+ * @returns {Promise<object>} Status with { verified, email }
453
+ * @throws {AuthError} On verification check failure
454
+ */
455
+ async checkEmailVerified(sessionToken) {
456
+ try {
457
+ const response = await this._request('POST', '/api/v1/auth/verify-status', {
458
+ sessionToken,
459
+ });
460
+
461
+ return {
462
+ verified: response.verified === true,
463
+ email: response.email,
464
+ };
465
+ } catch (error) {
466
+ throw new AuthError(
467
+ error.message || 'Failed to check email verification status',
468
+ error.code || 'VERIFY_CHECK_FAILED',
469
+ error.details,
470
+ );
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Activate Pro via authenticated session.
476
+ *
477
+ * Server-side flow: verify session → check email verified → validate buyer → generate/recover key → activate.
478
+ *
479
+ * @param {string} sessionToken - Session token from login
480
+ * @param {string} machineId - Machine fingerprint
481
+ * @param {string} aiosCoreVersion - AIOS Core version
482
+ * @returns {Promise<object>} Activation result (same shape as activate())
483
+ * @throws {AuthError} On auth failure
484
+ * @throws {BuyerValidationError} If user is not a buyer
485
+ * @throws {LicenseActivationError} On activation failure
486
+ */
487
+ async activateByAuth(sessionToken, machineId, aiosCoreVersion) {
488
+ try {
489
+ const response = await this._request('POST', '/api/v1/auth/activate-pro', {
490
+ sessionToken,
491
+ machineId,
492
+ aiosCoreVersion,
493
+ });
494
+
495
+ if (!response.features || !Array.isArray(response.features)) {
496
+ throw new LicenseActivationError(
497
+ 'Invalid activation response: missing features',
498
+ 'INVALID_RESPONSE',
499
+ );
500
+ }
501
+
502
+ return {
503
+ key: response.key,
504
+ features: response.features,
505
+ seats: response.seats || { used: 1, max: 2 },
506
+ expiresAt: response.expiresAt,
507
+ cacheValidDays: response.cacheValidDays || 30,
508
+ gracePeriodDays: response.gracePeriodDays || 7,
509
+ activatedAt: new Date().toISOString(),
510
+ };
511
+ } catch (error) {
512
+ // Re-throw typed errors
513
+ if (error instanceof AuthError || error instanceof BuyerValidationError) {
514
+ throw error;
515
+ }
516
+
517
+ if (error.code === 'EMAIL_NOT_VERIFIED') {
518
+ throw AuthError.emailNotVerified();
519
+ }
520
+ if (error.code === 'NOT_A_BUYER') {
521
+ throw BuyerValidationError.notABuyer();
522
+ }
523
+ if (error.code === 'BUYER_SERVICE_UNAVAILABLE') {
524
+ throw BuyerValidationError.serviceUnavailable();
525
+ }
526
+ if (error.code === 'SEAT_LIMIT_EXCEEDED') {
527
+ throw LicenseActivationError.seatLimitExceeded(
528
+ error.details?.used || 0,
529
+ error.details?.max || 0,
530
+ );
531
+ }
532
+
533
+ throw new AuthError(
534
+ error.message || 'Pro activation failed',
535
+ error.code || 'ACTIVATE_PRO_FAILED',
536
+ error.details,
537
+ );
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Resend email verification.
543
+ *
544
+ * @param {string} sessionToken - Session token
545
+ * @returns {Promise<object>} Result with { message }
546
+ * @throws {AuthError} On failure
547
+ */
548
+ async resendVerification(sessionToken) {
549
+ try {
550
+ const response = await this._request('POST', '/api/v1/auth/resend-verification', {
551
+ sessionToken,
552
+ });
553
+
554
+ return {
555
+ message: response.message || 'Verification email resent.',
556
+ };
557
+ } catch (error) {
558
+ if (error.code === 'RATE_LIMITED') {
559
+ throw AuthError.rateLimited(error.details?.retryAfter);
560
+ }
561
+ throw new AuthError(
562
+ error.message || 'Failed to resend verification email',
563
+ error.code || 'RESEND_FAILED',
564
+ error.details,
565
+ );
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Check if the API server is reachable.
571
+ *
572
+ * @returns {Promise<boolean>} true if server is reachable
573
+ */
574
+ async isOnline() {
575
+ try {
576
+ const url = new URL('/health', this.baseUrl);
577
+ const isHttps = url.protocol === 'https:';
578
+ const client = isHttps ? https : http;
579
+
580
+ return new Promise((resolve) => {
581
+ const req = client.request(
582
+ {
583
+ method: 'HEAD',
584
+ hostname: url.hostname,
585
+ port: url.port || (isHttps ? 443 : 80),
586
+ path: url.pathname,
587
+ timeout: 3000, // Quick check
588
+ },
589
+ (res) => {
590
+ resolve(res.statusCode < 500);
591
+ },
592
+ );
593
+
594
+ req.on('timeout', () => {
595
+ req.destroy();
596
+ resolve(false);
597
+ });
598
+
599
+ req.on('error', () => {
600
+ resolve(false);
601
+ });
602
+
603
+ req.end();
604
+ });
605
+ } catch {
606
+ return false;
607
+ }
608
+ }
609
+ }
610
+
611
+ // Singleton instance
612
+ const licenseApi = new LicenseApiClient();
613
+
614
+ module.exports = {
615
+ LicenseApiClient,
616
+ licenseApi,
617
+ };