agentgate-mcp 0.2.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.
@@ -0,0 +1,411 @@
1
+ import { createLogger } from './logger.js';
2
+
3
+ const log = createLogger('browser-runtime');
4
+
5
+ function getByPath(obj, path) {
6
+ const parts = path.split('.');
7
+ let cursor = obj;
8
+ for (const part of parts) {
9
+ if (cursor == null) {
10
+ return undefined;
11
+ }
12
+ cursor = cursor[part];
13
+ }
14
+ return cursor;
15
+ }
16
+
17
+ function setByPath(obj, path, value) {
18
+ const parts = path.split('.');
19
+ let cursor = obj;
20
+ for (let i = 0; i < parts.length - 1; i += 1) {
21
+ const key = parts[i];
22
+ if (typeof cursor[key] !== 'object' || cursor[key] === null) {
23
+ cursor[key] = {};
24
+ }
25
+ cursor = cursor[key];
26
+ }
27
+ cursor[parts[parts.length - 1]] = value;
28
+ }
29
+
30
+ function interpolate(template, state) {
31
+ if (typeof template !== 'string') {
32
+ return template;
33
+ }
34
+
35
+ return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_match, token) => {
36
+ const value = getByPath(state, token.trim());
37
+ return value == null ? '' : String(value);
38
+ });
39
+ }
40
+
41
+ function coerceTimeout(value, fallback = 15_000) {
42
+ const n = Number(value);
43
+ return Number.isFinite(n) ? n : fallback;
44
+ }
45
+
46
+ async function withRetry(fn, { retries = 0, delayMs = 1_000, label = '' } = {}) {
47
+ let lastError;
48
+ for (let attempt = 0; attempt <= retries; attempt++) {
49
+ try {
50
+ return await fn();
51
+ } catch (error) {
52
+ lastError = error;
53
+ if (attempt < retries) {
54
+ log.warn(`Retry ${attempt + 1}/${retries} for ${label}: ${error instanceof Error ? error.message : String(error)}`);
55
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
56
+ }
57
+ }
58
+ }
59
+ throw lastError;
60
+ }
61
+
62
+ export class BrowserRuntime {
63
+ constructor({ page, state, db, gmailWatcher, captchaSolver }) {
64
+ this.page = page;
65
+ this.state = state;
66
+ this.db = db;
67
+ this.gmailWatcher = gmailWatcher;
68
+ this.captchaSolver = captchaSolver;
69
+ }
70
+
71
+ async run(actions) {
72
+ for (const [index, action] of actions.entries()) {
73
+ if (!action || typeof action !== 'object') {
74
+ throw new Error(`Invalid action at index ${index}`);
75
+ }
76
+ log.debug(`Action ${index}: ${action.type}`, { selector: action.selector, url: action.url });
77
+
78
+ const retries = Number(action.retries) || 0;
79
+ const retryDelay = Number(action.retryDelayMs) || 1_000;
80
+
81
+ await withRetry(() => this.execute(action, index), {
82
+ retries,
83
+ delayMs: retryDelay,
84
+ label: `${action.type}@${index}`
85
+ });
86
+ }
87
+ }
88
+
89
+ async execute(action, index) {
90
+ const type = action.type;
91
+
92
+ switch (type) {
93
+ case 'goto':
94
+ await this.page.goto(interpolate(action.url, this.state), {
95
+ waitUntil: action.waitUntil || 'domcontentloaded',
96
+ timeout: coerceTimeout(action.timeoutMs)
97
+ });
98
+ break;
99
+
100
+ case 'click':
101
+ await this.page.click(interpolate(action.selector, this.state), {
102
+ timeout: coerceTimeout(action.timeoutMs)
103
+ });
104
+ break;
105
+
106
+ case 'fill':
107
+ await this.page.fill(
108
+ interpolate(action.selector, this.state),
109
+ interpolate(action.value, this.state),
110
+ { timeout: coerceTimeout(action.timeoutMs) }
111
+ );
112
+ break;
113
+
114
+ case 'select':
115
+ await this.page.selectOption(
116
+ interpolate(action.selector, this.state),
117
+ interpolate(action.value, this.state),
118
+ { timeout: coerceTimeout(action.timeoutMs) }
119
+ );
120
+ break;
121
+
122
+ case 'press':
123
+ await this.page.press(
124
+ interpolate(action.selector, this.state),
125
+ interpolate(action.key, this.state),
126
+ { timeout: coerceTimeout(action.timeoutMs) }
127
+ );
128
+ break;
129
+
130
+ case 'wait_for':
131
+ await this.page.waitForSelector(interpolate(action.selector, this.state), {
132
+ timeout: coerceTimeout(action.timeoutMs),
133
+ state: action.state || 'visible'
134
+ });
135
+ break;
136
+
137
+ case 'wait_for_navigation':
138
+ await this.page.waitForNavigation({
139
+ waitUntil: action.waitUntil || 'domcontentloaded',
140
+ timeout: coerceTimeout(action.timeoutMs)
141
+ });
142
+ break;
143
+
144
+ case 'sleep':
145
+ await this.page.waitForTimeout(coerceTimeout(action.ms, 500));
146
+ break;
147
+
148
+ case 'screenshot': {
149
+ const screenshotPath = interpolate(action.path, this.state) || '/tmp/agentgate-screenshot.png';
150
+ await this.page.screenshot({ path: screenshotPath, fullPage: Boolean(action.fullPage) });
151
+ log.info(`Screenshot saved to ${screenshotPath}`);
152
+ break;
153
+ }
154
+
155
+ case 'extract_text': {
156
+ const target = this.resolveTarget(action);
157
+ const text = await target.textContent(
158
+ interpolate(action.selector, this.state),
159
+ { timeout: coerceTimeout(action.timeoutMs) }
160
+ );
161
+ setByPath(this.state, action.target, (text || '').trim());
162
+ break;
163
+ }
164
+
165
+ case 'extract_value': {
166
+ const target = this.resolveTarget(action);
167
+ const value = await target.inputValue(
168
+ interpolate(action.selector, this.state),
169
+ { timeout: coerceTimeout(action.timeoutMs) }
170
+ );
171
+ setByPath(this.state, action.target, value);
172
+ break;
173
+ }
174
+
175
+ case 'extract_attribute': {
176
+ const target = this.resolveTarget(action);
177
+ const value = await target.getAttribute(
178
+ interpolate(action.selector, this.state),
179
+ interpolate(action.attribute, this.state),
180
+ { timeout: coerceTimeout(action.timeoutMs) }
181
+ );
182
+ setByPath(this.state, action.target, value || '');
183
+ break;
184
+ }
185
+
186
+ case 'regex_extract': {
187
+ const source = getByPath(this.state, action.source);
188
+ const pattern = new RegExp(action.pattern, action.flags || '');
189
+ const match = String(source || '').match(pattern);
190
+ if (!match) {
191
+ throw new Error(`regex_extract failed at action ${index}: pattern did not match`);
192
+ }
193
+ const group = Number.isInteger(action.group) ? action.group : 1;
194
+ setByPath(this.state, action.target, match[group] || '');
195
+ break;
196
+ }
197
+
198
+ case 'set_var':
199
+ setByPath(this.state, action.target, interpolate(action.value, this.state));
200
+ break;
201
+
202
+ case 'assert_present': {
203
+ const value = getByPath(this.state, action.path);
204
+ if (value == null || value === '') {
205
+ throw new Error(action.message || `Missing required state value: ${action.path}`);
206
+ }
207
+ break;
208
+ }
209
+
210
+ case 'store_alias': {
211
+ const emailAlias = getByPath(this.state, action.emailPath || 'generated.emailAlias');
212
+ const password = getByPath(this.state, action.passwordPath || 'generated.password');
213
+ if (!emailAlias) {
214
+ throw new Error('store_alias action missing email alias in state');
215
+ }
216
+ this.db.saveAlias({
217
+ service: this.state.service.name,
218
+ emailAlias,
219
+ passwordHint: password ? `${String(password).slice(0, 2)}...${String(password).slice(-2)}` : null
220
+ });
221
+ break;
222
+ }
223
+
224
+ case 'wait_for_email_code': {
225
+ if (!this.gmailWatcher) {
226
+ throw new Error('wait_for_email_code requested but Gmail watcher is not configured');
227
+ }
228
+ const result = await this.gmailWatcher.waitForCode({
229
+ serviceName: this.state.service.name,
230
+ timeoutMs: coerceTimeout(action.timeoutMs, 120_000),
231
+ pollMs: coerceTimeout(action.pollMs, 5_000),
232
+ regex: action.regex || '(\\d{6})'
233
+ });
234
+ setByPath(this.state, action.target || 'scratch.emailCode', result.code);
235
+ break;
236
+ }
237
+
238
+ case 'solve_captcha': {
239
+ if (!this.captchaSolver) {
240
+ throw new Error('solve_captcha requested but CAPTCHA solver is not configured');
241
+ }
242
+ const solved = await this.captchaSolver.solve({
243
+ serviceName: this.state.service.name,
244
+ provider: action.provider || 'capsolver',
245
+ siteKey: action.siteKey,
246
+ siteUrl: action.siteUrl || this.page.url(),
247
+ captchaType: action.captchaType,
248
+ timeoutMs: coerceTimeout(action.timeoutMs, 120_000)
249
+ });
250
+ setByPath(this.state, action.target || 'scratch.captchaToken', solved.token);
251
+ break;
252
+ }
253
+
254
+ case 'fill_card':
255
+ await this.fillCard(action);
256
+ break;
257
+
258
+ case 'switch_to_iframe': {
259
+ const frameSelector = interpolate(action.selector, this.state);
260
+ const frameElement = await this.page.waitForSelector(frameSelector, {
261
+ timeout: coerceTimeout(action.timeoutMs),
262
+ state: 'attached'
263
+ });
264
+ const frame = await frameElement.contentFrame();
265
+ if (!frame) {
266
+ throw new Error(`No iframe found at selector: ${frameSelector}`);
267
+ }
268
+ this.state._iframeStack = this.state._iframeStack || [];
269
+ this.state._iframeStack.push(this.page);
270
+ this.page = frame;
271
+ log.debug(`Switched to iframe: ${frameSelector}`);
272
+ break;
273
+ }
274
+
275
+ case 'switch_to_parent': {
276
+ const stack = this.state._iframeStack;
277
+ if (!stack || stack.length === 0) {
278
+ throw new Error('switch_to_parent: no parent frame to switch to');
279
+ }
280
+ this.page = stack.pop();
281
+ log.debug('Switched to parent frame');
282
+ break;
283
+ }
284
+
285
+ default:
286
+ throw new Error(`Unsupported workflow action type: ${type}`);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Returns the page or iframe to operate on.
292
+ * If the action specifies an `iframe` selector, resolves into that frame first.
293
+ */
294
+ resolveTarget(action) {
295
+ return this.page;
296
+ }
297
+
298
+ async fillCard(action) {
299
+ const billing = this.state.vault?.billing || {};
300
+ const required = ['cardNumber', 'cardExpMonth', 'cardExpYear', 'cardCvc'];
301
+ for (const field of required) {
302
+ if (!billing[field]) {
303
+ throw new Error(`fill_card requires billing.${field} in vault`);
304
+ }
305
+ }
306
+
307
+ // Detect Stripe iframe and switch context if needed
308
+ let target = this.page;
309
+ let switchedToIframe = false;
310
+
311
+ const stripeIframeSelectors = [
312
+ "iframe[name*='__privateStripeFrame']",
313
+ "iframe[src*='stripe.com']",
314
+ "iframe[title*='card']",
315
+ "iframe[name*='__stripe']"
316
+ ];
317
+
318
+ const paddleIframeSelectors = [
319
+ "iframe[src*='paddle.com']",
320
+ "iframe[class*='paddle']"
321
+ ];
322
+
323
+ const allIframeSelectors = [...stripeIframeSelectors, ...paddleIframeSelectors];
324
+
325
+ for (const iframeSelector of allIframeSelectors) {
326
+ try {
327
+ const frameElement = await this.page.waitForSelector(iframeSelector, {
328
+ timeout: 2_000,
329
+ state: 'attached'
330
+ });
331
+ const frame = await frameElement.contentFrame();
332
+ if (frame) {
333
+ target = frame;
334
+ switchedToIframe = true;
335
+ log.info(`Detected payment iframe: ${iframeSelector}`);
336
+ break;
337
+ }
338
+ } catch {
339
+ // No such iframe, try next
340
+ }
341
+ }
342
+
343
+ const selectors = {
344
+ cardNumber: [
345
+ "input[name='cardnumber']",
346
+ "input[autocomplete='cc-number']",
347
+ "input[id*='card'][id*='number']",
348
+ "input[data-elements-stable-field-name='cardNumber']",
349
+ "input[name='number']"
350
+ ],
351
+ cardExp: [
352
+ "input[name='exp-date']",
353
+ "input[autocomplete='cc-exp']",
354
+ "input[id*='exp']",
355
+ "input[data-elements-stable-field-name='cardExpiry']",
356
+ "input[name='expiry']"
357
+ ],
358
+ cardCvc: [
359
+ "input[name='cvc']",
360
+ "input[autocomplete='cc-csc']",
361
+ "input[id*='cvc']",
362
+ "input[data-elements-stable-field-name='cardCvc']",
363
+ "input[name='verification_value']"
364
+ ],
365
+ cardName: [
366
+ "input[name='name']",
367
+ "input[autocomplete='cc-name']",
368
+ "input[id*='cardholder']"
369
+ ],
370
+ cardZip: [
371
+ "input[name='postal']",
372
+ "input[autocomplete='postal-code']",
373
+ "input[name='postalCode']"
374
+ ]
375
+ };
376
+
377
+ await this.fillFirstAvailable(target, selectors.cardNumber, billing.cardNumber, action.timeoutMs);
378
+ await this.fillFirstAvailable(
379
+ target,
380
+ selectors.cardExp,
381
+ `${billing.cardExpMonth}/${String(billing.cardExpYear).slice(-2)}`,
382
+ action.timeoutMs
383
+ );
384
+ await this.fillFirstAvailable(target, selectors.cardCvc, billing.cardCvc, action.timeoutMs);
385
+
386
+ if (billing.cardHolder) {
387
+ await this.fillFirstAvailable(target, selectors.cardName, billing.cardHolder, action.timeoutMs, true);
388
+ }
389
+ if (billing.cardZip) {
390
+ await this.fillFirstAvailable(target, selectors.cardZip, billing.cardZip, action.timeoutMs, true);
391
+ }
392
+ }
393
+
394
+ async fillFirstAvailable(target, selectors, value, timeoutMs, optional = false) {
395
+ for (const selector of selectors) {
396
+ try {
397
+ await target.waitForSelector(selector, {
398
+ timeout: coerceTimeout(timeoutMs, 1_000),
399
+ state: 'visible'
400
+ });
401
+ await target.fill(selector, String(value));
402
+ return;
403
+ } catch {
404
+ // Try next selector.
405
+ }
406
+ }
407
+ if (!optional) {
408
+ throw new Error(`Could not find any card field selector: ${selectors.join(', ')}`);
409
+ }
410
+ }
411
+ }
package/src/cli.js ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
+
3
+ import process from 'node:process';
4
+ import { config, ensureDirs, fileExists } from './config.js';
5
+ import { KeyDatabase } from './db.js';
6
+ import { enableFileLogging, createLogger } from './logger.js';
7
+ import { McpServer } from './mcp-server.js';
8
+ import { Orchestrator } from './orchestrator.js';
9
+ import { ServiceRegistry } from './registry.js';
10
+ import { runScaffold } from './scaffold.js';
11
+ import { SignupEngine } from './signup-engine.js';
12
+ import { Vault } from './vault.js';
13
+
14
+ const log = createLogger('cli');
15
+
16
+ function bootstrap() {
17
+ ensureDirs();
18
+ enableFileLogging(config.LOG_DIR);
19
+
20
+ const vault = new Vault({
21
+ vaultFile: config.VAULT_FILE,
22
+ keyringFile: config.KEYRING_FILE
23
+ });
24
+ vault.initialize();
25
+
26
+ const db = new KeyDatabase(config.DB_FILE);
27
+ const registry = new ServiceRegistry(config.REGISTRY_DIR);
28
+ const automationEngine = new SignupEngine({
29
+ db,
30
+ vault,
31
+ browserProfileDir: config.BROWSER_PROFILE_DIR
32
+ });
33
+ const orchestrator = new Orchestrator({ db, registry, signupEngine: automationEngine });
34
+
35
+ return { vault, db, orchestrator, registry, automationEngine };
36
+ }
37
+
38
+ async function main() {
39
+ const command = process.argv[2] ?? 'serve';
40
+ const { vault, db, orchestrator, registry, automationEngine } = bootstrap();
41
+
42
+ switch (command) {
43
+ case 'login':
44
+ await automationEngine.login();
45
+ process.stdout.write('\nGoogle session saved. AgentGate can now create API keys for any service.\n');
46
+ process.stdout.write('Start the MCP server with: agentgate serve\n\n');
47
+ break;
48
+
49
+ case 'serve':
50
+ log.info('Starting MCP server');
51
+ new McpServer({ orchestrator }).start();
52
+ break;
53
+
54
+ case 'doctor':
55
+ await runDoctor({ orchestrator, registry, db });
56
+ break;
57
+
58
+ case 'scaffold': {
59
+ const code = runScaffold({
60
+ registryDir: config.REGISTRY_DIR,
61
+ argv: process.argv.slice(3),
62
+ stdout: process.stdout,
63
+ stderr: process.stderr
64
+ });
65
+ process.exitCode = code;
66
+ break;
67
+ }
68
+
69
+ default:
70
+ process.stderr.write(`Unknown command: ${command}\n`);
71
+ process.stderr.write('\nUsage:\n');
72
+ process.stderr.write(' agentgate login Sign in with Google (opens browser)\n');
73
+ process.stderr.write(' agentgate serve Start MCP server\n');
74
+ process.stderr.write(' agentgate doctor Health check\n');
75
+ process.stderr.write(' agentgate scaffold Generate a service recipe\n\n');
76
+ process.exitCode = 1;
77
+ }
78
+ }
79
+
80
+ async function runDoctor({ orchestrator, registry, db }) {
81
+ const checks = {};
82
+
83
+ checks.googleLoggedIn = fileExists(config.BROWSER_PROFILE_DIR);
84
+ checks.vaultExists = fileExists(config.VAULT_FILE);
85
+ checks.keyringExists = fileExists(config.KEYRING_FILE);
86
+
87
+ const recipes = registry.listServices();
88
+ checks.serviceRecipes = recipes.length;
89
+
90
+ checks.dbFile = config.DB_FILE;
91
+ checks.dbAccessible = true;
92
+ try { db.getAllKeys(); } catch { checks.dbAccessible = false; }
93
+
94
+ try {
95
+ const keys = db.getAllKeys();
96
+ checks.storedKeys = keys.length;
97
+ checks.activeKeys = keys.filter((k) => k.status === 'active').length;
98
+ } catch {
99
+ checks.storedKeys = 0;
100
+ checks.activeKeys = 0;
101
+ }
102
+
103
+ checks.playwrightInstalled = false;
104
+ try { await import('playwright'); checks.playwrightInstalled = true; } catch {}
105
+
106
+ checks.nodeVersion = process.version;
107
+ checks.browserProfileDir = config.BROWSER_PROFILE_DIR;
108
+ checks.logDir = config.LOG_DIR;
109
+
110
+ process.stdout.write(`${JSON.stringify(checks, null, 2)}\n`);
111
+ }
112
+
113
+ main().catch((error) => {
114
+ log.error('Fatal error', { error: error instanceof Error ? error.message : String(error) });
115
+ process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
116
+ process.exit(1);
117
+ });
package/src/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ function resolveAppDir() {
6
+ if (process.env.AGENTGATE_HOME) {
7
+ return path.resolve(process.env.AGENTGATE_HOME);
8
+ }
9
+
10
+ const preferred = path.join(os.homedir(), '.agentgate');
11
+ try {
12
+ fs.mkdirSync(preferred, { recursive: true });
13
+ return preferred;
14
+ } catch {
15
+ return path.resolve(process.cwd(), '.agentgate');
16
+ }
17
+ }
18
+
19
+ const APP_DIR = resolveAppDir();
20
+ const DATA_DIR = path.join(APP_DIR, 'data');
21
+ const LOG_DIR = path.join(APP_DIR, 'logs');
22
+ const BROWSER_PROFILE_DIR = path.join(APP_DIR, 'browser-profile');
23
+ const REGISTRY_DIR = path.resolve(process.cwd(), 'services');
24
+ const VAULT_FILE = path.join(APP_DIR, 'vault.enc');
25
+ const KEYRING_FILE = path.join(APP_DIR, 'keyring.json');
26
+ const DB_FILE = path.join(DATA_DIR, 'agentgate.sqlite');
27
+ const SETTINGS_FILE = path.join(APP_DIR, 'settings.json');
28
+
29
+ export const config = {
30
+ APP_DIR,
31
+ DATA_DIR,
32
+ LOG_DIR,
33
+ BROWSER_PROFILE_DIR,
34
+ REGISTRY_DIR,
35
+ VAULT_FILE,
36
+ KEYRING_FILE,
37
+ DB_FILE,
38
+ SETTINGS_FILE
39
+ };
40
+
41
+ export function ensureDirs() {
42
+ fs.mkdirSync(APP_DIR, { recursive: true });
43
+ fs.mkdirSync(DATA_DIR, { recursive: true });
44
+ fs.mkdirSync(LOG_DIR, { recursive: true });
45
+ }
46
+
47
+ export function fileExists(filePath) {
48
+ return fs.existsSync(filePath);
49
+ }
package/src/db.js ADDED
@@ -0,0 +1,89 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import crypto from 'node:crypto';
3
+
4
+ export class KeyDatabase {
5
+ constructor(filePath) {
6
+ this.db = new DatabaseSync(filePath);
7
+ this.migrate();
8
+ }
9
+
10
+ migrate() {
11
+ this.db.exec(`
12
+ CREATE TABLE IF NOT EXISTS api_keys (
13
+ id TEXT PRIMARY KEY,
14
+ service TEXT NOT NULL,
15
+ api_key TEXT NOT NULL,
16
+ status TEXT NOT NULL DEFAULT 'active',
17
+ created_at TEXT NOT NULL,
18
+ updated_at TEXT NOT NULL,
19
+ metadata_json TEXT NOT NULL DEFAULT '{}'
20
+ );
21
+
22
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_service_active
23
+ ON api_keys(service)
24
+ WHERE status = 'active';
25
+
26
+ CREATE TABLE IF NOT EXISTS aliases (
27
+ id TEXT PRIMARY KEY,
28
+ service TEXT NOT NULL,
29
+ email_alias TEXT NOT NULL,
30
+ password_hint TEXT,
31
+ created_at TEXT NOT NULL
32
+ );
33
+ `);
34
+ }
35
+
36
+ getActiveKey(service) {
37
+ const stmt = this.db.prepare(
38
+ 'SELECT * FROM api_keys WHERE service = ? AND status = ? LIMIT 1'
39
+ );
40
+ return stmt.get(service, 'active') ?? null;
41
+ }
42
+
43
+ upsertActiveKey({ service, apiKey, metadata }) {
44
+ const now = new Date().toISOString();
45
+ const existing = this.getActiveKey(service);
46
+ const serializedMetadata = JSON.stringify(metadata || {});
47
+
48
+ if (existing) {
49
+ const stmt = this.db.prepare(
50
+ 'UPDATE api_keys SET api_key = ?, updated_at = ?, metadata_json = ? WHERE id = ?'
51
+ );
52
+ stmt.run(apiKey, now, serializedMetadata, existing.id);
53
+ return this.getActiveKey(service);
54
+ }
55
+
56
+ const id = crypto.randomUUID();
57
+ const stmt = this.db.prepare(
58
+ 'INSERT INTO api_keys (id, service, api_key, status, created_at, updated_at, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)'
59
+ );
60
+ stmt.run(id, service, apiKey, 'active', now, now, serializedMetadata);
61
+ return this.getActiveKey(service);
62
+ }
63
+
64
+ revokeKey(service) {
65
+ const now = new Date().toISOString();
66
+ const stmt = this.db.prepare(
67
+ 'UPDATE api_keys SET status = ?, updated_at = ? WHERE service = ? AND status = ?'
68
+ );
69
+ const result = stmt.run('revoked', now, service, 'active');
70
+ return result.changes > 0;
71
+ }
72
+
73
+ getAllKeys() {
74
+ const stmt = this.db.prepare(
75
+ 'SELECT service, api_key, status, created_at, updated_at FROM api_keys ORDER BY created_at DESC'
76
+ );
77
+ return stmt.all();
78
+ }
79
+
80
+ saveAlias({ service, emailAlias, passwordHint }) {
81
+ const id = crypto.randomUUID();
82
+ const now = new Date().toISOString();
83
+ const stmt = this.db.prepare(
84
+ 'INSERT INTO aliases (id, service, email_alias, password_hint, created_at) VALUES (?, ?, ?, ?, ?)'
85
+ );
86
+ stmt.run(id, service, emailAlias, passwordHint || null, now);
87
+ return id;
88
+ }
89
+ }