patchwork-os 0.2.0-alpha.35 → 0.2.0-alpha.37

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 (207) hide show
  1. package/README.md +70 -15
  2. package/dist/activityLog.d.ts +49 -0
  3. package/dist/activityLog.js +78 -0
  4. package/dist/activityLog.js.map +1 -1
  5. package/dist/approvalHttp.d.ts +25 -0
  6. package/dist/approvalHttp.js +74 -18
  7. package/dist/approvalHttp.js.map +1 -1
  8. package/dist/approvalInsights.d.ts +49 -0
  9. package/dist/approvalInsights.js +97 -0
  10. package/dist/approvalInsights.js.map +1 -0
  11. package/dist/approvalQueue.d.ts +11 -0
  12. package/dist/approvalQueue.js +80 -1
  13. package/dist/approvalQueue.js.map +1 -1
  14. package/dist/approvalSignals.d.ts +124 -0
  15. package/dist/approvalSignals.js +512 -0
  16. package/dist/approvalSignals.js.map +1 -0
  17. package/dist/automation.d.ts +37 -0
  18. package/dist/automation.js +105 -61
  19. package/dist/automation.js.map +1 -1
  20. package/dist/automationSuggestions.d.ts +79 -0
  21. package/dist/automationSuggestions.js +150 -0
  22. package/dist/automationSuggestions.js.map +1 -0
  23. package/dist/bridge.js +46 -0
  24. package/dist/bridge.js.map +1 -1
  25. package/dist/ccPermissions.d.ts +15 -0
  26. package/dist/ccPermissions.js +15 -0
  27. package/dist/ccPermissions.js.map +1 -1
  28. package/dist/claudeDriver.js +74 -16
  29. package/dist/claudeDriver.js.map +1 -1
  30. package/dist/commands/patchworkInit.d.ts +8 -0
  31. package/dist/commands/patchworkInit.js +41 -5
  32. package/dist/commands/patchworkInit.js.map +1 -1
  33. package/dist/commands/recipe.d.ts +20 -0
  34. package/dist/commands/recipe.js +194 -5
  35. package/dist/commands/recipe.js.map +1 -1
  36. package/dist/commands/recipeInstall.js +93 -4
  37. package/dist/commands/recipeInstall.js.map +1 -1
  38. package/dist/commands/tracesExport.d.ts +83 -0
  39. package/dist/commands/tracesExport.js +269 -0
  40. package/dist/commands/tracesExport.js.map +1 -0
  41. package/dist/commands/tracesImport.d.ts +56 -0
  42. package/dist/commands/tracesImport.js +161 -0
  43. package/dist/commands/tracesImport.js.map +1 -0
  44. package/dist/config.d.ts +8 -0
  45. package/dist/config.js +9 -1
  46. package/dist/config.js.map +1 -1
  47. package/dist/connectorRoutes.d.ts +43 -0
  48. package/dist/connectorRoutes.js +1713 -0
  49. package/dist/connectorRoutes.js.map +1 -0
  50. package/dist/connectors/asana.js +6 -7
  51. package/dist/connectors/asana.js.map +1 -1
  52. package/dist/connectors/baseConnector.d.ts +20 -0
  53. package/dist/connectors/baseConnector.js +45 -4
  54. package/dist/connectors/baseConnector.js.map +1 -1
  55. package/dist/connectors/discord.js +6 -7
  56. package/dist/connectors/discord.js.map +1 -1
  57. package/dist/connectors/gmail.js +39 -10
  58. package/dist/connectors/gmail.js.map +1 -1
  59. package/dist/connectors/googleCalendar.js +36 -10
  60. package/dist/connectors/googleCalendar.js.map +1 -1
  61. package/dist/connectors/googleDrive.js +22 -6
  62. package/dist/connectors/googleDrive.js.map +1 -1
  63. package/dist/connectors/linear.js +2 -2
  64. package/dist/connectors/linear.js.map +1 -1
  65. package/dist/connectors/mcpOAuth.js +26 -2
  66. package/dist/connectors/mcpOAuth.js.map +1 -1
  67. package/dist/connectors/oauthStateStore.d.ts +31 -0
  68. package/dist/connectors/oauthStateStore.js +52 -0
  69. package/dist/connectors/oauthStateStore.js.map +1 -0
  70. package/dist/connectors/slack.d.ts +15 -0
  71. package/dist/connectors/slack.js +54 -4
  72. package/dist/connectors/slack.js.map +1 -1
  73. package/dist/connectors/tokenStorage.js +27 -2
  74. package/dist/connectors/tokenStorage.js.map +1 -1
  75. package/dist/connectors/zendesk.js +19 -1
  76. package/dist/connectors/zendesk.js.map +1 -1
  77. package/dist/cors.d.ts +10 -0
  78. package/dist/cors.js +29 -0
  79. package/dist/cors.js.map +1 -0
  80. package/dist/decisionReplay.d.ts +72 -0
  81. package/dist/decisionReplay.js +92 -0
  82. package/dist/decisionReplay.js.map +1 -0
  83. package/dist/decisionTraceLog.d.ts +6 -0
  84. package/dist/decisionTraceLog.js +54 -2
  85. package/dist/decisionTraceLog.js.map +1 -1
  86. package/dist/fp/automationInterpreter.js +25 -21
  87. package/dist/fp/automationInterpreter.js.map +1 -1
  88. package/dist/fp/automationState.js +4 -1
  89. package/dist/fp/automationState.js.map +1 -1
  90. package/dist/fp/policyParser.js +4 -1
  91. package/dist/fp/policyParser.js.map +1 -1
  92. package/dist/inboxRoutes.d.ts +22 -0
  93. package/dist/inboxRoutes.js +114 -0
  94. package/dist/inboxRoutes.js.map +1 -0
  95. package/dist/index.js +479 -17
  96. package/dist/index.js.map +1 -1
  97. package/dist/mcpRoutes.d.ts +37 -0
  98. package/dist/mcpRoutes.js +76 -0
  99. package/dist/mcpRoutes.js.map +1 -0
  100. package/dist/oauth.d.ts +3 -0
  101. package/dist/oauth.js +151 -26
  102. package/dist/oauth.js.map +1 -1
  103. package/dist/oauthRoutes.d.ts +32 -0
  104. package/dist/oauthRoutes.js +124 -0
  105. package/dist/oauthRoutes.js.map +1 -0
  106. package/dist/orchestrator/orchestratorBridge.js +2 -2
  107. package/dist/orchestrator/orchestratorBridge.js.map +1 -1
  108. package/dist/patchworkConfig.d.ts +7 -0
  109. package/dist/patchworkConfig.js.map +1 -1
  110. package/dist/pluginLoader.d.ts +12 -0
  111. package/dist/pluginLoader.js +43 -4
  112. package/dist/pluginLoader.js.map +1 -1
  113. package/dist/pluginWatcher.js +8 -3
  114. package/dist/pluginWatcher.js.map +1 -1
  115. package/dist/preToolUseHook.d.ts +12 -0
  116. package/dist/preToolUseHook.js +23 -0
  117. package/dist/preToolUseHook.js.map +1 -1
  118. package/dist/recipeOrchestration.d.ts +1 -0
  119. package/dist/recipeOrchestration.js +173 -13
  120. package/dist/recipeOrchestration.js.map +1 -1
  121. package/dist/recipeRoutes.d.ts +154 -0
  122. package/dist/recipeRoutes.js +1107 -0
  123. package/dist/recipeRoutes.js.map +1 -0
  124. package/dist/recipes/chainedRunner.d.ts +15 -0
  125. package/dist/recipes/chainedRunner.js +73 -8
  126. package/dist/recipes/chainedRunner.js.map +1 -1
  127. package/dist/recipes/compiler.js +3 -3
  128. package/dist/recipes/compiler.js.map +1 -1
  129. package/dist/recipes/installer.js +3 -3
  130. package/dist/recipes/installer.js.map +1 -1
  131. package/dist/recipes/migrationWarnings.d.ts +12 -0
  132. package/dist/recipes/migrationWarnings.js +44 -0
  133. package/dist/recipes/migrationWarnings.js.map +1 -0
  134. package/dist/recipes/resolveRecipePath.d.ts +69 -0
  135. package/dist/recipes/resolveRecipePath.js +202 -0
  136. package/dist/recipes/resolveRecipePath.js.map +1 -0
  137. package/dist/recipes/tools/file.d.ts +6 -0
  138. package/dist/recipes/tools/file.js +12 -8
  139. package/dist/recipes/tools/file.js.map +1 -1
  140. package/dist/recipes/tools/index.d.ts +2 -0
  141. package/dist/recipes/tools/index.js +2 -0
  142. package/dist/recipes/tools/index.js.map +1 -1
  143. package/dist/recipes/tools/jira.d.ts +14 -0
  144. package/dist/recipes/tools/jira.js +369 -0
  145. package/dist/recipes/tools/jira.js.map +1 -0
  146. package/dist/recipes/tools/linear.js +6 -3
  147. package/dist/recipes/tools/linear.js.map +1 -1
  148. package/dist/recipes/tools/sentry.d.ts +12 -0
  149. package/dist/recipes/tools/sentry.js +73 -0
  150. package/dist/recipes/tools/sentry.js.map +1 -0
  151. package/dist/recipes/tools/slack.js +7 -3
  152. package/dist/recipes/tools/slack.js.map +1 -1
  153. package/dist/recipes/validation.js +83 -14
  154. package/dist/recipes/validation.js.map +1 -1
  155. package/dist/recipes/yamlRunner.d.ts +7 -0
  156. package/dist/recipes/yamlRunner.js +107 -13
  157. package/dist/recipes/yamlRunner.js.map +1 -1
  158. package/dist/recipesHttp.d.ts +44 -1
  159. package/dist/recipesHttp.js +168 -15
  160. package/dist/recipesHttp.js.map +1 -1
  161. package/dist/runLog.d.ts +14 -0
  162. package/dist/runLog.js +88 -4
  163. package/dist/runLog.js.map +1 -1
  164. package/dist/schemas/dry-run-plan.v1.json +139 -0
  165. package/dist/schemas/recipe.v1.json +684 -0
  166. package/dist/server.d.ts +71 -10
  167. package/dist/server.js +363 -1703
  168. package/dist/server.js.map +1 -1
  169. package/dist/ssrfGuard.d.ts +54 -0
  170. package/dist/ssrfGuard.js +122 -0
  171. package/dist/ssrfGuard.js.map +1 -0
  172. package/dist/streamableHttp.d.ts +8 -0
  173. package/dist/streamableHttp.js +112 -21
  174. package/dist/streamableHttp.js.map +1 -1
  175. package/dist/tools/getDocumentSymbols.d.ts +24 -0
  176. package/dist/tools/getDocumentSymbols.js +74 -8
  177. package/dist/tools/getDocumentSymbols.js.map +1 -1
  178. package/dist/tools/getSecurityAdvisories.js +10 -1
  179. package/dist/tools/getSecurityAdvisories.js.map +1 -1
  180. package/dist/tools/getSessionUsage.d.ts +3 -0
  181. package/dist/tools/getSessionUsage.js +3 -0
  182. package/dist/tools/getSessionUsage.js.map +1 -1
  183. package/dist/tools/index.d.ts +8 -0
  184. package/dist/tools/index.js +32 -2
  185. package/dist/tools/index.js.map +1 -1
  186. package/dist/tools/transaction.d.ts +19 -0
  187. package/dist/tools/transaction.js +29 -0
  188. package/dist/tools/transaction.js.map +1 -1
  189. package/dist/traceEncryption.d.ts +46 -0
  190. package/dist/traceEncryption.js +124 -0
  191. package/dist/traceEncryption.js.map +1 -0
  192. package/dist/transport.d.ts +39 -0
  193. package/dist/transport.js +88 -8
  194. package/dist/transport.js.map +1 -1
  195. package/package.json +4 -2
  196. package/templates/policies/README.md +72 -0
  197. package/templates/policies/conservative.json +14 -0
  198. package/templates/policies/developer.json +14 -0
  199. package/templates/policies/headless-ci.json +24 -0
  200. package/templates/policies/personal-assistant.json +15 -0
  201. package/templates/policies/regulated-industry.json +18 -0
  202. package/templates/recipes/webhook/README.md +70 -0
  203. package/templates/recipes/webhook/capture-thought.yaml +26 -0
  204. package/templates/recipes/webhook/customer-escalation.yaml +49 -0
  205. package/templates/recipes/webhook/incident-intake.yaml +46 -0
  206. package/templates/recipes/webhook/meeting-prep.yaml +48 -0
  207. package/templates/recipes/webhook/morning-brief.yaml +57 -0
package/dist/server.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import http from "node:http";
3
- import os from "node:os";
4
- import path from "node:path";
5
3
  import { WebSocket, WebSocketServer as WsServer } from "ws";
6
- import { computeSummary as computeActivationSummary, loadMetrics as loadActivationMetrics, } from "./activationMetrics.js";
7
4
  import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
8
5
  import { getApprovalQueue } from "./approvalQueue.js";
9
6
  import { saveBridgeConfigDriver } from "./config.js";
7
+ import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
10
8
  import { timingSafeStringEqual } from "./crypto.js";
11
9
  import { renderDashboardHtml } from "./dashboard.js";
