teleportation-cli 1.0.0 → 1.0.1

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,478 @@
1
+ /**
2
+ * Mech Vault Client for secure credential management
3
+ *
4
+ * Handles:
5
+ * - Storing/retrieving secrets for remote sessions
6
+ * - SSH key generation for remote machine access
7
+ * - Environment file management
8
+ * - Deployment credential bundling
9
+ */
10
+
11
+ import {
12
+ createVaultErrorFromResponse,
13
+ wrapNetworkError,
14
+ VaultRetryExhaustedError,
15
+ VaultValidationError,
16
+ SecretNotFoundError,
17
+ } from '../utils/vault-errors.js';
18
+
19
+ export class VaultClient {
20
+ constructor(config = {}) {
21
+ this.apiUrl = config.apiUrl || process.env.MECH_VAULT_URL || 'https://vault.mechdna.net/api';
22
+ this.apiKey = config.apiKey || process.env.MECH_API_KEY;
23
+ this.appId = config.appId || process.env.MECH_APP_ID;
24
+ this.maxRetries = config.maxRetries || 3;
25
+
26
+ // Support environment variable override for retry delays
27
+ const defaultRetries = process.env.VAULT_RETRY_DELAYS
28
+ ? process.env.VAULT_RETRY_DELAYS.split(',').map(Number)
29
+ : [1000, 2000, 4000]; // Exponential backoff
30
+ this.retryDelays = config.retryDelays || defaultRetries;
31
+
32
+ this.timeout = config.timeout || 30000; // 30 second default timeout
33
+ this.debug = config.debug ?? (process.env.VAULT_DEBUG === 'true');
34
+
35
+ if (!this.apiKey || !this.appId) {
36
+ throw new Error('MECH_API_KEY and MECH_APP_ID are required');
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Create common headers for all requests
42
+ */
43
+ _headers() {
44
+ return {
45
+ 'Content-Type': 'application/json',
46
+ 'X-API-Key': this.apiKey,
47
+ 'X-App-ID': this.appId,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Fetch with timeout support
53
+ * @private
54
+ * @param {string} url - URL to fetch
55
+ * @param {Object} options - Fetch options
56
+ * @returns {Promise<Response>} Fetch response
57
+ * @throws {Error} If request times out or fails
58
+ */
59
+ async _fetchWithTimeout(url, options = {}) {
60
+ const controller = new AbortController();
61
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
62
+
63
+ try {
64
+ const response = await fetch(url, {
65
+ ...options,
66
+ signal: controller.signal,
67
+ });
68
+ clearTimeout(timeoutId);
69
+ return response;
70
+ } catch (error) {
71
+ clearTimeout(timeoutId);
72
+ if (error.name === 'AbortError') {
73
+ throw new Error(`Request timed out after ${this.timeout}ms: ${url}`);
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Log debug messages if debug mode is enabled
81
+ * @private
82
+ * @param {string} message - Message to log
83
+ */
84
+ _log(message) {
85
+ if (this.debug) {
86
+ console.error(`[VaultClient] ${message}`);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Validate namespace format (alphanumeric, dots, dashes, underscores)
92
+ */
93
+ _validateNamespace(namespace) {
94
+ if (!namespace || namespace.trim() === '') {
95
+ throw new VaultValidationError('namespace is required');
96
+ }
97
+ const trimmed = namespace.trim();
98
+ const normalized = trimmed
99
+ .replace(/[^a-zA-Z0-9-]+/g, '-')
100
+ .replace(/-+/g, '-')
101
+ .replace(/^-+/, '')
102
+ .replace(/-+$/, '');
103
+
104
+ if (this.debug && normalized && normalized !== trimmed) {
105
+ this._log(`Normalizing namespace from "${trimmed}" to "${normalized}"`);
106
+ }
107
+
108
+ if (!normalized) {
109
+ throw new VaultValidationError('namespace is required');
110
+ }
111
+
112
+ if (!/^[a-zA-Z0-9-]+$/.test(normalized)) {
113
+ throw new VaultValidationError(
114
+ 'Invalid namespace format: must contain only alphanumeric characters and dashes'
115
+ );
116
+ }
117
+
118
+ return normalized;
119
+ }
120
+
121
+ /**
122
+ * Validate secret name format (alphanumeric, dashes, underscores)
123
+ */
124
+ _validateName(name) {
125
+ if (!name || name.trim() === '') {
126
+ throw new VaultValidationError('name is required');
127
+ }
128
+ const trimmed = name.trim();
129
+ if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
130
+ throw new VaultValidationError(
131
+ 'Invalid name format: must contain only alphanumeric characters, dashes, and underscores'
132
+ );
133
+ }
134
+ return trimmed;
135
+ }
136
+
137
+ /**
138
+ * Validate secret value is not empty
139
+ */
140
+ _validateValue(value) {
141
+ if (!value || value.trim() === '') {
142
+ throw new VaultValidationError('value is required');
143
+ }
144
+ return value;
145
+ }
146
+
147
+ /**
148
+ * Validate secretId is not empty
149
+ */
150
+ _validateSecretId(secretId) {
151
+ if (!secretId || secretId.trim() === '') {
152
+ throw new VaultValidationError('secretId is required');
153
+ }
154
+ return secretId;
155
+ }
156
+
157
+ /**
158
+ * Validate expiresAt is in the future
159
+ */
160
+ _validateExpiresAt(expiresAt) {
161
+ if (expiresAt) {
162
+ const expiryDate = new Date(expiresAt);
163
+ if (expiryDate <= new Date()) {
164
+ throw new VaultValidationError('expiresAt must be in the future');
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Execute fetch with retry logic and exponential backoff
171
+ */
172
+ async _fetchWithRetry(url, options, retryCount = 0) {
173
+ try {
174
+ const response = await this._fetchWithTimeout(url, options);
175
+
176
+ // Don't retry on client errors (4xx)
177
+ if (!response.ok && response.status >= 400 && response.status < 500) {
178
+ let responseBody;
179
+ try {
180
+ responseBody = await response.text();
181
+ } catch {
182
+ responseBody = undefined;
183
+ }
184
+ throw createVaultErrorFromResponse(response, { url, responseBody });
185
+ }
186
+
187
+ // Retry on server errors (5xx)
188
+ if (!response.ok && response.status >= 500) {
189
+ let responseBody;
190
+ try {
191
+ responseBody = await response.text();
192
+ } catch {
193
+ responseBody = undefined;
194
+ }
195
+ const serverError = createVaultErrorFromResponse(response, { url, responseBody });
196
+ if (retryCount < this.maxRetries - 1) {
197
+ const delay = this.retryDelays[retryCount] || 4000;
198
+ this._log(`Retry attempt ${retryCount + 1} after ${delay}ms due to ${response.status} ${response.statusText}`);
199
+ await this._delay(delay);
200
+ return this._fetchWithRetry(url, options, retryCount + 1);
201
+ }
202
+ throw serverError;
203
+ }
204
+
205
+ return response;
206
+ } catch (error) {
207
+ // Check if error is a VaultError by checking if it has our custom properties
208
+ // VaultErrors have a code property (VAULT_*, SECRET_*, etc.)
209
+ const isVaultError = error.code && typeof error.statusCode === 'number';
210
+
211
+ // Don't retry client errors (4xx) - they won't succeed on retry
212
+ if (isVaultError && error.statusCode >= 400 && error.statusCode < 500) {
213
+ throw error;
214
+ }
215
+
216
+ // Retry server errors (5xx)
217
+ if (isVaultError && error.statusCode >= 500) {
218
+ if (retryCount < this.maxRetries - 1) {
219
+ const delay = this.retryDelays[retryCount] || 4000;
220
+ this._log(`Retry attempt ${retryCount + 1} after ${delay}ms due to server error`);
221
+ await this._delay(delay);
222
+ return this._fetchWithRetry(url, options, retryCount + 1);
223
+ }
224
+ // Max retries reached for server error
225
+ throw error;
226
+ }
227
+
228
+ // Network errors (not VaultErrors) - retry with exponential backoff
229
+ if (!isVaultError) {
230
+ if (retryCount < this.maxRetries - 1) {
231
+ const delay = this.retryDelays[retryCount] || 4000;
232
+ this._log(`Retry attempt ${retryCount + 1} after ${delay}ms due to: ${error.message}`);
233
+ await this._delay(delay);
234
+ return this._fetchWithRetry(url, options, retryCount + 1);
235
+ }
236
+
237
+ // Exhausted retries - wrap network error
238
+ const networkError = wrapNetworkError(error, { url, vaultUrl: this.apiUrl });
239
+ throw new VaultRetryExhaustedError(networkError, this.maxRetries);
240
+ }
241
+
242
+ // Other errors - don't retry
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Delay helper for exponential backoff
249
+ */
250
+ _delay(ms) {
251
+ return new Promise(resolve => setTimeout(resolve, ms));
252
+ }
253
+
254
+ /**
255
+ * Store a secret for a remote session
256
+ * @param {string} namespace - e.g., "teleportation.remote.session-abc123"
257
+ * @param {string} name - Secret name (e.g., "github_token")
258
+ * @param {string} value - Secret value
259
+ * @param {Object} options - { description, tags, expiresAt, metadata }
260
+ */
261
+ async storeSecret(namespace, name, value, options = {}) {
262
+ // Validate inputs
263
+ const validNamespace = this._validateNamespace(namespace);
264
+ const validName = this._validateName(name);
265
+ const validValue = this._validateValue(value);
266
+ this._validateExpiresAt(options.expiresAt);
267
+
268
+ const response = await this._fetchWithRetry(`${this.apiUrl}/secrets`, {
269
+ method: 'POST',
270
+ headers: this._headers(),
271
+ body: JSON.stringify({
272
+ namespace: validNamespace,
273
+ name: validName,
274
+ value: validValue,
275
+ description: options.description,
276
+ tags: options.tags || [],
277
+ expiresAt: options.expiresAt, // ISO 8601 date
278
+ metadata: options.metadata || {},
279
+ }),
280
+ });
281
+
282
+ return response.json();
283
+ }
284
+
285
+ /**
286
+ * Retrieve a secret value (triggers audit log)
287
+ */
288
+ async getSecretValue(secretId) {
289
+ const validSecretId = this._validateSecretId(secretId);
290
+
291
+ const response = await this._fetchWithRetry(`${this.apiUrl}/secrets/${validSecretId}/value`, {
292
+ method: 'GET',
293
+ headers: this._headers(),
294
+ });
295
+
296
+ return response.json();
297
+ }
298
+
299
+ /**
300
+ * List secrets by namespace
301
+ */
302
+ async listSecrets(namespace, options = {}) {
303
+ const validNamespace = this._validateNamespace(namespace);
304
+
305
+ const params = new URLSearchParams({
306
+ namespace: validNamespace,
307
+ ...options, // page, limit, tags
308
+ });
309
+
310
+ const response = await this._fetchWithRetry(`${this.apiUrl}/secrets?${params}`, {
311
+ method: 'GET',
312
+ headers: this._headers(),
313
+ });
314
+
315
+ return response.json();
316
+ }
317
+
318
+ /**
319
+ * Generate SSH key pair for remote machine access
320
+ * @param {string} keyType - "ed25519" or "rsa"
321
+ * @param {Object} metadata - Session info
322
+ */
323
+ async generateSSHKey(keyType = 'ed25519', metadata = {}) {
324
+ const {
325
+ namespace,
326
+ name,
327
+ metadata: _explicitMetadata,
328
+ ..._metadataFields
329
+ } = metadata && typeof metadata === 'object' ? metadata : {};
330
+
331
+ const sanitizeAlphaNumeric = (value, fallback) => {
332
+ const raw = typeof value === 'string' && value.trim() ? value.trim() : fallback;
333
+ const cleaned = String(raw).replace(/[^a-zA-Z0-9]/g, '');
334
+ return cleaned || String(fallback).replace(/[^a-zA-Z0-9]/g, '') || 'teleportation';
335
+ };
336
+
337
+ const requestNamespace = sanitizeAlphaNumeric(namespace, 'teleportationremote');
338
+ const requestName = sanitizeAlphaNumeric(name, `session${Date.now()}`);
339
+
340
+ const response = await this._fetchWithRetry(`${this.apiUrl}/ssh-keys/generate`, {
341
+ method: 'POST',
342
+ headers: this._headers(),
343
+ body: JSON.stringify({
344
+ keyType,
345
+ namespace: requestNamespace,
346
+ name: requestName,
347
+ }),
348
+ });
349
+
350
+ return response.json();
351
+ }
352
+
353
+ /**
354
+ * Get public key only (for adding to remote machine)
355
+ */
356
+ async getPublicKey(keyId) {
357
+ const validKeyId = this._validateSecretId(keyId);
358
+
359
+ const response = await this._fetchWithRetry(`${this.apiUrl}/ssh-keys/${validKeyId}/public-key`, {
360
+ method: 'GET',
361
+ headers: this._headers(),
362
+ });
363
+
364
+ return response.json();
365
+ }
366
+
367
+ /**
368
+ * Bundle all secrets needed for deployment
369
+ * @param {string[]} secretIds - Array of secret IDs
370
+ */
371
+ async bundleDeploymentSecrets(secretIds) {
372
+ if (!Array.isArray(secretIds)) {
373
+ throw new VaultValidationError('secretIds must be an array');
374
+ }
375
+ if (secretIds.length === 0) {
376
+ throw new VaultValidationError('secretIds cannot be empty');
377
+ }
378
+
379
+ const response = await this._fetchWithRetry(`${this.apiUrl}/deployment/secrets`, {
380
+ method: 'POST',
381
+ headers: this._headers(),
382
+ body: JSON.stringify({ secretIds }),
383
+ });
384
+
385
+ return response.json();
386
+ }
387
+
388
+ /**
389
+ * Export secrets as environment file for remote machine
390
+ * @param {string} namespace - Namespace to export
391
+ * @param {string} format - "env" or "json"
392
+ */
393
+ async exportEnvFile(namespace, format = 'env') {
394
+ const validNamespace = this._validateNamespace(namespace);
395
+
396
+ const response = await this._fetchWithRetry(`${this.apiUrl}/env-files/export`, {
397
+ method: 'POST',
398
+ headers: this._headers(),
399
+ body: JSON.stringify({
400
+ namespace: validNamespace,
401
+ format,
402
+ }),
403
+ });
404
+
405
+ return response.text(); // Returns .env file content
406
+ }
407
+
408
+ /**
409
+ * Import local .env file to vault
410
+ * @param {string} envFileContent - Contents of .env file
411
+ * @param {string} namespace - Target namespace
412
+ */
413
+ async importEnvFile(envFileContent, namespace) {
414
+ if (!envFileContent || envFileContent.trim() === '') {
415
+ throw new VaultValidationError('envFileContent is required');
416
+ }
417
+ const validNamespace = this._validateNamespace(namespace);
418
+
419
+ const response = await this._fetchWithRetry(`${this.apiUrl}/env-files/import`, {
420
+ method: 'POST',
421
+ headers: this._headers(),
422
+ body: JSON.stringify({
423
+ content: envFileContent,
424
+ namespace: validNamespace,
425
+ }),
426
+ });
427
+
428
+ return response.json();
429
+ }
430
+
431
+ /**
432
+ * Delete secret (cleanup after session ends)
433
+ */
434
+ async deleteSecret(secretId) {
435
+ const validSecretId = this._validateSecretId(secretId);
436
+
437
+ try {
438
+ await this._fetchWithRetry(`${this.apiUrl}/secrets/${validSecretId}`, {
439
+ method: 'DELETE',
440
+ headers: this._headers(),
441
+ });
442
+
443
+ return true;
444
+ } catch (error) {
445
+ // Handle 404 gracefully - secret already deleted
446
+ if (error instanceof SecretNotFoundError || error.statusCode === 404) {
447
+ return false;
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Get access logs for audit
455
+ */
456
+ async getAccessLogs(secretId) {
457
+ const validSecretId = this._validateSecretId(secretId);
458
+
459
+ const response = await this._fetchWithRetry(`${this.apiUrl}/secrets/${validSecretId}/access-logs`, {
460
+ method: 'GET',
461
+ headers: this._headers(),
462
+ });
463
+
464
+ return response.json();
465
+ }
466
+
467
+ /**
468
+ * Cleanup expired secrets
469
+ */
470
+ async cleanup() {
471
+ const response = await this._fetchWithRetry(`${this.apiUrl}/admin/cleanup`, {
472
+ method: 'POST',
473
+ headers: this._headers(),
474
+ });
475
+
476
+ return response.json();
477
+ }
478
+ }
@@ -4,28 +4,51 @@
4
4
  * Extracts project information, git status, and other context
5
5
  */
6
6
 
7
- import { execSync } from 'child_process';
8
- import { basename, dirname, join } from 'path';
7
+ import { execFileSync } from 'child_process';
8
+ import { existsSync } from 'fs';
9
+ import { basename, join } from 'path';
9
10
  import { homedir, hostname, userInfo } from 'os';
10
- import { stat, readFile } from 'fs/promises';
11
+ import { readFile } from 'fs/promises';
12
+
13
+ function getGitEnv() {
14
+ const env = { ...process.env };
15
+ // Prevent ambient git env vars (from other tests/processes) from changing behavior.
16
+ delete env.GIT_DIR;
17
+ delete env.GIT_WORK_TREE;
18
+ delete env.GIT_COMMON_DIR;
19
+ delete env.GIT_INDEX_FILE;
20
+ delete env.GIT_OBJECT_DIRECTORY;
21
+ delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
22
+ delete env.GIT_CEILING_DIRECTORIES;
23
+ return env;
24
+ }
25
+
26
+ function gitExec(cwd, args) {
27
+ return execFileSync('git', args, {
28
+ cwd,
29
+ env: getGitEnv(),
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'ignore']
32
+ }).trim();
33
+ }
11
34
 
12
35
  /**
13
36
  * Extract project name from git repository or fall back to directory name
14
37
  */
15
38
  export async function getProjectName(cwd) {
16
39
  try {
40
+ if (!isGitRepo(cwd)) {
41
+ return basename(cwd);
42
+ }
43
+
17
44
  // Try to get git remote URL
18
- const gitRemote = execSync('git config --get remote.origin.url', {
19
- cwd,
20
- encoding: 'utf8',
21
- stdio: ['ignore', 'pipe', 'ignore']
22
- }).trim();
45
+ const gitRemote = gitExec(cwd, ['config', '--get', 'remote.origin.url']);
23
46
 
24
47
  if (gitRemote) {
25
- // Extract repo name from URL (handles both SSH and HTTPS)
26
- const match = gitRemote.match(/(?:.*\/)?([^\/]+?)(?:\.git)?$/);
27
- if (match && match[1]) {
28
- return match[1];
48
+ const cleaned = gitRemote.replace(/\/+$/, '');
49
+ const lastPart = cleaned.split(/[\/:]/).pop();
50
+ if (lastPart) {
51
+ return lastPart.replace(/\.git$/, '');
29
52
  }
30
53
  }
31
54
  } catch (e) {
@@ -41,11 +64,10 @@ export async function getProjectName(cwd) {
41
64
  */
42
65
  export function getCurrentBranch(cwd) {
43
66
  try {
44
- const branch = execSync('git rev-parse --abbrev-ref HEAD', {
45
- cwd,
46
- encoding: 'utf8',
47
- stdio: ['ignore', 'pipe', 'ignore']
48
- }).trim();
67
+ if (!isGitRepo(cwd)) {
68
+ return null;
69
+ }
70
+ const branch = gitExec(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
49
71
  return branch || null;
50
72
  } catch (e) {
51
73
  return null;
@@ -57,11 +79,10 @@ export function getCurrentBranch(cwd) {
57
79
  */
58
80
  export function getCommitHash(cwd) {
59
81
  try {
60
- const hash = execSync('git rev-parse --short HEAD', {
61
- cwd,
62
- encoding: 'utf8',
63
- stdio: ['ignore', 'pipe', 'ignore']
64
- }).trim();
82
+ if (!isGitRepo(cwd)) {
83
+ return null;
84
+ }
85
+ const hash = gitExec(cwd, ['rev-parse', '--short', 'HEAD']);
65
86
  return hash || null;
66
87
  } catch (e) {
67
88
  return null;
@@ -73,12 +94,12 @@ export function getCommitHash(cwd) {
73
94
  */
74
95
  export function getLastEditedFile(cwd) {
75
96
  try {
97
+ if (!isGitRepo(cwd)) {
98
+ return null;
99
+ }
100
+
76
101
  // Get modified files from git status
77
- const status = execSync('git status --porcelain', {
78
- cwd,
79
- encoding: 'utf8',
80
- stdio: ['ignore', 'pipe', 'ignore']
81
- }).trim();
102
+ const status = gitExec(cwd, ['status', '--porcelain']);
82
103
 
83
104
  if (status) {
84
105
  // Get the first modified file
@@ -106,15 +127,25 @@ export function getLastEditedFile(cwd) {
106
127
  }
107
128
  }
108
129
 
109
- // Try to get the most recently modified file from git log
110
- const lastFile = execSync('git diff --name-only HEAD~1 HEAD 2>/dev/null || git ls-files -m | head -1', {
111
- cwd,
112
- encoding: 'utf8',
113
- stdio: ['ignore', 'pipe', 'ignore'],
114
- shell: true
115
- }).trim();
116
-
117
- return lastFile || null;
130
+ // Try to get the most recently modified file from git diff
131
+ try {
132
+ const diffNames = gitExec(cwd, ['diff', '--name-only', 'HEAD~1', 'HEAD']);
133
+ const first = diffNames.split('\n').find(Boolean);
134
+ if (first) {
135
+ return first.trim();
136
+ }
137
+ } catch (_) {
138
+ // Ignore
139
+ }
140
+
141
+ // Fallback: any modified tracked file
142
+ try {
143
+ const modified = gitExec(cwd, ['ls-files', '-m']);
144
+ const first = modified.split('\n').find(Boolean);
145
+ return first ? first.trim() : null;
146
+ } catch (_) {
147
+ return null;
148
+ }
118
149
  } catch (e) {
119
150
  return null;
120
151
  }
@@ -125,19 +156,19 @@ export function getLastEditedFile(cwd) {
125
156
  */
126
157
  export function getRecentCommits(cwd, count = 3) {
127
158
  try {
128
- const log = execSync(`git log -${count} --pretty=format:"%h|%s"`, {
129
- cwd,
130
- encoding: 'utf8',
131
- stdio: ['ignore', 'pipe', 'ignore']
132
- }).trim();
159
+ if (!isGitRepo(cwd)) {
160
+ return [];
161
+ }
162
+
163
+ const log = gitExec(cwd, ['log', '-n', String(count), '--pretty=format:%h%x1f%s']);
133
164
 
134
165
  if (!log) return [];
135
166
 
136
167
  return log.split('\n').map(line => {
137
- const [hash, ...messageParts] = line.split('|');
168
+ const [hash, ...messageParts] = line.split('\x1f');
138
169
  return {
139
170
  hash: hash || '',
140
- message: messageParts.join('|') || ''
171
+ message: messageParts.join('\x1f') || ''
141
172
  };
142
173
  });
143
174
  } catch (e) {
@@ -176,12 +207,12 @@ export function getCurrentTask(cwd) {
176
207
  */
177
208
  export function isGitRepo(cwd) {
178
209
  try {
179
- execSync('git rev-parse --git-dir', {
180
- cwd,
181
- encoding: 'utf8',
182
- stdio: ['ignore', 'pipe', 'ignore']
183
- });
184
- return true;
210
+ // Avoid discovering a parent repo by requiring a .git marker in this directory.
211
+ if (!existsSync(join(cwd, '.git'))) {
212
+ return false;
213
+ }
214
+ const inside = gitExec(cwd, ['rev-parse', '--is-inside-work-tree']);
215
+ return inside === 'true';
185
216
  } catch (e) {
186
217
  return false;
187
218
  }
@@ -6,7 +6,8 @@
6
6
 
7
7
  // Simple in-memory cache for mute status
8
8
  // Key: session_id, Value: { muted: boolean, timestamp: number }
9
- const muteCache = new Map();
9
+ const MUTE_CACHE_KEY = Symbol.for('teleportation.session.muteCache');
10
+ const muteCache = globalThis[MUTE_CACHE_KEY] || (globalThis[MUTE_CACHE_KEY] = new Map());
10
11
  const CACHE_TTL = 60000; // 1 minute cache TTL
11
12
 
12
13
  /**