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,128 @@
1
+ import https from 'node:https';
2
+ import { createLogger } from '../logger.js';
3
+
4
+ const log = createLogger('captcha-solver');
5
+
6
+ const CAPSOLVER_API = 'https://api.capsolver.com';
7
+
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ function postJson(url, body) {
13
+ return new Promise((resolve, reject) => {
14
+ const data = JSON.stringify(body);
15
+ const parsed = new URL(url);
16
+ const req = https.request(
17
+ {
18
+ hostname: parsed.hostname,
19
+ path: parsed.pathname,
20
+ method: 'POST',
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ 'Content-Length': Buffer.byteLength(data)
24
+ }
25
+ },
26
+ (res) => {
27
+ const chunks = [];
28
+ res.on('data', (chunk) => chunks.push(chunk));
29
+ res.on('end', () => {
30
+ const text = Buffer.concat(chunks).toString('utf8');
31
+ try {
32
+ resolve(JSON.parse(text));
33
+ } catch {
34
+ reject(new Error(`Invalid JSON from CapSolver: ${text.slice(0, 200)}`));
35
+ }
36
+ });
37
+ }
38
+ );
39
+ req.on('error', reject);
40
+ req.write(data);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ export class CaptchaSolver {
46
+ constructor({ vault }) {
47
+ this.vault = vault;
48
+ }
49
+
50
+ async solve({ serviceName, provider = 'capsolver', siteKey, siteUrl, captchaType = 'ReCaptchaV2TaskProxyLess', timeoutMs = 120_000 }) {
51
+ // Mock mode for testing
52
+ const mockToken = process.env.AGENTGATE_CAPTCHA_MOCK_TOKEN;
53
+ if (mockToken) {
54
+ log.info(`Returning mock CAPTCHA token for ${serviceName}`);
55
+ return { provider, service: serviceName, token: mockToken, source: 'mock' };
56
+ }
57
+
58
+ const apiKey = process.env.CAPSOLVER_API_KEY;
59
+ if (!apiKey) {
60
+ throw new Error(
61
+ 'CAPTCHA requested but no solver configured. Set CAPSOLVER_API_KEY or AGENTGATE_CAPTCHA_MOCK_TOKEN.'
62
+ );
63
+ }
64
+
65
+ if (provider !== 'capsolver') {
66
+ throw new Error(`Unsupported CAPTCHA provider: ${provider}. Only "capsolver" is supported.`);
67
+ }
68
+
69
+ log.info(`Creating CAPTCHA task for ${serviceName}`, { captchaType });
70
+
71
+ // Create task
72
+ const createResult = await postJson(`${CAPSOLVER_API}/createTask`, {
73
+ clientKey: apiKey,
74
+ task: {
75
+ type: captchaType,
76
+ websiteURL: siteUrl || `https://${serviceName}.com`,
77
+ websiteKey: siteKey || ''
78
+ }
79
+ });
80
+
81
+ if (createResult.errorId && createResult.errorId !== 0) {
82
+ throw new Error(`CapSolver createTask error: ${createResult.errorDescription || createResult.errorCode}`);
83
+ }
84
+
85
+ const taskId = createResult.taskId;
86
+ if (!taskId) {
87
+ throw new Error('CapSolver did not return a taskId');
88
+ }
89
+
90
+ log.info(`CapSolver task created: ${taskId}`);
91
+
92
+ // Poll for result
93
+ const started = Date.now();
94
+ const pollMs = 3_000;
95
+
96
+ while (Date.now() - started < timeoutMs) {
97
+ await sleep(pollMs);
98
+
99
+ const result = await postJson(`${CAPSOLVER_API}/getTaskResult`, {
100
+ clientKey: apiKey,
101
+ taskId
102
+ });
103
+
104
+ if (result.errorId && result.errorId !== 0) {
105
+ throw new Error(`CapSolver error: ${result.errorDescription || result.errorCode}`);
106
+ }
107
+
108
+ if (result.status === 'ready') {
109
+ const token =
110
+ result.solution?.gRecaptchaResponse ||
111
+ result.solution?.token ||
112
+ result.solution?.text ||
113
+ '';
114
+
115
+ if (!token) {
116
+ throw new Error('CapSolver returned ready status but no token in solution');
117
+ }
118
+
119
+ log.info(`CAPTCHA solved for ${serviceName}`);
120
+ return { provider, service: serviceName, token, source: 'capsolver' };
121
+ }
122
+
123
+ log.debug(`CapSolver task ${taskId} still processing...`);
124
+ }
125
+
126
+ throw new Error(`CAPTCHA solving timed out after ${timeoutMs}ms for ${serviceName}`);
127
+ }
128
+ }
@@ -0,0 +1,129 @@
1
+ import https from 'node:https';
2
+ import { createLogger } from '../logger.js';
3
+
4
+ const log = createLogger('gmail-watcher');
5
+
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+
10
+ function httpsRequest(url, options = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const req = https.request(url, options, (res) => {
13
+ const chunks = [];
14
+ res.on('data', (chunk) => chunks.push(chunk));
15
+ res.on('end', () => {
16
+ const body = Buffer.concat(chunks).toString('utf8');
17
+ if (res.statusCode >= 400) {
18
+ reject(new Error(`Gmail API ${res.statusCode}: ${body}`));
19
+ } else {
20
+ resolve(JSON.parse(body));
21
+ }
22
+ });
23
+ });
24
+ req.on('error', reject);
25
+ req.end();
26
+ });
27
+ }
28
+
29
+ export class GmailWatcher {
30
+ constructor({ vault }) {
31
+ this.vault = vault;
32
+ }
33
+
34
+ async waitForCode({ serviceName, timeoutMs = 120_000, pollMs = 5_000, regex = '(\\d{6})' }) {
35
+ // Mock mode: environment variable override for testing
36
+ const mockCode = process.env.AGENTGATE_GMAIL_MOCK_CODE;
37
+ if (mockCode) {
38
+ const compiled = new RegExp(regex);
39
+ const match = mockCode.match(compiled);
40
+ if (match && match[1]) {
41
+ log.info(`Returning mock email code for ${serviceName}`);
42
+ return { code: match[1], source: 'mock', service: serviceName };
43
+ }
44
+ }
45
+
46
+ const payload = this.vault.getPayload();
47
+ const token = payload.google?.gmailOAuthToken;
48
+ if (!token) {
49
+ throw new Error('Gmail OAuth token missing. Run `agentgate setup`.');
50
+ }
51
+
52
+ log.info(`Polling Gmail for verification code from ${serviceName}`);
53
+ const started = Date.now();
54
+ const compiled = new RegExp(regex);
55
+ const searchAfter = Math.floor(started / 1000) - 60; // messages from last minute
56
+
57
+ while (Date.now() - started < timeoutMs) {
58
+ try {
59
+ const code = await this.pollOnce({ token, serviceName, compiled, searchAfter });
60
+ if (code) {
61
+ log.info(`Found verification code for ${serviceName}`);
62
+ return { code, source: 'gmail_api', service: serviceName };
63
+ }
64
+ } catch (error) {
65
+ log.warn(`Gmail poll error: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+
68
+ await sleep(pollMs);
69
+ }
70
+
71
+ throw new Error(`Timed out waiting for verification email for ${serviceName}`);
72
+ }
73
+
74
+ async pollOnce({ token, serviceName, compiled, searchAfter }) {
75
+ const query = encodeURIComponent(`from:${serviceName} after:${searchAfter} is:unread`);
76
+ const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${query}&maxResults=5`;
77
+
78
+ const listResult = await httpsRequest(listUrl, {
79
+ headers: { Authorization: `Bearer ${token}` }
80
+ });
81
+
82
+ if (!listResult.messages || listResult.messages.length === 0) {
83
+ return null;
84
+ }
85
+
86
+ for (const msg of listResult.messages) {
87
+ const msgUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=full`;
88
+ const detail = await httpsRequest(msgUrl, {
89
+ headers: { Authorization: `Bearer ${token}` }
90
+ });
91
+
92
+ const body = this.extractBody(detail);
93
+ if (!body) continue;
94
+
95
+ const match = body.match(compiled);
96
+ if (match && match[1]) {
97
+ return match[1];
98
+ }
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ extractBody(message) {
105
+ if (!message.payload) return null;
106
+
107
+ // Check snippet first (often contains the code)
108
+ if (message.snippet) {
109
+ return message.snippet;
110
+ }
111
+
112
+ // Decode body parts
113
+ const parts = message.payload.parts || [message.payload];
114
+ for (const part of parts) {
115
+ if (part.mimeType === 'text/plain' && part.body?.data) {
116
+ return Buffer.from(part.body.data, 'base64url').toString('utf8');
117
+ }
118
+ }
119
+
120
+ // Fallback to HTML part
121
+ for (const part of parts) {
122
+ if (part.mimeType === 'text/html' && part.body?.data) {
123
+ return Buffer.from(part.body.data, 'base64url').toString('utf8');
124
+ }
125
+ }
126
+
127
+ return null;
128
+ }
129
+ }
package/src/logger.js ADDED
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
5
+
6
+ let globalLevel = LEVELS[process.env.AGENTGATE_LOG_LEVEL?.toLowerCase()] ?? LEVELS.info;
7
+ let logStream = null;
8
+ let logFilePath = null;
9
+ let logFileSize = 0;
10
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
11
+ const MAX_ROTATED = 3;
12
+
13
+ export function setLogLevel(level) {
14
+ const normalized = String(level).toLowerCase();
15
+ if (normalized in LEVELS) {
16
+ globalLevel = LEVELS[normalized];
17
+ }
18
+ }
19
+
20
+ export function getLogLevel() {
21
+ return Object.entries(LEVELS).find(([, v]) => v === globalLevel)?.[0] ?? 'info';
22
+ }
23
+
24
+ export function enableFileLogging(logsDir) {
25
+ try {
26
+ fs.mkdirSync(logsDir, { recursive: true });
27
+ logFilePath = path.join(logsDir, 'agentgate.log');
28
+ rotateIfNeeded();
29
+ logStream = fs.createWriteStream(logFilePath, { flags: 'a', mode: 0o600 });
30
+ logFileSize = fs.existsSync(logFilePath) ? fs.statSync(logFilePath).size : 0;
31
+ } catch {
32
+ // File logging is optional — silently degrade
33
+ logStream = null;
34
+ }
35
+ }
36
+
37
+ function rotateIfNeeded() {
38
+ if (!logFilePath || !fs.existsSync(logFilePath)) return;
39
+ const stat = fs.statSync(logFilePath);
40
+ if (stat.size < MAX_LOG_SIZE) return;
41
+
42
+ for (let i = MAX_ROTATED - 1; i >= 1; i--) {
43
+ const src = `${logFilePath}.${i}`;
44
+ const dst = `${logFilePath}.${i + 1}`;
45
+ if (fs.existsSync(src)) {
46
+ try { fs.renameSync(src, dst); } catch { /* best effort */ }
47
+ }
48
+ }
49
+ try {
50
+ fs.renameSync(logFilePath, `${logFilePath}.1`);
51
+ } catch { /* best effort */ }
52
+ logFileSize = 0;
53
+ }
54
+
55
+ export function maskSecrets(str) {
56
+ if (typeof str !== 'string') return str;
57
+ // Mask strings that look like API keys (20+ chars), keeping last 4.
58
+ // Only mask strings with mixed character classes (upper+lower, or letters+digits)
59
+ // to avoid masking normal identifiers like function names.
60
+ return str.replace(/\b([A-Za-z0-9_\-]{20,})\b/g, (_match, key) => {
61
+ const hasUpper = /[A-Z]/.test(key);
62
+ const hasLower = /[a-z]/.test(key);
63
+ const hasDigit = /\d/.test(key);
64
+ const classCount = [hasUpper, hasLower, hasDigit].filter(Boolean).length;
65
+ if (classCount < 2) return key;
66
+ return `***${key.slice(-4)}`;
67
+ });
68
+ }
69
+
70
+ function formatEntry(level, module, msg, data) {
71
+ const entry = {
72
+ ts: new Date().toISOString(),
73
+ level,
74
+ module,
75
+ msg
76
+ };
77
+ if (data !== undefined) {
78
+ entry.data = data;
79
+ }
80
+ return JSON.stringify(entry);
81
+ }
82
+
83
+ function writeLog(line) {
84
+ process.stderr.write(line + '\n');
85
+
86
+ if (logStream) {
87
+ const bytes = Buffer.byteLength(line) + 1;
88
+ logFileSize += bytes;
89
+ logStream.write(line + '\n');
90
+ if (logFileSize >= MAX_LOG_SIZE) {
91
+ logStream.end();
92
+ rotateIfNeeded();
93
+ logStream = fs.createWriteStream(logFilePath, { flags: 'a', mode: 0o600 });
94
+ }
95
+ }
96
+ }
97
+
98
+ export function createLogger(module) {
99
+ function log(level, msg, data) {
100
+ if (LEVELS[level] < globalLevel) return;
101
+ const safeMsg = maskSecrets(msg);
102
+ const safeData = data !== undefined ? JSON.parse(maskSecrets(JSON.stringify(data))) : undefined;
103
+ const line = formatEntry(level, module, safeMsg, safeData);
104
+ writeLog(line);
105
+ }
106
+
107
+ return {
108
+ debug: (msg, data) => log('debug', msg, data),
109
+ info: (msg, data) => log('info', msg, data),
110
+ warn: (msg, data) => log('warn', msg, data),
111
+ error: (msg, data) => log('error', msg, data)
112
+ };
113
+ }
114
+
115
+ export function closeLogger() {
116
+ if (logStream) {
117
+ logStream.end();
118
+ logStream = null;
119
+ }
120
+ }
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { createLogger } from './logger.js';
5
+
6
+ const log = createLogger('mcp-server');
7
+
8
+ function loadVersion() {
9
+ try {
10
+ const pkgPath = path.resolve(import.meta.dirname, '..', 'package.json');
11
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
12
+ return pkg.version || '0.0.0';
13
+ } catch {
14
+ return '0.0.0';
15
+ }
16
+ }
17
+
18
+ const VERSION = loadVersion();
19
+
20
+ const TOOL_DEFS = [
21
+ {
22
+ name: 'get_or_create_key',
23
+ description:
24
+ 'Get an API key for any service. Returns cached key if one exists, otherwise opens a browser ' +
25
+ 'with the saved Google session to sign up and extract a new key. Works for ANY service — ' +
26
+ 'just provide the service name and signup URL.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ service: {
31
+ type: 'string',
32
+ description: 'Service name (e.g. "openai", "anthropic", "twelvelabs")'
33
+ },
34
+ signup_url: {
35
+ type: 'string',
36
+ description: 'URL to the service signup or login page (e.g. "https://platform.openai.com/signup")'
37
+ },
38
+ api_key_url: {
39
+ type: 'string',
40
+ description: 'Direct URL to the API keys dashboard (e.g. "https://platform.openai.com/api-keys"). If provided, navigates here after sign-in to find/create the key.'
41
+ }
42
+ },
43
+ required: ['service', 'signup_url']
44
+ }
45
+ },
46
+ {
47
+ name: 'list_my_keys',
48
+ description: 'List all API keys stored by AgentGate, including active and revoked keys.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {}
52
+ }
53
+ },
54
+ {
55
+ name: 'revoke_key',
56
+ description: 'Revoke the stored API key for a service. This only removes it from AgentGate — it does not revoke it on the provider side.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ service: {
61
+ type: 'string',
62
+ description: 'Service name to revoke the key for'
63
+ }
64
+ },
65
+ required: ['service']
66
+ }
67
+ },
68
+ {
69
+ name: 'check_key_status',
70
+ description: 'Check whether AgentGate has an active API key stored for a service.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ service: {
75
+ type: 'string',
76
+ description: 'Service name to check'
77
+ }
78
+ },
79
+ required: ['service']
80
+ }
81
+ }
82
+ ];
83
+
84
+ export class McpServer {
85
+ constructor({ orchestrator }) {
86
+ this.orchestrator = orchestrator;
87
+ this.queue = Promise.resolve();
88
+ this.initialized = false;
89
+ }
90
+
91
+ start() {
92
+ const rl = readline.createInterface({
93
+ input: process.stdin,
94
+ crlfDelay: Infinity
95
+ });
96
+
97
+ rl.on('line', (line) => {
98
+ this.queue = this.queue.then(() => this.processLine(line));
99
+ });
100
+
101
+ rl.on('close', () => {
102
+ log.info('stdin closed, shutting down');
103
+ });
104
+
105
+ log.info('MCP server started', { version: VERSION });
106
+ }
107
+
108
+ async processLine(line) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed) return;
111
+
112
+ let request;
113
+ try {
114
+ request = JSON.parse(trimmed);
115
+ } catch {
116
+ this.write({
117
+ jsonrpc: '2.0',
118
+ error: { code: -32700, message: 'Parse error' },
119
+ id: null
120
+ });
121
+ return;
122
+ }
123
+
124
+ if (typeof request.id === 'undefined') {
125
+ this.handleNotification(request);
126
+ return;
127
+ }
128
+
129
+ try {
130
+ const result = await this.handleRequest(request);
131
+ this.write({ jsonrpc: '2.0', id: request.id, result });
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : 'Unknown error';
134
+ log.error(`Request failed: ${request.method}`, { error: message });
135
+ this.write({
136
+ jsonrpc: '2.0',
137
+ id: request.id,
138
+ error: { code: -32000, message }
139
+ });
140
+ }
141
+ }
142
+
143
+ handleNotification(request) {
144
+ const { method } = request;
145
+ if (method === 'notifications/initialized') {
146
+ this.initialized = true;
147
+ log.info('Client initialized');
148
+ } else if (method === 'notifications/cancelled') {
149
+ log.info('Client cancelled request', { params: request.params });
150
+ } else {
151
+ log.debug(`Unhandled notification: ${method}`);
152
+ }
153
+ }
154
+
155
+ async handleRequest(request) {
156
+ const { method, params } = request;
157
+
158
+ if (method === 'initialize') {
159
+ return {
160
+ protocolVersion: '2024-11-05',
161
+ serverInfo: { name: 'agentgate', version: VERSION },
162
+ capabilities: { tools: {} }
163
+ };
164
+ }
165
+
166
+ if (method === 'ping') {
167
+ return {};
168
+ }
169
+
170
+ if (method === 'tools/list') {
171
+ return { tools: TOOL_DEFS };
172
+ }
173
+
174
+ if (method === 'tools/call') {
175
+ const { name, arguments: args } = params || {};
176
+ log.info(`Calling tool: ${name}`, { args });
177
+ const output = await this.callTool(name, args || {});
178
+ return {
179
+ content: [{ type: 'text', text: JSON.stringify(output) }]
180
+ };
181
+ }
182
+
183
+ throw new Error(`Unsupported method: ${method}`);
184
+ }
185
+
186
+ async callTool(name, args) {
187
+ switch (name) {
188
+ case 'get_or_create_key':
189
+ return this.orchestrator.getOrCreateKey(args);
190
+ case 'list_my_keys':
191
+ return this.orchestrator.getAllMyKeys();
192
+ case 'revoke_key':
193
+ return this.orchestrator.revokeKey(args.service);
194
+ case 'check_key_status':
195
+ return this.orchestrator.checkKeyStatus(args.service);
196
+ default:
197
+ throw new Error(`Unknown tool: ${name}`);
198
+ }
199
+ }
200
+
201
+ write(payload) {
202
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
203
+ }
204
+ }
@@ -0,0 +1,107 @@
1
+ import { createLogger } from './logger.js';
2
+
3
+ const log = createLogger('orchestrator');
4
+
5
+ export class Orchestrator {
6
+ constructor({ db, registry, signupEngine }) {
7
+ this.db = db;
8
+ this.registry = registry;
9
+ this.signupEngine = signupEngine;
10
+ }
11
+
12
+ getAllMyKeys() {
13
+ return this.db.getAllKeys().map((row) => ({
14
+ service: row.service,
15
+ api_key: row.api_key,
16
+ status: row.status,
17
+ created_at: row.created_at,
18
+ updated_at: row.updated_at
19
+ }));
20
+ }
21
+
22
+ checkKeyStatus(service) {
23
+ const existing = this.db.getActiveKey(service);
24
+ if (!existing) {
25
+ return { service, exists: false, status: 'missing' };
26
+ }
27
+ return {
28
+ service,
29
+ exists: true,
30
+ status: existing.status,
31
+ created_at: existing.created_at,
32
+ updated_at: existing.updated_at
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Get or create an API key for any service.
38
+ *
39
+ * @param {object} opts
40
+ * @param {string} opts.service - Service name (e.g. "openai")
41
+ * @param {string} opts.signup_url - Signup or login URL
42
+ * @param {string} [opts.api_key_url] - Direct URL to API keys dashboard
43
+ */
44
+ async getOrCreateKey({ service, signup_url, api_key_url }) {
45
+ if (!service) throw new Error('service is required');
46
+ if (!signup_url) throw new Error('signup_url is required');
47
+
48
+ // Check cache first
49
+ const existing = this.db.getActiveKey(service);
50
+ if (existing) {
51
+ log.info(`Returning cached key for ${service}`);
52
+ return {
53
+ service,
54
+ api_key: existing.api_key,
55
+ source: 'cache',
56
+ created_at: existing.created_at
57
+ };
58
+ }
59
+
60
+ // Build service definition — check registry for a recipe, else use smart mode
61
+ let serviceDef = this.registry.getService(service);
62
+
63
+ if (!serviceDef) {
64
+ // No recipe — create a dynamic service definition
65
+ serviceDef = {
66
+ name: service,
67
+ signup_url,
68
+ api_key_url: api_key_url || null,
69
+ workflow: [] // empty = smart navigation mode
70
+ };
71
+ } else {
72
+ // Recipe exists — override URLs if caller provided them
73
+ if (signup_url) serviceDef.signup_url = signup_url;
74
+ if (api_key_url) serviceDef.api_key_url = api_key_url;
75
+ }
76
+
77
+ log.info(`Creating key for ${service}`, {
78
+ mode: serviceDef.workflow?.length > 0 ? 'recipe' : 'smart',
79
+ signup_url: serviceDef.signup_url,
80
+ api_key_url: serviceDef.api_key_url
81
+ });
82
+
83
+ const fresh = await this.signupEngine.createKey(serviceDef);
84
+ const stored = this.db.upsertActiveKey({
85
+ service,
86
+ apiKey: fresh.apiKey,
87
+ metadata: fresh.metadata
88
+ });
89
+
90
+ log.info(`Key created for ${service}`, { source: fresh.metadata?.flow });
91
+
92
+ return {
93
+ service,
94
+ api_key: stored.api_key,
95
+ source: fresh.metadata?.flow || 'signup',
96
+ created_at: stored.created_at
97
+ };
98
+ }
99
+
100
+ revokeKey(service) {
101
+ const changed = this.db.revokeKey(service);
102
+ if (changed) {
103
+ log.info(`Revoked key for ${service}`);
104
+ }
105
+ return { service, revoked: changed };
106
+ }
107
+ }