@desplega.ai/agent-swarm 1.70.0 → 1.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/openapi.json +226 -1
  2. package/package.json +1 -1
  3. package/src/be/db-queries/oauth.ts +45 -15
  4. package/src/be/db-queries/tracker.ts +109 -0
  5. package/src/be/migrations/043_jira_source.sql +128 -0
  6. package/src/commands/runner.ts +7 -2
  7. package/src/http/core.ts +6 -21
  8. package/src/http/index.ts +9 -1
  9. package/src/http/route-def.ts +19 -0
  10. package/src/http/trackers/index.ts +13 -0
  11. package/src/http/trackers/jira.ts +395 -0
  12. package/src/http/trackers/linear.ts +47 -4
  13. package/src/http/utils.ts +27 -0
  14. package/src/jira/adf.ts +132 -0
  15. package/src/jira/app.ts +83 -0
  16. package/src/jira/client.ts +82 -0
  17. package/src/jira/index.ts +24 -0
  18. package/src/jira/metadata.ts +117 -0
  19. package/src/jira/oauth.ts +98 -0
  20. package/src/jira/outbound.ts +155 -0
  21. package/src/jira/sync.ts +534 -0
  22. package/src/jira/templates.ts +84 -0
  23. package/src/jira/types.ts +35 -0
  24. package/src/jira/webhook-lifecycle.ts +363 -0
  25. package/src/jira/webhook.ts +159 -0
  26. package/src/linear/app.ts +17 -0
  27. package/src/linear/oauth.ts +24 -0
  28. package/src/oauth/wrapper.ts +11 -1
  29. package/src/tasks/context-key.ts +29 -1
  30. package/src/telemetry.ts +38 -3
  31. package/src/tests/context-key.test.ts +19 -0
  32. package/src/tests/jira-adf.test.ts +239 -0
  33. package/src/tests/jira-metadata.test.ts +147 -0
  34. package/src/tests/jira-oauth.test.ts +167 -0
  35. package/src/tests/jira-outbound-sync.test.ts +334 -0
  36. package/src/tests/jira-sync.test.ts +327 -0
  37. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  38. package/src/tests/jira-webhook.test.ts +274 -0
  39. package/src/tests/telemetry-init.test.ts +108 -0
  40. package/src/tools/tracker/tracker-link-task.ts +1 -1
  41. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  42. package/src/tools/tracker/tracker-status.ts +1 -1
  43. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  44. package/src/tracker/types.ts +1 -1
  45. package/src/types.ts +1 -0