10
+ import { tryHandleInboxRoute } from "./inboxRoutes.js";
11
+ import { tryHandleMcpRoute } from "./mcpRoutes.js";
12
+ import { tryHandleOAuthRoute } from "./oauthRoutes.js";
12
13
  import { loadConfig as loadPatchworkConfig, defaultConfigPath as patchworkConfigPath, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
13
- import { BRIDGE_PROTOCOL_VERSION, PACKAGE_LICENSE, PACKAGE_VERSION, } from "./version.js";
14
+ import { tryHandleRecipeRoute } from "./recipeRoutes.js";
15
+ import { PACKAGE_VERSION } from "./version.js";
14
16
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
15
17
  const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
18
  function enableTcpKeepalive(ws) {
@@ -20,30 +22,10 @@ function enableTcpKeepalive(ws) {
20
22
  rawSocket.setKeepAlive(true, 60_000); // 60s TCP keepalive as defense-in-depth
21
23
  }
22
24
  }
23
- /**
24
- * Return the CORS origin to reflect, or null if the origin is untrusted.
25
- * Loopback origins are always allowed. Additional origins can be passed via
26
- * --cors-origin (e.g. https://claude.ai for remote deployments).
27
- */
28
- export function corsOrigin(requestOrigin, extraOrigins = []) {
29
- if (!requestOrigin)
30
- return null;
31
- if (extraOrigins.includes(requestOrigin))
32
- return requestOrigin;
33
- try {
34
- const { hostname, protocol } = new URL(requestOrigin);
35
- if (protocol === "http:" &&
36
- (hostname === "localhost" ||
37
- hostname === "127.0.0.1" ||
38
- hostname === "[::1]")) {
39
- return requestOrigin;
40
- }
41
- }
42
- catch {
43
- // malformed origin — deny
44
- }
45
- return null;
46
- }
25
+ import { corsOrigin } from "./cors.js";
26
+ // Re-exported for streamableHttp.ts and any external callers; new code
27
+ // should import directly from "./cors.js".
28
+ export { corsOrigin };
47
29
  // Re-export canonical constant-time comparison for use in this module.
48
30
  // Implementation lives in src/crypto.ts — see there for security notes.
49
31
  const timingSafeTokenCompare = timingSafeStringEqual;
@@ -76,6 +58,8 @@ export class Server extends EventEmitter {
76
58
  oauthServer = null;
77
59
  oauthIssuerUrl = null;
78
60
  sseSubscriberCount = 0;
61
+ /** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
62
+ _explainRulesCache = null;
79
63
  static MAX_SSE_SUBSCRIBERS = 20;
80
64
  /** Set by bridge to provide health data */
81
65
  healthDataFn = null;
@@ -91,6 +75,10 @@ export class Server extends EventEmitter {
91
75
  tasksFn = null;
92
76
  /** Set by bridge to cancel a running/pending task by id. Returns true if found. */
93
77
  cancelTaskFn = null;
78
+ /** Patchwork: set by bridge to set the trust level for a recipe by name. */
79
+ setRecipeTrustFn = null;
80
+ /** Patchwork: set by bridge to generate a recipe YAML draft from a natural-language prompt. */
81
+ generateRecipeFn = null;
94
82
  /** Patchwork: set by bridge to list installed recipes for the dashboard. */
95
83
  recipesFn = null;
96
84
  /** Patchwork: set by bridge to load raw recipe source content by name. */
@@ -99,6 +87,10 @@ export class Server extends EventEmitter {
99
87
  saveRecipeContentFn = null;
100
88
  /** Patchwork: set by bridge to delete a recipe by name. */
101
89
  deleteRecipeContentFn = null;
90
+ /** Patchwork: set by bridge to promote a variant recipe to the canonical name. */
91
+ promoteRecipeVariantFn = null;
92
+ /** Patchwork: set by bridge to duplicate a recipe as a variant. */
93
+ duplicateRecipeFn = null;
102
94
  /** Patchwork: set by bridge to lint raw recipe content without saving. */
103
95
  lintRecipeContentFn = null;
104
96
  /** Patchwork: set by bridge to save a new recipe draft to disk. */
@@ -130,8 +122,38 @@ export class Server extends EventEmitter {
130
122
  pushServiceBaseUrl = undefined;
131
123
  /** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
132
124
  onApprovalDecision = undefined;
125
+ /**
126
+ * Patchwork: activity log handle, used by approvalHttp to compute
127
+ * passive risk personalization signals (`src/approvalSignals.ts`).
128
+ * When unset, personalSignals are simply omitted from queue entries.
129
+ */
130
+ activityLog = undefined;
131
+ /**
132
+ * Patchwork: recipe-run log handle, used by approvalHttp for the
133
+ * "recipe-step trust" heuristic (h6 in src/approvalSignals.ts). When
134
+ * unset, h6 is silently skipped; the other personalSignals heuristics
135
+ * still compute as long as `activityLog` is wired.
136
+ */
137
+ recipeRunLog = undefined;
138
+ /**
139
+ * Patchwork: opt-in switch for personalSignals heuristic 10
140
+ * (time-of-day anomaly). Off by default — see config.ts. Threaded into
141
+ * routeApprovalRequest deps so the personalSignals computation honors
142
+ * the user's preference.
143
+ */
144
+ enableTimeOfDayAnomaly = false;
133
145
  /** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
134
146
  webhookFn = null;
147
+ /**
148
+ * Patchwork: ring buffer of recent webhook payloads, keyed by path
149
+ * (e.g. "/incident-war-room"). The last MAX_WEBHOOK_PAYLOADS entries are
150
+ * retained per path so the dashboard can show what the recipe most
151
+ * recently received — answers "did the trigger fire? what did it send?"
152
+ * without forcing the user to dig through bridge logs. In-memory only;
153
+ * cleared on restart.
154
+ */
155
+ webhookPayloads = new Map();
156
+ static MAX_WEBHOOK_PAYLOADS = 5;
135
157
  /** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
136
158
  httpMcpHandler = null;
137
159
  /** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
@@ -150,8 +172,6 @@ export class Server extends EventEmitter {
150
172
  notifyFn = null;
151
173
  /** Patchwork: set by bridge to list active agent sessions for the dashboard. */
152
174
  sessionsFn = null;
153
- _templatesCache = null;
154
- _templatesCacheTs = 0;
155
175
  /** Patchwork: set by bridge to answer GET /sessions/:id with per-session event stream + approvals. */
156
176
  sessionDetailFn = null;
157
177
  /** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
@@ -211,141 +231,37 @@ export class Server extends EventEmitter {
211
231
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
212
232
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
213
233
  }
214
- const parsedUrl = new URL(req.url ?? "/", "http://localhost");
215
- // ── OAuth 2.0 endpoints (unauthenticated handled before bearer check) ──
216
- // RFC 8414 discovery document
217
- if (parsedUrl.pathname === "/.well-known/oauth-authorization-server" &&
218
- req.method === "GET") {
219
- if (this.oauthServer) {
220
- this.oauthServer.handleDiscovery(res);
221
- }
222
- else {
223
- res.writeHead(404, { "Content-Type": "text/plain" });
224
- res.end("OAuth not configured");
225
- }
226
- return;
227
- }
228
- // RFC 9396 Protected Resource Metadata — Claude.ai probes this to discover
229
- // which authorization server protects this resource. Both the bare and
230
- // resource-path variants are handled.
231
- if (req.method === "GET" &&
232
- (parsedUrl.pathname === "/.well-known/oauth-protected-resource" ||
233
- parsedUrl.pathname.startsWith("/.well-known/oauth-protected-resource/"))) {
234
- if (this.oauthServer && this.oauthIssuerUrl) {
235
- res.writeHead(200, {
236
- "Content-Type": "application/json",
237
- "Cache-Control": "no-store",
238
- });
239
- res.end(JSON.stringify({
240
- resource: this.oauthIssuerUrl,
241
- authorization_servers: [this.oauthIssuerUrl],
242
- }));
243
- }
244
- else {
245
- res.writeHead(404, { "Content-Type": "text/plain" });
246
- res.end("OAuth not configured");
247
- }
248
- return;
249
- }
250
- // Authorization endpoint
251
- if (parsedUrl.pathname === "/oauth/authorize" &&
252
- (req.method === "GET" || req.method === "POST")) {
253
- if (this.oauthServer) {
254
- this.oauthServer.handleAuthorize(req, res);
255
- }
256
- else {
257
- res.writeHead(404, { "Content-Type": "text/plain" });
258
- res.end("OAuth not configured");
259
- }
260
- return;
261
- }
262
- // Dynamic Client Registration endpoint (RFC 7591)
263
- if (parsedUrl.pathname === "/oauth/register") {
264
- if (this.oauthServer) {
265
- this.oauthServer.handleRegister(req, res).catch((err) => {
266
- if (!res.headersSent) {
267
- res.writeHead(500, { "Content-Type": "application/json" });
268
- res.end(JSON.stringify({ error: String(err) }));
269
- }
270
- });
271
- }
272
- else {
273
- res.writeHead(404, { "Content-Type": "text/plain" });
274
- res.end("OAuth not configured");
275
- }
276
- return;
277
- }
278
- // Token endpoint
279
- if (parsedUrl.pathname === "/oauth/token" && req.method === "POST") {
280
- if (this.oauthServer) {
281
- this.oauthServer.handleToken(req, res).catch((err) => {
282
- if (!res.headersSent) {
283
- res.writeHead(500, { "Content-Type": "application/json" });
284
- res.end(JSON.stringify({ error: String(err) }));
285
- }
286
- });
287
- }
288
- else {
289
- res.writeHead(404, { "Content-Type": "text/plain" });
290
- res.end("OAuth not configured");
291
- }
292
- return;
293
- }
294
- // Revocation endpoint (RFC 7009)
295
- if (parsedUrl.pathname === "/oauth/revoke" && req.method === "POST") {
296
- if (this.oauthServer) {
297
- this.oauthServer.handleRevoke(req, res).catch(() => {
298
- // RFC 7009: always 200
299
- if (!res.headersSent) {
300
- res.writeHead(200, { "Content-Type": "application/json" });
301
- res.end("{}");
302
- }
303
- });
304
- }
305
- else {
306
- res.writeHead(200, { "Content-Type": "application/json" });
307
- res.end("{}");
308
- }
234
+ // DNS rebinding defense: validate Host header on every HTTP request,
235
+ // mirroring the WS upgrade handler below. Without this, attacker DNS
236
+ // can rebind a public hostname to 127.0.0.1 in the victim's browser
237
+ // and reach `/dashboard`, `/health`, `/metrics`, `/mcp`, OAuth
238
+ // endpoints, etc. with arbitrary Host headers. CORS gates browser
239
+ // *reads* of responses but does NOT gate top-level navigations or
240
+ // simple side-effect-bearing POSTs (e.g. `/oauth/authorize`).
241
+ const rawHost = req.headers.host ?? "";
242
+ const host = rawHost.startsWith("[")
243
+ ? rawHost.slice(0, rawHost.indexOf("]") + 1)
244
+ : rawHost.replace(/:\d+$/, "");
245
+ if (!host || !this.allowedHosts.has(host)) {
246
+ this.logger.warn(`Rejected HTTP request with invalid Host header: ${rawHost}`);
247
+ res.writeHead(403, { "Content-Type": "text/plain" });
248
+ res.end("Invalid Host header");
309
249
  return;
310
250
  }
311
- // ── MCP server-card (public) ──────────────────────────────────────────
312
- if (req.url === "/.well-known/mcp/server-card.json" ||
313
- req.url === "/.well-known/mcp") {
314
- const card = {
315
- name: "claude-ide-bridge",
316
- version: BRIDGE_PROTOCOL_VERSION,
317
- description: "MCP bridge providing full IDE integration for Claude Code — LSP, diagnostics, file operations, terminal, debug adapters, and AI task orchestration",
318
- homepage: "https://github.com/Oolab-labs/claude-ide-bridge",
319
- transport: ["websocket", "stdio", "streamable-http"],
320
- capabilities: {
321
- tools: true,
322
- resources: true,
323
- prompts: true,
324
- elicitation: true,
325
- },
326
- author: "Oolab Labs",
327
- license: PACKAGE_LICENSE,
328
- repository: "https://github.com/Oolab-labs/claude-ide-bridge",
329
- };
330
- res.writeHead(200, {
331
- "Content-Type": "application/json",
332
- "Access-Control-Allow-Origin": "*",
333
- });
334
- res.end(JSON.stringify(card, null, 2));
251
+ const parsedUrl = new URL(req.url ?? "/", "http://localhost");
252
+ // ── OAuth 2.0 endpoints (extracted to src/oauthRoutes.ts) ────────────
253
+ // Unauthenticated — must run BEFORE the bearer-auth gate.
254
+ if (tryHandleOAuthRoute(req, res, parsedUrl, {
255
+ oauthServer: this.oauthServer,
256
+ oauthIssuerUrl: this.oauthIssuerUrl,
257
+ })) {
335
258
  return;
336
259
  }
337
- // CORS preflight for /mcp browsers (and Claude Desktop's web renderer) send
338
- // OPTIONS before POST. Respond without requiring auth so the preflight succeeds.
339
- if (req.method === "OPTIONS" && parsedUrl.pathname === "/mcp") {
340
- const origin = corsOrigin(req.headers.origin, this.extraCorsOrigins);
341
- if (origin) {
342
- res.setHeader("Access-Control-Allow-Origin", origin);
343
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
344
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
345
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
346
- }
347
- res.writeHead(204);
348
- res.end();
260
+ // ── MCP server-card + CORS preflight (extracted to src/mcpRoutes.ts)
261
+ // Unauthenticated must run BEFORE the bearer-auth gate.
262
+ if (tryHandleMcpRoute(req, res, parsedUrl, {
263
+ extraCorsOrigins: this.extraCorsOrigins,
264
+ })) {
349
265
  return;
350
266
  }
351
267
  // Unauthenticated liveness probe — safe to expose; contains no sensitive data.
@@ -385,110 +301,10 @@ export class Server extends EventEmitter {
385
301
  res.end(JSON.stringify({ ok: true, v: PACKAGE_VERSION }));
386
302
  return;
387
303
  }
388
- // ── Connector OAuth callbacks (unauthenticated browser redirect from vendor) ──
389
- if (parsedUrl.pathname === "/connections/github/callback" &&
390
- req.method === "GET") {
391
- void (async () => {
392
- const { handleGithubCallback } = await import("./connectors/github.js");
393
- const code = parsedUrl.searchParams.get("code");
394
- const state = parsedUrl.searchParams.get("state");
395
- const error = parsedUrl.searchParams.get("error");
396
- const result = await handleGithubCallback(code, state, error);
397
- res.writeHead(result.status, {
398
- "Content-Type": result.contentType ?? "application/json",
399
- });
400
- res.end(result.body);
401
- })();
402
- return;
403
- }
404
- if (parsedUrl.pathname === "/connections/linear/callback" &&
405
- req.method === "GET") {
406
- void (async () => {
407
- const { handleLinearCallback } = await import("./connectors/linear.js");
408
- const code = parsedUrl.searchParams.get("code");
409
- const state = parsedUrl.searchParams.get("state");
410
- const error = parsedUrl.searchParams.get("error");
411
- const result = await handleLinearCallback(code, state, error);
412
- res.writeHead(result.status, {
413
- "Content-Type": result.contentType ?? "application/json",
414
- });
415
- res.end(result.body);
416
- })();
417
- return;
418
- }
419
- if (parsedUrl.pathname === "/connections/sentry/callback" &&
420
- req.method === "GET") {
421
- void (async () => {
422
- const { handleSentryCallback } = await import("./connectors/sentry.js");
423
- const code = parsedUrl.searchParams.get("code");
424
- const state = parsedUrl.searchParams.get("state");
425
- const error = parsedUrl.searchParams.get("error");
426
- const result = await handleSentryCallback(code, state, error);
427
- res.writeHead(result.status, {
428
- "Content-Type": result.contentType ?? "application/json",
429
- });
430
- res.end(result.body);
431
- })();
432
- return;
433
- }
434
- if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
435
- req.method === "GET") {
436
- void (async () => {
437
- const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
438
- const code = parsedUrl.searchParams.get("code");
439
- const state = parsedUrl.searchParams.get("state");
440
- const error = parsedUrl.searchParams.get("error");
441
- const result = await handleCalendarCallback(code, state, error);
442
- res.writeHead(result.status, {
443
- "Content-Type": result.contentType ?? "application/json",
444
- });
445
- res.end(result.body);
446
- })();
447
- return;
448
- }
449
- if (parsedUrl.pathname === "/connections/google-drive/callback" &&
450
- req.method === "GET") {
451
- void (async () => {
452
- const { handleDriveCallback } = await import("./connectors/googleDrive.js");
453
- const code = parsedUrl.searchParams.get("code");
454
- const state = parsedUrl.searchParams.get("state");
455
- const error = parsedUrl.searchParams.get("error");
456
- const result = await handleDriveCallback(code, state, error);
457
- res.writeHead(result.status, {
458
- "Content-Type": result.contentType ?? "application/json",
459
- });
460
- res.end(result.body);
461
- })();
462
- return;
463
- }
464
- if (parsedUrl.pathname === "/connections/slack/callback" &&
465
- req.method === "GET") {
466
- void (async () => {
467
- const { handleSlackCallback } = await import("./connectors/slack.js");
468
- const code = parsedUrl.searchParams.get("code");
469
- const state = parsedUrl.searchParams.get("state");
470
- const error = parsedUrl.searchParams.get("error");
471
- const result = await handleSlackCallback(code, state, error);
472
- res.writeHead(result.status, {
473
- "Content-Type": result.contentType ?? "application/json",
474
- });
475
- res.end(result.body);
476
- })();
477
- return;
478
- }
479
- if (parsedUrl.pathname === "/connections/gmail/callback" &&
480
- req.method === "GET") {
481
- void (async () => {
482
- const { handleGmailCallback } = await import("./connectors/gmail.js");
483
- const code = parsedUrl.searchParams.get("code");
484
- const state = parsedUrl.searchParams.get("state");
485
- const error = parsedUrl.searchParams.get("error");
486
- const result = await handleGmailCallback(code, state, error);
487
- res.writeHead(result.status, {
488
- "Content-Type": result.contentType ?? "text/html",
489
- });
490
- res.end(result.body);
491
- })();
304
+ // ── Connector OAuth callbacks (extracted to src/connectorRoutes.ts) ──
305
+ // Unauthenticated browser redirects from vendor must run BEFORE the
306
+ // bearer-auth gate.
307
+ if (tryHandlePublicConnectorRoute(req, res, parsedUrl)) {
492
308
  return;
493
309
  }
494
310
  // ── /schemas/* — unauthenticated registry-derived JSON Schemas ────────
@@ -666,6 +482,83 @@ export class Server extends EventEmitter {
666
482
  }
667
483
  return;
668
484
  }
485
+ if (parsedUrl.pathname === "/traces/export" && req.method === "GET") {
486
+ void (async () => {
487
+ try {
488
+ // Accept passphrase only via header — never query string (prevents
489
+ // proxy access-log exposure and browser-history leakage).
490
+ if (parsedUrl.searchParams?.get("passphrase")) {
491
+ res.writeHead(400, { "Content-Type": "application/json" });
492
+ res.end(JSON.stringify({
493
+ error: "passphrase must be sent in the X-Trace-Passphrase header, not the URL",
494
+ }));
495
+ return;
496
+ }
497
+ const passphraseRaw = req.headers["x-trace-passphrase"] ?? null;
498
+ if (passphraseRaw !== null && passphraseRaw.length > 4096) {
499
+ res.writeHead(400, { "Content-Type": "application/json" });
500
+ res.end(JSON.stringify({
501
+ error: "passphrase too long (max 4096 chars)",
502
+ }));
503
+ return;
504
+ }
505
+ if (passphraseRaw !== null && passphraseRaw.length < 12) {
506
+ res.writeHead(400, { "Content-Type": "application/json" });
507
+ res.end(JSON.stringify({
508
+ error: "passphrase too short (min 12 chars)",
509
+ }));
510
+ return;
511
+ }
512
+ const passphrase = passphraseRaw;
513
+ const { runTracesExportToStream } = await import("./commands/tracesExport.js");
514
+ const stamp = new Date()
515
+ .toISOString()
516
+ .replace(/:/g, "-")
517
+ .replace(/\..+$/, "");
518
+ if (passphrase) {
519
+ // Encrypted export — buffer the gzip, then AES-256-GCM encrypt.
520
+ const { encryptTraceBundle } = await import("./traceEncryption.js");
521
+ const chunks = [];
522
+ const { Writable } = await import("node:stream");
523
+ const collector = new Writable({
524
+ write(chunk, _enc, cb) {
525
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
526
+ cb();
527
+ },
528
+ });
529
+ await runTracesExportToStream(collector);
530
+ const plain = Buffer.concat(chunks);
531
+ const encrypted = encryptTraceBundle(plain, passphrase);
532
+ const filename = `traces-export-${stamp}.enc`;
533
+ res.writeHead(200, {
534
+ "Content-Type": "application/octet-stream",
535
+ "Content-Disposition": `attachment; filename="${filename}"`,
536
+ "Cache-Control": "no-store",
537
+ "Content-Length": String(encrypted.length),
538
+ });
539
+ res.end(encrypted);
540
+ }
541
+ else {
542
+ const filename = `traces-export-${stamp}.jsonl.gz`;
543
+ res.writeHead(200, {
544
+ "Content-Type": "application/gzip",
545
+ "Content-Disposition": `attachment; filename="${filename}"`,
546
+ "Cache-Control": "no-store",
547
+ });
548
+ await runTracesExportToStream(res);
549
+ }
550
+ }
551
+ catch (err) {
552
+ if (!res.headersSent) {
553
+ res.writeHead(500, { "Content-Type": "application/json" });
554
+ res.end(JSON.stringify({
555
+ error: err instanceof Error ? err.message : String(err),
556
+ }));
557
+ }
558
+ }
559
+ })();
560
+ return;
561
+ }
669
562
  if (parsedUrl.pathname === "/analytics" && req.method === "GET") {
670
563
  try {
671
564
  const wh = parsedUrl.searchParams.get("windowHours");
@@ -849,1469 +742,212 @@ export class Server extends EventEmitter {
849
742
  : result.error === "not_found"
850
743
  ? 404
851
744
  : 400;
745
+ // Record in ring buffer so the dashboard can show "last
746
+ // payload" per recipe. Skip not_found so unknown paths don't
747
+ // pollute the buffer with garbage / scanner traffic.
748
+ if (result.error !== "not_found") {
749
+ const existing = this.webhookPayloads.get(hookPath) ?? [];
750
+ existing.unshift({
751
+ receivedAt: Date.now(),
752
+ payload,
753
+ ok: result.ok,
754
+ error: result.error,
755
+ taskId: result.taskId,
756
+ recipeName: result.name,
757
+ });
758
+ if (existing.length > Server.MAX_WEBHOOK_PAYLOADS) {
759
+ existing.length = Server.MAX_WEBHOOK_PAYLOADS;
760
+ }
761
+ this.webhookPayloads.set(hookPath, existing);
762
+ }
852
763
  res.writeHead(status, { "Content-Type": "application/json" });
853
764
  res.end(JSON.stringify(result));
854
765
  })();
855
766
  });
856
767
  return;
857
768
  }
858
- // ── Gmail / Connections endpoints ───────────────────────────────────────
859
- if (parsedUrl.pathname === "/connections" && req.method === "GET") {
860
- void (async () => {
861
- const { handleConnectionsList } = await import("./connectors/gmail.js");
862
- const result = await handleConnectionsList();
863
- res.writeHead(result.status, {
864
- "Content-Type": result.contentType ?? "application/json",
865
- });
866
- res.end(result.body);
867
- })();
868
- return;
869
- }
870
- if (parsedUrl.pathname === "/connections/gmail/auth" &&
769
+ if (parsedUrl.pathname?.startsWith("/webhook-payloads/") &&
871
770
  req.method === "GET") {
872
- void (async () => {
873
- const { handleGmailAuthRedirect } = await import("./connectors/gmail.js");
874
- const result = handleGmailAuthRedirect();
875
- if (result.redirect) {
876
- res.writeHead(302, { Location: result.redirect });
877
- res.end();
878
- }
879
- else {
880
- res.writeHead(result.status, {
881
- "Content-Type": result.contentType ?? "application/json",
882
- });
883
- res.end(result.body);
884
- }
885
- })();
771
+ const hookPath = parsedUrl.pathname.substring("/webhook-payloads".length);
772
+ const entries = this.webhookPayloads.get(hookPath) ?? [];
773
+ res.writeHead(200, { "Content-Type": "application/json" });
774
+ res.end(JSON.stringify({ path: hookPath, entries }));
886
775
  return;
887
776
  }
888
- if (parsedUrl.pathname === "/connections/gmail" &&
889
- req.method === "DELETE") {
890
- void (async () => {
891
- const { handleGmailDisconnect } = await import("./connectors/gmail.js");
892
- const result = await handleGmailDisconnect();
893
- res.writeHead(result.status, {
894
- "Content-Type": result.contentType ?? "application/json",
895
- });
896
- res.end(result.body);
897
- })();
777
+ // Activity-based automation suggestions (Phase 3 §4). Read-only
778
+ // pattern-mining over the running bridge's activity log + recipe
779
+ // run history. Same logic the `patchwork suggest` CLI calls — this
780
+ // exposes it to the dashboard so suggestions live where users look.
781
+ if (parsedUrl.pathname === "/suggestions" && req.method === "GET") {
782
+ if (!this.activityLog) {
783
+ res.writeHead(503, { "Content-Type": "application/json" });
784
+ res.end(JSON.stringify({
785
+ error: "activity log not wired — bridge probably not in a configuration that records activity",
786
+ }));
787
+ return;
788
+ }
789
+ const sinceDaysParam = parsedUrl.searchParams?.get("sinceDays");
790
+ const sinceDays = sinceDaysParam !== null && sinceDaysParam !== undefined
791
+ ? Number.parseInt(sinceDaysParam, 10)
792
+ : undefined;
793
+ const { computeAutomationSuggestions } = await import("./automationSuggestions.js");
794
+ const opts = {
795
+ activityLog: this.activityLog,
796
+ };
797
+ if (this.recipeRunLog)
798
+ opts.recipeRunLog = this.recipeRunLog;
799
+ if (sinceDays !== undefined && Number.isFinite(sinceDays)) {
800
+ opts.activitySinceMs = sinceDays * 24 * 60 * 60 * 1000;
801
+ }
802
+ const suggestions = computeAutomationSuggestions(opts);
803
+ res.writeHead(200, { "Content-Type": "application/json" });
804
+ res.end(JSON.stringify({
805
+ suggestions,
806
+ generatedAt: new Date().toISOString(),
807
+ }));
898
808
  return;
899
809
  }
900
- if (parsedUrl.pathname === "/connections/gmail/test" &&
901
- req.method === "POST") {
902
- void (async () => {
903
- const { handleGmailTest } = await import("./connectors/gmail.js");
904
- const result = await handleGmailTest();
905
- res.writeHead(result.status, {
906
- "Content-Type": result.contentType ?? "application/json",
907
- });
908
- res.end(result.body);
909
- })();
810
+ // Approval insights aggregate approval-decision history for Phase 3 §3
811
+ // passive risk personalization. Read-only; no state changes.
812
+ if (parsedUrl.pathname === "/approval-insights" && req.method === "GET") {
813
+ if (!this.activityLog) {
814
+ res.writeHead(503, { "Content-Type": "application/json" });
815
+ res.end(JSON.stringify({
816
+ error: "activity log not wired",
817
+ }));
818
+ return;
819
+ }
820
+ const { computeApprovalInsights } = await import("./approvalInsights.js");
821
+ const result = computeApprovalInsights(this.activityLog);
822
+ res.writeHead(200, { "Content-Type": "application/json" });
823
+ res.end(JSON.stringify(result));
910
824
  return;
911
825
  }
912
- // ── GitHub MCP connector routes ─────────────────────────────────────
913
- if (parsedUrl.pathname === "/connections/github/auth" &&
826
+ // Decision replay Phase 3 §2. Re-evaluates historical approval
827
+ // decisions against the current CC policy. Read-only; no side effects.
828
+ if (parsedUrl.pathname === "/approval-insights/replay" &&
914
829
  req.method === "GET") {
915
- void (async () => {
916
- const { handleGithubAuthorize } = await import("./connectors/github.js");
917
- const result = await handleGithubAuthorize();
918
- if (result.redirect) {
919
- res.writeHead(302, { Location: result.redirect });
920
- res.end();
921
- }
922
- else {
923
- res.writeHead(result.status, {
924
- "Content-Type": result.contentType ?? "application/json",
925
- });
926
- res.end(result.body);
927
- }
928
- })();
929
- return;
930
- }
931
- if (parsedUrl.pathname === "/connections/github/test" &&
932
- req.method === "POST") {
933
- void (async () => {
934
- const { handleGithubTest } = await import("./connectors/github.js");
935
- const result = await handleGithubTest();
936
- res.writeHead(result.status, {
937
- "Content-Type": result.contentType ?? "application/json",
938
- });
939
- res.end(result.body);
940
- })();
941
- return;
942
- }
943
- if (parsedUrl.pathname === "/connections/github" &&
944
- req.method === "DELETE") {
945
- void (async () => {
946
- const { handleGithubDisconnect } = await import("./connectors/github.js");
947
- const result = await handleGithubDisconnect();
948
- res.writeHead(result.status, {
949
- "Content-Type": result.contentType ?? "application/json",
950
- });
951
- res.end(result.body);
952
- })();
830
+ if (!this.activityLog) {
831
+ res.writeHead(503, { "Content-Type": "application/json" });
832
+ res.end(JSON.stringify({ error: "activity log not wired" }));
833
+ return;
834
+ }
835
+ const sinceDaysParam = parsedUrl.searchParams?.get("sinceDays");
836
+ const sinceDays = sinceDaysParam !== null && sinceDaysParam !== undefined
837
+ ? Number.parseInt(sinceDaysParam, 10)
838
+ : 7;
839
+ const sinceMs = Number.isFinite(sinceDays)
840
+ ? Date.now() - sinceDays * 24 * 60 * 60 * 1000
841
+ : 0;
842
+ const { computeDecisionReplay } = await import("./decisionReplay.js");
843
+ const result = computeDecisionReplay(this.activityLog, {
844
+ workspace: process.cwd(),
845
+ sinceMs,
846
+ });
847
+ res.writeHead(200, { "Content-Type": "application/json" });
848
+ res.end(JSON.stringify(result));
953
849
  return;
954
850
  }
955
- // ── Sentry MCP connector routes ─────────────────────────────────────
956
- if (parsedUrl.pathname === "/connections/sentry/auth" &&
851
+ // Rule explanation returns which CC permission rule matched a tool call
852
+ // and why approval was required. Phase 1 §2 Delegation Policy UX.
853
+ if (parsedUrl.pathname === "/approval-insights/explain" &&
957
854
  req.method === "GET") {
958
- void (async () => {
959
- const { handleSentryAuthorize } = await import("./connectors/sentry.js");
960
- const result = await handleSentryAuthorize();
961
- if (result.redirect) {
962
- res.writeHead(302, { Location: result.redirect });
963
- res.end();
964
- }
965
- else {
966
- res.writeHead(result.status, {
967
- "Content-Type": result.contentType ?? "application/json",
968
- });
969
- res.end(result.body);
970
- }
971
- })();
855
+ const tool = parsedUrl.searchParams?.get("tool") ?? "";
856
+ const specifier = parsedUrl.searchParams?.get("specifier") ?? undefined;
857
+ if (!tool) {
858
+ res.writeHead(400, { "Content-Type": "application/json" });
859
+ res.end(JSON.stringify({ error: "tool param required" }));
860
+ return;
861
+ }
862
+ const { loadCcPermissionsAttributed, explainRules } = await import("./ccPermissions.js");
863
+ // Cache rules for 30 s — loadCcPermissionsAttributed walks the
864
+ // filesystem and this endpoint can be polled by the dashboard.
865
+ const now = Date.now();
866
+ if (!this._explainRulesCache ||
867
+ now - this._explainRulesCache.at > 30_000) {
868
+ this._explainRulesCache = {
869
+ at: now,
870
+ rules: loadCcPermissionsAttributed(process.cwd()),
871
+ };
872
+ }
873
+ const explanation = explainRules(tool, specifier || undefined, this._explainRulesCache.rules);
874
+ res.writeHead(200, { "Content-Type": "application/json" });
875
+ res.end(JSON.stringify({ tool, specifier: specifier ?? null, explanation }));
972
876
  return;
973
877
  }
974
- if (parsedUrl.pathname === "/connections/sentry/callback" &&
975
- req.method === "GET") {
976
- void (async () => {
977
- const { handleSentryCallback } = await import("./connectors/sentry.js");
978
- const code = parsedUrl.searchParams.get("code");
979
- const state = parsedUrl.searchParams.get("state");
980
- const error = parsedUrl.searchParams.get("error");
981
- const result = await handleSentryCallback(code, state, error);
982
- res.writeHead(result.status, {
983
- "Content-Type": result.contentType ?? "application/json",
984
- });
985
- res.end(result.body);
986
- })();
878
+ // Reversible-refactoring surface list active staged transactions
879
+ // (Phase 1 §3 dashboard ask). Read-only metadata for the dashboard
880
+ // /transactions page; no file contents leave the bridge.
881
+ if (parsedUrl.pathname === "/transactions" && req.method === "GET") {
882
+ const { listActiveTransactions } = await import("./tools/transaction.js");
883
+ const transactions = listActiveTransactions();
884
+ res.writeHead(200, { "Content-Type": "application/json" });
885
+ res.end(JSON.stringify({ transactions }));
987
886
  return;
988
887
  }
989
- if (parsedUrl.pathname === "/connections/sentry/test" &&
888
+ // Discard-only we deliberately do NOT expose commit via HTTP because
889
+ // the commit handler needs per-workspace context wired through
890
+ // createTransactionTools(). Rollback is pure-memory and workspace-
891
+ // agnostic, safe to expose. Commit from the agent side via MCP.
892
+ if (parsedUrl.pathname?.match(/^\/transactions\/[^/]+\/rollback$/) &&
990
893
  req.method === "POST") {
991
- void (async () => {
992
- const { handleSentryTest } = await import("./connectors/sentry.js");
993
- const result = await handleSentryTest();
994
- res.writeHead(result.status, {
995
- "Content-Type": result.contentType ?? "application/json",
996
- });
997
- res.end(result.body);
998
- })();
999
- return;
1000
- }
1001
- if (parsedUrl.pathname === "/connections/sentry" &&
1002
- req.method === "DELETE") {
1003
- void (async () => {
1004
- const { handleSentryDisconnect } = await import("./connectors/sentry.js");
1005
- const result = await handleSentryDisconnect();
1006
- res.writeHead(result.status, {
1007
- "Content-Type": result.contentType ?? "application/json",
1008
- });
1009
- res.end(result.body);
1010
- })();
894
+ const id = parsedUrl.pathname.split("/")[2] ?? "";
895
+ const { rollbackTransactionById } = await import("./tools/transaction.js");
896
+ const ok = id !== "" && rollbackTransactionById(id);
897
+ res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json" });
898
+ res.end(JSON.stringify(ok
899
+ ? { ok: true, transactionId: id }
900
+ : { ok: false, error: "transaction not found" }));
901
+ return;
902
+ }
903
+ // ── Connector routes (extracted to src/connectorRoutes.ts) ──────────
904
+ if (tryHandleConnectorRoute(req, res, parsedUrl)) {
905
+ return;
906
+ }
907
+ // ── Inbox routes (extracted to src/inboxRoutes.ts) ───────────────────
908
+ if (tryHandleInboxRoute(req, res, parsedUrl)) {
909
+ return;
910
+ }
911
+ // ── Recipe / runs / templates routes (extracted to src/recipeRoutes.ts)
912
+ if (tryHandleRecipeRoute(req, res, parsedUrl, {
913
+ setRecipeTrustFn: this.setRecipeTrustFn,
914
+ generateRecipeFn: this.generateRecipeFn,
915
+ recipesFn: this.recipesFn,
916
+ loadRecipeContentFn: this.loadRecipeContentFn,
917
+ saveRecipeContentFn: this.saveRecipeContentFn,
918
+ deleteRecipeContentFn: this.deleteRecipeContentFn,
919
+ duplicateRecipeFn: this.duplicateRecipeFn,
920
+ promoteRecipeVariantFn: this.promoteRecipeVariantFn,
921
+ lintRecipeContentFn: this.lintRecipeContentFn,
922
+ saveRecipeFn: this.saveRecipeFn,
923
+ setRecipeEnabledFn: this.setRecipeEnabledFn,
924
+ runsFn: this.runsFn,
925
+ runDetailFn: this.runDetailFn,
926
+ runPlanFn: this.runPlanFn,
927
+ runReplayFn: this.runReplayFn,
928
+ runRecipeFn: this.runRecipeFn,
929
+ })) {
1011
930
  return;
1012
931
  }
1013
- // ── Linear MCP connector routes ─────────────────────────────────────
1014
- if (parsedUrl.pathname === "/connections/linear/auth" &&
1015
- req.method === "GET") {
1016
- void (async () => {
1017
- const { handleLinearAuthorize } = await import("./connectors/linear.js");
1018
- const result = await handleLinearAuthorize();
1019
- if (result.redirect) {
1020
- res.writeHead(302, { Location: result.redirect });
1021
- res.end();
1022
- }
1023
- else {
1024
- res.writeHead(result.status, {
1025
- "Content-Type": result.contentType ?? "application/json",
1026
- });
1027
- res.end(result.body);
932
+ const sessionDetailMatch = /^\/sessions\/([A-Za-z0-9-]+)$/.exec(parsedUrl.pathname);
933
+ if (sessionDetailMatch && req.method === "GET") {
934
+ const id = sessionDetailMatch[1];
935
+ try {
936
+ const data = this.sessionDetailFn?.(id);
937
+ if (!data?.summary) {
938
+ res.writeHead(404, { "Content-Type": "application/json" });
939
+ res.end(JSON.stringify({ error: "unknown sessionId" }));
940
+ return;
1028
941
  }
1029
- })();
1030
- return;
1031
- }
1032
- if (parsedUrl.pathname === "/connections/linear/callback" &&
1033
- req.method === "GET") {
1034
- void (async () => {
1035
- const { handleLinearCallback } = await import("./connectors/linear.js");
1036
- const code = parsedUrl.searchParams.get("code");
1037
- const state = parsedUrl.searchParams.get("state");
1038
- const error = parsedUrl.searchParams.get("error");
1039
- const result = await handleLinearCallback(code, state, error);
1040
- res.writeHead(result.status, {
1041
- "Content-Type": result.contentType ?? "application/json",
1042
- });
1043
- res.end(result.body);
1044
- })();
1045
- return;
1046
- }
1047
- if (parsedUrl.pathname === "/connections/linear/test" &&
1048
- req.method === "POST") {
1049
- void (async () => {
1050
- const { handleLinearTest } = await import("./connectors/linear.js");
1051
- const result = await handleLinearTest();
1052
- res.writeHead(result.status, {
1053
- "Content-Type": result.contentType ?? "application/json",
1054
- });
1055
- res.end(result.body);
1056
- })();
1057
- return;
1058
- }
1059
- if (parsedUrl.pathname === "/connections/linear" &&
1060
- req.method === "DELETE") {
1061
- void (async () => {
1062
- const { handleLinearDisconnect } = await import("./connectors/linear.js");
1063
- const result = await handleLinearDisconnect();
1064
- res.writeHead(result.status, {
1065
- "Content-Type": result.contentType ?? "application/json",
1066
- });
1067
- res.end(result.body);
1068
- })();
1069
- return;
1070
- }
1071
- // ── Slack connector routes ──────────────────────────────────────
1072
- if ((parsedUrl.pathname === "/connections/slack/auth" ||
1073
- parsedUrl.pathname === "/connections/slack/authorize") &&
1074
- req.method === "GET") {
1075
- const { handleSlackAuthorize } = await import("./connectors/slack.js");
1076
- const result = handleSlackAuthorize();
1077
- if (result.redirect) {
1078
- res.writeHead(302, { Location: result.redirect });
1079
- res.end();
1080
- }
1081
- else {
1082
- res.writeHead(result.status, {
1083
- "Content-Type": result.contentType ?? "application/json",
1084
- });
1085
- res.end(result.body);
1086
- }
1087
- return;
1088
- }
1089
- if (parsedUrl.pathname === "/connections/slack/test" &&
1090
- req.method === "POST") {
1091
- void (async () => {
1092
- const { handleSlackTest } = await import("./connectors/slack.js");
1093
- const result = await handleSlackTest();
1094
- res.writeHead(result.status, {
1095
- "Content-Type": result.contentType ?? "application/json",
1096
- });
1097
- res.end(result.body);
1098
- })();
1099
- return;
1100
- }
1101
- if (parsedUrl.pathname === "/connections/slack" &&
1102
- req.method === "DELETE") {
1103
- const { handleSlackDisconnect } = await import("./connectors/slack.js");
1104
- const result = handleSlackDisconnect();
1105
- res.writeHead(result.status, {
1106
- "Content-Type": result.contentType ?? "application/json",
1107
- });
1108
- res.end(result.body);
1109
- return;
1110
- }
1111
- // ── Discord connector routes ───────────────────────────────────
1112
- if ((parsedUrl.pathname === "/connections/discord/auth" ||
1113
- parsedUrl.pathname === "/connections/discord/authorize") &&
1114
- req.method === "GET") {
1115
- const { handleDiscordAuthorize } = await import("./connectors/discord.js");
1116
- const result = handleDiscordAuthorize();
1117
- if (result.redirect) {
1118
- res.writeHead(302, { Location: result.redirect });
1119
- res.end();
1120
- }
1121
- else {
1122
- res.writeHead(result.status, {
1123
- "Content-Type": result.contentType ?? "application/json",
1124
- });
1125
- res.end(result.body);
1126
- }
1127
- return;
1128
- }
1129
- if (parsedUrl.pathname === "/connections/discord/callback" &&
1130
- req.method === "GET") {
1131
- void (async () => {
1132
- const { handleDiscordCallback } = await import("./connectors/discord.js");
1133
- const code = parsedUrl.searchParams.get("code");
1134
- const state = parsedUrl.searchParams.get("state");
1135
- const error = parsedUrl.searchParams.get("error");
1136
- const result = await handleDiscordCallback(code, state, error);
1137
- res.writeHead(result.status, {
1138
- "Content-Type": result.contentType ?? "text/html",
1139
- });
1140
- res.end(result.body);
1141
- })();
1142
- return;
1143
- }
1144
- if (parsedUrl.pathname === "/connections/discord/test" &&
1145
- req.method === "POST") {
1146
- void (async () => {
1147
- const { handleDiscordTest } = await import("./connectors/discord.js");
1148
- const result = await handleDiscordTest();
1149
- res.writeHead(result.status, {
1150
- "Content-Type": result.contentType ?? "application/json",
1151
- });
1152
- res.end(result.body);
1153
- })();
1154
- return;
1155
- }
1156
- if (parsedUrl.pathname === "/connections/discord" &&
1157
- req.method === "DELETE") {
1158
- void (async () => {
1159
- const { handleDiscordDisconnect } = await import("./connectors/discord.js");
1160
- const result = await handleDiscordDisconnect();
1161
- res.writeHead(result.status, {
1162
- "Content-Type": result.contentType ?? "application/json",
1163
- });
1164
- res.end(result.body);
1165
- })();
1166
- return;
1167
- }
1168
- // ── Asana connector routes ─────────────────────────────────────
1169
- if ((parsedUrl.pathname === "/connections/asana/auth" ||
1170
- parsedUrl.pathname === "/connections/asana/authorize") &&
1171
- req.method === "GET") {
1172
- const { handleAsanaAuthorize } = await import("./connectors/asana.js");
1173
- const result = handleAsanaAuthorize();
1174
- if (result.redirect) {
1175
- res.writeHead(302, { Location: result.redirect });
1176
- res.end();
1177
- }
1178
- else {
1179
- res.writeHead(result.status, {
1180
- "Content-Type": result.contentType ?? "application/json",
1181
- });
1182
- res.end(result.body);
1183
- }
1184
- return;
1185
- }
1186
- if (parsedUrl.pathname === "/connections/asana/callback" &&
1187
- req.method === "GET") {
1188
- void (async () => {
1189
- const { handleAsanaCallback } = await import("./connectors/asana.js");
1190
- const code = parsedUrl.searchParams.get("code");
1191
- const state = parsedUrl.searchParams.get("state");
1192
- const error = parsedUrl.searchParams.get("error");
1193
- const result = await handleAsanaCallback(code, state, error);
1194
- res.writeHead(result.status, {
1195
- "Content-Type": result.contentType ?? "text/html",
1196
- });
1197
- res.end(result.body);
1198
- })();
1199
- return;
1200
- }
1201
- if (parsedUrl.pathname === "/connections/asana/test" &&
1202
- req.method === "POST") {
1203
- void (async () => {
1204
- const { handleAsanaTest } = await import("./connectors/asana.js");
1205
- const result = await handleAsanaTest();
1206
- res.writeHead(result.status, {
1207
- "Content-Type": result.contentType ?? "application/json",
1208
- });
1209
- res.end(result.body);
1210
- })();
1211
- return;
1212
- }
1213
- if (parsedUrl.pathname === "/connections/asana" &&
1214
- req.method === "DELETE") {
1215
- void (async () => {
1216
- const { handleAsanaDisconnect } = await import("./connectors/asana.js");
1217
- const result = await handleAsanaDisconnect();
1218
- res.writeHead(result.status, {
1219
- "Content-Type": result.contentType ?? "application/json",
1220
- });
1221
- res.end(result.body);
1222
- })();
1223
- return;
1224
- }
1225
- // ── GitLab connector routes ────────────────────────────────────
1226
- if ((parsedUrl.pathname === "/connections/gitlab/auth" ||
1227
- parsedUrl.pathname === "/connections/gitlab/authorize") &&
1228
- req.method === "GET") {
1229
- const { handleGitLabAuthorize } = await import("./connectors/gitlab.js");
1230
- const result = handleGitLabAuthorize();
1231
- if (result.redirect) {
1232
- res.writeHead(302, { Location: result.redirect });
1233
- res.end();
1234
- }
1235
- else {
1236
- res.writeHead(result.status, {
1237
- "Content-Type": result.contentType ?? "application/json",
1238
- });
1239
- res.end(result.body);
1240
- }
1241
- return;
1242
- }
1243
- if (parsedUrl.pathname === "/connections/gitlab/callback" &&
1244
- req.method === "GET") {
1245
- void (async () => {
1246
- const { handleGitLabCallback } = await import("./connectors/gitlab.js");
1247
- const code = parsedUrl.searchParams.get("code");
1248
- const state = parsedUrl.searchParams.get("state");
1249
- const error = parsedUrl.searchParams.get("error");
1250
- const result = await handleGitLabCallback(code, state, error);
1251
- res.writeHead(result.status, {
1252
- "Content-Type": result.contentType ?? "text/html",
1253
- });
1254
- res.end(result.body);
1255
- })();
1256
- return;
1257
- }
1258
- if (parsedUrl.pathname === "/connections/gitlab/test" &&
1259
- req.method === "POST") {
1260
- void (async () => {
1261
- const { handleGitLabTest } = await import("./connectors/gitlab.js");
1262
- const result = await handleGitLabTest();
1263
- res.writeHead(result.status, {
1264
- "Content-Type": result.contentType ?? "application/json",
1265
- });
1266
- res.end(result.body);
1267
- })();
1268
- return;
1269
- }
1270
- if (parsedUrl.pathname === "/connections/gitlab" &&
1271
- req.method === "DELETE") {
1272
- void (async () => {
1273
- const { handleGitLabDisconnect } = await import("./connectors/gitlab.js");
1274
- const result = await handleGitLabDisconnect();
1275
- res.writeHead(result.status, {
1276
- "Content-Type": result.contentType ?? "application/json",
1277
- });
1278
- res.end(result.body);
1279
- })();
1280
- return;
1281
- }
1282
- // ── Notion routes ──────────────────────────────────────────────
1283
- if (parsedUrl.pathname === "/connections/notion/connect" &&
1284
- req.method === "POST") {
1285
- const chunks = [];
1286
- req.on("data", (c) => chunks.push(c));
1287
- req.on("end", () => {
1288
- void (async () => {
1289
- const { handleNotionConnect } = await import("./connectors/notion.js");
1290
- const result = await handleNotionConnect(Buffer.concat(chunks).toString("utf-8"));
1291
- res.writeHead(result.status, {
1292
- "Content-Type": result.contentType ?? "application/json",
1293
- });
1294
- res.end(result.body);
1295
- })();
1296
- });
1297
- return;
1298
- }
1299
- if (parsedUrl.pathname === "/connections/notion/test" &&
1300
- req.method === "POST") {
1301
- void (async () => {
1302
- const { handleNotionTest } = await import("./connectors/notion.js");
1303
- const result = await handleNotionTest();
1304
- res.writeHead(result.status, {
1305
- "Content-Type": result.contentType ?? "application/json",
1306
- });
1307
- res.end(result.body);
1308
- })();
1309
- return;
1310
- }
1311
- if (parsedUrl.pathname === "/connections/notion" &&
1312
- req.method === "DELETE") {
1313
- const { handleNotionDisconnect } = await import("./connectors/notion.js");
1314
- const result = handleNotionDisconnect();
1315
- res.writeHead(result.status, {
1316
- "Content-Type": result.contentType ?? "application/json",
1317
- });
1318
- res.end(result.body);
1319
- return;
1320
- }
1321
- // ── Confluence routes ───────────────────────────────────────────
1322
- if (parsedUrl.pathname === "/connections/confluence/connect" &&
1323
- req.method === "POST") {
1324
- const chunks = [];
1325
- req.on("data", (c) => chunks.push(c));
1326
- req.on("end", () => {
1327
- void (async () => {
1328
- const { handleConfluenceConnect } = await import("./connectors/confluence.js");
1329
- const result = await handleConfluenceConnect(Buffer.concat(chunks).toString("utf-8"));
1330
- res.writeHead(result.status, {
1331
- "Content-Type": result.contentType ?? "application/json",
1332
- });
1333
- res.end(result.body);
1334
- })();
1335
- });
1336
- return;
1337
- }
1338
- if (parsedUrl.pathname === "/connections/confluence/test" &&
1339
- req.method === "POST") {
1340
- void (async () => {
1341
- const { handleConfluenceTest } = await import("./connectors/confluence.js");
1342
- const result = await handleConfluenceTest();
1343
- res.writeHead(result.status, {
1344
- "Content-Type": result.contentType ?? "application/json",
1345
- });
1346
- res.end(result.body);
1347
- })();
1348
- return;
1349
- }
1350
- if (parsedUrl.pathname === "/connections/confluence" &&
1351
- req.method === "DELETE") {
1352
- const { handleConfluenceDisconnect } = await import("./connectors/confluence.js");
1353
- const result = handleConfluenceDisconnect();
1354
- res.writeHead(result.status, {
1355
- "Content-Type": result.contentType ?? "application/json",
1356
- });
1357
- res.end(result.body);
1358
- return;
1359
- }
1360
- // ── Zendesk routes ──────────────────────────────────────────────
1361
- if (parsedUrl.pathname === "/connections/zendesk/connect" &&
1362
- req.method === "POST") {
1363
- const chunks = [];
1364
- req.on("data", (c) => chunks.push(c));
1365
- req.on("end", () => {
1366
- void (async () => {
1367
- const { handleZendeskConnect } = await import("./connectors/zendesk.js");
1368
- const result = await handleZendeskConnect(Buffer.concat(chunks).toString("utf-8"));
1369
- res.writeHead(result.status, {
1370
- "Content-Type": result.contentType ?? "application/json",
1371
- });
1372
- res.end(result.body);
1373
- })();
1374
- });
1375
- return;
1376
- }
1377
- if (parsedUrl.pathname === "/connections/zendesk/test" &&
1378
- req.method === "POST") {
1379
- void (async () => {
1380
- const { handleZendeskTest } = await import("./connectors/zendesk.js");
1381
- const result = await handleZendeskTest();
1382
- res.writeHead(result.status, {
1383
- "Content-Type": result.contentType ?? "application/json",
1384
- });
1385
- res.end(result.body);
1386
- })();
1387
- return;
1388
- }
1389
- if (parsedUrl.pathname === "/connections/zendesk" &&
1390
- req.method === "DELETE") {
1391
- const { handleZendeskDisconnect } = await import("./connectors/zendesk.js");
1392
- const result = handleZendeskDisconnect();
1393
- res.writeHead(result.status, {
1394
- "Content-Type": result.contentType ?? "application/json",
1395
- });
1396
- res.end(result.body);
1397
- return;
1398
- }
1399
- // ── Intercom routes ─────────────────────────────────────────────
1400
- if (parsedUrl.pathname === "/connections/intercom/connect" &&
1401
- req.method === "POST") {
1402
- const chunks = [];
1403
- req.on("data", (c) => chunks.push(c));
1404
- req.on("end", () => {
1405
- void (async () => {
1406
- const { handleIntercomConnect } = await import("./connectors/intercom.js");
1407
- const result = await handleIntercomConnect(Buffer.concat(chunks).toString("utf-8"));
1408
- res.writeHead(result.status, {
1409
- "Content-Type": result.contentType ?? "application/json",
1410
- });
1411
- res.end(result.body);
1412
- })();
1413
- });
1414
- return;
1415
- }
1416
- if (parsedUrl.pathname === "/connections/intercom/test" &&
1417
- req.method === "POST") {
1418
- void (async () => {
1419
- const { handleIntercomTest } = await import("./connectors/intercom.js");
1420
- const result = await handleIntercomTest();
1421
- res.writeHead(result.status, {
1422
- "Content-Type": result.contentType ?? "application/json",
1423
- });
1424
- res.end(result.body);
1425
- })();
1426
- return;
1427
- }
1428
- if (parsedUrl.pathname === "/connections/intercom" &&
1429
- req.method === "DELETE") {
1430
- const { handleIntercomDisconnect } = await import("./connectors/intercom.js");
1431
- const result = handleIntercomDisconnect();
1432
- res.writeHead(result.status, {
1433
- "Content-Type": result.contentType ?? "application/json",
1434
- });
1435
- res.end(result.body);
1436
- return;
1437
- }
1438
- // ── HubSpot routes ─────────────────────────────────────────────
1439
- if (parsedUrl.pathname === "/connections/hubspot/connect" &&
1440
- req.method === "POST") {
1441
- const chunks = [];
1442
- req.on("data", (c) => chunks.push(c));
1443
- req.on("end", () => {
1444
- void (async () => {
1445
- const { handleHubSpotConnect } = await import("./connectors/hubspot.js");
1446
- const result = await handleHubSpotConnect(Buffer.concat(chunks).toString("utf-8"));
1447
- res.writeHead(result.status, {
1448
- "Content-Type": result.contentType ?? "application/json",
1449
- });
1450
- res.end(result.body);
1451
- })();
1452
- });
1453
- return;
1454
- }
1455
- if (parsedUrl.pathname === "/connections/hubspot/test" &&
1456
- req.method === "POST") {
1457
- const { handleHubSpotTest } = await import("./connectors/hubspot.js");
1458
- const result = await handleHubSpotTest();
1459
- res.writeHead(result.status, {
1460
- "Content-Type": result.contentType ?? "application/json",
1461
- });
1462
- res.end(result.body);
1463
- return;
1464
- }
1465
- if (parsedUrl.pathname === "/connections/hubspot" &&
1466
- req.method === "DELETE") {
1467
- const { handleHubSpotDisconnect } = await import("./connectors/hubspot.js");
1468
- const result = handleHubSpotDisconnect();
1469
- res.writeHead(result.status, {
1470
- "Content-Type": result.contentType ?? "application/json",
1471
- });
1472
- res.end(result.body);
1473
- return;
1474
- }
1475
- // ── Datadog routes ─────────────────────────────────────────────
1476
- if (parsedUrl.pathname === "/connections/datadog/connect" &&
1477
- req.method === "POST") {
1478
- const chunks = [];
1479
- req.on("data", (c) => chunks.push(c));
1480
- req.on("end", () => {
1481
- void (async () => {
1482
- const { handleDatadogConnect } = await import("./connectors/datadog.js");
1483
- const result = await handleDatadogConnect(Buffer.concat(chunks).toString("utf-8"));
1484
- res.writeHead(result.status, {
1485
- "Content-Type": result.contentType ?? "application/json",
1486
- });
1487
- res.end(result.body);
1488
- })();
1489
- });
1490
- return;
1491
- }
1492
- if (parsedUrl.pathname === "/connections/datadog/test" &&
1493
- req.method === "POST") {
1494
- void (async () => {
1495
- const { handleDatadogTest } = await import("./connectors/datadog.js");
1496
- const result = await handleDatadogTest();
1497
- res.writeHead(result.status, {
1498
- "Content-Type": result.contentType ?? "application/json",
1499
- });
1500
- res.end(result.body);
1501
- })();
1502
- return;
1503
- }
1504
- if (parsedUrl.pathname === "/connections/datadog" &&
1505
- req.method === "DELETE") {
1506
- const { handleDatadogDisconnect } = await import("./connectors/datadog.js");
1507
- const result = handleDatadogDisconnect();
1508
- res.writeHead(result.status, {
1509
- "Content-Type": result.contentType ?? "application/json",
1510
- });
1511
- res.end(result.body);
1512
- return;
1513
- }
1514
- // ── PagerDuty routes ───────────────────────────────────────────
1515
- if (parsedUrl.pathname === "/connections/pagerduty/connect" &&
1516
- req.method === "POST") {
1517
- const chunks = [];
1518
- req.on("data", (c) => chunks.push(c));
1519
- req.on("end", () => {
1520
- void (async () => {
1521
- const { handlePagerDutyConnect } = await import("./connectors/pagerduty.js");
1522
- const result = await handlePagerDutyConnect(Buffer.concat(chunks).toString("utf-8"));
1523
- res.writeHead(result.status, {
1524
- "Content-Type": result.contentType ?? "application/json",
1525
- });
1526
- res.end(result.body);
1527
- })();
1528
- });
1529
- return;
1530
- }
1531
- if (parsedUrl.pathname === "/connections/pagerduty/test" &&
1532
- req.method === "POST") {
1533
- void (async () => {
1534
- const { handlePagerDutyTest } = await import("./connectors/pagerduty.js");
1535
- const result = await handlePagerDutyTest();
1536
- res.writeHead(result.status, {
1537
- "Content-Type": result.contentType ?? "application/json",
1538
- });
1539
- res.end(result.body);
1540
- })();
1541
- return;
1542
- }
1543
- if (parsedUrl.pathname === "/connections/pagerduty" &&
1544
- req.method === "DELETE") {
1545
- const { handlePagerDutyDisconnect } = await import("./connectors/pagerduty.js");
1546
- const result = handlePagerDutyDisconnect();
1547
- res.writeHead(result.status, {
1548
- "Content-Type": result.contentType ?? "application/json",
1549
- });
1550
- res.end(result.body);
1551
- return;
1552
- }
1553
- // ── Stripe routes ───────────────────────────────────────────────
1554
- if (parsedUrl.pathname === "/connections/stripe/connect" &&
1555
- req.method === "POST") {
1556
- let body = "";
1557
- req.on("data", (chunk) => {
1558
- body += chunk.toString();
1559
- });
1560
- req.on("end", () => {
1561
- void (async () => {
1562
- const { handleStripeConnect } = await import("./connectors/stripe.js");
1563
- const result = await handleStripeConnect(body);
1564
- res.writeHead(result.status, {
1565
- "Content-Type": result.contentType ?? "application/json",
1566
- });
1567
- res.end(result.body);
1568
- })();
1569
- });
1570
- return;
1571
- }
1572
- if (parsedUrl.pathname === "/connections/stripe/test" &&
1573
- req.method === "POST") {
1574
- const { handleStripeTest } = await import("./connectors/stripe.js");
1575
- const result = await handleStripeTest();
1576
- res.writeHead(result.status, {
1577
- "Content-Type": result.contentType ?? "application/json",
1578
- });
1579
- res.end(result.body);
1580
- return;
1581
- }
1582
- if (parsedUrl.pathname === "/connections/stripe" &&
1583
- req.method === "DELETE") {
1584
- const { handleStripeDisconnect } = await import("./connectors/stripe.js");
1585
- const result = handleStripeDisconnect();
1586
- res.writeHead(result.status, {
1587
- "Content-Type": result.contentType ?? "application/json",
1588
- });
1589
- res.end(result.body);
1590
- return;
1591
- }
1592
- // ── Google Calendar routes ──────────────────────────────────────
1593
- if (parsedUrl.pathname === "/connections/google-calendar/auth" &&
1594
- req.method === "GET") {
1595
- void (async () => {
1596
- const { handleCalendarAuthRedirect } = await import("./connectors/googleCalendar.js");
1597
- const result = handleCalendarAuthRedirect();
1598
- if (result.redirect) {
1599
- res.writeHead(302, { Location: result.redirect });
1600
- res.end();
1601
- }
1602
- else {
1603
- res.writeHead(result.status, {
1604
- "Content-Type": result.contentType ?? "application/json",
1605
- });
1606
- res.end(result.body);
1607
- }
1608
- })();
1609
- return;
1610
- }
1611
- if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
1612
- req.method === "GET") {
1613
- void (async () => {
1614
- const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
1615
- const code = parsedUrl.searchParams.get("code");
1616
- const state = parsedUrl.searchParams.get("state");
1617
- const error = parsedUrl.searchParams.get("error");
1618
- const result = await handleCalendarCallback(code, state, error);
1619
- res.writeHead(result.status, {
1620
- "Content-Type": result.contentType ?? "application/json",
1621
- });
1622
- res.end(result.body);
1623
- })();
1624
- return;
1625
- }
1626
- if (parsedUrl.pathname === "/connections/google-calendar/test" &&
1627
- req.method === "POST") {
1628
- void (async () => {
1629
- const { handleCalendarTest } = await import("./connectors/googleCalendar.js");
1630
- const result = await handleCalendarTest();
1631
- res.writeHead(result.status, {
1632
- "Content-Type": result.contentType ?? "application/json",
1633
- });
1634
- res.end(result.body);
1635
- })();
1636
- return;
1637
- }
1638
- if (parsedUrl.pathname === "/connections/google-calendar" &&
1639
- req.method === "DELETE") {
1640
- void (async () => {
1641
- const { handleCalendarDisconnect } = await import("./connectors/googleCalendar.js");
1642
- const result = await handleCalendarDisconnect();
1643
- res.writeHead(result.status, {
1644
- "Content-Type": result.contentType ?? "application/json",
1645
- });
1646
- res.end(result.body);
1647
- })();
1648
- return;
1649
- }
1650
- if (parsedUrl.pathname === "/connections/google-drive/auth" &&
1651
- req.method === "GET") {
1652
- void (async () => {
1653
- const { handleDriveAuthRedirect } = await import("./connectors/googleDrive.js");
1654
- const result = handleDriveAuthRedirect();
1655
- if (result.redirect) {
1656
- res.writeHead(302, { Location: result.redirect });
1657
- res.end();
1658
- }
1659
- else {
1660
- res.writeHead(result.status, {
1661
- "Content-Type": result.contentType ?? "application/json",
1662
- });
1663
- res.end(result.body);
1664
- }
1665
- })();
1666
- return;
1667
- }
1668
- if (parsedUrl.pathname === "/connections/google-drive/callback" &&
1669
- req.method === "GET") {
1670
- void (async () => {
1671
- const { handleDriveCallback } = await import("./connectors/googleDrive.js");
1672
- const code = parsedUrl.searchParams.get("code");
1673
- const state = parsedUrl.searchParams.get("state");
1674
- const error = parsedUrl.searchParams.get("error");
1675
- const result = await handleDriveCallback(code, state, error);
1676
- res.writeHead(result.status, {
1677
- "Content-Type": result.contentType ?? "application/json",
1678
- });
1679
- res.end(result.body);
1680
- })();
1681
- return;
1682
- }
1683
- if (parsedUrl.pathname === "/connections/google-drive/test" &&
1684
- req.method === "POST") {
1685
- void (async () => {
1686
- const { handleDriveTest } = await import("./connectors/googleDrive.js");
1687
- const result = await handleDriveTest();
1688
- res.writeHead(result.status, {
1689
- "Content-Type": result.contentType ?? "application/json",
1690
- });
1691
- res.end(result.body);
1692
- })();
1693
- return;
1694
- }
1695
- if (parsedUrl.pathname === "/connections/google-drive" &&
1696
- req.method === "DELETE") {
1697
- void (async () => {
1698
- const { handleDriveDisconnect } = await import("./connectors/googleDrive.js");
1699
- const result = await handleDriveDisconnect();
1700
- res.writeHead(result.status, {
1701
- "Content-Type": result.contentType ?? "application/json",
1702
- });
1703
- res.end(result.body);
1704
- })();
1705
- return;
1706
- }
1707
- // ── Inbox routes ────────────────────────────────────────────────────
1708
- if (parsedUrl.pathname === "/inbox" && req.method === "GET") {
1709
- void (async () => {
1710
- try {
1711
- const { readdir, readFile, stat } = await import("node:fs/promises");
1712
- const { existsSync } = await import("node:fs");
1713
- const inboxDir = path.join(os.homedir(), ".patchwork", "inbox");
1714
- if (!existsSync(inboxDir)) {
1715
- res.writeHead(200, { "Content-Type": "application/json" });
1716
- res.end(JSON.stringify({ items: [] }));
1717
- return;
1718
- }
1719
- const files = (await readdir(inboxDir)).filter((f) => f.endsWith(".md"));
1720
- const items = await Promise.all(files.map(async (name) => {
1721
- const filePath = path.join(inboxDir, name);
1722
- const [content, stats] = await Promise.all([
1723
- readFile(filePath, "utf8"),
1724
- stat(filePath),
1725
- ]);
1726
- const stripped = content
1727
- .split("\n")
1728
- .filter((l) => !l.startsWith("#"))
1729
- .join("\n")
1730
- .trim();
1731
- return {
1732
- name,
1733
- path: filePath,
1734
- modifiedAt: stats.mtime.toISOString(),
1735
- preview: stripped.slice(0, 200),
1736
- };
1737
- }));
1738
- items.sort((a, b) => new Date(b.modifiedAt).getTime() -
1739
- new Date(a.modifiedAt).getTime());
1740
- res.writeHead(200, { "Content-Type": "application/json" });
1741
- res.end(JSON.stringify({ items }));
1742
- }
1743
- catch (err) {
1744
- res.writeHead(500, { "Content-Type": "application/json" });
1745
- res.end(JSON.stringify({
1746
- error: err instanceof Error ? err.message : String(err),
1747
- }));
1748
- }
1749
- })();
1750
- return;
1751
- }
1752
- const inboxFileMatch = parsedUrl.pathname?.match(/^\/inbox\/([^/]+\.md)$/);
1753
- if (inboxFileMatch && req.method === "GET") {
1754
- void (async () => {
1755
- try {
1756
- const { readFile, stat } = await import("node:fs/promises");
1757
- const filename = decodeURIComponent(inboxFileMatch[1] ?? "");
1758
- // Prevent path traversal — filename must not contain directory separators
1759
- if (filename.includes("/") || filename.includes("\\")) {
1760
- res.writeHead(400, { "Content-Type": "application/json" });
1761
- res.end(JSON.stringify({ error: "Invalid filename" }));
1762
- return;
1763
- }
1764
- const filePath = path.join(os.homedir(), ".patchwork", "inbox", filename);
1765
- const [content, stats] = await Promise.all([
1766
- readFile(filePath, "utf8"),
1767
- stat(filePath),
1768
- ]);
1769
- res.writeHead(200, { "Content-Type": "application/json" });
1770
- res.end(JSON.stringify({
1771
- name: filename,
1772
- content,
1773
- modifiedAt: stats.mtime.toISOString(),
1774
- }));
1775
- }
1776
- catch (err) {
1777
- const code = err.code;
1778
- if (code === "ENOENT") {
1779
- res.writeHead(404, { "Content-Type": "application/json" });
1780
- res.end(JSON.stringify({ error: "Not found" }));
1781
- }
1782
- else {
1783
- res.writeHead(500, { "Content-Type": "application/json" });
1784
- res.end(JSON.stringify({
1785
- error: err instanceof Error ? err.message : String(err),
1786
- }));
1787
- }
1788
- }
1789
- })();
1790
- return;
1791
- }
1792
- // ── End inbox routes ─────────────────────────────────────────────────
1793
- const recipeNameRunMatch = req.method === "POST"
1794
- ? /^\/recipes\/([^/]+)\/run$/.exec(parsedUrl.pathname)
1795
- : null;
1796
- if (recipeNameRunMatch) {
1797
- const nameFromPath = decodeURIComponent(recipeNameRunMatch[1] ?? "");
1798
- const chunks = [];
1799
- req.on("data", (c) => chunks.push(c));
1800
- req.on("end", () => {
1801
- void (async () => {
1802
- try {
1803
- const body = Buffer.concat(chunks).toString("utf-8");
1804
- const parsed = body
1805
- ? JSON.parse(body)
1806
- : {};
1807
- const varsRaw = parsed.vars ?? parsed.inputs;
1808
- const vars = varsRaw &&
1809
- typeof varsRaw === "object" &&
1810
- !Array.isArray(varsRaw)
1811
- ? varsRaw
1812
- : undefined;
1813
- if (!this.runRecipeFn) {
1814
- res.writeHead(503, { "Content-Type": "application/json" });
1815
- res.end(JSON.stringify({
1816
- ok: false,
1817
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
1818
- }));
1819
- return;
1820
- }
1821
- const result = await this.runRecipeFn(nameFromPath, vars);
1822
- res.writeHead(result.ok ? 200 : 400, {
1823
- "Content-Type": "application/json",
1824
- });
1825
- res.end(JSON.stringify(result));
1826
- }
1827
- catch {
1828
- res.writeHead(400, { "Content-Type": "application/json" });
1829
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1830
- }
1831
- })();
1832
- });
1833
- return;
1834
- }
1835
- if (parsedUrl.pathname === "/recipes/run" && req.method === "POST") {
1836
- const chunks = [];
1837
- req.on("data", (c) => chunks.push(c));
1838
- req.on("end", () => {
1839
- void (async () => {
1840
- try {
1841
- const body = Buffer.concat(chunks).toString("utf-8");
1842
- const parsed = JSON.parse(body || "{}");
1843
- const name = parsed.name;
1844
- const vars = parsed.vars &&
1845
- typeof parsed.vars === "object" &&
1846
- !Array.isArray(parsed.vars)
1847
- ? parsed.vars
1848
- : undefined;
1849
- if (typeof name !== "string" || !name) {
1850
- res.writeHead(400, { "Content-Type": "application/json" });
1851
- res.end(JSON.stringify({ ok: false, error: "name required" }));
1852
- return;
1853
- }
1854
- if (!this.runRecipeFn) {
1855
- res.writeHead(503, { "Content-Type": "application/json" });
1856
- res.end(JSON.stringify({
1857
- ok: false,
1858
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
1859
- }));
1860
- return;
1861
- }
1862
- const result = await this.runRecipeFn(name, vars);
1863
- res.writeHead(result.ok ? 200 : 400, {
1864
- "Content-Type": "application/json",
1865
- });
1866
- res.end(JSON.stringify(result));
1867
- }
1868
- catch {
1869
- res.writeHead(400, { "Content-Type": "application/json" });
1870
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1871
- }
1872
- })();
1873
- });
1874
- return;
1875
- }
1876
- if (parsedUrl.pathname === "/activation-metrics" &&
1877
- req.method === "GET") {
1878
- try {
1879
- const metrics = loadActivationMetrics();
1880
- const summary = computeActivationSummary(metrics);
1881
- res.writeHead(200, { "Content-Type": "application/json" });
1882
- res.end(JSON.stringify({ metrics, summary }));
1883
- }
1884
- catch (err) {
1885
- res.writeHead(500, { "Content-Type": "application/json" });
1886
- res.end(JSON.stringify({
1887
- error: err instanceof Error ? err.message : String(err),
1888
- }));
1889
- }
1890
- return;
1891
- }
1892
- if (parsedUrl.pathname === "/runs" && req.method === "GET") {
1893
- try {
1894
- const sp = parsedUrl.searchParams;
1895
- const limitRaw = sp.get("limit");
1896
- const afterRaw = sp.get("after");
1897
- const trigger = sp.get("trigger");
1898
- const status = sp.get("status");
1899
- const recipe = sp.get("recipe");
1900
- const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
1901
- const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
1902
- const runs = this.runsFn?.({
1903
- ...(Number.isFinite(limit) && { limit }),
1904
- ...(trigger && { trigger }),
1905
- ...(status && { status }),
1906
- ...(recipe && { recipe }),
1907
- ...(Number.isFinite(after) && { after }),
1908
- }) ?? [];
1909
- res.writeHead(200, { "Content-Type": "application/json" });
1910
- res.end(JSON.stringify({ runs }));
1911
- }
1912
- catch (err) {
1913
- res.writeHead(500, { "Content-Type": "application/json" });
1914
- res.end(JSON.stringify({
1915
- error: err instanceof Error ? err.message : String(err),
1916
- }));
1917
- }
1918
- return;
1919
- }
1920
- // GET /runs/:seq — single run detail (includes stepResults if present)
1921
- const runDetailMatch = req.method === "GET"
1922
- ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname)
1923
- : null;
1924
- if (runDetailMatch?.[1]) {
1925
- const seq = Number.parseInt(runDetailMatch[1], 10);
1926
- try {
1927
- const run = this.runDetailFn?.(seq) ?? null;
1928
- if (!run) {
1929
- res.writeHead(404, { "Content-Type": "application/json" });
1930
- res.end(JSON.stringify({ error: "not_found" }));
1931
- }
1932
- else {
1933
- res.writeHead(200, { "Content-Type": "application/json" });
1934
- res.end(JSON.stringify({ run }));
1935
- }
1936
- }
1937
- catch (err) {
1938
- res.writeHead(500, { "Content-Type": "application/json" });
1939
- res.end(JSON.stringify({
1940
- error: err instanceof Error ? err.message : String(err),
1941
- }));
1942
- }
1943
- return;
1944
- }
1945
- // POST /runs/:seq/replay — VD-4 mocked replay. Re-runs the recipe
1946
- // with all tool/agent execution intercepted to return captured
1947
- // outputs from the original run. No external IO, no side effects.
1948
- // Real-mode replay is not exposed here yet — must ship separately
1949
- // with confirmation UX + kill-switch interaction.
1950
- const runReplayMatch = req.method === "POST"
1951
- ? /^\/runs\/(\d+)\/replay$/.exec(parsedUrl.pathname)
1952
- : null;
1953
- if (runReplayMatch?.[1]) {
1954
- const seq = Number.parseInt(runReplayMatch[1], 10);
1955
- try {
1956
- if (!this.runReplayFn) {
1957
- res.writeHead(503, { "Content-Type": "application/json" });
1958
- res.end(JSON.stringify({ error: "replay_unavailable" }));
1959
- return;
1960
- }
1961
- const result = await this.runReplayFn(seq);
1962
- if (result.error === "run_not_found") {
1963
- res.writeHead(404, { "Content-Type": "application/json" });
1964
- }
1965
- else if (!result.ok) {
1966
- res.writeHead(500, { "Content-Type": "application/json" });
1967
- }
1968
- else {
1969
- res.writeHead(200, { "Content-Type": "application/json" });
1970
- }
1971
- res.end(JSON.stringify(result));
1972
- }
1973
- catch (err) {
1974
- const msg = err instanceof Error ? err.message : String(err);
1975
- res.writeHead(500, { "Content-Type": "application/json" });
1976
- res.end(JSON.stringify({ error: msg }));
1977
- }
1978
- return;
1979
- }
1980
- // GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
1981
- const runPlanMatch = req.method === "GET"
1982
- ? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
1983
- : null;
1984
- if (runPlanMatch?.[1]) {
1985
- const seq = Number.parseInt(runPlanMatch[1], 10);
1986
- try {
1987
- const run = this.runDetailFn?.(seq) ?? null;
1988
- if (!run) {
1989
- res.writeHead(404, { "Content-Type": "application/json" });
1990
- res.end(JSON.stringify({ error: "run_not_found" }));
1991
- return;
1992
- }
1993
- if (!this.runPlanFn) {
1994
- res.writeHead(503, { "Content-Type": "application/json" });
1995
- res.end(JSON.stringify({ error: "plan_unavailable" }));
1996
- return;
1997
- }
1998
- // triggerSource appends ":agent" suffix — strip before file lookup
1999
- const recipeName = run.recipeName.replace(/:agent$/, "");
2000
- const plan = await this.runPlanFn(recipeName);
2001
- res.writeHead(200, { "Content-Type": "application/json" });
2002
- res.end(JSON.stringify({ plan }));
2003
- }
2004
- catch (err) {
2005
- const msg = err instanceof Error ? err.message : String(err);
2006
- const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
2007
- res.writeHead(status, { "Content-Type": "application/json" });
2008
- res.end(JSON.stringify({ error: msg }));
2009
- }
2010
- return;
2011
- }
2012
- if (req.url === "/recipes" && req.method === "POST") {
2013
- const chunks = [];
2014
- req.on("data", (c) => chunks.push(c));
2015
- req.on("end", () => {
2016
- try {
2017
- const body = Buffer.concat(chunks).toString("utf-8");
2018
- const draft = JSON.parse(body || "{}");
2019
- if (typeof draft.name !== "string" || !draft.name) {
2020
- res.writeHead(400, { "Content-Type": "application/json" });
2021
- res.end(JSON.stringify({ ok: false, error: "name required" }));
2022
- return;
2023
- }
2024
- if (!this.saveRecipeFn) {
2025
- res.writeHead(503, { "Content-Type": "application/json" });
2026
- res.end(JSON.stringify({
2027
- ok: false,
2028
- error: "Recipe saving unavailable",
2029
- }));
2030
- return;
2031
- }
2032
- const result = this.saveRecipeFn(draft);
2033
- res.writeHead(result.ok ? 201 : 400, {
2034
- "Content-Type": "application/json",
2035
- });
2036
- res.end(JSON.stringify(result));
2037
- }
2038
- catch {
2039
- res.writeHead(400, { "Content-Type": "application/json" });
2040
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
2041
- }
2042
- });
2043
- return;
2044
- }
2045
- const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
2046
- if (recipePatchMatch && req.method === "PATCH") {
2047
- const name = decodeURIComponent(recipePatchMatch[1] ?? "");
2048
- const chunks = [];
2049
- req.on("data", (c) => chunks.push(c));
2050
- req.on("end", () => {
2051
- try {
2052
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
2053
- if (typeof body.enabled !== "boolean") {
2054
- res.writeHead(400, { "Content-Type": "application/json" });
2055
- res.end(JSON.stringify({
2056
- ok: false,
2057
- error: "enabled (boolean) required",
2058
- }));
2059
- return;
2060
- }
2061
- if (!this.setRecipeEnabledFn) {
2062
- res.writeHead(503, { "Content-Type": "application/json" });
2063
- res.end(JSON.stringify({ ok: false, error: "Not available" }));
2064
- return;
2065
- }
2066
- const result = this.setRecipeEnabledFn(name, body.enabled);
2067
- res.writeHead(result.ok ? 200 : 400, {
2068
- "Content-Type": "application/json",
2069
- });
2070
- res.end(JSON.stringify(result));
2071
- }
2072
- catch {
2073
- res.writeHead(400, { "Content-Type": "application/json" });
2074
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
2075
- }
2076
- });
2077
- return;
2078
- }
2079
- if (parsedUrl.pathname === "/recipes/lint" && req.method === "POST") {
2080
- const chunks = [];
2081
- req.on("data", (c) => chunks.push(c));
2082
- req.on("end", () => {
2083
- try {
2084
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
2085
- if (typeof body?.content !== "string") {
2086
- res.writeHead(400, { "Content-Type": "application/json" });
2087
- res.end(JSON.stringify({
2088
- ok: false,
2089
- error: "content (string) required",
2090
- }));
2091
- return;
2092
- }
2093
- if (!this.lintRecipeContentFn) {
2094
- res.writeHead(503, { "Content-Type": "application/json" });
2095
- res.end(JSON.stringify({
2096
- ok: false,
2097
- error: "Recipe lint unavailable",
2098
- }));
2099
- return;
2100
- }
2101
- const result = this.lintRecipeContentFn(body.content);
2102
- res.writeHead(200, { "Content-Type": "application/json" });
2103
- res.end(JSON.stringify(result));
2104
- }
2105
- catch {
2106
- res.writeHead(400, { "Content-Type": "application/json" });
2107
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
2108
- }
2109
- });
2110
- return;
2111
- }
2112
- const recipeContentMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
2113
- if (recipeContentMatch && req.method === "GET") {
2114
- const name = decodeURIComponent(recipeContentMatch[1] ?? "");
2115
- if (!this.loadRecipeContentFn) {
2116
- res.writeHead(503, { "Content-Type": "application/json" });
2117
- res.end(JSON.stringify({ ok: false, error: "Recipe content unavailable" }));
2118
- return;
2119
- }
2120
- const result = this.loadRecipeContentFn(name);
2121
- if (!result) {
2122
- res.writeHead(404, { "Content-Type": "application/json" });
2123
- res.end(JSON.stringify({ ok: false, error: "Recipe not found" }));
2124
- return;
2125
- }
2126
- res.writeHead(200, { "Content-Type": "application/json" });
2127
- res.end(JSON.stringify(result));
2128
- return;
2129
- }
2130
- if (recipeContentMatch && req.method === "PUT") {
2131
- const name = decodeURIComponent(recipeContentMatch[1] ?? "");
2132
- const chunks = [];
2133
- req.on("data", (c) => chunks.push(c));
2134
- req.on("end", () => {
2135
- try {
2136
- const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
2137
- if (typeof body.content !== "string") {
2138
- res.writeHead(400, { "Content-Type": "application/json" });
2139
- res.end(JSON.stringify({
2140
- ok: false,
2141
- error: "content (string) required",
2142
- }));
2143
- return;
2144
- }
2145
- if (!this.saveRecipeContentFn) {
2146
- res.writeHead(503, { "Content-Type": "application/json" });
2147
- res.end(JSON.stringify({
2148
- ok: false,
2149
- error: "Recipe content saving unavailable",
2150
- }));
2151
- return;
2152
- }
2153
- const result = this.saveRecipeContentFn(name, body.content);
2154
- res.writeHead(result.ok ? 200 : 400, {
2155
- "Content-Type": "application/json",
2156
- });
2157
- res.end(JSON.stringify(result));
2158
- }
2159
- catch {
2160
- res.writeHead(400, { "Content-Type": "application/json" });
2161
- res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
2162
- }
2163
- });
2164
- return;
2165
- }
2166
- if (recipeContentMatch && req.method === "DELETE") {
2167
- const name = decodeURIComponent(recipeContentMatch[1] ?? "");
2168
- if (!this.deleteRecipeContentFn) {
2169
- res.writeHead(503, { "Content-Type": "application/json" });
2170
- res.end(JSON.stringify({
2171
- ok: false,
2172
- error: "Recipe deletion unavailable",
2173
- }));
2174
- return;
2175
- }
2176
- const result = this.deleteRecipeContentFn(name);
2177
- const status = result.ok
2178
- ? 200
2179
- : result.error === "Recipe not found"
2180
- ? 404
2181
- : 400;
2182
- res.writeHead(status, { "Content-Type": "application/json" });
2183
- res.end(JSON.stringify(result));
2184
- return;
2185
- }
2186
- if (req.url === "/recipes" && req.method === "GET") {
2187
- try {
2188
- const data = this.recipesFn?.() ?? { recipesDir: null, recipes: [] };
2189
- res.writeHead(200, { "Content-Type": "application/json" });
2190
- res.end(JSON.stringify(data));
2191
- }
2192
- catch (err) {
2193
- res.writeHead(500, { "Content-Type": "application/json" });
2194
- res.end(JSON.stringify({
2195
- error: err instanceof Error ? err.message : String(err),
2196
- }));
2197
- }
2198
- return;
2199
- }
2200
- if (parsedUrl.pathname === "/templates" && req.method === "GET") {
2201
- try {
2202
- const now = Date.now();
2203
- if (!this._templatesCache ||
2204
- now - this._templatesCacheTs > 5 * 60 * 1000) {
2205
- const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
2206
- if (!ghRes.ok) {
2207
- throw new Error(`GitHub returned ${ghRes.status}`);
2208
- }
2209
- this._templatesCache = (await ghRes.json());
2210
- this._templatesCacheTs = now;
2211
- }
2212
- res.writeHead(200, { "Content-Type": "application/json" });
2213
- res.end(JSON.stringify(this._templatesCache));
2214
- }
2215
- catch (err) {
2216
- res.writeHead(502, { "Content-Type": "application/json" });
2217
- res.end(JSON.stringify({
2218
- ok: false,
2219
- error: err instanceof Error ? err.message : String(err),
2220
- }));
2221
- }
2222
- return;
2223
- }
2224
- if (parsedUrl.pathname === "/recipes/install" && req.method === "POST") {
2225
- let body = "";
2226
- req.on("data", (chunk) => {
2227
- body += chunk.toString();
2228
- });
2229
- req.on("end", async () => {
2230
- try {
2231
- const { source } = JSON.parse(body);
2232
- if (!source) {
2233
- res.writeHead(400, { "Content-Type": "application/json" });
2234
- res.end(JSON.stringify({ ok: false, error: "Missing source field" }));
2235
- return;
2236
- }
2237
- const githubPrefix = "github:patchworkos/recipes/recipes/";
2238
- let fetchUrl;
2239
- let recipeName;
2240
- if (source.startsWith(githubPrefix)) {
2241
- recipeName = source.slice(githubPrefix.length);
2242
- fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
2243
- }
2244
- else if (source.startsWith("https://")) {
2245
- fetchUrl = source;
2246
- const urlParts = fetchUrl.split("/");
2247
- recipeName = (urlParts[urlParts.length - 1] ?? "recipe").replace(/\.ya?ml$/i, "");
2248
- }
2249
- else {
2250
- res.writeHead(400, { "Content-Type": "application/json" });
2251
- res.end(JSON.stringify({
2252
- ok: false,
2253
- error: "Unsupported source format",
2254
- }));
2255
- return;
2256
- }
2257
- const yamlRes = await fetch(fetchUrl);
2258
- if (!yamlRes.ok) {
2259
- throw new Error(`Fetch failed: ${yamlRes.status} ${yamlRes.statusText}`);
2260
- }
2261
- const yamlText = await yamlRes.text();
2262
- const tmpFile = path.join(os.tmpdir(), `patchwork-install-${Date.now()}-${recipeName}.yaml`);
2263
- const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
2264
- writeFileSync(tmpFile, yamlText, "utf-8");
2265
- let result;
2266
- try {
2267
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
2268
- mkdirSync(recipesDir, { recursive: true });
2269
- const { installRecipeFromFile } = await import("./recipes/installer.js");
2270
- const installResult = installRecipeFromFile(tmpFile, {
2271
- recipesDir,
2272
- });
2273
- result = { action: installResult.action, name: recipeName };
2274
- }
2275
- finally {
2276
- try {
2277
- unlinkSync(tmpFile);
2278
- }
2279
- catch {
2280
- // best-effort cleanup
2281
- }
2282
- }
2283
- res.writeHead(200, { "Content-Type": "application/json" });
2284
- res.end(JSON.stringify({ ok: true, ...result }));
2285
- }
2286
- catch (err) {
2287
- res.writeHead(500, { "Content-Type": "application/json" });
2288
- res.end(JSON.stringify({
2289
- ok: false,
2290
- error: err instanceof Error ? err.message : String(err),
2291
- }));
2292
- }
2293
- });
2294
- return;
2295
- }
2296
- const sessionDetailMatch = /^\/sessions\/([A-Za-z0-9-]+)$/.exec(parsedUrl.pathname);
2297
- if (sessionDetailMatch && req.method === "GET") {
2298
- const id = sessionDetailMatch[1];
2299
- try {
2300
- const data = this.sessionDetailFn?.(id);
2301
- if (!data?.summary) {
2302
- res.writeHead(404, { "Content-Type": "application/json" });
2303
- res.end(JSON.stringify({ error: "unknown sessionId" }));
2304
- return;
2305
- }
2306
- res.writeHead(200, { "Content-Type": "application/json" });
2307
- res.end(JSON.stringify(data));
2308
- }
2309
- catch (err) {
2310
- res.writeHead(500, { "Content-Type": "application/json" });
2311
- res.end(JSON.stringify({
2312
- error: err instanceof Error ? err.message : String(err),
2313
- }));
2314
- }
942
+ res.writeHead(200, { "Content-Type": "application/json" });
943
+ res.end(JSON.stringify(data));
944
+ }
945
+ catch (err) {
946
+ res.writeHead(500, { "Content-Type": "application/json" });
947
+ res.end(JSON.stringify({
948
+ error: err instanceof Error ? err.message : String(err),
949
+ }));
950
+ }
2315
951
  return;
2316
952
  }
2317
953
  if (parsedUrl.pathname === "/sessions" && req.method === "GET") {
@@ -2373,6 +1009,21 @@ export class Server extends EventEmitter {
2373
1009
  cfg.approvalGate = gateRaw;
2374
1010
  this.approvalGate = gateRaw;
2375
1011
  }
1012
+ // h10 toggle: must be boolean if present. Persists to
1013
+ // ~/.patchwork/config.json AND live-mutates the Server
1014
+ // field so the next /approvals POST honors it without
1015
+ // needing a bridge restart.
1016
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1017
+ if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
1018
+ res.writeHead(400, { "Content-Type": "application/json" });
1019
+ res.end(JSON.stringify({
1020
+ error: "enableTimeOfDayAnomaly must be a boolean",
1021
+ }));
1022
+ return;
1023
+ }
1024
+ cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1025
+ this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1026
+ }
2376
1027
  const driverRaw = body.driver;
2377
1028
  if (driverRaw !== undefined) {
2378
1029
  const validDrivers = [
@@ -2557,6 +1208,15 @@ export class Server extends EventEmitter {
2557
1208
  pushServiceUrl: this.pushServiceUrl,
2558
1209
  pushServiceToken: this.pushServiceToken,
2559
1210
  pushServiceBaseUrl: this.pushServiceBaseUrl,
1211
+ activityLog: this.activityLog,
1212
+ // RecipeRunLog satisfies RecipeRunQuerier structurally
1213
+ // — the cast bridges TS contravariance: RecipeRunQuerier's
1214
+ // narrow query interface (`status?: string`) is deliberately
1215
+ // loose so tests can mock it; RecipeRunLog's stricter
1216
+ // RunStatus union is a strict subset and fails the param
1217
+ // contravariance check despite being safe at runtime.
1218
+ recipeRunLog: this.recipeRunLog,
1219
+ enableTimeOfDayAnomaly: this.enableTimeOfDayAnomaly,
2560
1220
  });
2561
1221
  res.writeHead(result.status, {
2562
1222
  "Content-Type": "application/json",