crewly 1.11.4 → 1.11.6
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/backend/backend/src/constants.d.ts +22 -1
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +22 -1
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts +90 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.js +309 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.js +134 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts +78 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.js +358 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup.types.d.ts +163 -0
- package/dist/backend/backend/src/services/backup/backup.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup.types.js +13 -0
- package/dist/backend/backend/src/services/backup/backup.types.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts +29 -2
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.js +97 -13
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +22 -1
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +22 -1
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts +70 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts.map +1 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js +427 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts +90 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.js +309 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.js +134 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts +78 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.js +358 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup.types.d.ts +163 -0
- package/dist/cli/backend/src/services/backup/backup.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup.types.js +13 -0
- package/dist/cli/backend/src/services/backup/backup.types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts +410 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js +863 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts +292 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.js +1093 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +328 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js +171 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts +89 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.js +148 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.js.map +1 -0
- package/dist/cli/backend/src/services/user/user-identity.service.d.ts +86 -0
- package/dist/cli/backend/src/services/user/user-identity.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/user/user-identity.service.js +190 -0
- package/dist/cli/backend/src/services/user/user-identity.service.js.map +1 -0
- package/dist/cli/cli/src/commands/backup.d.ts +31 -0
- package/dist/cli/cli/src/commands/backup.d.ts.map +1 -0
- package/dist/cli/cli/src/commands/backup.js +280 -0
- package/dist/cli/cli/src/commands/backup.js.map +1 -0
- package/dist/cli/cli/src/index.js +10 -0
- package/dist/cli/cli/src/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud Client Service
|
|
3
|
+
*
|
|
4
|
+
* Singleton service responsible for all interactions with CrewlyAI Cloud.
|
|
5
|
+
* Handles authentication, premium template fetching, and subscription
|
|
6
|
+
* status synchronization.
|
|
7
|
+
*
|
|
8
|
+
* Premium templates are loaded into memory only — never written to disk
|
|
9
|
+
* to prevent IP leakage.
|
|
10
|
+
*
|
|
11
|
+
* @module services/cloud/cloud-client.service
|
|
12
|
+
*/
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
|
16
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
17
|
+
import { CLOUD_CONSTANTS, AUTH_CONSTANTS, } from '../../constants.js';
|
|
18
|
+
/**
|
|
19
|
+
* Decode the `exp` (expiry, seconds since epoch) claim from a JWT without
|
|
20
|
+
* verifying its signature. Returns null when the token is malformed or has
|
|
21
|
+
* no `exp`. Used to schedule proactive relay-token refresh before expiry so
|
|
22
|
+
* a continuously-open relay socket never carries an expired token.
|
|
23
|
+
*
|
|
24
|
+
* @param token - JWT string (may be null)
|
|
25
|
+
* @returns The `exp` claim in seconds, or null when unavailable
|
|
26
|
+
*/
|
|
27
|
+
export function decodeJwtExp(token) {
|
|
28
|
+
if (!token)
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const parts = token.split('.');
|
|
32
|
+
if (parts.length !== 3)
|
|
33
|
+
return null;
|
|
34
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
35
|
+
return typeof payload.exp === 'number' ? payload.exp : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Service
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* CloudClientService singleton.
|
|
46
|
+
*
|
|
47
|
+
* Manages the lifecycle of the connection between a local Crewly instance
|
|
48
|
+
* and CrewlyAI Cloud. All cloud API calls are made via native fetch with
|
|
49
|
+
* bearer-token authentication.
|
|
50
|
+
*/
|
|
51
|
+
export class CloudClientService {
|
|
52
|
+
static instance = null;
|
|
53
|
+
logger;
|
|
54
|
+
/** Cloud API base URL (e.g. "https://api.crewlyai.com") */
|
|
55
|
+
cloudUrl = null;
|
|
56
|
+
/** Bearer token obtained during connect() */
|
|
57
|
+
token = null;
|
|
58
|
+
/** Refresh token for auto-renewing expired access tokens */
|
|
59
|
+
refreshToken = null;
|
|
60
|
+
/** Current connection status */
|
|
61
|
+
connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.DISCONNECTED;
|
|
62
|
+
/** Subscription tier reported by cloud */
|
|
63
|
+
tier = CLOUD_CONSTANTS.TIERS.FREE;
|
|
64
|
+
/** Timestamp of the most recent successful cloud API call */
|
|
65
|
+
lastSyncAt = null;
|
|
66
|
+
/** Timer for proactive token refresh (fires 5 min before expiry) */
|
|
67
|
+
refreshTimer = null;
|
|
68
|
+
/** Guard to prevent concurrent refresh attempts */
|
|
69
|
+
refreshInProgress = false;
|
|
70
|
+
/** Callbacks invoked when the access token is refreshed */
|
|
71
|
+
tokenRefreshCallbacks = [];
|
|
72
|
+
/**
|
|
73
|
+
* Relay-signed access JWT used by BrowserProxyService to register with the
|
|
74
|
+
* Cloud Relay as role 'agent'. First-class, stored credential — distinct
|
|
75
|
+
* from the access `token` and decoupled from socket lifetime. NEVER the
|
|
76
|
+
* Google/Supabase access token (RELAY-TOKEN-TYPE invariant).
|
|
77
|
+
*/
|
|
78
|
+
relayToken = null;
|
|
79
|
+
/** Parsed `exp` (seconds) of {@link relayToken}, for proactive refresh. */
|
|
80
|
+
relayTokenExp = null;
|
|
81
|
+
/** Timer for proactive relay-token refresh (fires SKEW before exp). */
|
|
82
|
+
relayRefreshTimer = null;
|
|
83
|
+
/** Callbacks invoked when the relay token is refreshed (distinct channel). */
|
|
84
|
+
relayTokenRefreshCallbacks = [];
|
|
85
|
+
constructor() {
|
|
86
|
+
this.logger = LoggerService.getInstance().createComponentLogger('CloudClientService');
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get the singleton instance.
|
|
90
|
+
*
|
|
91
|
+
* @returns CloudClientService instance
|
|
92
|
+
*/
|
|
93
|
+
static getInstance() {
|
|
94
|
+
if (!CloudClientService.instance) {
|
|
95
|
+
CloudClientService.instance = new CloudClientService();
|
|
96
|
+
}
|
|
97
|
+
return CloudClientService.instance;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Reset the singleton (for testing).
|
|
101
|
+
*/
|
|
102
|
+
static resetInstance() {
|
|
103
|
+
CloudClientService.instance = null;
|
|
104
|
+
}
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
// Public API
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
/**
|
|
109
|
+
* Connect to CrewlyAI Cloud by verifying the provided token.
|
|
110
|
+
*
|
|
111
|
+
* Calls the cloud auth endpoint to validate credentials and retrieve
|
|
112
|
+
* the subscription tier. On success the service transitions to
|
|
113
|
+
* "connected" status.
|
|
114
|
+
*
|
|
115
|
+
* @param cloudUrl - Base URL of the CrewlyAI Cloud API
|
|
116
|
+
* @param token - Authentication token (API key or JWT)
|
|
117
|
+
* @returns Object with connection result
|
|
118
|
+
* @throws Error when the auth request fails or token is invalid
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* const client = CloudClientService.getInstance();
|
|
123
|
+
* await client.connect('https://api.crewlyai.com', 'sk-abc123');
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
async connect(cloudUrl, token, refreshToken) {
|
|
127
|
+
this.logger.info('Connecting to CrewlyAI Cloud', { cloudUrl });
|
|
128
|
+
const url = `${cloudUrl}${CLOUD_CONSTANTS.ENDPOINTS.AUTH_TOKEN}`;
|
|
129
|
+
const response = await fetch(url, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
Authorization: `Bearer ${token}`,
|
|
134
|
+
},
|
|
135
|
+
signal: AbortSignal.timeout(CLOUD_CONSTANTS.TIMEOUTS.CONNECT),
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
this.connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.ERROR;
|
|
139
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
140
|
+
this.logger.error('Cloud connection failed', { status: response.status, errorText });
|
|
141
|
+
throw new Error(`Cloud authentication failed: ${response.status} ${errorText}`);
|
|
142
|
+
}
|
|
143
|
+
// crewly-auth /api/cloud/validate returns { success, data: { plan, relayToken, ... } }
|
|
144
|
+
// Also accept legacy { tier } format for forward compatibility
|
|
145
|
+
const data = (await response.json());
|
|
146
|
+
this.logger.debug('Cloud validate response', { hasData: !!data.data, hasPlan: !!data.data?.plan, hasRelayToken: !!data.data?.relayToken, dataKeys: data.data ? Object.keys(data.data) : [] });
|
|
147
|
+
const resolvedTier = data.data?.plan || data.tier;
|
|
148
|
+
this.cloudUrl = cloudUrl;
|
|
149
|
+
this.token = token;
|
|
150
|
+
this.refreshToken = refreshToken || this.refreshToken;
|
|
151
|
+
this.tier = resolvedTier || CLOUD_CONSTANTS.TIERS.FREE;
|
|
152
|
+
this.connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.CONNECTED;
|
|
153
|
+
this.lastSyncAt = new Date().toISOString();
|
|
154
|
+
// Use relay token from Cloud validate response for BrowserProxyService.
|
|
155
|
+
// Store it as a first-class credential (not a transient callback arg) so
|
|
156
|
+
// it can be persisted, refreshed proactively, and re-fed on restart.
|
|
157
|
+
if (data.data?.relayToken) {
|
|
158
|
+
this.logger.info('Relay token received from Cloud');
|
|
159
|
+
this.storeRelayToken(data.data.relayToken);
|
|
160
|
+
}
|
|
161
|
+
this.logger.info('Connected to CrewlyAI Cloud', { tier: this.tier, hasRelayToken: !!data.data?.relayToken });
|
|
162
|
+
// Schedule proactive token refresh
|
|
163
|
+
this.scheduleTokenRefresh(token);
|
|
164
|
+
// Persist credentials for auto-reconnect on restart
|
|
165
|
+
this.persistConfig().catch((err) => {
|
|
166
|
+
this.logger.warn('Failed to persist cloud config (non-fatal)', {
|
|
167
|
+
error: err instanceof Error ? err.message : String(err),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
return { success: true, tier: this.tier };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Connect using a locally verified JWT (no remote API call needed).
|
|
174
|
+
*
|
|
175
|
+
* Used when the JWT was issued by this same instance or the JWT secret
|
|
176
|
+
* is shared between OSS and Cloud. Avoids the round-trip to the cloud
|
|
177
|
+
* auth endpoint.
|
|
178
|
+
*
|
|
179
|
+
* @param cloudUrl - Base URL of the CrewlyAI Cloud API
|
|
180
|
+
* @param token - JWT access token (already verified locally)
|
|
181
|
+
* @param tier - Subscription tier extracted from the JWT payload
|
|
182
|
+
* @param refreshToken - Optional refresh token for auto-renewal
|
|
183
|
+
*/
|
|
184
|
+
connectLocal(cloudUrl, token, tier, refreshToken) {
|
|
185
|
+
this.cloudUrl = cloudUrl;
|
|
186
|
+
this.token = token;
|
|
187
|
+
this.refreshToken = refreshToken || this.refreshToken;
|
|
188
|
+
this.tier = tier || CLOUD_CONSTANTS.TIERS.FREE;
|
|
189
|
+
this.connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.CONNECTED;
|
|
190
|
+
this.lastSyncAt = new Date().toISOString();
|
|
191
|
+
this.logger.info('Connected to CrewlyAI Cloud (local verification)', { tier: this.tier });
|
|
192
|
+
// Schedule proactive token refresh
|
|
193
|
+
this.scheduleTokenRefresh(token);
|
|
194
|
+
// Persist credentials for auto-reconnect on restart
|
|
195
|
+
this.persistConfig().catch((err) => {
|
|
196
|
+
this.logger.warn('Failed to persist cloud config (non-fatal)', {
|
|
197
|
+
error: err instanceof Error ? err.message : String(err),
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
// Fetch relay token asynchronously (non-blocking)
|
|
201
|
+
// connectLocal skips Cloud API, but relay token requires Cloud-signed JWT
|
|
202
|
+
this.fetchRelayTokenFromValidate().catch((err) => {
|
|
203
|
+
this.logger.debug('Relay token fetch after connectLocal failed (non-fatal)', {
|
|
204
|
+
error: err instanceof Error ? err.message : String(err),
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Disconnect from CrewlyAI Cloud.
|
|
210
|
+
*
|
|
211
|
+
* Clears stored credentials and resets the connection state.
|
|
212
|
+
*/
|
|
213
|
+
disconnect() {
|
|
214
|
+
this.logger.info('Disconnecting from CrewlyAI Cloud');
|
|
215
|
+
this.cloudUrl = null;
|
|
216
|
+
this.token = null;
|
|
217
|
+
this.refreshToken = null;
|
|
218
|
+
this.connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.DISCONNECTED;
|
|
219
|
+
this.tier = CLOUD_CONSTANTS.TIERS.FREE;
|
|
220
|
+
this.lastSyncAt = null;
|
|
221
|
+
this.relayToken = null;
|
|
222
|
+
this.relayTokenExp = null;
|
|
223
|
+
if (this.refreshTimer) {
|
|
224
|
+
clearTimeout(this.refreshTimer);
|
|
225
|
+
this.refreshTimer = null;
|
|
226
|
+
}
|
|
227
|
+
if (this.relayRefreshTimer) {
|
|
228
|
+
clearTimeout(this.relayRefreshTimer);
|
|
229
|
+
this.relayRefreshTimer = null;
|
|
230
|
+
}
|
|
231
|
+
// Remove persisted config
|
|
232
|
+
this.removePersistedConfig().catch((err) => {
|
|
233
|
+
this.logger.warn('Failed to remove persisted cloud config (non-fatal)', {
|
|
234
|
+
error: err instanceof Error ? err.message : String(err),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
// Config Persistence
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
/**
|
|
242
|
+
* Path to the persisted cloud config file.
|
|
243
|
+
*/
|
|
244
|
+
static getConfigPath() {
|
|
245
|
+
return path.join(os.homedir(), '.crewly', 'cloud', 'config.json');
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Load persisted cloud config from disk.
|
|
249
|
+
* Returns null if file doesn't exist or is invalid.
|
|
250
|
+
*/
|
|
251
|
+
async loadPersistedConfig() {
|
|
252
|
+
try {
|
|
253
|
+
const data = await readFile(CloudClientService.getConfigPath(), 'utf-8');
|
|
254
|
+
const config = JSON.parse(data);
|
|
255
|
+
if (config.cloudUrl && config.token && config.tier) {
|
|
256
|
+
// Restore the persisted relay token immediately so BrowserProxy can
|
|
257
|
+
// re-register on restart without waiting for a fresh validate
|
|
258
|
+
// round-trip. fetchRelayTokenFromValidate (kicked off by connectLocal)
|
|
259
|
+
// will refresh it shortly after. Do not broadcast here — connect/
|
|
260
|
+
// connectLocal own the wiring of subscribers; this just primes state.
|
|
261
|
+
if (config.relayToken) {
|
|
262
|
+
this.relayToken = config.relayToken;
|
|
263
|
+
this.relayTokenExp = decodeJwtExp(config.relayToken);
|
|
264
|
+
this.scheduleRelayTokenRefresh();
|
|
265
|
+
}
|
|
266
|
+
return config;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Persist current cloud connection config to disk for auto-reconnect.
|
|
276
|
+
*/
|
|
277
|
+
async persistConfig() {
|
|
278
|
+
if (!this.cloudUrl || !this.token)
|
|
279
|
+
return;
|
|
280
|
+
const config = {
|
|
281
|
+
cloudUrl: this.cloudUrl,
|
|
282
|
+
token: this.token,
|
|
283
|
+
tier: this.tier,
|
|
284
|
+
connectedAt: new Date().toISOString(),
|
|
285
|
+
...(this.refreshToken && { refreshToken: this.refreshToken }),
|
|
286
|
+
...(this.relayToken && { relayToken: this.relayToken }),
|
|
287
|
+
};
|
|
288
|
+
const configPath = CloudClientService.getConfigPath();
|
|
289
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
290
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
291
|
+
this.logger.debug('Persisted cloud config to disk');
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Remove persisted cloud config from disk (on disconnect).
|
|
295
|
+
*/
|
|
296
|
+
async removePersistedConfig() {
|
|
297
|
+
try {
|
|
298
|
+
await unlink(CloudClientService.getConfigPath());
|
|
299
|
+
this.logger.debug('Removed persisted cloud config');
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// File may not exist — not an error
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
// Template Fetching
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
/**
|
|
309
|
+
* Fetch the list of premium templates available on CrewlyAI Cloud.
|
|
310
|
+
*
|
|
311
|
+
* Requires an active cloud connection.
|
|
312
|
+
*
|
|
313
|
+
* @returns Array of template summaries
|
|
314
|
+
* @throws Error when not connected or fetch fails
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```ts
|
|
318
|
+
* const templates = await client.getTemplates();
|
|
319
|
+
* console.log(templates.map(t => t.name));
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
async getTemplates() {
|
|
323
|
+
// If token is already known to be expired, return empty without hitting the API
|
|
324
|
+
if (this.isTokenExpired())
|
|
325
|
+
return [];
|
|
326
|
+
this.ensureConnected();
|
|
327
|
+
const url = `${this.cloudUrl}${CLOUD_CONSTANTS.ENDPOINTS.TEMPLATES}`;
|
|
328
|
+
const response = await fetch(url, {
|
|
329
|
+
method: 'GET',
|
|
330
|
+
headers: this.authHeaders(),
|
|
331
|
+
signal: AbortSignal.timeout(CLOUD_CONSTANTS.TIMEOUTS.FETCH_TEMPLATES),
|
|
332
|
+
});
|
|
333
|
+
if (!response.ok) {
|
|
334
|
+
// 401/403 means token expired or revoked — return empty list + update status
|
|
335
|
+
if (this.isAuthError(response.status)) {
|
|
336
|
+
this.handleAuthFailure('getTemplates', response.status);
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
this.logger.error('Failed to fetch templates', { status: response.status });
|
|
340
|
+
throw new Error(`Failed to fetch templates: ${response.status}`);
|
|
341
|
+
}
|
|
342
|
+
const data = (await response.json());
|
|
343
|
+
this.lastSyncAt = new Date().toISOString();
|
|
344
|
+
return data.templates || [];
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Fetch full detail for a single premium template.
|
|
348
|
+
*
|
|
349
|
+
* The returned data is held in memory only and must never be
|
|
350
|
+
* persisted to disk.
|
|
351
|
+
*
|
|
352
|
+
* @param id - Template identifier
|
|
353
|
+
* @returns Template detail object
|
|
354
|
+
* @throws Error when not connected, template not found, or fetch fails
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* ```ts
|
|
358
|
+
* const detail = await client.getTemplateDetail('tpl-tiktok-ops');
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
async getTemplateDetail(id) {
|
|
362
|
+
// If token is already known to be expired, throw user-friendly error
|
|
363
|
+
if (this.isTokenExpired()) {
|
|
364
|
+
throw new Error('Cloud token expired. Please reconnect to CrewlyAI Cloud.');
|
|
365
|
+
}
|
|
366
|
+
this.ensureConnected();
|
|
367
|
+
const endpoint = CLOUD_CONSTANTS.ENDPOINTS.TEMPLATE_DETAIL.replace(':id', id);
|
|
368
|
+
const url = `${this.cloudUrl}${endpoint}`;
|
|
369
|
+
const response = await fetch(url, {
|
|
370
|
+
method: 'GET',
|
|
371
|
+
headers: this.authHeaders(),
|
|
372
|
+
signal: AbortSignal.timeout(CLOUD_CONSTANTS.TIMEOUTS.FETCH_TEMPLATE_DETAIL),
|
|
373
|
+
});
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
// 401/403 means token expired or revoked
|
|
376
|
+
if (this.isAuthError(response.status)) {
|
|
377
|
+
this.handleAuthFailure('getTemplateDetail', response.status);
|
|
378
|
+
throw new Error('Cloud token expired. Please reconnect to CrewlyAI Cloud.');
|
|
379
|
+
}
|
|
380
|
+
if (response.status === 404) {
|
|
381
|
+
throw new Error(`Template not found: ${id}`);
|
|
382
|
+
}
|
|
383
|
+
this.logger.error('Failed to fetch template detail', { id, status: response.status });
|
|
384
|
+
throw new Error(`Failed to fetch template detail: ${response.status}`);
|
|
385
|
+
}
|
|
386
|
+
const data = (await response.json());
|
|
387
|
+
this.lastSyncAt = new Date().toISOString();
|
|
388
|
+
return data;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Get the stored cloud API base URL (set during connect).
|
|
392
|
+
*
|
|
393
|
+
* @returns Cloud URL or null if not connected
|
|
394
|
+
*/
|
|
395
|
+
getCloudUrl() {
|
|
396
|
+
return this.cloudUrl;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Get the current cloud connection status and subscription tier.
|
|
400
|
+
*
|
|
401
|
+
* @returns Current status snapshot
|
|
402
|
+
*/
|
|
403
|
+
getStatus() {
|
|
404
|
+
return {
|
|
405
|
+
connectionStatus: this.connectionStatus,
|
|
406
|
+
cloudUrl: this.cloudUrl,
|
|
407
|
+
tier: this.tier,
|
|
408
|
+
lastSyncAt: this.lastSyncAt,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Check whether the client is currently connected to cloud.
|
|
413
|
+
*
|
|
414
|
+
* @returns true if connected
|
|
415
|
+
*/
|
|
416
|
+
isConnected() {
|
|
417
|
+
return this.connectionStatus === CLOUD_CONSTANTS.CONNECTION_STATUS.CONNECTED;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Check whether the cloud token has expired (401/403 from cloud API).
|
|
421
|
+
*
|
|
422
|
+
* @returns true if the token has expired
|
|
423
|
+
*/
|
|
424
|
+
isTokenExpired() {
|
|
425
|
+
return this.connectionStatus === CLOUD_CONSTANTS.CONNECTION_STATUS.TOKEN_EXPIRED;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Get the current subscription tier.
|
|
429
|
+
*
|
|
430
|
+
* @returns Current tier value
|
|
431
|
+
*/
|
|
432
|
+
getTier() {
|
|
433
|
+
return this.tier;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Fetch the list of devices registered to this user from Cloud.
|
|
437
|
+
*
|
|
438
|
+
* Proxies GET /api/v1/relay/devices on crewlyai.com and returns
|
|
439
|
+
* the device list for the authenticated user.
|
|
440
|
+
*
|
|
441
|
+
* @returns Array of cloud relay devices
|
|
442
|
+
* @throws Error when not connected or fetch fails
|
|
443
|
+
*/
|
|
444
|
+
async fetchCloudDevices() {
|
|
445
|
+
// If token is already known to be expired, return empty without hitting the API
|
|
446
|
+
if (this.isTokenExpired())
|
|
447
|
+
return [];
|
|
448
|
+
this.ensureConnected();
|
|
449
|
+
const url = `${this.cloudUrl}/api/v1/relay/devices`;
|
|
450
|
+
const response = await fetch(url, {
|
|
451
|
+
method: 'GET',
|
|
452
|
+
headers: this.authHeaders(),
|
|
453
|
+
signal: AbortSignal.timeout(CLOUD_CONSTANTS.TIMEOUTS.FETCH_TEMPLATES),
|
|
454
|
+
});
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
// 401/403 means token expired or revoked — return empty list + update status
|
|
457
|
+
if (this.isAuthError(response.status)) {
|
|
458
|
+
this.handleAuthFailure('fetchCloudDevices', response.status);
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
// 404 means the cloud devices endpoint is not yet available — return empty list gracefully
|
|
462
|
+
if (response.status === 404) {
|
|
463
|
+
this.logger.warn('Cloud devices endpoint not available (404), returning empty list');
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
this.logger.error('Failed to fetch cloud devices', { status: response.status });
|
|
467
|
+
throw new Error(`Failed to fetch cloud devices: ${response.status}`);
|
|
468
|
+
}
|
|
469
|
+
const data = (await response.json());
|
|
470
|
+
if (!data.success) {
|
|
471
|
+
throw new Error('Cloud devices API returned unsuccessful response');
|
|
472
|
+
}
|
|
473
|
+
this.lastSyncAt = new Date().toISOString();
|
|
474
|
+
return data.devices ?? [];
|
|
475
|
+
}
|
|
476
|
+
// -------------------------------------------------------------------------
|
|
477
|
+
// Token Auto-Refresh
|
|
478
|
+
// -------------------------------------------------------------------------
|
|
479
|
+
/**
|
|
480
|
+
* Set the refresh token for auto-renewal.
|
|
481
|
+
*
|
|
482
|
+
* Called by the connect controller after OAuth login provides a refresh token.
|
|
483
|
+
*
|
|
484
|
+
* @param refreshToken - Refresh token string
|
|
485
|
+
*/
|
|
486
|
+
setRefreshToken(refreshToken) {
|
|
487
|
+
this.refreshToken = refreshToken;
|
|
488
|
+
// Re-persist config with the new refresh token
|
|
489
|
+
this.persistConfig().catch(() => { });
|
|
490
|
+
this.logger.debug('Refresh token stored');
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Get the current access token (for CloudSyncService token updates).
|
|
494
|
+
*
|
|
495
|
+
* @returns Current access token or null
|
|
496
|
+
*/
|
|
497
|
+
getToken() {
|
|
498
|
+
return this.token;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Register a callback to be invoked whenever the access token is refreshed.
|
|
502
|
+
* Used by BrowserProxyService to update its relay auth token in real-time.
|
|
503
|
+
*
|
|
504
|
+
* @param callback - Function receiving the new access token string
|
|
505
|
+
*/
|
|
506
|
+
onTokenRefresh(callback) {
|
|
507
|
+
this.tokenRefreshCallbacks.push(callback);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Get the current relay-signed token (for BrowserProxyService registration).
|
|
511
|
+
*
|
|
512
|
+
* This is DISTINCT from {@link getToken} (the access/HTTP token). The
|
|
513
|
+
* BrowserProxy must register with the relay token, never the access token —
|
|
514
|
+
* see the RELAY-TOKEN-TYPE invariant. Returns null until a relay token has
|
|
515
|
+
* been minted by the Cloud validate/refresh endpoint.
|
|
516
|
+
*
|
|
517
|
+
* @returns Current relay token or null
|
|
518
|
+
*/
|
|
519
|
+
getRelayToken() {
|
|
520
|
+
return this.relayToken;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Register a callback invoked whenever the relay token is refreshed.
|
|
524
|
+
*
|
|
525
|
+
* Distinct subscription channel from {@link onTokenRefresh}: subscribers
|
|
526
|
+
* here (BrowserProxyService) receive the relay-signed token, never the
|
|
527
|
+
* access token. Decoupling the channels prevents the proxy from ever being
|
|
528
|
+
* fed the wrong token type on refresh.
|
|
529
|
+
*
|
|
530
|
+
* @param callback - Function receiving the new relay token string
|
|
531
|
+
*/
|
|
532
|
+
onRelayTokenRefresh(callback) {
|
|
533
|
+
this.relayTokenRefreshCallbacks.push(callback);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Store a freshly-minted relay token as a first-class credential, parse its
|
|
537
|
+
* expiry, schedule proactive refresh, persist it, and notify subscribers.
|
|
538
|
+
*
|
|
539
|
+
* Centralizes relay-token handling so every code path that obtains one
|
|
540
|
+
* (connect, refresh, validate) treats it identically. Persistence is
|
|
541
|
+
* fire-and-forget; a persist failure must not break the refresh chain.
|
|
542
|
+
*
|
|
543
|
+
* @param relayToken - The relay-signed access JWT from Cloud
|
|
544
|
+
*/
|
|
545
|
+
storeRelayToken(relayToken) {
|
|
546
|
+
this.relayToken = relayToken;
|
|
547
|
+
this.relayTokenExp = decodeJwtExp(relayToken);
|
|
548
|
+
// Schedule proactive refresh before this relay token's real exp so a
|
|
549
|
+
// continuously-open relay socket never carries an expired token.
|
|
550
|
+
this.scheduleRelayTokenRefresh();
|
|
551
|
+
// Persist alongside the access token for restart resilience.
|
|
552
|
+
this.persistConfig().catch(() => { });
|
|
553
|
+
// Notify subscribers (BrowserProxyService) on the relay-token channel.
|
|
554
|
+
for (const callback of this.relayTokenRefreshCallbacks) {
|
|
555
|
+
try {
|
|
556
|
+
callback(relayToken);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
// Non-fatal — one bad callback must not break the refresh chain.
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Attempt to refresh the access token using the stored refresh token.
|
|
565
|
+
*
|
|
566
|
+
* Issues a new access token locally (since both access and refresh tokens
|
|
567
|
+
* are signed with the same HMAC secret on this OSS instance).
|
|
568
|
+
*
|
|
569
|
+
* @returns true if refresh succeeded, false if no refresh token or refresh failed
|
|
570
|
+
*/
|
|
571
|
+
async tryRefreshToken() {
|
|
572
|
+
if (this.refreshInProgress) {
|
|
573
|
+
this.logger.debug('Token refresh already in progress, skipping');
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
if (!this.refreshToken) {
|
|
577
|
+
this.logger.debug('No refresh token available for auto-refresh');
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
this.refreshInProgress = true;
|
|
581
|
+
try {
|
|
582
|
+
// Try Cloud-side refresh first (gets a token signed with the Cloud's secret,
|
|
583
|
+
// which is accepted by all Cloud services without needing CREWLY_JWT_SECRET locally).
|
|
584
|
+
const cloudUrl = this.cloudUrl || CLOUD_CONSTANTS.DEFAULT_CLOUD_URL;
|
|
585
|
+
const cloudRefreshUrl = `${cloudUrl}/api/cloud/refresh`;
|
|
586
|
+
let newAccessToken = null;
|
|
587
|
+
let relayToken = null;
|
|
588
|
+
try {
|
|
589
|
+
const response = await fetch(cloudRefreshUrl, {
|
|
590
|
+
method: 'POST',
|
|
591
|
+
headers: { 'Content-Type': 'application/json' },
|
|
592
|
+
body: JSON.stringify({ refreshToken: this.refreshToken }),
|
|
593
|
+
signal: AbortSignal.timeout(10_000),
|
|
594
|
+
});
|
|
595
|
+
if (response.ok) {
|
|
596
|
+
const data = await response.json();
|
|
597
|
+
if (data.success && data.accessToken) {
|
|
598
|
+
newAccessToken = data.accessToken;
|
|
599
|
+
relayToken = data.relayToken ?? null;
|
|
600
|
+
this.logger.info('Token refreshed via Cloud auth service');
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
this.logger.debug('Cloud refresh unavailable, falling back to local refresh');
|
|
606
|
+
}
|
|
607
|
+
// Fallback: local refresh (requires CREWLY_JWT_SECRET to match Cloud)
|
|
608
|
+
// NOTE: locally-signed tokens may not be accepted by Cloud relay if the
|
|
609
|
+
// JWT secret doesn't match. If Cloud refresh failed, warn the user.
|
|
610
|
+
if (!newAccessToken) {
|
|
611
|
+
this.logger.warn('Cloud token refresh failed — locally-signed token may not work with Cloud relay. Re-login with `crewly cloud login` to get a Cloud-signed refresh token.');
|
|
612
|
+
const { verifyJwt, signJwt } = await import('../../controllers/cloud/cloud-google-auth.controller.js');
|
|
613
|
+
const payload = verifyJwt(this.refreshToken);
|
|
614
|
+
if (!payload || payload.type !== 'refresh') {
|
|
615
|
+
this.logger.warn('Refresh token verification failed — cannot auto-refresh. Please re-login with `crewly cloud login`.');
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
let deviceId;
|
|
619
|
+
try {
|
|
620
|
+
const { DeviceIdentityService } = await import('./device-identity.service.js');
|
|
621
|
+
const identity = await DeviceIdentityService.getInstance().getOrCreateIdentity();
|
|
622
|
+
deviceId = identity.deviceId;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
deviceId = payload.deviceId;
|
|
626
|
+
}
|
|
627
|
+
const now = Math.floor(Date.now() / 1000);
|
|
628
|
+
newAccessToken = signJwt({
|
|
629
|
+
sub: payload.sub,
|
|
630
|
+
email: payload.email || '',
|
|
631
|
+
name: payload.name || '',
|
|
632
|
+
plan: payload.plan || 'free',
|
|
633
|
+
...(deviceId && { deviceId }),
|
|
634
|
+
iat: now,
|
|
635
|
+
exp: now + AUTH_CONSTANTS.JWT.ACCESS_TOKEN_EXPIRY_S,
|
|
636
|
+
iss: AUTH_CONSTANTS.JWT.ISSUER,
|
|
637
|
+
type: 'access',
|
|
638
|
+
});
|
|
639
|
+
this.logger.info('Token refreshed locally (fallback)');
|
|
640
|
+
}
|
|
641
|
+
// Update service state
|
|
642
|
+
this.token = newAccessToken;
|
|
643
|
+
this.connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.CONNECTED;
|
|
644
|
+
this.lastSyncAt = new Date().toISOString();
|
|
645
|
+
// Schedule next refresh
|
|
646
|
+
this.scheduleTokenRefresh(newAccessToken);
|
|
647
|
+
// Persist new token
|
|
648
|
+
await this.persistConfig();
|
|
649
|
+
// Update CloudSyncService with the new token (if running)
|
|
650
|
+
try {
|
|
651
|
+
const { CloudSyncService } = await import('./cloud-sync.service.js');
|
|
652
|
+
const syncService = CloudSyncService.getInstance();
|
|
653
|
+
if (syncService.isStarted()) {
|
|
654
|
+
syncService.updateToken(newAccessToken);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// CloudSyncService may not be available — non-fatal
|
|
659
|
+
}
|
|
660
|
+
// Notify access-token refresh subscribers with the ACCESS token only.
|
|
661
|
+
// The relay token has its own channel (storeRelayToken below) — never
|
|
662
|
+
// substitute the access token for the relay token (RELAY-TOKEN-TYPE
|
|
663
|
+
// invariant). Subscribers on this channel that need the relay token
|
|
664
|
+
// should subscribe via onRelayTokenRefresh instead.
|
|
665
|
+
for (const callback of this.tokenRefreshCallbacks) {
|
|
666
|
+
try {
|
|
667
|
+
callback(newAccessToken);
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
// Non-fatal — don't let one bad callback break the refresh chain
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Store the relay token as a first-class credential when the refresh
|
|
674
|
+
// returned one. If none was returned, keep the existing stored relay
|
|
675
|
+
// token rather than clobbering it with the access token — a continuing
|
|
676
|
+
// socket keeps working on its still-valid relay token until its own
|
|
677
|
+
// proactive refresh fires.
|
|
678
|
+
if (relayToken) {
|
|
679
|
+
this.storeRelayToken(relayToken);
|
|
680
|
+
}
|
|
681
|
+
this.logger.info('Access token auto-refreshed successfully', { hasRelayToken: !!relayToken });
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
this.logger.warn('Token auto-refresh failed', {
|
|
686
|
+
error: error instanceof Error ? error.message : String(error),
|
|
687
|
+
});
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
this.refreshInProgress = false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Schedule a proactive token refresh 5 minutes before expiry.
|
|
696
|
+
*
|
|
697
|
+
* Parses the JWT `exp` claim and sets a timer. If the token has
|
|
698
|
+
* less than 5 minutes remaining, refreshes immediately.
|
|
699
|
+
*
|
|
700
|
+
* @param token - JWT access token to extract expiry from
|
|
701
|
+
*/
|
|
702
|
+
scheduleTokenRefresh(token) {
|
|
703
|
+
if (this.refreshTimer) {
|
|
704
|
+
clearTimeout(this.refreshTimer);
|
|
705
|
+
this.refreshTimer = null;
|
|
706
|
+
}
|
|
707
|
+
if (!this.refreshToken)
|
|
708
|
+
return;
|
|
709
|
+
try {
|
|
710
|
+
// Decode JWT payload to get exp (without verifying — just need the timestamp)
|
|
711
|
+
const parts = token.split('.');
|
|
712
|
+
if (parts.length !== 3)
|
|
713
|
+
return;
|
|
714
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
715
|
+
const exp = payload.exp;
|
|
716
|
+
if (!exp)
|
|
717
|
+
return;
|
|
718
|
+
const now = Math.floor(Date.now() / 1000);
|
|
719
|
+
// Refresh 5 minutes (300s) before expiry
|
|
720
|
+
const refreshAt = exp - 300;
|
|
721
|
+
const delayMs = Math.max(0, (refreshAt - now) * 1000);
|
|
722
|
+
if (delayMs <= 0) {
|
|
723
|
+
// Token already near expiry — refresh immediately
|
|
724
|
+
this.logger.info('Access token near expiry, refreshing immediately');
|
|
725
|
+
this.tryRefreshToken().catch(() => { });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this.refreshTimer = setTimeout(() => {
|
|
729
|
+
this.logger.info('Proactive token refresh triggered');
|
|
730
|
+
this.tryRefreshToken().catch(() => { });
|
|
731
|
+
}, delayMs);
|
|
732
|
+
// Unref so the timer doesn't keep the process alive
|
|
733
|
+
if (this.refreshTimer.unref)
|
|
734
|
+
this.refreshTimer.unref();
|
|
735
|
+
const minutesUntilRefresh = Math.round(delayMs / 60_000);
|
|
736
|
+
this.logger.debug('Token refresh scheduled', { minutesUntilRefresh });
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
this.logger.debug('Could not schedule token refresh (non-fatal)');
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Schedule a proactive relay-token refresh `RELAY_REFRESH_SKEW_S` seconds
|
|
744
|
+
* before the relay token's real `exp`.
|
|
745
|
+
*
|
|
746
|
+
* The relay token (~60min TTL) is decoupled from the access token, so it
|
|
747
|
+
* needs its own refresh schedule. When the timer fires it calls
|
|
748
|
+
* {@link fetchRelayTokenFromValidate}, which re-mints a relay token and
|
|
749
|
+
* stores it (re-arming this timer). This keeps a continuously-open relay
|
|
750
|
+
* socket from ever carrying an expired token — the DURABLE-CREDENTIAL
|
|
751
|
+
* invariant. Driven by the JWT `exp` claim, not a wall-clock heuristic.
|
|
752
|
+
*/
|
|
753
|
+
scheduleRelayTokenRefresh() {
|
|
754
|
+
if (this.relayRefreshTimer) {
|
|
755
|
+
clearTimeout(this.relayRefreshTimer);
|
|
756
|
+
this.relayRefreshTimer = null;
|
|
757
|
+
}
|
|
758
|
+
if (!this.relayTokenExp)
|
|
759
|
+
return;
|
|
760
|
+
const now = Math.floor(Date.now() / 1000);
|
|
761
|
+
// Refresh 5 minutes (300s) before expiry — same skew as the access token.
|
|
762
|
+
const refreshAt = this.relayTokenExp - 300;
|
|
763
|
+
const delayMs = Math.max(0, (refreshAt - now) * 1000);
|
|
764
|
+
if (delayMs <= 0) {
|
|
765
|
+
// Already near expiry — refresh immediately.
|
|
766
|
+
this.logger.info('Relay token near expiry, refreshing immediately');
|
|
767
|
+
this.fetchRelayTokenFromValidate().catch(() => { });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
this.relayRefreshTimer = setTimeout(() => {
|
|
771
|
+
this.logger.info('Proactive relay-token refresh triggered');
|
|
772
|
+
this.fetchRelayTokenFromValidate().catch(() => { });
|
|
773
|
+
}, delayMs);
|
|
774
|
+
// Unref so the timer doesn't keep the process alive.
|
|
775
|
+
if (this.relayRefreshTimer.unref)
|
|
776
|
+
this.relayRefreshTimer.unref();
|
|
777
|
+
const minutesUntilRefresh = Math.round(delayMs / 60_000);
|
|
778
|
+
this.logger.debug('Relay token refresh scheduled', { minutesUntilRefresh });
|
|
779
|
+
}
|
|
780
|
+
// -------------------------------------------------------------------------
|
|
781
|
+
// Private helpers
|
|
782
|
+
// -------------------------------------------------------------------------
|
|
783
|
+
/**
|
|
784
|
+
* Fetch relay token by calling Cloud validate endpoint.
|
|
785
|
+
* Used after connectLocal() to get a Cloud-signed relay JWT
|
|
786
|
+
* without blocking the connection flow.
|
|
787
|
+
*
|
|
788
|
+
* @returns Promise that resolves when relay token is fetched (or skipped)
|
|
789
|
+
*/
|
|
790
|
+
async fetchRelayTokenFromValidate() {
|
|
791
|
+
if (!this.token || !this.cloudUrl)
|
|
792
|
+
return;
|
|
793
|
+
const url = `${this.cloudUrl}${CLOUD_CONSTANTS.ENDPOINTS.AUTH_TOKEN}`;
|
|
794
|
+
const response = await fetch(url, {
|
|
795
|
+
method: 'POST',
|
|
796
|
+
headers: {
|
|
797
|
+
'Content-Type': 'application/json',
|
|
798
|
+
Authorization: `Bearer ${this.token}`,
|
|
799
|
+
},
|
|
800
|
+
signal: AbortSignal.timeout(10_000),
|
|
801
|
+
});
|
|
802
|
+
if (!response.ok) {
|
|
803
|
+
this.logger.debug('Relay token validate call failed', { status: response.status });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const data = await response.json();
|
|
807
|
+
if (data.data?.relayToken) {
|
|
808
|
+
this.logger.info('Relay token received from Cloud validate (after connectLocal)');
|
|
809
|
+
this.storeRelayToken(data.data.relayToken);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Throw if the client is not in a connected state.
|
|
814
|
+
*
|
|
815
|
+
* @throws Error when not connected
|
|
816
|
+
*/
|
|
817
|
+
ensureConnected() {
|
|
818
|
+
if (!this.isConnected() || !this.cloudUrl || !this.token) {
|
|
819
|
+
throw new Error('Not connected to CrewlyAI Cloud. Call connect() first.');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Build the standard authorization headers for cloud API requests.
|
|
824
|
+
*
|
|
825
|
+
* @returns Headers object with Authorization and Content-Type
|
|
826
|
+
*/
|
|
827
|
+
authHeaders() {
|
|
828
|
+
return {
|
|
829
|
+
Authorization: `Bearer ${this.token}`,
|
|
830
|
+
'Content-Type': 'application/json',
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Check if an HTTP status code indicates an authentication/authorization failure.
|
|
835
|
+
*
|
|
836
|
+
* @param status - HTTP status code
|
|
837
|
+
* @returns true if the status is 401 or 403
|
|
838
|
+
*/
|
|
839
|
+
isAuthError(status) {
|
|
840
|
+
return status === 401 || status === 403;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Handle a 401/403 response from the cloud API by transitioning
|
|
844
|
+
* the connection status to TOKEN_EXPIRED. This signals the frontend
|
|
845
|
+
* to show a reconnect prompt instead of a raw error.
|
|
846
|
+
*
|
|
847
|
+
* @param context - Description of the failed operation (for logging)
|
|
848
|
+
* @param status - HTTP status code from the cloud API
|
|
849
|
+
*/
|
|
850
|
+
handleAuthFailure(context, status) {
|
|
851
|
+
this.logger.warn(`Cloud token expired or revoked during ${context}`, { status });
|
|
852
|
+
this.connectionStatus = CLOUD_CONSTANTS.CONNECTION_STATUS.TOKEN_EXPIRED;
|
|
853
|
+
// Attempt auto-refresh in background if refresh token is available
|
|
854
|
+
if (this.refreshToken) {
|
|
855
|
+
this.tryRefreshToken().then((refreshed) => {
|
|
856
|
+
if (refreshed) {
|
|
857
|
+
this.logger.info(`Token auto-refreshed after ${context} auth failure`);
|
|
858
|
+
}
|
|
859
|
+
}).catch(() => { });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
//# sourceMappingURL=cloud-client.service.js.map
|