@iterable/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/TOOLS.md +133 -0
  4. package/dist/client-detection.d.ts +7 -0
  5. package/dist/client-detection.d.ts.map +1 -0
  6. package/dist/client-detection.js +22 -0
  7. package/dist/client-detection.js.map +1 -0
  8. package/dist/config.d.ts +25 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +74 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/http-headers.d.ts +5 -0
  13. package/dist/http-headers.d.ts.map +1 -0
  14. package/dist/http-headers.js +5 -0
  15. package/dist/http-headers.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +43 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/install.d.ts +41 -0
  21. package/dist/install.d.ts.map +1 -0
  22. package/dist/install.js +725 -0
  23. package/dist/install.js.map +1 -0
  24. package/dist/key-manager.d.ts +236 -0
  25. package/dist/key-manager.d.ts.map +1 -0
  26. package/dist/key-manager.js +776 -0
  27. package/dist/key-manager.js.map +1 -0
  28. package/dist/keys-cli.d.ts +8 -0
  29. package/dist/keys-cli.d.ts.map +1 -0
  30. package/dist/keys-cli.js +487 -0
  31. package/dist/keys-cli.js.map +1 -0
  32. package/dist/prompts.d.ts +15 -0
  33. package/dist/prompts.d.ts.map +1 -0
  34. package/dist/prompts.js +66 -0
  35. package/dist/prompts.js.map +1 -0
  36. package/dist/schema-utils.d.ts +22 -0
  37. package/dist/schema-utils.d.ts.map +1 -0
  38. package/dist/schema-utils.js +65 -0
  39. package/dist/schema-utils.js.map +1 -0
  40. package/dist/server.d.ts +13 -0
  41. package/dist/server.d.ts.map +1 -0
  42. package/dist/server.js +172 -0
  43. package/dist/server.js.map +1 -0
  44. package/dist/shutdown.d.ts +5 -0
  45. package/dist/shutdown.d.ts.map +1 -0
  46. package/dist/shutdown.js +68 -0
  47. package/dist/shutdown.js.map +1 -0
  48. package/dist/tool-filter.d.ts +19 -0
  49. package/dist/tool-filter.d.ts.map +1 -0
  50. package/dist/tool-filter.js +144 -0
  51. package/dist/tool-filter.js.map +1 -0
  52. package/dist/tools/campaigns.d.ts +7 -0
  53. package/dist/tools/campaigns.d.ts.map +1 -0
  54. package/dist/tools/campaigns.js +88 -0
  55. package/dist/tools/campaigns.js.map +1 -0
  56. package/dist/tools/catalogs.d.ts +7 -0
  57. package/dist/tools/catalogs.d.ts.map +1 -0
  58. package/dist/tools/catalogs.js +85 -0
  59. package/dist/tools/catalogs.js.map +1 -0
  60. package/dist/tools/events.d.ts +7 -0
  61. package/dist/tools/events.d.ts.map +1 -0
  62. package/dist/tools/events.js +34 -0
  63. package/dist/tools/events.js.map +1 -0
  64. package/dist/tools/experiments.d.ts +7 -0
  65. package/dist/tools/experiments.d.ts.map +1 -0
  66. package/dist/tools/experiments.js +16 -0
  67. package/dist/tools/experiments.js.map +1 -0
  68. package/dist/tools/export.d.ts +7 -0
  69. package/dist/tools/export.d.ts.map +1 -0
  70. package/dist/tools/export.js +41 -0
  71. package/dist/tools/export.js.map +1 -0
  72. package/dist/tools/index.d.ts +19 -0
  73. package/dist/tools/index.d.ts.map +1 -0
  74. package/dist/tools/index.js +44 -0
  75. package/dist/tools/index.js.map +1 -0
  76. package/dist/tools/journeys.d.ts +10 -0
  77. package/dist/tools/journeys.d.ts.map +1 -0
  78. package/dist/tools/journeys.js +25 -0
  79. package/dist/tools/journeys.js.map +1 -0
  80. package/dist/tools/lists.d.ts +7 -0
  81. package/dist/tools/lists.d.ts.map +1 -0
  82. package/dist/tools/lists.js +58 -0
  83. package/dist/tools/lists.js.map +1 -0
  84. package/dist/tools/messaging.d.ts +11 -0
  85. package/dist/tools/messaging.d.ts.map +1 -0
  86. package/dist/tools/messaging.js +103 -0
  87. package/dist/tools/messaging.js.map +1 -0
  88. package/dist/tools/snippets.d.ts +7 -0
  89. package/dist/tools/snippets.d.ts.map +1 -0
  90. package/dist/tools/snippets.js +46 -0
  91. package/dist/tools/snippets.js.map +1 -0
  92. package/dist/tools/subscriptions.d.ts +4 -0
  93. package/dist/tools/subscriptions.d.ts.map +1 -0
  94. package/dist/tools/subscriptions.js +37 -0
  95. package/dist/tools/subscriptions.js.map +1 -0
  96. package/dist/tools/templates.d.ts +7 -0
  97. package/dist/tools/templates.d.ts.map +1 -0
  98. package/dist/tools/templates.js +118 -0
  99. package/dist/tools/templates.js.map +1 -0
  100. package/dist/tools/users.d.ts +10 -0
  101. package/dist/tools/users.d.ts.map +1 -0
  102. package/dist/tools/users.js +74 -0
  103. package/dist/tools/users.js.map +1 -0
  104. package/dist/tools/webhooks.d.ts +7 -0
  105. package/dist/tools/webhooks.d.ts.map +1 -0
  106. package/dist/tools/webhooks.js +22 -0
  107. package/dist/tools/webhooks.js.map +1 -0
  108. package/dist/utils/cli-env.d.ts +11 -0
  109. package/dist/utils/cli-env.d.ts.map +1 -0
  110. package/dist/utils/cli-env.js +32 -0
  111. package/dist/utils/cli-env.js.map +1 -0
  112. package/dist/utils/endpoint-prompt.d.ts +14 -0
  113. package/dist/utils/endpoint-prompt.d.ts.map +1 -0
  114. package/dist/utils/endpoint-prompt.js +73 -0
  115. package/dist/utils/endpoint-prompt.js.map +1 -0
  116. package/dist/utils/formatting.d.ts +10 -0
  117. package/dist/utils/formatting.d.ts.map +1 -0
  118. package/dist/utils/formatting.js +12 -0
  119. package/dist/utils/formatting.js.map +1 -0
  120. package/dist/utils/password-prompt.d.ts +19 -0
  121. package/dist/utils/password-prompt.d.ts.map +1 -0
  122. package/dist/utils/password-prompt.js +37 -0
  123. package/dist/utils/password-prompt.js.map +1 -0
  124. package/dist/utils/sanitize.d.ts +13 -0
  125. package/dist/utils/sanitize.d.ts.map +1 -0
  126. package/dist/utils/sanitize.js +23 -0
  127. package/dist/utils/sanitize.js.map +1 -0
  128. package/dist/utils/tool-config.d.ts +13 -0
  129. package/dist/utils/tool-config.d.ts.map +1 -0
  130. package/dist/utils/tool-config.js +45 -0
  131. package/dist/utils/tool-config.js.map +1 -0
  132. package/dist/utils/ui.d.ts +90 -0
  133. package/dist/utils/ui.d.ts.map +1 -0
  134. package/dist/utils/ui.js +298 -0
  135. package/dist/utils/ui.js.map +1 -0
  136. package/dist/utils/url.d.ts +20 -0
  137. package/dist/utils/url.d.ts.map +1 -0
  138. package/dist/utils/url.js +53 -0
  139. package/dist/utils/url.js.map +1 -0
  140. package/package.json +105 -0
