cyrus-edge-worker 0.0.40 → 0.2.0-rc

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.
@@ -1,33 +1,34 @@
1
- import { randomUUID } from "node:crypto";
2
- import { createServer, } from "node:http";
3
- import { URL } from "node:url";
4
- import { forward } from "@ngrok/ngrok";
5
- import { DEFAULT_PROXY_URL } from "cyrus-core";
1
+ import { CloudflareTunnelClient } from "cyrus-cloudflare-tunnel-client";
2
+ import Fastify from "fastify";
6
3
  /**
7
4
  * Shared application server that handles both webhooks and OAuth callbacks on a single port
8
5
  * Consolidates functionality from SharedWebhookServer and CLI OAuth server
9
6
  */
10
7
  export class SharedApplicationServer {
11
- server = null;
8
+ app = null;
12
9
  webhookHandlers = new Map();
13
- // Separate handlers for LinearWebhookClient that handle raw req/res
10
+ // Legacy handlers for direct Linear webhook registration (deprecated)
14
11
  linearWebhookHandlers = new Map();
15
12
  oauthCallbacks = new Map();
16
- oauthCallbackHandler = null;
17
- oauthStates = new Map();
18
13
  pendingApprovals = new Map();
19
14
  port;
20
15
  host;
21
16
  isListening = false;
22
- ngrokListener = null;
23
- ngrokAuthToken = null;
24
- ngrokUrl = null;
25
- proxyUrl;
26
- constructor(port = 3456, host = "localhost", ngrokAuthToken, proxyUrl) {
17
+ tunnelClient = null;
18
+ constructor(port = 3456, host = "localhost") {
27
19
  this.port = port;
28
20
  this.host = host;
29
- this.ngrokAuthToken = ngrokAuthToken || null;
30
- this.proxyUrl = proxyUrl || process.env.PROXY_URL || DEFAULT_PROXY_URL;
21
+ }
22
+ /**
23
+ * Initialize the Fastify app instance (must be called before registering routes)
24
+ */
25
+ initializeFastify() {
26
+ if (this.app) {
27
+ return; // Already initialized
28
+ }
29
+ this.app = Fastify({
30
+ logger: false,
31
+ });
31
32
  }
32
33
  /**
33
34
  * Start the shared application server
@@ -36,30 +37,63 @@ export class SharedApplicationServer {
36
37
  if (this.isListening) {
37
38
  return; // Already listening
38
39
  }
39
- return new Promise((resolve, reject) => {
40
- this.server = createServer((req, res) => {
41
- this.handleRequest(req, res);
40
+ // Initialize Fastify if not already done
41
+ this.initializeFastify();
42
+ try {
43
+ await this.app.listen({
44
+ port: this.port,
45
+ host: this.host,
42
46
  });
43
- this.server.listen(this.port, this.host, async () => {
44
- this.isListening = true;
45
- console.log(`🔗 Shared application server listening on http://${this.host}:${this.port}`);
46
- // Start ngrok tunnel if auth token is provided and not external host
47
- const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
48
- if (this.ngrokAuthToken && !isExternalHost) {
49
- try {
50
- await this.startNgrokTunnel();
51
- }
52
- catch (error) {
53
- console.error("🔴 Failed to start ngrok tunnel:", error);
54
- // Don't reject here - server can still work without ngrok
55
- }
47
+ this.isListening = true;
48
+ console.log(`🔗 Shared application server listening on http://${this.host}:${this.port}`);
49
+ // Start Cloudflare tunnel if CLOUDFLARE_TOKEN is set
50
+ if (process.env.CLOUDFLARE_TOKEN) {
51
+ await this.startCloudflareTunnel(process.env.CLOUDFLARE_TOKEN);
52
+ }
53
+ }
54
+ catch (error) {
55
+ this.isListening = false;
56
+ throw error;
57
+ }
58
+ }
59
+ /**
60
+ * Start Cloudflare tunnel and wait for 4 'connected' events
61
+ */
62
+ async startCloudflareTunnel(cloudflareToken) {
63
+ return new Promise((resolve, reject) => {
64
+ let connectionCount = 0;
65
+ const requiredConnections = 4;
66
+ this.tunnelClient = new CloudflareTunnelClient(cloudflareToken, this.port);
67
+ // Listen for connection events (Cloudflare establishes 4 connections per tunnel)
68
+ this.tunnelClient.on("connected", () => {
69
+ connectionCount++;
70
+ console.log(`🔗 Cloudflare tunnel connection ${connectionCount}/${requiredConnections} established`);
71
+ if (connectionCount === requiredConnections) {
72
+ console.log("✅ Cloudflare tunnel fully connected and ready");
73
+ resolve();
56
74
  }
57
- resolve();
58
75
  });
59
- this.server.on("error", (error) => {
60
- this.isListening = false;
76
+ // Listen for ready event to get tunnel URL
77
+ this.tunnelClient.on("ready", (tunnelUrl) => {
78
+ console.log(`🔗 Cloudflare tunnel URL: ${tunnelUrl}`);
79
+ });
80
+ // Listen for error events
81
+ this.tunnelClient.on("error", (error) => {
82
+ console.error("❌ Cloudflare tunnel error:", error);
61
83
  reject(error);
62
84
  });
85
+ // Listen for disconnect events
86
+ this.tunnelClient.on("disconnect", (reason) => {
87
+ console.log(`🔗 Cloudflare tunnel disconnected: ${reason}`);
88
+ });
89
+ // Start the tunnel
90
+ this.tunnelClient.startTunnel().catch(reject);
91
+ // Timeout after 30 seconds
92
+ setTimeout(() => {
93
+ if (connectionCount < requiredConnections) {
94
+ reject(new Error(`Timeout waiting for Cloudflare tunnel (${connectionCount}/${requiredConnections} connections)`));
95
+ }
96
+ }, 30000);
63
97
  });
64
98
  }
65
99
  /**
@@ -72,26 +106,16 @@ export class SharedApplicationServer {
72
106
  console.log(`🔐 Rejected pending approval for session ${sessionId} due to shutdown`);
73
107
  }
74
108
  this.pendingApprovals.clear();
75
- // Stop ngrok tunnel first
76
- if (this.ngrokListener) {
77
- try {
78
- await this.ngrokListener.close();
79
- this.ngrokListener = null;
80
- this.ngrokUrl = null;
81
- console.log("🔗 Ngrok tunnel stopped");
82
- }
83
- catch (error) {
84
- console.error("🔴 Failed to stop ngrok tunnel:", error);
85
- }
109
+ // Stop Cloudflare tunnel if running
110
+ if (this.tunnelClient) {
111
+ this.tunnelClient.disconnect();
112
+ this.tunnelClient = null;
113
+ console.log("🔗 Cloudflare tunnel stopped");
86
114
  }
87
- if (this.server && this.isListening) {
88
- return new Promise((resolve) => {
89
- this.server.close(() => {
90
- this.isListening = false;
91
- console.log("🔗 Shared application server stopped");
92
- resolve();
93
- });
94
- });
115
+ if (this.app && this.isListening) {
116
+ await this.app.close();
117
+ this.isListening = false;
118
+ console.log("🔗 Shared application server stopped");
95
119
  }
96
120
  }
97
121
  /**
@@ -101,42 +125,20 @@ export class SharedApplicationServer {
101
125
  return this.port;
102
126
  }
103
127
  /**
104
- * Get the base URL for the server (ngrok URL if available, otherwise local URL)
128
+ * Get the Fastify instance for registering routes
129
+ * Initializes Fastify if not already done
105
130
  */
106
- getBaseUrl() {
107
- if (this.ngrokUrl) {
108
- return this.ngrokUrl;
109
- }
110
- return process.env.CYRUS_BASE_URL || `http://${this.host}:${this.port}`;
111
- }
112
- /**
113
- * Start ngrok tunnel for the server
114
- */
115
- async startNgrokTunnel() {
116
- if (!this.ngrokAuthToken) {
117
- return;
118
- }
119
- try {
120
- console.log("🔗 Starting ngrok tunnel...");
121
- this.ngrokListener = await forward({
122
- addr: this.port,
123
- authtoken: this.ngrokAuthToken,
124
- });
125
- this.ngrokUrl = this.ngrokListener.url();
126
- console.log(`🌐 Ngrok tunnel active: ${this.ngrokUrl}`);
127
- // Override CYRUS_BASE_URL with ngrok URL
128
- process.env.CYRUS_BASE_URL = this.ngrokUrl || undefined;
129
- }
130
- catch (error) {
131
- console.error("🔴 Failed to start ngrok tunnel:", error);
132
- throw error;
133
- }
131
+ getFastifyInstance() {
132
+ this.initializeFastify();
133
+ return this.app;
134
134
  }
135
135
  /**
136
- * Register a webhook handler for a specific token
136
+ * Register a webhook handler for a specific token (LEGACY - deprecated)
137
137
  * Supports two signatures:
138
138
  * 1. For ndjson-client: (token, secret, handler)
139
- * 2. For linear-webhook-client: (token, handler) where handler takes (req, res)
139
+ * 2. For legacy direct registration: (token, handler) where handler takes (req, res)
140
+ *
141
+ * NOTE: New code should use LinearEventTransport which registers routes directly with Fastify
140
142
  */
141
143
  registerWebhookHandler(token, secretOrHandler, handler) {
142
144
  if (typeof secretOrHandler === "string" && handler) {
@@ -145,9 +147,9 @@ export class SharedApplicationServer {
145
147
  console.log(`🔗 Registered webhook handler (proxy-style) for token ending in ...${token.slice(-4)}`);
146
148
  }
147
149
  else if (typeof secretOrHandler === "function") {
148
- // linear-webhook-client style registration
150
+ // Legacy direct registration
149
151
  this.linearWebhookHandlers.set(token, secretOrHandler);
150
- console.log(`🔗 Registered webhook handler (direct-style) for token ending in ...${token.slice(-4)}`);
152
+ console.log(`🔗 Registered webhook handler (legacy direct-style) for token ending in ...${token.slice(-4)}`);
151
153
  }
152
154
  else {
153
155
  throw new Error("Invalid webhook handler registration parameters");
@@ -163,13 +165,6 @@ export class SharedApplicationServer {
163
165
  console.log(`🔗 Unregistered webhook handler for token ending in ...${token.slice(-4)}`);
164
166
  }
165
167
  }
166
- /**
167
- * Register an OAuth callback handler
168
- */
169
- registerOAuthCallbackHandler(handler) {
170
- this.oauthCallbackHandler = handler;
171
- console.log("🔐 Registered OAuth callback handler");
172
- }
173
168
  /**
174
169
  * Start OAuth flow and return promise that resolves when callback is received
175
170
  */
@@ -182,7 +177,7 @@ export class SharedApplicationServer {
182
177
  // Check if we should use direct Linear OAuth (when self-hosting)
183
178
  const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
184
179
  const useDirectOAuth = isExternalHost && process.env.LINEAR_CLIENT_ID;
185
- const callbackBaseUrl = this.getBaseUrl();
180
+ const callbackBaseUrl = `http://${this.host}:${this.port}`;
186
181
  let authUrl;
187
182
  if (useDirectOAuth) {
188
183
  // Use local OAuth authorize endpoint
@@ -205,25 +200,10 @@ export class SharedApplicationServer {
205
200
  });
206
201
  }
207
202
  /**
208
- * Get the public URL (ngrok URL if available, otherwise base URL)
209
- */
210
- getPublicUrl() {
211
- // Use ngrok URL if available
212
- if (this.ngrokUrl) {
213
- return this.ngrokUrl;
214
- }
215
- // If CYRUS_BASE_URL is set (could be from external proxy), use that
216
- if (process.env.CYRUS_BASE_URL) {
217
- return process.env.CYRUS_BASE_URL;
218
- }
219
- // Default to local URL
220
- return `http://${this.host}:${this.port}`;
221
- }
222
- /**
223
- * Get the webhook URL for registration with proxy
203
+ * Get the webhook URL
224
204
  */
225
205
  getWebhookUrl() {
226
- return `${this.getPublicUrl()}/webhook`;
206
+ return `http://${this.host}:${this.port}/webhook`;
227
207
  }
228
208
  /**
229
209
  * Get the OAuth callback URL for registration with proxy
@@ -231,442 +211,6 @@ export class SharedApplicationServer {
231
211
  getOAuthCallbackUrl() {
232
212
  return `http://${this.host}:${this.port}/callback`;
233
213
  }
234
- /**
235
- * Handle incoming requests (both webhooks and OAuth callbacks)
236
- */
237
- async handleRequest(req, res) {
238
- try {
239
- const url = new URL(req.url, `http://${this.host}:${this.port}`);
240
- if (url.pathname === "/webhook") {
241
- await this.handleWebhookRequest(req, res);
242
- }
243
- else if (url.pathname === "/callback") {
244
- await this.handleOAuthCallback(req, res, url);
245
- }
246
- else if (url.pathname === "/oauth/authorize") {
247
- await this.handleOAuthAuthorize(req, res, url);
248
- }
249
- else if (url.pathname === "/approval") {
250
- await this.handleApprovalRequest(req, res, url);
251
- }
252
- else {
253
- res.writeHead(404, { "Content-Type": "text/plain" });
254
- res.end("Not Found");
255
- }
256
- }
257
- catch (error) {
258
- console.error("🔗 Request handling error:", error);
259
- res.writeHead(500, { "Content-Type": "text/plain" });
260
- res.end("Internal Server Error");
261
- }
262
- }
263
- /**
264
- * Handle incoming webhook requests
265
- */
266
- async handleWebhookRequest(req, res) {
267
- try {
268
- console.log(`🔗 Incoming webhook request: ${req.method} ${req.url}`);
269
- if (req.method !== "POST") {
270
- console.log(`🔗 Rejected non-POST request: ${req.method}`);
271
- res.writeHead(405, { "Content-Type": "text/plain" });
272
- res.end("Method Not Allowed");
273
- return;
274
- }
275
- // Check if this is a direct Linear webhook (has linear-signature header)
276
- const linearSignature = req.headers["linear-signature"];
277
- const isDirectWebhook = !!linearSignature;
278
- if (isDirectWebhook && this.linearWebhookHandlers.size > 0) {
279
- // For direct Linear webhooks, pass the raw request to the handler
280
- // The LinearWebhookClient will handle its own signature verification
281
- console.log(`🔗 Direct Linear webhook received, trying ${this.linearWebhookHandlers.size} direct handlers`);
282
- // Try each direct handler
283
- for (const [token, handler] of this.linearWebhookHandlers) {
284
- try {
285
- // The handler will manage the response
286
- await handler(req, res);
287
- console.log(`🔗 Direct webhook delivered to token ending in ...${token.slice(-4)}`);
288
- return;
289
- }
290
- catch (error) {
291
- console.error(`🔗 Error in direct webhook handler for token ...${token.slice(-4)}:`, error);
292
- }
293
- }
294
- // No direct handler could process it
295
- console.error(`🔗 Direct webhook processing failed for all ${this.linearWebhookHandlers.size} handlers`);
296
- res.writeHead(401, { "Content-Type": "text/plain" });
297
- res.end("Unauthorized");
298
- return;
299
- }
300
- // Otherwise, handle as proxy-style webhook
301
- // Read request body
302
- let body = "";
303
- req.on("data", (chunk) => {
304
- body += chunk.toString();
305
- });
306
- req.on("end", () => {
307
- try {
308
- // For proxy-style webhooks, we need the signature header
309
- const signature = req.headers["x-webhook-signature"];
310
- const timestamp = req.headers["x-webhook-timestamp"];
311
- console.log(`🔗 Proxy webhook received with ${body.length} bytes, ${this.webhookHandlers.size} registered handlers`);
312
- if (!signature) {
313
- console.log("🔗 Webhook rejected: Missing signature header");
314
- res.writeHead(400, { "Content-Type": "text/plain" });
315
- res.end("Missing signature");
316
- return;
317
- }
318
- // Try each registered handler until one verifies the signature
319
- let handlerAttempts = 0;
320
- for (const [token, { handler }] of this.webhookHandlers) {
321
- handlerAttempts++;
322
- try {
323
- if (handler(body, signature, timestamp)) {
324
- // Handler verified signature and processed webhook
325
- res.writeHead(200, { "Content-Type": "text/plain" });
326
- res.end("OK");
327
- console.log(`🔗 Webhook delivered to token ending in ...${token.slice(-4)} (attempt ${handlerAttempts}/${this.webhookHandlers.size})`);
328
- return;
329
- }
330
- }
331
- catch (error) {
332
- console.error(`🔗 Error in webhook handler for token ...${token.slice(-4)}:`, error);
333
- }
334
- }
335
- // No handler could verify the signature
336
- console.error(`🔗 Webhook signature verification failed for all ${this.webhookHandlers.size} registered handlers`);
337
- res.writeHead(401, { "Content-Type": "text/plain" });
338
- res.end("Unauthorized");
339
- }
340
- catch (error) {
341
- console.error("🔗 Error processing webhook:", error);
342
- res.writeHead(400, { "Content-Type": "text/plain" });
343
- res.end("Bad Request");
344
- }
345
- });
346
- req.on("error", (error) => {
347
- console.error("🔗 Request error:", error);
348
- res.writeHead(500, { "Content-Type": "text/plain" });
349
- res.end("Internal Server Error");
350
- });
351
- }
352
- catch (error) {
353
- console.error("🔗 Webhook request error:", error);
354
- res.writeHead(500, { "Content-Type": "text/plain" });
355
- res.end("Internal Server Error");
356
- }
357
- }
358
- /**
359
- * Handle OAuth callback requests
360
- */
361
- async handleOAuthCallback(_req, res, url) {
362
- try {
363
- const code = url.searchParams.get("code");
364
- const state = url.searchParams.get("state");
365
- // Check if this is a direct Linear callback (has code and state)
366
- const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
367
- const isDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase().trim() === "true";
368
- // Handle direct callback if both external host and direct webhooks are enabled
369
- if (code && state && isExternalHost && isDirectWebhooks) {
370
- await this.handleDirectLinearCallback(_req, res, url);
371
- return;
372
- }
373
- // Otherwise handle as proxy callback
374
- const token = url.searchParams.get("token");
375
- const workspaceId = url.searchParams.get("workspaceId");
376
- const workspaceName = url.searchParams.get("workspaceName");
377
- if (token && workspaceId && workspaceName) {
378
- // Success! Return the Linear credentials
379
- const linearCredentials = {
380
- linearToken: token,
381
- linearWorkspaceId: workspaceId,
382
- linearWorkspaceName: workspaceName,
383
- };
384
- // Send success response
385
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
386
- res.end(`
387
- <!DOCTYPE html>
388
- <html>
389
- <head>
390
- <meta charset="UTF-8">
391
- <title>Authorization Successful</title>
392
- </head>
393
- <body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
394
- <h1>✅ Authorization Successful!</h1>
395
- <p>You can close this window and return to the terminal.</p>
396
- <p>Your Linear workspace <strong>${workspaceName}</strong> has been connected.</p>
397
- <p style="margin-top: 30px;">
398
- <a href="${this.proxyUrl}/oauth/authorize?callback=${process.env.CYRUS_BASE_URL || `http://${this.host}:${this.port}`}/callback"
399
- style="padding: 10px 20px; background: #5E6AD2; color: white; text-decoration: none; border-radius: 5px;">
400
- Connect Another Workspace
401
- </a>
402
- </p>
403
- <script>setTimeout(() => window.close(), 10000)</script>
404
- </body>
405
- </html>
406
- `);
407
- console.log(`🔐 OAuth callback received for workspace: ${workspaceName}`);
408
- // Resolve any waiting promises
409
- if (this.oauthCallbacks.size > 0) {
410
- const callback = this.oauthCallbacks.values().next().value;
411
- if (callback) {
412
- callback.resolve(linearCredentials);
413
- this.oauthCallbacks.delete(callback.id);
414
- }
415
- }
416
- // Call the registered OAuth callback handler
417
- if (this.oauthCallbackHandler) {
418
- try {
419
- await this.oauthCallbackHandler(token, workspaceId, workspaceName);
420
- }
421
- catch (error) {
422
- console.error("🔐 Error in OAuth callback handler:", error);
423
- }
424
- }
425
- }
426
- else {
427
- res.writeHead(400, { "Content-Type": "text/html" });
428
- res.end("<h1>Error: No token received</h1>");
429
- // Reject any waiting promises
430
- for (const [id, callback] of this.oauthCallbacks) {
431
- callback.reject(new Error("No token received"));
432
- this.oauthCallbacks.delete(id);
433
- }
434
- }
435
- }
436
- catch (error) {
437
- console.error("🔐 OAuth callback error:", error);
438
- res.writeHead(500, { "Content-Type": "text/plain" });
439
- res.end("Internal Server Error");
440
- }
441
- }
442
- /**
443
- * Handle OAuth authorization requests for direct Linear OAuth
444
- */
445
- async handleOAuthAuthorize(_req, res, _url) {
446
- try {
447
- // Check if we're in external host mode with direct webhooks
448
- const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
449
- const isDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase().trim() === "true";
450
- // Only handle OAuth locally if both external host AND direct webhooks are enabled
451
- if (!isExternalHost || !isDirectWebhooks) {
452
- // Redirect to proxy OAuth endpoint
453
- const callbackBaseUrl = this.getBaseUrl();
454
- const proxyAuthUrl = `${this.proxyUrl}/oauth/authorize?callback=${callbackBaseUrl}/callback`;
455
- res.writeHead(302, { Location: proxyAuthUrl });
456
- res.end();
457
- return;
458
- }
459
- // Check for LINEAR_CLIENT_ID
460
- const clientId = process.env.LINEAR_CLIENT_ID;
461
- if (!clientId) {
462
- res.writeHead(400, { "Content-Type": "text/plain" });
463
- res.end("LINEAR_CLIENT_ID environment variable is required for direct OAuth");
464
- return;
465
- }
466
- // Generate state for CSRF protection
467
- const state = randomUUID();
468
- // Store state with expiration (10 minutes)
469
- this.oauthStates.set(state, {
470
- createdAt: Date.now(),
471
- redirectUri: `${this.getBaseUrl()}/callback`,
472
- });
473
- // Clean up expired states (older than 10 minutes)
474
- const now = Date.now();
475
- for (const [stateKey, stateData] of this.oauthStates) {
476
- if (now - stateData.createdAt > 10 * 60 * 1000) {
477
- this.oauthStates.delete(stateKey);
478
- }
479
- }
480
- // Build Linear OAuth URL
481
- const authUrl = new URL("https://linear.app/oauth/authorize");
482
- authUrl.searchParams.set("client_id", clientId);
483
- authUrl.searchParams.set("redirect_uri", `${this.getBaseUrl()}/callback`);
484
- authUrl.searchParams.set("response_type", "code");
485
- authUrl.searchParams.set("state", state);
486
- authUrl.searchParams.set("scope", "read,write,app:assignable,app:mentionable");
487
- authUrl.searchParams.set("actor", "app");
488
- authUrl.searchParams.set("prompt", "consent");
489
- console.log(`🔐 Redirecting to Linear OAuth: ${authUrl.toString()}`);
490
- // Redirect to Linear OAuth
491
- res.writeHead(302, { Location: authUrl.toString() });
492
- res.end();
493
- }
494
- catch (error) {
495
- console.error("🔐 OAuth authorize error:", error);
496
- res.writeHead(500, { "Content-Type": "text/plain" });
497
- res.end("Internal Server Error");
498
- }
499
- }
500
- /**
501
- * Handle direct Linear OAuth callback (exchange code for token)
502
- */
503
- async handleDirectLinearCallback(_req, res, url) {
504
- try {
505
- const code = url.searchParams.get("code");
506
- const state = url.searchParams.get("state");
507
- if (!code || !state) {
508
- res.writeHead(400, { "Content-Type": "text/plain" });
509
- res.end("Missing code or state parameter");
510
- return;
511
- }
512
- // Validate state
513
- const stateData = this.oauthStates.get(state);
514
- if (!stateData) {
515
- res.writeHead(400, { "Content-Type": "text/plain" });
516
- res.end("Invalid or expired state");
517
- return;
518
- }
519
- // Delete state after use
520
- this.oauthStates.delete(state);
521
- // Exchange code for token
522
- const tokenResponse = await this.exchangeCodeForToken(code);
523
- // Get workspace info using the token
524
- const workspaceInfo = await this.getWorkspaceInfo(tokenResponse.access_token);
525
- // Success! Return the Linear credentials
526
- const linearCredentials = {
527
- linearToken: tokenResponse.access_token,
528
- linearWorkspaceId: workspaceInfo.organization.id,
529
- linearWorkspaceName: workspaceInfo.organization.name,
530
- };
531
- // Send success response
532
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
533
- res.end(`
534
- <!DOCTYPE html>
535
- <html>
536
- <head>
537
- <meta charset="UTF-8">
538
- <title>Authorization Successful</title>
539
- </head>
540
- <body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
541
- <h1>✅ Authorization Successful!</h1>
542
- <p>You can close this window and return to the terminal.</p>
543
- <p>Your Linear workspace <strong>${workspaceInfo.organization.name}</strong> has been connected.</p>
544
- <p style="margin-top: 30px;">
545
- <a href="${this.getBaseUrl()}/oauth/authorize"
546
- style="padding: 10px 20px; background: #5E6AD2; color: white; text-decoration: none; border-radius: 5px;">
547
- Connect Another Workspace
548
- </a>
549
- </p>
550
- <script>setTimeout(() => window.close(), 10000)</script>
551
- </body>
552
- </html>
553
- `);
554
- console.log(`🔐 Direct OAuth callback received for workspace: ${workspaceInfo.organization.name}`);
555
- // Resolve any waiting promises
556
- if (this.oauthCallbacks.size > 0) {
557
- const callback = this.oauthCallbacks.values().next().value;
558
- if (callback) {
559
- callback.resolve(linearCredentials);
560
- this.oauthCallbacks.delete(callback.id);
561
- }
562
- }
563
- // Call the registered OAuth callback handler
564
- if (this.oauthCallbackHandler) {
565
- try {
566
- await this.oauthCallbackHandler(tokenResponse.access_token, workspaceInfo.organization.id, workspaceInfo.organization.name);
567
- }
568
- catch (error) {
569
- console.error("🔐 Error in OAuth callback handler:", error);
570
- }
571
- }
572
- }
573
- catch (error) {
574
- console.error("🔐 Direct Linear callback error:", error);
575
- res.writeHead(500, { "Content-Type": "text/plain" });
576
- res.end(`OAuth failed: ${error.message}`);
577
- // Reject any waiting promises
578
- for (const [id, callback] of this.oauthCallbacks) {
579
- callback.reject(error);
580
- this.oauthCallbacks.delete(id);
581
- }
582
- }
583
- }
584
- /**
585
- * Exchange authorization code for access token
586
- */
587
- async exchangeCodeForToken(code) {
588
- const clientId = process.env.LINEAR_CLIENT_ID;
589
- const clientSecret = process.env.LINEAR_CLIENT_SECRET;
590
- if (!clientId || !clientSecret) {
591
- throw new Error("LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET are required");
592
- }
593
- const response = await fetch("https://api.linear.app/oauth/token", {
594
- method: "POST",
595
- headers: {
596
- "Content-Type": "application/x-www-form-urlencoded",
597
- },
598
- body: new URLSearchParams({
599
- grant_type: "authorization_code",
600
- client_id: clientId,
601
- client_secret: clientSecret,
602
- redirect_uri: `${this.getBaseUrl()}/callback`,
603
- code: code,
604
- }),
605
- });
606
- if (!response.ok) {
607
- const error = await response.text();
608
- throw new Error(`Token exchange failed: ${error}`);
609
- }
610
- return await response.json();
611
- }
612
- /**
613
- * Get workspace information using access token
614
- */
615
- async getWorkspaceInfo(accessToken) {
616
- const response = await fetch("https://api.linear.app/graphql", {
617
- method: "POST",
618
- headers: {
619
- "Content-Type": "application/json",
620
- Authorization: `Bearer ${accessToken}`,
621
- },
622
- body: JSON.stringify({
623
- query: `
624
- query {
625
- viewer {
626
- id
627
- name
628
- email
629
- organization {
630
- id
631
- name
632
- urlKey
633
- teams {
634
- nodes {
635
- id
636
- key
637
- name
638
- }
639
- }
640
- }
641
- }
642
- }
643
- `,
644
- }),
645
- });
646
- if (!response.ok) {
647
- throw new Error("Failed to get workspace info");
648
- }
649
- const data = (await response.json());
650
- if (data.errors) {
651
- throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
652
- }
653
- return {
654
- userId: data.data.viewer.id,
655
- userEmail: data.data.viewer.email,
656
- organization: data.data.viewer.organization,
657
- };
658
- }
659
- /**
660
- * Escape HTML special characters to prevent XSS attacks
661
- */
662
- escapeHtml(unsafe) {
663
- return unsafe
664
- .replace(/&/g, "&amp;")
665
- .replace(/</g, "&lt;")
666
- .replace(/>/g, "&gt;")
667
- .replace(/"/g, "&quot;")
668
- .replace(/'/g, "&#039;");
669
- }
670
214
  /**
671
215
  * Register an approval request and get approval URL
672
216
  */
@@ -689,224 +233,9 @@ export class SharedApplicationServer {
689
233
  });
690
234
  });
691
235
  // Generate approval URL
692
- const url = `${this.getBaseUrl()}/approval?session=${encodeURIComponent(sessionId)}`;
236
+ const url = `http://${this.host}:${this.port}/approval?session=${encodeURIComponent(sessionId)}`;
693
237
  console.log(`🔐 Registered approval request for session ${sessionId}: ${url}`);
694
238
  return { promise, url };
695
239
  }
696
- /**
697
- * Handle approval requests
698
- */
699
- async handleApprovalRequest(_req, res, url) {
700
- try {
701
- const sessionId = url.searchParams.get("session");
702
- const action = url.searchParams.get("action"); // "approve" or "reject"
703
- const feedback = url.searchParams.get("feedback");
704
- if (!sessionId) {
705
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
706
- res.end(`
707
- <!DOCTYPE html>
708
- <html>
709
- <head>
710
- <meta charset="UTF-8">
711
- <title>Invalid Request</title>
712
- </head>
713
- <body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
714
- <h1>❌ Invalid Request</h1>
715
- <p>Missing session parameter.</p>
716
- </body>
717
- </html>
718
- `);
719
- return;
720
- }
721
- const approval = this.pendingApprovals.get(sessionId);
722
- // If no action specified, show approval UI
723
- if (!action) {
724
- const approvalExists = !!approval;
725
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
726
- res.end(`
727
- <!DOCTYPE html>
728
- <html>
729
- <head>
730
- <meta charset="UTF-8">
731
- <title>Approval Required</title>
732
- <style>
733
- body {
734
- font-family: system-ui, -apple-system, sans-serif;
735
- max-width: 700px;
736
- margin: 50px auto;
737
- padding: 20px;
738
- background: #f5f5f5;
739
- }
740
- .card {
741
- background: white;
742
- padding: 30px;
743
- border-radius: 8px;
744
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
745
- }
746
- h1 {
747
- margin-top: 0;
748
- color: #333;
749
- }
750
- .status {
751
- padding: 15px;
752
- border-radius: 5px;
753
- margin: 20px 0;
754
- }
755
- .status.pending {
756
- background: #fff3cd;
757
- border-left: 4px solid #ffc107;
758
- }
759
- .status.resolved {
760
- background: #d4edda;
761
- border-left: 4px solid #28a745;
762
- }
763
- .buttons {
764
- display: flex;
765
- gap: 10px;
766
- margin-top: 20px;
767
- }
768
- button {
769
- padding: 12px 24px;
770
- font-size: 16px;
771
- border: none;
772
- border-radius: 5px;
773
- cursor: pointer;
774
- transition: opacity 0.2s;
775
- }
776
- button:hover:not(:disabled) {
777
- opacity: 0.9;
778
- }
779
- button:disabled {
780
- opacity: 0.5;
781
- cursor: not-allowed;
782
- }
783
- .approve-btn {
784
- background: #28a745;
785
- color: white;
786
- flex: 1;
787
- }
788
- .reject-btn {
789
- background: #dc3545;
790
- color: white;
791
- flex: 1;
792
- }
793
- textarea {
794
- width: 100%;
795
- padding: 10px;
796
- border: 1px solid #ddd;
797
- border-radius: 5px;
798
- font-family: inherit;
799
- margin-top: 10px;
800
- resize: vertical;
801
- }
802
- label {
803
- display: block;
804
- margin-top: 15px;
805
- color: #666;
806
- font-size: 14px;
807
- }
808
- </style>
809
- </head>
810
- <body>
811
- <div class="card">
812
- ${approvalExists
813
- ? `
814
- <h1>🔔 Approval Required</h1>
815
- <div class="status pending">
816
- <strong>Status:</strong> Waiting for your decision
817
- </div>
818
- <p>The agent is requesting your approval to proceed with the next step of the workflow.</p>
819
-
820
- <label for="feedback">Optional feedback or instructions:</label>
821
- <textarea id="feedback" rows="3" placeholder="Enter any feedback or additional instructions..."></textarea>
822
-
823
- <div class="buttons">
824
- <button class="approve-btn" onclick="handleAction('approve')">
825
- ✅ Approve
826
- </button>
827
- <button class="reject-btn" onclick="handleAction('reject')">
828
- ❌ Reject
829
- </button>
830
- </div>
831
- `
832
- : `
833
- <h1>â„šī¸ Approval Already Processed</h1>
834
- <div class="status resolved">
835
- This approval request has already been processed or has expired.
836
- </div>
837
- <p>You can close this window.</p>
838
- `}
839
- </div>
840
-
841
- <script>
842
- async function handleAction(action) {
843
- const feedback = document.getElementById('feedback')?.value || '';
844
- const url = new URL(window.location.href);
845
- url.searchParams.set('action', action);
846
- if (feedback) {
847
- url.searchParams.set('feedback', feedback);
848
- }
849
-
850
- // Disable buttons
851
- document.querySelectorAll('button').forEach(btn => btn.disabled = true);
852
-
853
- // Navigate to confirmation
854
- window.location.href = url.toString();
855
- }
856
- </script>
857
- </body>
858
- </html>
859
- `);
860
- return;
861
- }
862
- // Handle approval/rejection
863
- if (!approval) {
864
- res.writeHead(410, { "Content-Type": "text/html; charset=utf-8" });
865
- res.end(`
866
- <!DOCTYPE html>
867
- <html>
868
- <head>
869
- <meta charset="UTF-8">
870
- <title>Approval Expired</title>
871
- </head>
872
- <body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
873
- <h1>⏰ Approval Expired</h1>
874
- <p>This approval request has already been processed or has expired.</p>
875
- <p>You can close this window.</p>
876
- </body>
877
- </html>
878
- `);
879
- return;
880
- }
881
- // Process the approval/rejection
882
- const approved = action === "approve";
883
- approval.resolve(approved, feedback || undefined);
884
- this.pendingApprovals.delete(sessionId);
885
- console.log(`🔐 Approval ${approved ? "granted" : "rejected"} for session ${sessionId}${feedback ? ` with feedback: ${feedback}` : ""}`);
886
- // Send success response
887
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
888
- res.end(`
889
- <!DOCTYPE html>
890
- <html>
891
- <head>
892
- <meta charset="UTF-8">
893
- <title>Approval ${approved ? "Granted" : "Rejected"}</title>
894
- </head>
895
- <body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
896
- <h1>${approved ? "✅ Approval Granted" : "❌ Approval Rejected"}</h1>
897
- <p>Your decision has been recorded. The agent will ${approved ? "proceed with the next step" : "stop the current workflow"}.</p>
898
- ${feedback ? `<p><strong>Feedback provided:</strong> ${this.escapeHtml(feedback)}</p>` : ""}
899
- <p style="margin-top: 30px; color: #666;">You can close this window and return to Linear.</p>
900
- <script>setTimeout(() => window.close(), 5000)</script>
901
- </body>
902
- </html>
903
- `);
904
- }
905
- catch (error) {
906
- console.error("🔐 Approval request error:", error);
907
- res.writeHead(500, { "Content-Type": "text/plain" });
908
- res.end("Internal Server Error");
909
- }
910
- }
911
240
  }
912
241
  //# sourceMappingURL=SharedApplicationServer.js.map