@@ -60,6 +60,25 @@ interface RouteHandle<TParams, TQuery, TBody> {
60
60
  /** Global registry — populated at import time, read by OpenAPI generator */
61
61
  export const routeRegistry: RouteDef[] = [];
62
62
 
63
+ /**
64
+ * Check whether a request targets a route declared (via the `route()` factory)
65
+ * with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
66
+ * bearer check. Handler files must use the `route()` factory for this to take
67
+ * effect; unknown paths fail closed (auth required).
68
+ */
69
+ export function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
70
+ for (const def of routeRegistry) {
71
+ if (def.auth?.apiKey === false) {
72
+ if (
73
+ matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
74
+ ) {
75
+ return true;
76
+ }
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
63
82
  // ─── Factory ─────────────────────────────────────────────────────────────────
64
83
 
65
84
  export function route<
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { handleJiraTracker } from "./jira";
2
3
  import { handleLinearTracker } from "./linear";
3
4
 
4
5
  export async function handleTrackers(
@@ -6,5 +7,17 @@ export async function handleTrackers(
6
7
  res: ServerResponse,
7
8
  pathSegments: string[],
8
9
  ): Promise<boolean> {
10
+ // Provider-specific dispatch based on the third path segment
11
+ // (e.g. "api", "trackers", "<provider>", ...).
12
+ if (pathSegments[0] === "api" && pathSegments[1] === "trackers") {
13
+ if (pathSegments[2] === "jira") {
14
+ return await handleJiraTracker(req, res, pathSegments);
15
+ }
16
+ if (pathSegments[2] === "linear") {
17
+ return await handleLinearTracker(req, res, pathSegments);
18
+ }
19
+ }
20
+ // Fallback: try Linear (preserves existing behavior for any path that
21
+ // somehow falls through without an explicit provider segment).
9
22
  return await handleLinearTracker(req, res, pathSegments);
10
23
  }
@@ -0,0 +1,395 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { deleteOAuthTokens, getOAuthTokens } from "../../be/db-queries/oauth";
4
+ import { isJiraEnabled } from "../../jira/app";
5
+ import { clearJiraMetadata, getJiraMetadata } from "../../jira/metadata";
6
+ import { getJiraAuthorizationUrl, handleJiraCallback } from "../../jira/oauth";
7
+ import { handleJiraWebhook } from "../../jira/webhook";
8
+ import { deleteJiraWebhook, registerJiraWebhook } from "../../jira/webhook-lifecycle";
9
+ import { route } from "../route-def";
10
+ import { deriveApiBaseUrl, parseQueryParams } from "../utils";
11
+
12
+ const MANUAL_WEBHOOK_INSTRUCTIONS =
13
+ "See docs-site/.../guides/jira-integration.mdx for manual webhook registration steps.";
14
+
15
+ // ─── Route Definitions ───────────────────────────────────────────────────────
16
+
17
+ const jiraAuthorize = route({
18
+ method: "get",
19
+ path: "/api/trackers/jira/authorize",
20
+ pattern: ["api", "trackers", "jira", "authorize"],
21
+ summary: "Redirect to Atlassian OAuth consent screen",
22
+ tags: ["Trackers"],
23
+ auth: { apiKey: false },
24
+ responses: {
25
+ 302: { description: "Redirect to Atlassian OAuth" },
26
+ 500: { description: "Failed to generate authorization URL" },
27
+ 503: { description: "Jira integration not configured" },
28
+ },
29
+ });
30
+
31
+ const jiraCallback = route({
32
+ method: "get",
33
+ path: "/api/trackers/jira/callback",
34
+ pattern: ["api", "trackers", "jira", "callback"],
35
+ summary: "Handle Jira OAuth callback (resolves cloudId via accessible-resources)",
36
+ tags: ["Trackers"],
37
+ auth: { apiKey: false },
38
+ query: z.object({
39
+ code: z.string(),
40
+ state: z.string(),
41
+ }),
42
+ responses: {
43
+ 200: { description: "OAuth complete" },
44
+ 400: { description: "Invalid state or code" },
45
+ 500: { description: "Token exchange or accessible-resources fetch failed" },
46
+ },
47
+ });
48
+
49
+ const jiraStatus = route({
50
+ method: "get",
51
+ path: "/api/trackers/jira/status",
52
+ pattern: ["api", "trackers", "jira", "status"],
53
+ summary:
54
+ "Jira connection status, cloudId/siteUrl, token expiry, expected webhook URL, scope/token-config flags",
55
+ tags: ["Trackers"],
56
+ responses: {
57
+ 200: { description: "Connection status" },
58
+ 503: { description: "Jira integration not configured" },
59
+ },
60
+ });
61
+
62
+ const jiraWebhook = route({
63
+ method: "post",
64
+ path: "/api/trackers/jira/webhook/{token}",
65
+ pattern: ["api", "trackers", "jira", "webhook", null],
66
+ summary:
67
+ "Receive Jira webhook events (URL-token authenticated). Phase 2 stub — Phase 3 fills in dispatch.",
68
+ tags: ["Trackers"],
69
+ auth: { apiKey: false },
70
+ params: z.object({ token: z.string() }),
71
+ responses: {
72
+ 200: { description: "Event accepted" },
73
+ 401: { description: "Invalid URL token" },
74
+ 503: { description: "Jira webhook handler not configured" },
75
+ },
76
+ });
77
+
78
+ // Admin: register a new dynamic webhook with Atlassian. apiKey is required
79
+ // (route-factory default). The registered URL embeds JIRA_WEBHOOK_TOKEN so
80
+ // inbound deliveries can be authenticated.
81
+ const jiraWebhookRegister = route({
82
+ method: "post",
83
+ path: "/api/trackers/jira/webhook-register",
84
+ pattern: ["api", "trackers", "jira", "webhook-register"],
85
+ summary: "Register a Jira dynamic webhook (admin only)",
86
+ tags: ["Trackers"],
87
+ body: z.object({
88
+ jqlFilter: z.string().min(1),
89
+ }),
90
+ responses: {
91
+ 200: { description: "Webhook registered" },
92
+ 400: { description: "Invalid jqlFilter" },
93
+ 503: { description: "Jira not connected or JIRA_WEBHOOK_TOKEN missing" },
94
+ },
95
+ });
96
+
97
+ // Admin: delete a dynamic webhook from Atlassian and remove from local
98
+ // metadata. apiKey is required (route-factory default).
99
+ const jiraWebhookDelete = route({
100
+ method: "delete",
101
+ path: "/api/trackers/jira/webhook/{id}",
102
+ pattern: ["api", "trackers", "jira", "webhook", null],
103
+ summary: "Delete a Jira dynamic webhook (admin only)",
104
+ tags: ["Trackers"],
105
+ params: z.object({
106
+ id: z.coerce.number().int().positive(),
107
+ }),
108
+ responses: {
109
+ 200: { description: "Webhook deleted" },
110
+ 400: { description: "Invalid webhook id" },
111
+ 503: { description: "Jira not connected" },
112
+ },
113
+ });
114
+
115
+ // Admin: full disconnect — delete all registered Atlassian webhooks, drop
116
+ // stored OAuth tokens, and clear cloudId/siteUrl/webhookIds metadata. Atlassian
117
+ // 3LO has no public token revocation endpoint, so the OAuth grant itself must
118
+ // be revoked by the user via id.atlassian.com → Connected apps.
119
+ const jiraDisconnect = route({
120
+ method: "delete",
121
+ path: "/api/trackers/jira/disconnect",
122
+ pattern: ["api", "trackers", "jira", "disconnect"],
123
+ summary: "Fully disconnect Jira: delete all webhooks, drop tokens, clear metadata",
124
+ tags: ["Trackers"],
125
+ responses: {
126
+ 200: { description: "Disconnected" },
127
+ 503: { description: "Jira not configured" },
128
+ },
129
+ });
130
+
131
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
132
+
133
+ function getWebhookUrl(req: IncomingMessage): string {
134
+ const token = process.env.JIRA_WEBHOOK_TOKEN ?? "<unset>";
135
+ return `${deriveApiBaseUrl(req)}/api/trackers/jira/webhook/${token}`;
136
+ }
137
+
138
+ function getRedirectUri(req: IncomingMessage): string {
139
+ return `${deriveApiBaseUrl(req)}/api/trackers/jira/callback`;
140
+ }
141
+
142
+ // ─── Handler ─────────────────────────────────────────────────────────────────
143
+
144
+ export async function handleJiraTracker(
145
+ req: IncomingMessage,
146
+ res: ServerResponse,
147
+ pathSegments: string[],
148
+ ): Promise<boolean> {
149
+ // GET /api/trackers/jira/authorize — redirect to Atlassian OAuth consent
150
+ if (jiraAuthorize.match(req.method, pathSegments)) {
151
+ if (!isJiraEnabled()) {
152
+ res.writeHead(503, { "Content-Type": "application/json" });
153
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
154
+ return true;
155
+ }
156
+
157
+ try {
158
+ const url = await getJiraAuthorizationUrl();
159
+ if (!url) {
160
+ res.writeHead(500, { "Content-Type": "application/json" });
161
+ res.end(JSON.stringify({ error: "Failed to generate authorization URL" }));
162
+ return true;
163
+ }
164
+
165
+ res.writeHead(302, { Location: url });
166
+ res.end();
167
+ } catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ console.error("[Jira] Failed to generate authorization URL:", message);
170
+ res.writeHead(500, { "Content-Type": "application/json" });
171
+ res.end(JSON.stringify({ error: "Failed to generate authorization URL" }));
172
+ }
173
+ return true;
174
+ }
175
+
176
+ // GET /api/trackers/jira/callback — handle OAuth callback from Atlassian
177
+ if (jiraCallback.match(req.method, pathSegments)) {
178
+ const queryParams = parseQueryParams(req.url || "");
179
+ const parsed = await jiraCallback.parse(req, res, pathSegments, queryParams);
180
+ if (!parsed) return true; // parse() already sent 400
181
+
182
+ const { code, state } = parsed.query;
183
+
184
+ try {
185
+ await handleJiraCallback(code, state);
186
+ res.writeHead(200, { "Content-Type": "text/html" });
187
+ res.end(`<!DOCTYPE html>
188
+ <html>
189
+ <head><title>Jira Connected</title></head>
190
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
191
+ <div style="text-align: center;">
192
+ <h1>Jira Connected</h1>
193
+ <p>OAuth authorization complete. You can close this window.</p>
194
+ </div>
195
+ </body>
196
+ </html>`);
197
+ } catch (err) {
198
+ const message = err instanceof Error ? err.message : String(err);
199
+ console.error("[Jira] OAuth callback failed:", message);
200
+
201
+ if (message.includes("Invalid or expired OAuth state")) {
202
+ res.writeHead(400, { "Content-Type": "application/json" });
203
+ res.end(JSON.stringify({ error: "Invalid or expired OAuth state" }));
204
+ } else {
205
+ res.writeHead(500, { "Content-Type": "application/json" });
206
+ res.end(JSON.stringify({ error: "OAuth callback failed", details: message }));
207
+ }
208
+ }
209
+ return true;
210
+ }
211
+
212
+ // GET /api/trackers/jira/status — connection status (works even when not connected)
213
+ if (jiraStatus.match(req.method, pathSegments)) {
214
+ if (!isJiraEnabled()) {
215
+ res.writeHead(503, { "Content-Type": "application/json" });
216
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
217
+ return true;
218
+ }
219
+
220
+ const tokens = getOAuthTokens("jira");
221
+ const meta = getJiraMetadata();
222
+ const scope = tokens?.scope ?? null;
223
+ // Atlassian returns scopes space-separated in the token response.
224
+ const scopeList = scope ? scope.split(/[\s,]+/).filter(Boolean) : [];
225
+
226
+ const hasManageWebhookScope = scopeList.includes("manage:jira-webhook");
227
+
228
+ const status: Record<string, unknown> = {
229
+ provider: "jira",
230
+ connected: !!tokens,
231
+ cloudId: meta.cloudId ?? null,
232
+ siteUrl: meta.siteUrl ?? null,
233
+ tokenExpiresAt: tokens?.expiresAt ?? null,
234
+ scope,
235
+ hasManageWebhookScope,
236
+ webhookTokenConfigured: Boolean(process.env.JIRA_WEBHOOK_TOKEN),
237
+ webhookUrl: getWebhookUrl(req),
238
+ redirectUri: getRedirectUri(req),
239
+ webhookIds: meta.webhookIds ?? [],
240
+ };
241
+
242
+ // Phase 5: surface manual-webhook instructions when the OAuth grant
243
+ // doesn't include `manage:jira-webhook` (admin must register webhooks
244
+ // manually in the Atlassian UI).
245
+ if (!hasManageWebhookScope) {
246
+ status.manualWebhookInstructions = MANUAL_WEBHOOK_INSTRUCTIONS;
247
+ }
248
+
249
+ res.writeHead(200, { "Content-Type": "application/json" });
250
+ res.end(JSON.stringify(status));
251
+ return true;
252
+ }
253
+
254
+ // POST /api/trackers/jira/webhook/:token — receive Jira dynamic-webhook events.
255
+ //
256
+ // Atlassian does not HMAC-sign OAuth 3LO dynamic webhooks (errata I8); we
257
+ // authenticate via a URL-path token compared with `JIRA_WEBHOOK_TOKEN`.
258
+ if (jiraWebhook.match(req.method, pathSegments)) {
259
+ // Path token sits at index 4 of the matched segments
260
+ // (["api","trackers","jira","webhook", null]). Use the route parser so
261
+ // we go through the same Zod path-param plumbing the rest of the route
262
+ // file uses.
263
+ const queryParams = parseQueryParams(req.url || "");
264
+ const parsed = await jiraWebhook.parse(req, res, pathSegments, queryParams);
265
+ if (!parsed) return true; // 400 already sent
266
+
267
+ // Read raw body using the same chunk-assembly pattern as
268
+ // src/http/trackers/linear.ts:166-171 — we don't trust the framework to
269
+ // hand us a parsed body for webhook routes.
270
+ const chunks: Buffer[] = [];
271
+ for await (const chunk of req) {
272
+ chunks.push(chunk);
273
+ }
274
+ const rawBody = Buffer.concat(chunks).toString();
275
+
276
+ const result = await handleJiraWebhook(parsed.params.token, rawBody);
277
+
278
+ // 401 with empty body — no info leak about valid-vs-missing token.
279
+ if (result.status === 401) {
280
+ res.writeHead(401);
281
+ res.end();
282
+ return true;
283
+ }
284
+
285
+ res.writeHead(result.status, { "Content-Type": "application/json" });
286
+ res.end(typeof result.body === "string" ? result.body : JSON.stringify(result.body));
287
+ return true;
288
+ }
289
+
290
+ // POST /api/trackers/jira/webhook-register — admin: register a dynamic webhook.
291
+ if (jiraWebhookRegister.match(req.method, pathSegments)) {
292
+ if (!isJiraEnabled()) {
293
+ res.writeHead(503, { "Content-Type": "application/json" });
294
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
295
+ return true;
296
+ }
297
+ if (!process.env.JIRA_WEBHOOK_TOKEN) {
298
+ res.writeHead(503, { "Content-Type": "application/json" });
299
+ res.end(JSON.stringify({ error: "JIRA_WEBHOOK_TOKEN is not set" }));
300
+ return true;
301
+ }
302
+
303
+ const queryParams = parseQueryParams(req.url || "");
304
+ const parsed = await jiraWebhookRegister.parse(req, res, pathSegments, queryParams);
305
+ if (!parsed) return true;
306
+
307
+ try {
308
+ const result = await registerJiraWebhook(parsed.body.jqlFilter);
309
+ res.writeHead(200, { "Content-Type": "application/json" });
310
+ res.end(JSON.stringify(result));
311
+ } catch (err) {
312
+ const message = err instanceof Error ? err.message : String(err);
313
+ console.error("[Jira] Webhook register failed:", message);
314
+ res.writeHead(500, { "Content-Type": "application/json" });
315
+ res.end(JSON.stringify({ error: "Webhook registration failed", details: message }));
316
+ }
317
+ return true;
318
+ }
319
+
320
+ // DELETE /api/trackers/jira/webhook/:id — admin: delete a dynamic webhook.
321
+ // Note: this pattern overlaps the POST /webhook/:token path; the matcher
322
+ // disambiguates by HTTP method.
323
+ if (jiraWebhookDelete.match(req.method, pathSegments)) {
324
+ if (!isJiraEnabled()) {
325
+ res.writeHead(503, { "Content-Type": "application/json" });
326
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
327
+ return true;
328
+ }
329
+
330
+ const queryParams = parseQueryParams(req.url || "");
331
+ const parsed = await jiraWebhookDelete.parse(req, res, pathSegments, queryParams);
332
+ if (!parsed) return true;
333
+
334
+ try {
335
+ await deleteJiraWebhook(parsed.params.id);
336
+ res.writeHead(200, { "Content-Type": "application/json" });
337
+ res.end(JSON.stringify({ deleted: true, webhookId: parsed.params.id }));
338
+ } catch (err) {
339
+ const message = err instanceof Error ? err.message : String(err);
340
+ console.error(`[Jira] Webhook delete failed (id=${parsed.params.id}):`, message);
341
+ res.writeHead(500, { "Content-Type": "application/json" });
342
+ res.end(JSON.stringify({ error: "Webhook delete failed", details: message }));
343
+ }
344
+ return true;
345
+ }
346
+
347
+ // DELETE /api/trackers/jira/disconnect — full cleanup.
348
+ if (jiraDisconnect.match(req.method, pathSegments)) {
349
+ if (!isJiraEnabled()) {
350
+ res.writeHead(503, { "Content-Type": "application/json" });
351
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
352
+ return true;
353
+ }
354
+
355
+ const meta = getJiraMetadata();
356
+ const ids = (meta.webhookIds ?? []).map((entry) => entry.id);
357
+
358
+ let webhooksDeleted = 0;
359
+ const webhookFailures: Array<{ id: number; error: string }> = [];
360
+ for (const id of ids) {
361
+ try {
362
+ await deleteJiraWebhook(id);
363
+ webhooksDeleted++;
364
+ } catch (err) {
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ console.warn(`[Jira] Disconnect: webhook delete failed (id=${id}): ${message}`);
367
+ webhookFailures.push({ id, error: message });
368
+ }
369
+ }
370
+
371
+ deleteOAuthTokens("jira");
372
+ clearJiraMetadata();
373
+
374
+ console.log(
375
+ `[Jira] Disconnected: ${webhooksDeleted}/${ids.length} webhooks deleted, tokens cleared, metadata reset`,
376
+ );
377
+
378
+ res.writeHead(200, { "Content-Type": "application/json" });
379
+ res.end(
380
+ JSON.stringify({
381
+ disconnected: true,
382
+ webhooksDeleted,
383
+ webhooksTotal: ids.length,
384
+ webhookFailures,
385
+ // Atlassian 3LO has no token revocation endpoint — surface this so
386
+ // the UI can prompt the user to revoke the grant manually if desired.
387
+ revokeNote:
388
+ "Atlassian OAuth grants must be revoked manually at https://id.atlassian.com/manage/connected-apps if you want to fully sever the consent.",
389
+ }),
390
+ );
391
+ return true;
392
+ }
393
+
394
+ return false;
395
+ }
@@ -1,11 +1,15 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getOAuthTokens } from "../../be/db-queries/oauth";
3
+ import { deleteOAuthTokens, getOAuthTokens } from "../../be/db-queries/oauth";
4
4
  import { isLinearEnabled } from "../../linear/app";
5
- import { getLinearAuthorizationUrl, handleLinearCallback } from "../../linear/oauth";
5
+ import {
6
+ getLinearAuthorizationUrl,
7
+ handleLinearCallback,
8
+ revokeLinearToken,
9
+ } from "../../linear/oauth";
6
10
  import { handleLinearWebhook } from "../../linear/webhook";
7
11
  import { route } from "../route-def";
8
- import { parseQueryParams } from "../utils";
12
+ import { deriveApiBaseUrl, parseQueryParams } from "../utils";
9
13
 
10
14
  // ─── Route Definitions ───────────────────────────────────────────────────────
11
15
 
@@ -67,6 +71,21 @@ const linearWebhook = route({
67
71
  },
68
72
  });
69
73
 
74
+ // Admin: full disconnect — best-effort revoke the OAuth grant with Linear,
75
+ // then drop stored tokens. Linear webhooks are configured globally on the
76
+ // OAuth app (not per-tenant), so no per-tenant webhook delete is needed.
77
+ const linearDisconnect = route({
78
+ method: "delete",
79
+ path: "/api/trackers/linear/disconnect",
80
+ pattern: ["api", "trackers", "linear", "disconnect"],
81
+ summary: "Fully disconnect Linear: revoke OAuth grant + drop tokens",
82
+ tags: ["Trackers"],
83
+ responses: {
84
+ 200: { description: "Disconnected" },
85
+ 503: { description: "Linear not configured" },
86
+ },
87
+ });
88
+
70
89
  // ─── Handler ─────────────────────────────────────────────────────────────────
71
90
 
72
91
  export async function handleLinearTracker(
@@ -146,7 +165,7 @@ export async function handleLinearTracker(
146
165
  }
147
166
 
148
167
  const tokens = getOAuthTokens("linear");
149
- const baseUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
168
+ const baseUrl = deriveApiBaseUrl(req);
150
169
 
151
170
  const status = {
152
171
  provider: "linear",
@@ -154,6 +173,7 @@ export async function handleLinearTracker(
154
173
  tokenExpiry: tokens?.expiresAt ?? null,
155
174
  scope: tokens?.scope ?? null,
156
175
  webhookUrl: `${baseUrl}/api/trackers/linear/webhook`,
176
+ redirectUri: `${baseUrl}/api/trackers/linear/callback`,
157
177
  };
158
178
 
159
179
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -183,5 +203,28 @@ export async function handleLinearTracker(
183
203
  return true;
184
204
  }
185
205
 
206
+ // DELETE /api/trackers/linear/disconnect — full cleanup.
207
+ if (linearDisconnect.match(req.method, pathSegments)) {
208
+ if (!isLinearEnabled()) {
209
+ res.writeHead(503, { "Content-Type": "application/json" });
210
+ res.end(JSON.stringify({ error: "Linear integration not configured" }));
211
+ return true;
212
+ }
213
+
214
+ const tokens = getOAuthTokens("linear");
215
+ let revoked = false;
216
+ if (tokens?.accessToken) {
217
+ revoked = await revokeLinearToken(tokens.accessToken);
218
+ }
219
+
220
+ deleteOAuthTokens("linear");
221
+
222
+ console.log(`[Linear] Disconnected: revoke=${revoked}, tokens cleared`);
223
+
224
+ res.writeHead(200, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify({ disconnected: true, revoked }));
226
+ return true;
227
+ }
228
+
186
229
  return false;
187
230
  }
package/src/http/utils.ts CHANGED
@@ -57,6 +57,33 @@ export function jsonError(res: ServerResponse, error: string, status = 400) {
57
57
  res.end(JSON.stringify({ error }));
58
58
  }
59
59
 
60
+ /**
61
+ * Derive the API base URL for outbound-facing values (webhook URLs, OAuth
62
+ * redirect URIs). Returns a URL with no trailing slash.
63
+ *
64
+ * Resolution order:
65
+ * 1. `MCP_BASE_URL` env (canonical)
66
+ * 2. Inbound request host — `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
67
+ * a proxy/tunnel (ngrok), else `Host` header. Lets the URL stay correct
68
+ * when MCP_BASE_URL is unset and the API is reached via an arbitrary
69
+ * external hostname.
70
+ * 3. `http://localhost:<PORT>` fallback
71
+ */
72
+ export function deriveApiBaseUrl(req: IncomingMessage): string {
73
+ const envBase = process.env.MCP_BASE_URL?.trim();
74
+ if (envBase) return envBase.replace(/\/+$/, "");
75
+
76
+ const fwdProtoRaw = req.headers["x-forwarded-proto"];
77
+ const fwdHostRaw = req.headers["x-forwarded-host"];
78
+ const fwdProto = Array.isArray(fwdProtoRaw) ? fwdProtoRaw[0] : fwdProtoRaw;
79
+ const fwdHost = Array.isArray(fwdHostRaw) ? fwdHostRaw[0] : fwdHostRaw;
80
+ const proto = fwdProto?.split(",")[0]?.trim() || "http";
81
+ const host = fwdHost?.split(",")[0]?.trim() || req.headers.host;
82
+
83
+ if (host) return `${proto}://${host}`;
84
+ return `http://localhost:${process.env.PORT || "3013"}`;
85
+ }
86
+
60
87
  /**
61
88
  * Match a route pattern against HTTP method and path segments.
62
89
  *