@@ -0,0 +1,776 @@
1
+ /**
2
+ * Secure API Key Management using macOS Keychain
3
+ *
4
+ * This module provides secure storage and management of multiple Iterable API keys
5
+ * using macOS Keychain via the native `security` command-line tool.
6
+ *
7
+ * Platform Support:
8
+ * - macOS: Full support with Keychain integration
9
+ * - Windows/Linux: Not supported - use ITERABLE_API_KEY environment variable
10
+ *
11
+ * Storage:
12
+ * - API keys: Stored securely in macOS Keychain (never on disk)
13
+ * - Metadata: Stored in ~/.iterable-mcp/keys.json (names, IDs, endpoints, timestamps)
14
+ * - Lock file: ~/.iterable-mcp/keys.lock (prevents concurrent modifications)
15
+ *
16
+ * Security Features:
17
+ * - Uses spawn() with argument arrays to prevent shell injection
18
+ * - File-based locking for concurrent access protection
19
+ * - Test-specific service name to isolate test keychain entries
20
+ * - API key format validation (32-char lowercase hex)
21
+ * - HTTPS-only URL validation
22
+ * - Duplicate key detection (both names and values)
23
+ */
24
+ import { logger } from "@iterable/api";
25
+ import { spawn } from "child_process";
26
+ import { randomUUID } from "crypto";
27
+ import { promises as fs } from "fs";
28
+ import os from "os";
29
+ import path from "path";
30
+ import { isHttpsOrLocalhost, isLocalhostHost } from "./utils/url.js";
31
+ // Service name for keychain entries
32
+ // IMPORTANT: This must be constant to avoid data loss. Never use NODE_ENV here!
33
+ // Tests should use dependency injection with mock execSecurity instead.
34
+ const SERVICE_NAME = "iterable-mcp";
35
+ /**
36
+ * Safely execute macOS security command with proper argument escaping
37
+ * Uses spawn to prevent shell injection vulnerabilities
38
+ */
39
+ async function execSecurityDefault(args) {
40
+ return new Promise((resolve, reject) => {
41
+ const child = spawn("security", args, {
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ });
44
+ let stdout = "";
45
+ let stderr = "";
46
+ child.stdout.on("data", (data) => {
47
+ stdout += data.toString();
48
+ });
49
+ child.stderr.on("data", (data) => {
50
+ stderr += data.toString();
51
+ });
52
+ child.on("error", (error) => {
53
+ reject(new Error(`Failed to execute security command: ${error.message}`));
54
+ });
55
+ child.on("close", (code) => {
56
+ if (code === 0) {
57
+ resolve(stdout.trim());
58
+ }
59
+ else {
60
+ reject(new Error(`Security command failed with code ${code}: ${stderr.trim() || stdout.trim()}`));
61
+ }
62
+ });
63
+ });
64
+ }
65
+ export class KeyManager {
66
+ configDir;
67
+ metadataFile;
68
+ lockFile;
69
+ store = null;
70
+ saveLock = null;
71
+ execSecurity;
72
+ /**
73
+ * Create a new KeyManager instance
74
+ *
75
+ * @param configDir - Optional custom config directory (defaults to ~/.iterable-mcp)
76
+ * @param execSecurity - Optional security command executor (for dependency injection in tests)
77
+ * @throws {Error} If not running on macOS and no mock executor is provided
78
+ */
79
+ constructor(configDir, execSecurity) {
80
+ // Check if running on macOS (skip check in tests when mock is provided)
81
+ if (!execSecurity && process.platform !== "darwin") {
82
+ throw new Error("Key Manager only supports macOS. Windows and Linux users should use the ITERABLE_API_KEY environment variable.");
83
+ }
84
+ this.configDir = configDir || path.join(os.homedir(), ".iterable-mcp");
85
+ this.metadataFile = path.join(this.configDir, "keys.json");
86
+ this.lockFile = path.join(this.configDir, "keys.lock");
87
+ this.execSecurity = execSecurity || execSecurityDefault;
88
+ }
89
+ /**
90
+ * Validate metadata against keychain and clean up orphaned entries
91
+ *
92
+ * Checks if each key in metadata still exists in the keychain.
93
+ * Removes any metadata entries that no longer have corresponding keychain entries.
94
+ * This prevents sync issues when keychain entries are manually deleted.
95
+ *
96
+ * @returns Array of cleaned up key names (if any)
97
+ */
98
+ async validateAndCleanup() {
99
+ if (!this.store || this.store.keys.length === 0) {
100
+ return [];
101
+ }
102
+ const orphanedKeys = [];
103
+ // Check each key to see if it exists in keychain
104
+ for (const keyMeta of this.store.keys) {
105
+ try {
106
+ await this.execSecurity([
107
+ "find-generic-password",
108
+ "-a",
109
+ keyMeta.id,
110
+ "-s",
111
+ SERVICE_NAME,
112
+ "-w",
113
+ ]);
114
+ // Key exists - no action needed
115
+ }
116
+ catch (error) {
117
+ // Key doesn't exist in keychain - mark for removal
118
+ if (error.message?.includes("could not be found")) {
119
+ orphanedKeys.push(keyMeta);
120
+ }
121
+ }
122
+ }
123
+ if (orphanedKeys.length > 0) {
124
+ // SAFETY: Don't automatically delete metadata if we can't find keychain entries
125
+ // This could be a false positive due to keychain access issues, permissions, etc.
126
+ const summary = orphanedKeys.map((k) => ({ id: k.id, name: k.name }));
127
+ logger.warn("Keychain mismatch detected; metadata NOT automatically deleted", {
128
+ action: "NOT_DELETED",
129
+ count: orphanedKeys.length,
130
+ });
131
+ // Provide a concise, actionable console message for humans
132
+ console.warn("\n⚠️ Keychain mismatch detected: the following key(s) exist in metadata but not in macOS Keychain.\n");
133
+ for (const { id, name } of summary) {
134
+ console.warn(` • ${name} (ID: ${id})`);
135
+ console.warn(` Delete from metadata: iterable-mcp keys delete "${id}"`);
136
+ console.warn(` macOS manual cleanup: security delete-generic-password -a "${id}" -s "${SERVICE_NAME}"`);
137
+ }
138
+ console.warn("\nNext steps:");
139
+ console.warn(" 1) List keys: iterable-mcp keys list");
140
+ console.warn(" 2) Delete any orphaned keys using the ID shown above");
141
+ console.warn(" 3) Re-run your command after cleanup\n");
142
+ // DO NOT automatically delete the metadata!
143
+ // Users should manually verify and clean up with 'keys delete' command
144
+ // This prevents data loss from false positives (permissions, keychain locked, etc.)
145
+ return orphanedKeys.map((k) => k.name);
146
+ }
147
+ return [];
148
+ }
149
+ /**
150
+ * Acquire an exclusive lock for metadata operations
151
+ *
152
+ * Uses atomic file creation (wx flag = O_CREAT | O_EXCL) to prevent concurrent
153
+ * modifications. Retries up to 20 times with 50ms delays (1 second total).
154
+ * Fails safely if lock cannot be acquired rather than forcefully stealing it.
155
+ *
156
+ * Lock contention is rare since writes only happen during explicit user operations
157
+ * (add, delete, activate keys) - no automatic updates.
158
+ *
159
+ * @returns An async unlock function that should be called in a finally block
160
+ * @throws {Error} If lock cannot be acquired after 1 second timeout
161
+ */
162
+ async acquireLock() {
163
+ const maxAttempts = 20; // Max 20 attempts (1 second with 50ms delay)
164
+ const maxLockAgeMs = 5 * 60 * 1000; // 5 minutes
165
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
166
+ try {
167
+ // Try to create lock file exclusively (atomic operation)
168
+ const lockData = JSON.stringify({ pid: process.pid, created: Date.now() });
169
+ await fs.writeFile(this.lockFile, lockData, {
170
+ // Create exclusively and set restrictive permissions on first write
171
+ flag: "wx", // O_CREAT | O_EXCL - atomic, no TOCTOU
172
+ mode: 0o600, // owner read/write only
173
+ });
174
+ // Successfully acquired lock - return unlock function
175
+ return async () => {
176
+ try {
177
+ // Only remove the lock if it still belongs to this process
178
+ const data = await fs.readFile(this.lockFile, "utf-8").catch(() => "");
179
+ if (data) {
180
+ try {
181
+ const parsed = JSON.parse(data);
182
+ if (Number(parsed?.pid) !== process.pid) {
183
+ return; // Do not remove someone else's lock
184
+ }
185
+ }
186
+ catch {
187
+ // If unreadable, proceed to remove to avoid deadlocks
188
+ }
189
+ }
190
+ await fs.unlink(this.lockFile);
191
+ }
192
+ catch {
193
+ // Ignore errors during unlock (file may already be gone)
194
+ }
195
+ };
196
+ }
197
+ catch (error) {
198
+ if (error.code !== "EEXIST") {
199
+ // Unexpected error (permissions, disk full, etc.)
200
+ throw error;
201
+ }
202
+ // Lock exists - attempt safe stale-lock recovery
203
+ try {
204
+ const raw = await fs.readFile(this.lockFile, "utf-8");
205
+ const parsed = JSON.parse(raw || "{}");
206
+ const pid = Number(parsed?.pid);
207
+ const created = Number(parsed?.created);
208
+ const isOld = Number.isFinite(created) && Date.now() - created > maxLockAgeMs;
209
+ let pidAlive = true;
210
+ if (Number.isFinite(pid) && pid > 0) {
211
+ try {
212
+ // Signal 0 checks for existence of the process on UNIX-like systems
213
+ process.kill(pid, 0);
214
+ pidAlive = true;
215
+ }
216
+ catch {
217
+ pidAlive = false;
218
+ }
219
+ }
220
+ if (isOld || !pidAlive) {
221
+ // Safe to remove stale lock
222
+ await fs.unlink(this.lockFile).catch(() => { });
223
+ }
224
+ }
225
+ catch {
226
+ // If we can't read/parse, just continue with normal retry
227
+ }
228
+ // Wait and retry
229
+ if (attempt < maxAttempts - 1) {
230
+ await new Promise((resolve) => setTimeout(resolve, 50));
231
+ }
232
+ }
233
+ }
234
+ // Timeout - fail rather than force acquisition (safer)
235
+ throw new Error("Failed to acquire lock: another process is modifying keys. Please try again.");
236
+ }
237
+ /**
238
+ * Initialize the key manager
239
+ *
240
+ * Creates the configuration directory (mode 0o700) and loads existing metadata.
241
+ * If the metadata file doesn't exist, creates a new empty store.
242
+ * Uses idiomatic Node.js approach: try to read, handle ENOENT if file missing.
243
+ * This method must be called before any other key management operations.
244
+ *
245
+ * @throws {Error} If the configuration directory cannot be created or metadata is corrupt
246
+ */
247
+ async initialize() {
248
+ try {
249
+ await fs.mkdir(this.configDir, { recursive: true, mode: 0o700 });
250
+ // Try to load existing metadata, create new if doesn't exist
251
+ try {
252
+ await this.loadMetadata();
253
+ }
254
+ catch (error) {
255
+ if (error.code === "ENOENT") {
256
+ // File doesn't exist - create new store
257
+ this.store = {
258
+ keys: [],
259
+ version: 1,
260
+ };
261
+ await this.saveMetadata();
262
+ }
263
+ else {
264
+ // Re-throw other errors (corrupt JSON, permissions, etc.)
265
+ throw error;
266
+ }
267
+ }
268
+ // Validate and clean up any orphaned metadata
269
+ await this.validateAndCleanup();
270
+ }
271
+ catch (error) {
272
+ logger.error("Failed to initialize key manager", { error });
273
+ throw new Error(`Failed to initialize key manager: ${error instanceof Error ? error.message : String(error)}`);
274
+ }
275
+ }
276
+ /**
277
+ * Load metadata from disk (with locking)
278
+ */
279
+ async loadMetadata() {
280
+ const unlock = await this.acquireLock();
281
+ try {
282
+ const data = await fs.readFile(this.metadataFile, "utf-8");
283
+ this.store = JSON.parse(data);
284
+ }
285
+ finally {
286
+ await unlock();
287
+ }
288
+ }
289
+ /**
290
+ * Create a backup of the metadata file before destructive operations
291
+ */
292
+ async backupMetadata() {
293
+ try {
294
+ const backupFile = `${this.metadataFile}.backup`;
295
+ const data = await fs.readFile(this.metadataFile, "utf-8");
296
+ await fs.writeFile(backupFile, data, { mode: 0o600 });
297
+ logger.debug("Created metadata backup", { backupFile });
298
+ }
299
+ catch (error) {
300
+ // If original file doesn't exist, that's ok - no backup needed
301
+ if (error.code !== "ENOENT") {
302
+ logger.warn("Failed to create metadata backup", { error });
303
+ }
304
+ }
305
+ }
306
+ /**
307
+ * Save metadata to disk (with locking to prevent concurrent modifications)
308
+ */
309
+ async saveMetadata() {
310
+ if (!this.store) {
311
+ throw new Error("Key store not initialized");
312
+ }
313
+ // If a save is already in progress, wait for it
314
+ if (this.saveLock) {
315
+ await this.saveLock;
316
+ }
317
+ // Acquire lock and save
318
+ this.saveLock = (async () => {
319
+ const unlock = await this.acquireLock();
320
+ try {
321
+ // Create backup before saving if file exists and has keys
322
+ try {
323
+ const existingData = await fs.readFile(this.metadataFile, "utf-8");
324
+ const existing = JSON.parse(existingData);
325
+ if (existing.keys && existing.keys.length > 0) {
326
+ // Only backup if we're about to overwrite a file with keys
327
+ await this.backupMetadata();
328
+ }
329
+ }
330
+ catch {
331
+ // Ignore errors reading existing file
332
+ }
333
+ await fs.writeFile(this.metadataFile, JSON.stringify(this.store, null, 2), { mode: 0o600 });
334
+ }
335
+ catch (error) {
336
+ logger.error("Failed to save key metadata", { error });
337
+ throw new Error(`Failed to save key metadata: ${error instanceof Error ? error.message : String(error)}`);
338
+ }
339
+ finally {
340
+ await unlock();
341
+ this.saveLock = null;
342
+ }
343
+ })();
344
+ await this.saveLock;
345
+ }
346
+ /**
347
+ * Generate a unique ID for a key
348
+ * Uses UUID v4 for guaranteed uniqueness and no collision risk
349
+ */
350
+ generateId() {
351
+ return randomUUID();
352
+ }
353
+ /**
354
+ * Validate API key format
355
+ */
356
+ validateApiKey(apiKey) {
357
+ if (!apiKey) {
358
+ throw new Error("API key is required");
359
+ }
360
+ if (!/^[a-f0-9]{32}$/.test(apiKey)) {
361
+ throw new Error("API key must be a 32-character lowercase hexadecimal string");
362
+ }
363
+ }
364
+ /**
365
+ * Validate and normalize base URL
366
+ */
367
+ validateBaseUrl(baseUrl) {
368
+ if (!baseUrl) {
369
+ throw new Error("Base URL is required");
370
+ }
371
+ let url;
372
+ try {
373
+ url = new URL(baseUrl);
374
+ }
375
+ catch (error) {
376
+ throw new Error(`Invalid base URL format: ${error instanceof Error ? error.message : String(error)}`);
377
+ }
378
+ // Require HTTPS for security, except allow http for localhost during local development
379
+ if (!isHttpsOrLocalhost(url)) {
380
+ throw new Error("Base URL must use HTTPS protocol for security. Insecure HTTP is only permitted for localhost.");
381
+ }
382
+ // Warn if not a standard Iterable domain
383
+ const isIterableDomain = url.hostname === "iterable.com" ||
384
+ url.hostname.endsWith(".iterable.com");
385
+ const isLocalhost = isLocalhostHost(url.hostname);
386
+ if (!isIterableDomain && !isLocalhost) {
387
+ logger.warn("Non-standard Iterable domain detected", {
388
+ hostname: url.hostname,
389
+ });
390
+ console.warn(`⚠️ Warning: Using non-standard Iterable domain: ${url.hostname}`);
391
+ }
392
+ }
393
+ /**
394
+ * Add a new API key
395
+ *
396
+ * Securely stores an API key in macOS Keychain and saves its metadata
397
+ * (name, base URL, timestamps) to disk. Each key is tightly coupled to
398
+ * its API endpoint.
399
+ *
400
+ * @param name - User-friendly name for the key (must be unique)
401
+ * @param apiKey - 32-character lowercase hexadecimal Iterable API key
402
+ * @param baseUrl - Iterable API base URL (must be HTTPS)
403
+ * @returns The unique ID generated for this key
404
+ * @throws {Error} If the key name already exists, validation fails, or keychain storage fails
405
+ */
406
+ async addKey(name, apiKey, baseUrl, envOverrides) {
407
+ if (!this.store) {
408
+ await this.initialize();
409
+ }
410
+ if (!this.store) {
411
+ throw new Error("Key store not initialized");
412
+ }
413
+ // Validate inputs
414
+ if (!name || name.trim().length === 0) {
415
+ throw new Error("Key name is required");
416
+ }
417
+ this.validateApiKey(apiKey);
418
+ this.validateBaseUrl(baseUrl);
419
+ // Check for duplicate names
420
+ if (this.store.keys.some((k) => k.name === name)) {
421
+ throw new Error(`Key with name "${name}" already exists`);
422
+ }
423
+ // Generate unique ID
424
+ const id = this.generateId();
425
+ // Store in macOS Keychain using security command
426
+ // Using spawn with argument array to prevent shell injection
427
+ try {
428
+ await this.execSecurity([
429
+ "add-generic-password",
430
+ "-a",
431
+ id,
432
+ "-s",
433
+ SERVICE_NAME,
434
+ "-w",
435
+ apiKey,
436
+ "-U",
437
+ ]);
438
+ }
439
+ catch (error) {
440
+ logger.error("Failed to store key in keychain", { error, id });
441
+ throw new Error(`Failed to store key in macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
442
+ }
443
+ // If this is the first key, make it active
444
+ const isActive = this.store.keys.length === 0;
445
+ // Create metadata
446
+ const metadata = {
447
+ id,
448
+ name,
449
+ baseUrl,
450
+ created: new Date().toISOString(),
451
+ isActive,
452
+ ...(envOverrides && Object.keys(envOverrides).length > 0
453
+ ? { env: envOverrides }
454
+ : {}),
455
+ };
456
+ // Store metadata
457
+ this.store.keys.push(metadata);
458
+ await this.saveMetadata();
459
+ return id;
460
+ }
461
+ /**
462
+ * Update per-key environment overrides for an existing key
463
+ */
464
+ async updateKeyEnv(idOrName, envOverrides) {
465
+ if (!this.store) {
466
+ await this.initialize();
467
+ }
468
+ if (!this.store) {
469
+ throw new Error("Key store not initialized");
470
+ }
471
+ const keyMeta = this.store.keys.find((k) => k.id === idOrName || k.name === idOrName);
472
+ if (!keyMeta) {
473
+ throw new Error(`Key not found: ${idOrName}`);
474
+ }
475
+ keyMeta.env = { ...(keyMeta.env || {}), ...envOverrides };
476
+ await this.saveMetadata();
477
+ }
478
+ /**
479
+ * List all keys (metadata only, not the actual keys)
480
+ *
481
+ * Returns metadata for all stored API keys including names, IDs, base URLs,
482
+ * timestamps, and active status. Does NOT return the actual API key values.
483
+ *
484
+ * @returns Array of API key metadata
485
+ * @throws {Error} If the key store is not initialized
486
+ */
487
+ async listKeys() {
488
+ if (!this.store) {
489
+ await this.initialize();
490
+ }
491
+ if (!this.store) {
492
+ throw new Error("Key store not initialized");
493
+ }
494
+ return [...this.store.keys];
495
+ }
496
+ /**
497
+ * Get a key by ID or name
498
+ *
499
+ * Retrieves the actual API key value from macOS Keychain.
500
+ *
501
+ * @param idOrName - The unique ID or user-friendly name of the key
502
+ * @returns The API key value, or null if not found
503
+ * @throws {Error} If keychain access fails
504
+ */
505
+ async getKey(idOrName) {
506
+ if (!this.store) {
507
+ await this.initialize();
508
+ }
509
+ if (!this.store) {
510
+ throw new Error("Key store not initialized");
511
+ }
512
+ // Find the key metadata
513
+ const keyMeta = this.store.keys.find((k) => k.id === idOrName || k.name === idOrName);
514
+ if (!keyMeta) {
515
+ return null;
516
+ }
517
+ // Get from macOS Keychain using security command
518
+ // Using spawn with argument array to prevent shell injection
519
+ let apiKey;
520
+ try {
521
+ apiKey = await this.execSecurity([
522
+ "find-generic-password",
523
+ "-a",
524
+ keyMeta.id,
525
+ "-s",
526
+ SERVICE_NAME,
527
+ "-w",
528
+ ]);
529
+ }
530
+ catch (error) {
531
+ logger.error("Failed to retrieve key from keychain", {
532
+ error,
533
+ id: keyMeta.id,
534
+ });
535
+ throw new Error(`Failed to retrieve key from macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
536
+ }
537
+ if (!apiKey) {
538
+ logger.error("Key not found in keychain", { id: keyMeta.id });
539
+ throw new Error(`Key not found in macOS Keychain for ID ${keyMeta.id}`);
540
+ }
541
+ return apiKey;
542
+ }
543
+ /**
544
+ * Get the currently active key
545
+ *
546
+ * Retrieves the API key value for whichever key is currently marked as active.
547
+ * Only one key can be active at a time.
548
+ *
549
+ * @returns The active API key value, or null if no key is active
550
+ * @throws {Error} If keychain access fails
551
+ */
552
+ async getActiveKey() {
553
+ if (!this.store) {
554
+ await this.initialize();
555
+ }
556
+ if (!this.store) {
557
+ throw new Error("Key store not initialized");
558
+ }
559
+ const activeKey = this.store.keys.find((k) => k.isActive);
560
+ if (!activeKey) {
561
+ return null;
562
+ }
563
+ return this.getKey(activeKey.id);
564
+ }
565
+ /**
566
+ * Get the active key metadata
567
+ *
568
+ * Returns metadata for the currently active key without retrieving the
569
+ * actual API key value from Keychain.
570
+ *
571
+ * @returns The active key metadata, or null if no key is active
572
+ * @throws {Error} If the key store is not initialized
573
+ */
574
+ async getActiveKeyMetadata() {
575
+ if (!this.store) {
576
+ await this.initialize();
577
+ }
578
+ if (!this.store) {
579
+ throw new Error("Key store not initialized");
580
+ }
581
+ return this.store.keys.find((k) => k.isActive) || null;
582
+ }
583
+ /**
584
+ * Set a key as active by ID or name
585
+ *
586
+ * Marks the specified key as active and deactivates all other keys.
587
+ * The active key's base URL and API key will be used by the MCP server.
588
+ * Only one key can be active at a time.
589
+ *
590
+ * @param idOrName - The unique ID or user-friendly name of the key to activate
591
+ * @throws {Error} If the key is not found or the store is not initialized
592
+ */
593
+ async setActiveKey(idOrName) {
594
+ if (!this.store) {
595
+ await this.initialize();
596
+ }
597
+ if (!this.store) {
598
+ throw new Error("Key store not initialized");
599
+ }
600
+ // Find the key
601
+ const keyMeta = this.store.keys.find((k) => k.id === idOrName || k.name === idOrName);
602
+ if (!keyMeta) {
603
+ throw new Error(`Key not found: ${idOrName}`);
604
+ }
605
+ // Deactivate all keys
606
+ this.store.keys.forEach((k) => {
607
+ k.isActive = false;
608
+ });
609
+ // Activate the selected key
610
+ keyMeta.isActive = true;
611
+ await this.saveMetadata();
612
+ }
613
+ /**
614
+ * Delete a key by ID
615
+ *
616
+ * Removes a key from both the macOS Keychain and the metadata store.
617
+ * The currently active key cannot be deleted - you must activate a different
618
+ * key first. If keychain deletion fails, the metadata will still be removed
619
+ * and a warning will be displayed.
620
+ *
621
+ * Note: Only accepts key ID (not name) since IDs are guaranteed unique.
622
+ *
623
+ * @param id - The unique ID of the key to delete
624
+ * @throws {Error} If the key is not found, is currently active, or the store is not initialized
625
+ */
626
+ async deleteKey(id) {
627
+ if (!this.store) {
628
+ await this.initialize();
629
+ }
630
+ if (!this.store) {
631
+ throw new Error("Key store not initialized");
632
+ }
633
+ // Find the key by ID only
634
+ const index = this.store.keys.findIndex((k) => k.id === id);
635
+ if (index === -1) {
636
+ throw new Error(`Key not found with ID: ${id}`);
637
+ }
638
+ const keyMeta = this.store.keys[index];
639
+ // Prevent deletion of the active key
640
+ if (keyMeta.isActive) {
641
+ throw new Error(`Cannot delete the currently active key "${keyMeta.name}". Please activate a different key first.`);
642
+ }
643
+ // Delete from macOS Keychain using security command
644
+ // Using spawn with argument array to prevent shell injection
645
+ let keychainDeleted = false;
646
+ try {
647
+ await this.execSecurity([
648
+ "delete-generic-password",
649
+ "-a",
650
+ keyMeta.id,
651
+ "-s",
652
+ SERVICE_NAME,
653
+ ]);
654
+ keychainDeleted = true;
655
+ }
656
+ catch (error) {
657
+ logger.error("Failed to delete key from keychain", {
658
+ error,
659
+ id: keyMeta.id,
660
+ });
661
+ // Continue anyway to clean up metadata, but warn the user
662
+ }
663
+ // Remove from metadata
664
+ this.store.keys.splice(index, 1);
665
+ await this.saveMetadata();
666
+ if (!keychainDeleted) {
667
+ logger.warn("Key removed from metadata but may still exist in Keychain", {
668
+ id: keyMeta.id,
669
+ name: keyMeta.name,
670
+ });
671
+ console.warn(`⚠️ Warning: Key "${keyMeta.name}" removed from metadata but may still exist in Keychain.`);
672
+ console.warn(` To manually remove: security delete-generic-password -a "${keyMeta.id}" -s "${SERVICE_NAME}"`);
673
+ }
674
+ else {
675
+ logger.info("API key deleted", { id: keyMeta.id, name: keyMeta.name });
676
+ }
677
+ }
678
+ /**
679
+ * Check if any keys exist
680
+ *
681
+ * Returns true if at least one API key has been stored in the key manager.
682
+ *
683
+ * @returns True if keys exist, false otherwise
684
+ * @throws {Error} If the key store is not initialized
685
+ */
686
+ async hasKeys() {
687
+ if (!this.store) {
688
+ await this.initialize();
689
+ }
690
+ if (!this.store) {
691
+ throw new Error("Key store not initialized");
692
+ }
693
+ return this.store.keys.length > 0;
694
+ }
695
+ /**
696
+ * Find a key by its actual API key value
697
+ *
698
+ * Checks all stored keys in the keychain to see if the given API key value
699
+ * already exists. Useful for preventing duplicate key values.
700
+ *
701
+ * @param apiKeyValue - The API key value to search for
702
+ * @returns The metadata of the matching key, or null if not found
703
+ * @throws {Error} If keychain access fails
704
+ */
705
+ async findKeyByValue(apiKeyValue) {
706
+ if (!this.store) {
707
+ await this.initialize();
708
+ }
709
+ if (!this.store) {
710
+ throw new Error("Key store not initialized");
711
+ }
712
+ // Check each key to see if the value matches
713
+ for (const keyMeta of this.store.keys) {
714
+ try {
715
+ const storedKey = await this.execSecurity([
716
+ "find-generic-password",
717
+ "-a",
718
+ keyMeta.id,
719
+ "-s",
720
+ SERVICE_NAME,
721
+ "-w",
722
+ ]);
723
+ if (storedKey.trim() === apiKeyValue) {
724
+ return keyMeta;
725
+ }
726
+ }
727
+ catch {
728
+ // Skip keys that can't be retrieved (may have been manually deleted from keychain)
729
+ continue;
730
+ }
731
+ }
732
+ return null;
733
+ }
734
+ /**
735
+ * Migrate a legacy API key from environment variable
736
+ *
737
+ * Adds an API key to the key manager with a default name if it doesn't
738
+ * already exist. This is used during the migration path from environment
739
+ * variable-based configuration to secure keychain storage.
740
+ *
741
+ * @param apiKey - The 32-character lowercase hexadecimal API key to migrate
742
+ * @param baseUrl - The Iterable API base URL for this key
743
+ * @param name - The name to assign (defaults to "default")
744
+ * @returns The unique ID of the migrated key (existing or newly created)
745
+ * @throws {Error} If validation fails or keychain storage fails
746
+ */
747
+ async migrateLegacyKey(apiKey, baseUrl, name = "default") {
748
+ // Check if a key with this name already exists
749
+ if (!this.store) {
750
+ await this.initialize();
751
+ }
752
+ if (!this.store) {
753
+ throw new Error("Key store not initialized");
754
+ }
755
+ const existing = this.store.keys.find((k) => k.name === name);
756
+ if (existing) {
757
+ logger.info("Legacy key already migrated", { name });
758
+ return existing.id;
759
+ }
760
+ // Add the legacy key
761
+ logger.info("Migrating legacy API key", { name });
762
+ return this.addKey(name, apiKey, baseUrl);
763
+ }
764
+ }
765
+ // Singleton instance
766
+ let keyManagerInstance = null;
767
+ /**
768
+ * Get the singleton KeyManager instance
769
+ */
770
+ export function getKeyManager() {
771
+ if (!keyManagerInstance) {
772
+ keyManagerInstance = new KeyManager();
773
+ }
774
+ return keyManagerInstance;
775
+ }
776
+ //# sourceMappingURL=key-manager.js.map