patchwork-os 0.2.0-alpha.34 → 0.2.0-alpha.36
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.
- package/README.md +202 -93
- package/deploy/bootstrap-new-vps.sh +12 -12
- package/deploy/bootstrap-vps.sh +6 -3
- package/deploy/deploy-landing.sh +59 -2
- package/dist/activityLog.d.ts +49 -0
- package/dist/activityLog.js +78 -0
- package/dist/activityLog.js.map +1 -1
- package/dist/approvalHttp.d.ts +25 -0
- package/dist/approvalHttp.js +74 -18
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalInsights.d.ts +49 -0
- package/dist/approvalInsights.js +97 -0
- package/dist/approvalInsights.js.map +1 -0
- package/dist/approvalQueue.d.ts +11 -0
- package/dist/approvalQueue.js +80 -1
- package/dist/approvalQueue.js.map +1 -1
- package/dist/approvalSignals.d.ts +124 -0
- package/dist/approvalSignals.js +512 -0
- package/dist/approvalSignals.js.map +1 -0
- package/dist/automation.d.ts +37 -0
- package/dist/automation.js +105 -61
- package/dist/automation.js.map +1 -1
- package/dist/automationSuggestions.d.ts +79 -0
- package/dist/automationSuggestions.js +150 -0
- package/dist/automationSuggestions.js.map +1 -0
- package/dist/bridge.js +78 -1
- package/dist/bridge.js.map +1 -1
- package/dist/ccPermissions.d.ts +15 -0
- package/dist/ccPermissions.js +15 -0
- package/dist/ccPermissions.js.map +1 -1
- package/dist/claudeDriver.js +74 -16
- package/dist/claudeDriver.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +8 -0
- package/dist/commands/patchworkInit.js +41 -5
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +20 -0
- package/dist/commands/recipe.js +212 -6
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.d.ts +79 -1
- package/dist/commands/recipeInstall.js +333 -16
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/tracesExport.d.ts +83 -0
- package/dist/commands/tracesExport.js +269 -0
- package/dist/commands/tracesExport.js.map +1 -0
- package/dist/commands/tracesImport.d.ts +56 -0
- package/dist/commands/tracesImport.js +161 -0
- package/dist/commands/tracesImport.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +9 -1
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.d.ts +43 -0
- package/dist/connectorRoutes.js +1023 -0
- package/dist/connectorRoutes.js.map +1 -0
- package/dist/connectors/asana.d.ts +198 -0
- package/dist/connectors/asana.js +679 -0
- package/dist/connectors/asana.js.map +1 -0
- package/dist/connectors/baseConnector.d.ts +36 -0
- package/dist/connectors/baseConnector.js +151 -28
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/discord.d.ts +150 -0
- package/dist/connectors/discord.js +543 -0
- package/dist/connectors/discord.js.map +1 -0
- package/dist/connectors/github.js +11 -4
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.d.ts +180 -0
- package/dist/connectors/gitlab.js +582 -0
- package/dist/connectors/gitlab.js.map +1 -0
- package/dist/connectors/gmail.js +50 -10
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.js +36 -10
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/connectors/googleDrive.d.ts +34 -0
- package/dist/connectors/googleDrive.js +321 -0
- package/dist/connectors/googleDrive.js.map +1 -0
- package/dist/connectors/linear.js +23 -4
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +26 -2
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/oauthStateStore.d.ts +31 -0
- package/dist/connectors/oauthStateStore.js +52 -0
- package/dist/connectors/oauthStateStore.js.map +1 -0
- package/dist/connectors/pagerduty.d.ts +160 -0
- package/dist/connectors/pagerduty.js +464 -0
- package/dist/connectors/pagerduty.js.map +1 -0
- package/dist/connectors/slack.d.ts +16 -1
- package/dist/connectors/slack.js +57 -5
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/tokenStorage.js +27 -2
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/connectors/zendesk.js +19 -1
- package/dist/connectors/zendesk.js.map +1 -1
- package/dist/cors.d.ts +10 -0
- package/dist/cors.js +29 -0
- package/dist/cors.js.map +1 -0
- package/dist/decisionReplay.d.ts +72 -0
- package/dist/decisionReplay.js +92 -0
- package/dist/decisionReplay.js.map +1 -0
- package/dist/decisionTraceLog.d.ts +6 -0
- package/dist/decisionTraceLog.js +54 -2
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/featureFlags.d.ts +17 -11
- package/dist/featureFlags.js +52 -47
- package/dist/featureFlags.js.map +1 -1
- package/dist/fp/automationInterpreter.js +25 -21
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationState.js +4 -1
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/policyParser.js +4 -1
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +114 -0
- package/dist/inboxRoutes.js.map +1 -0
- package/dist/index.js +734 -144
- package/dist/index.js.map +1 -1
- package/dist/mcpRoutes.d.ts +37 -0
- package/dist/mcpRoutes.js +76 -0
- package/dist/mcpRoutes.js.map +1 -0
- package/dist/oauth.d.ts +3 -0
- package/dist/oauth.js +151 -26
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.d.ts +32 -0
- package/dist/oauthRoutes.js +124 -0
- package/dist/oauthRoutes.js.map +1 -0
- package/dist/orchestrator/orchestratorBridge.js +2 -2
- package/dist/orchestrator/orchestratorBridge.js.map +1 -1
- package/dist/patchworkConfig.d.ts +7 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.d.ts +12 -0
- package/dist/pluginLoader.js +43 -4
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +8 -3
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.d.ts +12 -0
- package/dist/preToolUseHook.js +23 -0
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +8 -0
- package/dist/recipeOrchestration.js +320 -39
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +154 -0
- package/dist/recipeRoutes.js +1098 -0
- package/dist/recipeRoutes.js.map +1 -0
- package/dist/recipes/captureForRunlog.d.ts +27 -0
- package/dist/recipes/captureForRunlog.js +128 -0
- package/dist/recipes/captureForRunlog.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +54 -3
- package/dist/recipes/chainedRunner.js +256 -36
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/compiler.js +3 -3
- package/dist/recipes/compiler.js.map +1 -1
- package/dist/recipes/detectSilentFail.d.ts +34 -0
- package/dist/recipes/detectSilentFail.js +105 -0
- package/dist/recipes/detectSilentFail.js.map +1 -0
- package/dist/recipes/installer.js +3 -3
- package/dist/recipes/installer.js.map +1 -1
- package/dist/recipes/manifest.js +21 -6
- package/dist/recipes/manifest.js.map +1 -1
- package/dist/recipes/migrationWarnings.d.ts +12 -0
- package/dist/recipes/migrationWarnings.js +44 -0
- package/dist/recipes/migrationWarnings.js.map +1 -0
- package/dist/recipes/replayRun.d.ts +62 -0
- package/dist/recipes/replayRun.js +97 -0
- package/dist/recipes/replayRun.js.map +1 -0
- package/dist/recipes/resolveRecipePath.d.ts +69 -0
- package/dist/recipes/resolveRecipePath.js +202 -0
- package/dist/recipes/resolveRecipePath.js.map +1 -0
- package/dist/recipes/scheduler.js +102 -11
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schemaGenerator.js +3 -3
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/toolRegistry.d.ts +5 -0
- package/dist/recipes/toolRegistry.js +9 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/asana.d.ts +16 -0
- package/dist/recipes/tools/asana.js +524 -0
- package/dist/recipes/tools/asana.js.map +1 -0
- package/dist/recipes/tools/discord.d.ts +18 -0
- package/dist/recipes/tools/discord.js +254 -0
- package/dist/recipes/tools/discord.js.map +1 -0
- package/dist/recipes/tools/file.d.ts +6 -0
- package/dist/recipes/tools/file.js +12 -8
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/github.js +29 -4
- package/dist/recipes/tools/github.js.map +1 -1
- package/dist/recipes/tools/gitlab.d.ts +11 -0
- package/dist/recipes/tools/gitlab.js +285 -0
- package/dist/recipes/tools/gitlab.js.map +1 -0
- package/dist/recipes/tools/gmail.d.ts +1 -1
- package/dist/recipes/tools/gmail.js +230 -6
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/googleDrive.d.ts +1 -0
- package/dist/recipes/tools/googleDrive.js +55 -0
- package/dist/recipes/tools/googleDrive.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +8 -0
- package/dist/recipes/tools/index.js +8 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/jira.d.ts +14 -0
- package/dist/recipes/tools/jira.js +369 -0
- package/dist/recipes/tools/jira.js.map +1 -0
- package/dist/recipes/tools/linear.d.ts +2 -1
- package/dist/recipes/tools/linear.js +227 -3
- package/dist/recipes/tools/linear.js.map +1 -1
- package/dist/recipes/tools/meetingNotes.d.ts +21 -0
- package/dist/recipes/tools/meetingNotes.js +701 -0
- package/dist/recipes/tools/meetingNotes.js.map +1 -0
- package/dist/recipes/tools/pagerduty.d.ts +15 -0
- package/dist/recipes/tools/pagerduty.js +451 -0
- package/dist/recipes/tools/pagerduty.js.map +1 -0
- package/dist/recipes/tools/sentry.d.ts +12 -0
- package/dist/recipes/tools/sentry.js +73 -0
- package/dist/recipes/tools/sentry.js.map +1 -0
- package/dist/recipes/tools/slack.js +15 -5
- package/dist/recipes/tools/slack.js.map +1 -1
- package/dist/recipes/validation.js +83 -14
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +30 -2
- package/dist/recipes/yamlRunner.js +369 -70
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +76 -1
- package/dist/recipesHttp.js +474 -12
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +78 -2
- package/dist/runLog.js +204 -6
- package/dist/runLog.js.map +1 -1
- package/dist/schemas/dry-run-plan.v1.json +139 -0
- package/dist/schemas/recipe.v1.json +684 -0
- package/dist/server.d.ts +79 -10
- package/dist/server.js +366 -1384
- package/dist/server.js.map +1 -1
- package/dist/ssrfGuard.d.ts +54 -0
- package/dist/ssrfGuard.js +122 -0
- package/dist/ssrfGuard.js.map +1 -0
- package/dist/streamableHttp.d.ts +39 -1
- package/dist/streamableHttp.js +126 -17
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/getDocumentSymbols.d.ts +24 -0
- package/dist/tools/getDocumentSymbols.js +74 -8
- package/dist/tools/getDocumentSymbols.js.map +1 -1
- package/dist/tools/getSecurityAdvisories.js +10 -1
- package/dist/tools/getSecurityAdvisories.js.map +1 -1
- package/dist/tools/getSessionUsage.d.ts +3 -0
- package/dist/tools/getSessionUsage.js +3 -0
- package/dist/tools/getSessionUsage.js.map +1 -1
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +32 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/slackPostMessage.js +1 -1
- package/dist/tools/slackPostMessage.js.map +1 -1
- package/dist/tools/transaction.d.ts +19 -0
- package/dist/tools/transaction.js +29 -0
- package/dist/tools/transaction.js.map +1 -1
- package/dist/traceEncryption.d.ts +46 -0
- package/dist/traceEncryption.js +124 -0
- package/dist/traceEncryption.js.map +1 -0
- package/dist/transport.d.ts +39 -0
- package/dist/transport.js +88 -8
- package/dist/transport.js.map +1 -1
- package/package.json +22 -5
- package/templates/policies/README.md +72 -0
- package/templates/policies/conservative.json +14 -0
- package/templates/policies/developer.json +14 -0
- package/templates/policies/headless-ci.json +24 -0
- package/templates/policies/personal-assistant.json +15 -0
- package/templates/policies/regulated-industry.json +18 -0
- package/templates/recipes/project-health-check.yaml +1 -1
- package/templates/recipes/webhook/README.md +70 -0
- package/templates/recipes/webhook/capture-thought.yaml +26 -0
- package/templates/recipes/webhook/customer-escalation.yaml +49 -0
- package/templates/recipes/webhook/incident-intake.yaml +46 -0
- package/templates/recipes/webhook/meeting-prep.yaml +48 -0
- 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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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. */
|
|
@@ -109,6 +101,9 @@ export class Server extends EventEmitter {
|
|
|
109
101
|
runDetailFn = null;
|
|
110
102
|
/** Patchwork: set by bridge to generate a dry-run plan for a recipe by name. */
|
|
111
103
|
runPlanFn = null;
|
|
104
|
+
/** Patchwork (VD-4): mocked replay of an existing run. Returns the new
|
|
105
|
+
* run's seq plus any unmocked steps the caller may want to surface. */
|
|
106
|
+
runReplayFn = null;
|
|
112
107
|
/** Patchwork: set by bridge to launch a named recipe via the orchestrator. */
|
|
113
108
|
runRecipeFn = null;
|
|
114
109
|
/** Patchwork: admin-controlled managed settings path (highest rule precedence). */
|
|
@@ -127,8 +122,38 @@ export class Server extends EventEmitter {
|
|
|
127
122
|
pushServiceBaseUrl = undefined;
|
|
128
123
|
/** Patchwork: approval decision audit callback wired to activityLog.recordEvent. */
|
|
129
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;
|
|
130
145
|
/** Patchwork: set by bridge to match + fire webhook-triggered recipes. */
|
|
131
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;
|
|
132
157
|
/** Set by bridge to handle MCP Streamable HTTP sessions (POST/GET/DELETE /mcp) */
|
|
133
158
|
httpMcpHandler = null;
|
|
134
159
|
/** Set by bridge to subscribe a caller to real-time activity events. Returns unsubscribe fn. */
|
|
@@ -147,8 +172,6 @@ export class Server extends EventEmitter {
|
|
|
147
172
|
notifyFn = null;
|
|
148
173
|
/** Patchwork: set by bridge to list active agent sessions for the dashboard. */
|
|
149
174
|
sessionsFn = null;
|
|
150
|
-
_templatesCache = null;
|
|
151
|
-
_templatesCacheTs = 0;
|
|
152
175
|
/** Patchwork: set by bridge to answer GET /sessions/:id with per-session event stream + approvals. */
|
|
153
176
|
sessionDetailFn = null;
|
|
154
177
|
/** Set by bridge to handle POST /launch-quick-task — invokes launchQuickTask tool in-process. */
|
|
@@ -208,141 +231,37 @@ export class Server extends EventEmitter {
|
|
|
208
231
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
|
|
209
232
|
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
210
233
|
}
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// which authorization server protects this resource. Both the bare and
|
|
227
|
-
// resource-path variants are handled.
|
|
228
|
-
if (req.method === "GET" &&
|
|
229
|
-
(parsedUrl.pathname === "/.well-known/oauth-protected-resource" ||
|
|
230
|
-
parsedUrl.pathname.startsWith("/.well-known/oauth-protected-resource/"))) {
|
|
231
|
-
if (this.oauthServer && this.oauthIssuerUrl) {
|
|
232
|
-
res.writeHead(200, {
|
|
233
|
-
"Content-Type": "application/json",
|
|
234
|
-
"Cache-Control": "no-store",
|
|
235
|
-
});
|
|
236
|
-
res.end(JSON.stringify({
|
|
237
|
-
resource: this.oauthIssuerUrl,
|
|
238
|
-
authorization_servers: [this.oauthIssuerUrl],
|
|
239
|
-
}));
|
|
240
|
-
}
|
|
241
|
-
else {
|
|
242
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
243
|
-
res.end("OAuth not configured");
|
|
244
|
-
}
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
// Authorization endpoint
|
|
248
|
-
if (parsedUrl.pathname === "/oauth/authorize" &&
|
|
249
|
-
(req.method === "GET" || req.method === "POST")) {
|
|
250
|
-
if (this.oauthServer) {
|
|
251
|
-
this.oauthServer.handleAuthorize(req, res);
|
|
252
|
-
}
|
|
253
|
-
else {
|
|
254
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
255
|
-
res.end("OAuth not configured");
|
|
256
|
-
}
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
// Dynamic Client Registration endpoint (RFC 7591)
|
|
260
|
-
if (parsedUrl.pathname === "/oauth/register") {
|
|
261
|
-
if (this.oauthServer) {
|
|
262
|
-
this.oauthServer.handleRegister(req, res).catch((err) => {
|
|
263
|
-
if (!res.headersSent) {
|
|
264
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
265
|
-
res.end(JSON.stringify({ error: String(err) }));
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
271
|
-
res.end("OAuth not configured");
|
|
272
|
-
}
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
// Token endpoint
|
|
276
|
-
if (parsedUrl.pathname === "/oauth/token" && req.method === "POST") {
|
|
277
|
-
if (this.oauthServer) {
|
|
278
|
-
this.oauthServer.handleToken(req, res).catch((err) => {
|
|
279
|
-
if (!res.headersSent) {
|
|
280
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
281
|
-
res.end(JSON.stringify({ error: String(err) }));
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
else {
|
|
286
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
287
|
-
res.end("OAuth not configured");
|
|
288
|
-
}
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
// Revocation endpoint (RFC 7009)
|
|
292
|
-
if (parsedUrl.pathname === "/oauth/revoke" && req.method === "POST") {
|
|
293
|
-
if (this.oauthServer) {
|
|
294
|
-
this.oauthServer.handleRevoke(req, res).catch(() => {
|
|
295
|
-
// RFC 7009: always 200
|
|
296
|
-
if (!res.headersSent) {
|
|
297
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
298
|
-
res.end("{}");
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
304
|
-
res.end("{}");
|
|
305
|
-
}
|
|
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");
|
|
306
249
|
return;
|
|
307
250
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
homepage: "https://github.com/Oolab-labs/claude-ide-bridge",
|
|
316
|
-
transport: ["websocket", "stdio", "streamable-http"],
|
|
317
|
-
capabilities: {
|
|
318
|
-
tools: true,
|
|
319
|
-
resources: true,
|
|
320
|
-
prompts: true,
|
|
321
|
-
elicitation: true,
|
|
322
|
-
},
|
|
323
|
-
author: "Oolab Labs",
|
|
324
|
-
license: PACKAGE_LICENSE,
|
|
325
|
-
repository: "https://github.com/Oolab-labs/claude-ide-bridge",
|
|
326
|
-
};
|
|
327
|
-
res.writeHead(200, {
|
|
328
|
-
"Content-Type": "application/json",
|
|
329
|
-
"Access-Control-Allow-Origin": "*",
|
|
330
|
-
});
|
|
331
|
-
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
|
+
})) {
|
|
332
258
|
return;
|
|
333
259
|
}
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
if (req
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
340
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
341
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
|
|
342
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
343
|
-
}
|
|
344
|
-
res.writeHead(204);
|
|
345
|
-
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
|
+
})) {
|
|
346
265
|
return;
|
|
347
266
|
}
|
|
348
267
|
// Unauthenticated liveness probe — safe to expose; contains no sensitive data.
|
|
@@ -382,95 +301,10 @@ export class Server extends EventEmitter {
|
|
|
382
301
|
res.end(JSON.stringify({ ok: true, v: PACKAGE_VERSION }));
|
|
383
302
|
return;
|
|
384
303
|
}
|
|
385
|
-
// ── Connector OAuth callbacks (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const { handleGithubCallback } = await import("./connectors/github.js");
|
|
390
|
-
const code = parsedUrl.searchParams.get("code");
|
|
391
|
-
const state = parsedUrl.searchParams.get("state");
|
|
392
|
-
const error = parsedUrl.searchParams.get("error");
|
|
393
|
-
const result = await handleGithubCallback(code, state, error);
|
|
394
|
-
res.writeHead(result.status, {
|
|
395
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
396
|
-
});
|
|
397
|
-
res.end(result.body);
|
|
398
|
-
})();
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
if (parsedUrl.pathname === "/connections/linear/callback" &&
|
|
402
|
-
req.method === "GET") {
|
|
403
|
-
void (async () => {
|
|
404
|
-
const { handleLinearCallback } = await import("./connectors/linear.js");
|
|
405
|
-
const code = parsedUrl.searchParams.get("code");
|
|
406
|
-
const state = parsedUrl.searchParams.get("state");
|
|
407
|
-
const error = parsedUrl.searchParams.get("error");
|
|
408
|
-
const result = await handleLinearCallback(code, state, error);
|
|
409
|
-
res.writeHead(result.status, {
|
|
410
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
411
|
-
});
|
|
412
|
-
res.end(result.body);
|
|
413
|
-
})();
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (parsedUrl.pathname === "/connections/sentry/callback" &&
|
|
417
|
-
req.method === "GET") {
|
|
418
|
-
void (async () => {
|
|
419
|
-
const { handleSentryCallback } = await import("./connectors/sentry.js");
|
|
420
|
-
const code = parsedUrl.searchParams.get("code");
|
|
421
|
-
const state = parsedUrl.searchParams.get("state");
|
|
422
|
-
const error = parsedUrl.searchParams.get("error");
|
|
423
|
-
const result = await handleSentryCallback(code, state, error);
|
|
424
|
-
res.writeHead(result.status, {
|
|
425
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
426
|
-
});
|
|
427
|
-
res.end(result.body);
|
|
428
|
-
})();
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
|
|
432
|
-
req.method === "GET") {
|
|
433
|
-
void (async () => {
|
|
434
|
-
const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
|
|
435
|
-
const code = parsedUrl.searchParams.get("code");
|
|
436
|
-
const state = parsedUrl.searchParams.get("state");
|
|
437
|
-
const error = parsedUrl.searchParams.get("error");
|
|
438
|
-
const result = await handleCalendarCallback(code, state, error);
|
|
439
|
-
res.writeHead(result.status, {
|
|
440
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
441
|
-
});
|
|
442
|
-
res.end(result.body);
|
|
443
|
-
})();
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
if (parsedUrl.pathname === "/connections/slack/callback" &&
|
|
447
|
-
req.method === "GET") {
|
|
448
|
-
void (async () => {
|
|
449
|
-
const { handleSlackCallback } = await import("./connectors/slack.js");
|
|
450
|
-
const code = parsedUrl.searchParams.get("code");
|
|
451
|
-
const state = parsedUrl.searchParams.get("state");
|
|
452
|
-
const error = parsedUrl.searchParams.get("error");
|
|
453
|
-
const result = await handleSlackCallback(code, state, error);
|
|
454
|
-
res.writeHead(result.status, {
|
|
455
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
456
|
-
});
|
|
457
|
-
res.end(result.body);
|
|
458
|
-
})();
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
if (parsedUrl.pathname === "/connections/gmail/callback" &&
|
|
462
|
-
req.method === "GET") {
|
|
463
|
-
void (async () => {
|
|
464
|
-
const { handleGmailCallback } = await import("./connectors/gmail.js");
|
|
465
|
-
const code = parsedUrl.searchParams.get("code");
|
|
466
|
-
const state = parsedUrl.searchParams.get("state");
|
|
467
|
-
const error = parsedUrl.searchParams.get("error");
|
|
468
|
-
const result = await handleGmailCallback(code, state, error);
|
|
469
|
-
res.writeHead(result.status, {
|
|
470
|
-
"Content-Type": result.contentType ?? "text/html",
|
|
471
|
-
});
|
|
472
|
-
res.end(result.body);
|
|
473
|
-
})();
|
|
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)) {
|
|
474
308
|
return;
|
|
475
309
|
}
|
|
476
310
|
// ── /schemas/* — unauthenticated registry-derived JSON Schemas ────────
|
|
@@ -648,6 +482,83 @@ export class Server extends EventEmitter {
|
|
|
648
482
|
}
|
|
649
483
|
return;
|
|
650
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
|
+
}
|
|
651
562
|
if (parsedUrl.pathname === "/analytics" && req.method === "GET") {
|
|
652
563
|
try {
|
|
653
564
|
const wh = parsedUrl.searchParams.get("windowHours");
|
|
@@ -831,1165 +742,212 @@ export class Server extends EventEmitter {
|
|
|
831
742
|
: result.error === "not_found"
|
|
832
743
|
? 404
|
|
833
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
|
+
}
|
|
834
763
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
835
764
|
res.end(JSON.stringify(result));
|
|
836
765
|
})();
|
|
837
766
|
});
|
|
838
767
|
return;
|
|
839
768
|
}
|
|
840
|
-
|
|
841
|
-
if (parsedUrl.pathname === "/connections" && req.method === "GET") {
|
|
842
|
-
void (async () => {
|
|
843
|
-
const { handleConnectionsList } = await import("./connectors/gmail.js");
|
|
844
|
-
const result = await handleConnectionsList();
|
|
845
|
-
res.writeHead(result.status, {
|
|
846
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
847
|
-
});
|
|
848
|
-
res.end(result.body);
|
|
849
|
-
})();
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
if (parsedUrl.pathname === "/connections/gmail/auth" &&
|
|
769
|
+
if (parsedUrl.pathname?.startsWith("/webhook-payloads/") &&
|
|
853
770
|
req.method === "GET") {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
res.writeHead(302, { Location: result.redirect });
|
|
859
|
-
res.end();
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
res.writeHead(result.status, {
|
|
863
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
864
|
-
});
|
|
865
|
-
res.end(result.body);
|
|
866
|
-
}
|
|
867
|
-
})();
|
|
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 }));
|
|
868
775
|
return;
|
|
869
776
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
+
}));
|
|
880
808
|
return;
|
|
881
809
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
res.
|
|
888
|
-
|
|
889
|
-
});
|
|
890
|
-
|
|
891
|
-
}
|
|
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));
|
|
892
824
|
return;
|
|
893
825
|
}
|
|
894
|
-
//
|
|
895
|
-
|
|
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" &&
|
|
896
829
|
req.method === "GET") {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const { handleGithubTest } = await import("./connectors/github.js");
|
|
917
|
-
const result = await handleGithubTest();
|
|
918
|
-
res.writeHead(result.status, {
|
|
919
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
920
|
-
});
|
|
921
|
-
res.end(result.body);
|
|
922
|
-
})();
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
if (parsedUrl.pathname === "/connections/github" &&
|
|
926
|
-
req.method === "DELETE") {
|
|
927
|
-
void (async () => {
|
|
928
|
-
const { handleGithubDisconnect } = await import("./connectors/github.js");
|
|
929
|
-
const result = await handleGithubDisconnect();
|
|
930
|
-
res.writeHead(result.status, {
|
|
931
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
932
|
-
});
|
|
933
|
-
res.end(result.body);
|
|
934
|
-
})();
|
|
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));
|
|
935
849
|
return;
|
|
936
850
|
}
|
|
937
|
-
//
|
|
938
|
-
|
|
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" &&
|
|
939
854
|
req.method === "GET") {
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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 }));
|
|
954
876
|
return;
|
|
955
877
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
res.writeHead(result.status, {
|
|
965
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
966
|
-
});
|
|
967
|
-
res.end(result.body);
|
|
968
|
-
})();
|
|
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 }));
|
|
969
886
|
return;
|
|
970
887
|
}
|
|
971
|
-
|
|
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$/) &&
|
|
972
893
|
req.method === "POST") {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
+
})) {
|
|
993
930
|
return;
|
|
994
931
|
}
|
|
995
|
-
|
|
996
|
-
if (
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
res.
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
else {
|
|
1006
|
-
res.writeHead(result.status, {
|
|
1007
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1008
|
-
});
|
|
1009
|
-
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;
|
|
1010
941
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const error = parsedUrl.searchParams.get("error");
|
|
1021
|
-
const result = await handleLinearCallback(code, state, error);
|
|
1022
|
-
res.writeHead(result.status, {
|
|
1023
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1024
|
-
});
|
|
1025
|
-
res.end(result.body);
|
|
1026
|
-
})();
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
if (parsedUrl.pathname === "/connections/linear/test" &&
|
|
1030
|
-
req.method === "POST") {
|
|
1031
|
-
void (async () => {
|
|
1032
|
-
const { handleLinearTest } = await import("./connectors/linear.js");
|
|
1033
|
-
const result = await handleLinearTest();
|
|
1034
|
-
res.writeHead(result.status, {
|
|
1035
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1036
|
-
});
|
|
1037
|
-
res.end(result.body);
|
|
1038
|
-
})();
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
if (parsedUrl.pathname === "/connections/linear" &&
|
|
1042
|
-
req.method === "DELETE") {
|
|
1043
|
-
void (async () => {
|
|
1044
|
-
const { handleLinearDisconnect } = await import("./connectors/linear.js");
|
|
1045
|
-
const result = await handleLinearDisconnect();
|
|
1046
|
-
res.writeHead(result.status, {
|
|
1047
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1048
|
-
});
|
|
1049
|
-
res.end(result.body);
|
|
1050
|
-
})();
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
// ── Slack connector routes ──────────────────────────────────────
|
|
1054
|
-
if ((parsedUrl.pathname === "/connections/slack/auth" ||
|
|
1055
|
-
parsedUrl.pathname === "/connections/slack/authorize") &&
|
|
1056
|
-
req.method === "GET") {
|
|
1057
|
-
const { handleSlackAuthorize } = await import("./connectors/slack.js");
|
|
1058
|
-
const result = handleSlackAuthorize();
|
|
1059
|
-
if (result.redirect) {
|
|
1060
|
-
res.writeHead(302, { Location: result.redirect });
|
|
1061
|
-
res.end();
|
|
1062
|
-
}
|
|
1063
|
-
else {
|
|
1064
|
-
res.writeHead(result.status, {
|
|
1065
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1066
|
-
});
|
|
1067
|
-
res.end(result.body);
|
|
1068
|
-
}
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
if (parsedUrl.pathname === "/connections/slack/test" &&
|
|
1072
|
-
req.method === "POST") {
|
|
1073
|
-
void (async () => {
|
|
1074
|
-
const { handleSlackTest } = await import("./connectors/slack.js");
|
|
1075
|
-
const result = await handleSlackTest();
|
|
1076
|
-
res.writeHead(result.status, {
|
|
1077
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1078
|
-
});
|
|
1079
|
-
res.end(result.body);
|
|
1080
|
-
})();
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
if (parsedUrl.pathname === "/connections/slack" &&
|
|
1084
|
-
req.method === "DELETE") {
|
|
1085
|
-
const { handleSlackDisconnect } = await import("./connectors/slack.js");
|
|
1086
|
-
const result = handleSlackDisconnect();
|
|
1087
|
-
res.writeHead(result.status, {
|
|
1088
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1089
|
-
});
|
|
1090
|
-
res.end(result.body);
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
// ── Notion routes ──────────────────────────────────────────────
|
|
1094
|
-
if (parsedUrl.pathname === "/connections/notion/connect" &&
|
|
1095
|
-
req.method === "POST") {
|
|
1096
|
-
const chunks = [];
|
|
1097
|
-
req.on("data", (c) => chunks.push(c));
|
|
1098
|
-
req.on("end", () => {
|
|
1099
|
-
void (async () => {
|
|
1100
|
-
const { handleNotionConnect } = await import("./connectors/notion.js");
|
|
1101
|
-
const result = await handleNotionConnect(Buffer.concat(chunks).toString("utf-8"));
|
|
1102
|
-
res.writeHead(result.status, {
|
|
1103
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1104
|
-
});
|
|
1105
|
-
res.end(result.body);
|
|
1106
|
-
})();
|
|
1107
|
-
});
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
if (parsedUrl.pathname === "/connections/notion/test" &&
|
|
1111
|
-
req.method === "POST") {
|
|
1112
|
-
void (async () => {
|
|
1113
|
-
const { handleNotionTest } = await import("./connectors/notion.js");
|
|
1114
|
-
const result = await handleNotionTest();
|
|
1115
|
-
res.writeHead(result.status, {
|
|
1116
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1117
|
-
});
|
|
1118
|
-
res.end(result.body);
|
|
1119
|
-
})();
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
if (parsedUrl.pathname === "/connections/notion" &&
|
|
1123
|
-
req.method === "DELETE") {
|
|
1124
|
-
const { handleNotionDisconnect } = await import("./connectors/notion.js");
|
|
1125
|
-
const result = handleNotionDisconnect();
|
|
1126
|
-
res.writeHead(result.status, {
|
|
1127
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1128
|
-
});
|
|
1129
|
-
res.end(result.body);
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
// ── Confluence routes ───────────────────────────────────────────
|
|
1133
|
-
if (parsedUrl.pathname === "/connections/confluence/connect" &&
|
|
1134
|
-
req.method === "POST") {
|
|
1135
|
-
const chunks = [];
|
|
1136
|
-
req.on("data", (c) => chunks.push(c));
|
|
1137
|
-
req.on("end", () => {
|
|
1138
|
-
void (async () => {
|
|
1139
|
-
const { handleConfluenceConnect } = await import("./connectors/confluence.js");
|
|
1140
|
-
const result = await handleConfluenceConnect(Buffer.concat(chunks).toString("utf-8"));
|
|
1141
|
-
res.writeHead(result.status, {
|
|
1142
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1143
|
-
});
|
|
1144
|
-
res.end(result.body);
|
|
1145
|
-
})();
|
|
1146
|
-
});
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
if (parsedUrl.pathname === "/connections/confluence/test" &&
|
|
1150
|
-
req.method === "POST") {
|
|
1151
|
-
void (async () => {
|
|
1152
|
-
const { handleConfluenceTest } = await import("./connectors/confluence.js");
|
|
1153
|
-
const result = await handleConfluenceTest();
|
|
1154
|
-
res.writeHead(result.status, {
|
|
1155
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1156
|
-
});
|
|
1157
|
-
res.end(result.body);
|
|
1158
|
-
})();
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
if (parsedUrl.pathname === "/connections/confluence" &&
|
|
1162
|
-
req.method === "DELETE") {
|
|
1163
|
-
const { handleConfluenceDisconnect } = await import("./connectors/confluence.js");
|
|
1164
|
-
const result = handleConfluenceDisconnect();
|
|
1165
|
-
res.writeHead(result.status, {
|
|
1166
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1167
|
-
});
|
|
1168
|
-
res.end(result.body);
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
// ── Zendesk routes ──────────────────────────────────────────────
|
|
1172
|
-
if (parsedUrl.pathname === "/connections/zendesk/connect" &&
|
|
1173
|
-
req.method === "POST") {
|
|
1174
|
-
const chunks = [];
|
|
1175
|
-
req.on("data", (c) => chunks.push(c));
|
|
1176
|
-
req.on("end", () => {
|
|
1177
|
-
void (async () => {
|
|
1178
|
-
const { handleZendeskConnect } = await import("./connectors/zendesk.js");
|
|
1179
|
-
const result = await handleZendeskConnect(Buffer.concat(chunks).toString("utf-8"));
|
|
1180
|
-
res.writeHead(result.status, {
|
|
1181
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1182
|
-
});
|
|
1183
|
-
res.end(result.body);
|
|
1184
|
-
})();
|
|
1185
|
-
});
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
if (parsedUrl.pathname === "/connections/zendesk/test" &&
|
|
1189
|
-
req.method === "POST") {
|
|
1190
|
-
void (async () => {
|
|
1191
|
-
const { handleZendeskTest } = await import("./connectors/zendesk.js");
|
|
1192
|
-
const result = await handleZendeskTest();
|
|
1193
|
-
res.writeHead(result.status, {
|
|
1194
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1195
|
-
});
|
|
1196
|
-
res.end(result.body);
|
|
1197
|
-
})();
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
if (parsedUrl.pathname === "/connections/zendesk" &&
|
|
1201
|
-
req.method === "DELETE") {
|
|
1202
|
-
const { handleZendeskDisconnect } = await import("./connectors/zendesk.js");
|
|
1203
|
-
const result = handleZendeskDisconnect();
|
|
1204
|
-
res.writeHead(result.status, {
|
|
1205
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1206
|
-
});
|
|
1207
|
-
res.end(result.body);
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
// ── Intercom routes ─────────────────────────────────────────────
|
|
1211
|
-
if (parsedUrl.pathname === "/connections/intercom/connect" &&
|
|
1212
|
-
req.method === "POST") {
|
|
1213
|
-
const chunks = [];
|
|
1214
|
-
req.on("data", (c) => chunks.push(c));
|
|
1215
|
-
req.on("end", () => {
|
|
1216
|
-
void (async () => {
|
|
1217
|
-
const { handleIntercomConnect } = await import("./connectors/intercom.js");
|
|
1218
|
-
const result = await handleIntercomConnect(Buffer.concat(chunks).toString("utf-8"));
|
|
1219
|
-
res.writeHead(result.status, {
|
|
1220
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1221
|
-
});
|
|
1222
|
-
res.end(result.body);
|
|
1223
|
-
})();
|
|
1224
|
-
});
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
if (parsedUrl.pathname === "/connections/intercom/test" &&
|
|
1228
|
-
req.method === "POST") {
|
|
1229
|
-
void (async () => {
|
|
1230
|
-
const { handleIntercomTest } = await import("./connectors/intercom.js");
|
|
1231
|
-
const result = await handleIntercomTest();
|
|
1232
|
-
res.writeHead(result.status, {
|
|
1233
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1234
|
-
});
|
|
1235
|
-
res.end(result.body);
|
|
1236
|
-
})();
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
if (parsedUrl.pathname === "/connections/intercom" &&
|
|
1240
|
-
req.method === "DELETE") {
|
|
1241
|
-
const { handleIntercomDisconnect } = await import("./connectors/intercom.js");
|
|
1242
|
-
const result = handleIntercomDisconnect();
|
|
1243
|
-
res.writeHead(result.status, {
|
|
1244
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1245
|
-
});
|
|
1246
|
-
res.end(result.body);
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
// ── HubSpot routes ─────────────────────────────────────────────
|
|
1250
|
-
if (parsedUrl.pathname === "/connections/hubspot/connect" &&
|
|
1251
|
-
req.method === "POST") {
|
|
1252
|
-
const chunks = [];
|
|
1253
|
-
req.on("data", (c) => chunks.push(c));
|
|
1254
|
-
req.on("end", () => {
|
|
1255
|
-
void (async () => {
|
|
1256
|
-
const { handleHubSpotConnect } = await import("./connectors/hubspot.js");
|
|
1257
|
-
const result = await handleHubSpotConnect(Buffer.concat(chunks).toString("utf-8"));
|
|
1258
|
-
res.writeHead(result.status, {
|
|
1259
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1260
|
-
});
|
|
1261
|
-
res.end(result.body);
|
|
1262
|
-
})();
|
|
1263
|
-
});
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
if (parsedUrl.pathname === "/connections/hubspot/test" &&
|
|
1267
|
-
req.method === "POST") {
|
|
1268
|
-
const { handleHubSpotTest } = await import("./connectors/hubspot.js");
|
|
1269
|
-
const result = await handleHubSpotTest();
|
|
1270
|
-
res.writeHead(result.status, {
|
|
1271
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1272
|
-
});
|
|
1273
|
-
res.end(result.body);
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
if (parsedUrl.pathname === "/connections/hubspot" &&
|
|
1277
|
-
req.method === "DELETE") {
|
|
1278
|
-
const { handleHubSpotDisconnect } = await import("./connectors/hubspot.js");
|
|
1279
|
-
const result = handleHubSpotDisconnect();
|
|
1280
|
-
res.writeHead(result.status, {
|
|
1281
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1282
|
-
});
|
|
1283
|
-
res.end(result.body);
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
|
-
// ── Datadog routes ─────────────────────────────────────────────
|
|
1287
|
-
if (parsedUrl.pathname === "/connections/datadog/connect" &&
|
|
1288
|
-
req.method === "POST") {
|
|
1289
|
-
const chunks = [];
|
|
1290
|
-
req.on("data", (c) => chunks.push(c));
|
|
1291
|
-
req.on("end", () => {
|
|
1292
|
-
void (async () => {
|
|
1293
|
-
const { handleDatadogConnect } = await import("./connectors/datadog.js");
|
|
1294
|
-
const result = await handleDatadogConnect(Buffer.concat(chunks).toString("utf-8"));
|
|
1295
|
-
res.writeHead(result.status, {
|
|
1296
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1297
|
-
});
|
|
1298
|
-
res.end(result.body);
|
|
1299
|
-
})();
|
|
1300
|
-
});
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
if (parsedUrl.pathname === "/connections/datadog/test" &&
|
|
1304
|
-
req.method === "POST") {
|
|
1305
|
-
void (async () => {
|
|
1306
|
-
const { handleDatadogTest } = await import("./connectors/datadog.js");
|
|
1307
|
-
const result = await handleDatadogTest();
|
|
1308
|
-
res.writeHead(result.status, {
|
|
1309
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1310
|
-
});
|
|
1311
|
-
res.end(result.body);
|
|
1312
|
-
})();
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
if (parsedUrl.pathname === "/connections/datadog" &&
|
|
1316
|
-
req.method === "DELETE") {
|
|
1317
|
-
const { handleDatadogDisconnect } = await import("./connectors/datadog.js");
|
|
1318
|
-
const result = handleDatadogDisconnect();
|
|
1319
|
-
res.writeHead(result.status, {
|
|
1320
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1321
|
-
});
|
|
1322
|
-
res.end(result.body);
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
// ── Stripe routes ───────────────────────────────────────────────
|
|
1326
|
-
if (parsedUrl.pathname === "/connections/stripe/connect" &&
|
|
1327
|
-
req.method === "POST") {
|
|
1328
|
-
let body = "";
|
|
1329
|
-
req.on("data", (chunk) => {
|
|
1330
|
-
body += chunk.toString();
|
|
1331
|
-
});
|
|
1332
|
-
req.on("end", () => {
|
|
1333
|
-
void (async () => {
|
|
1334
|
-
const { handleStripeConnect } = await import("./connectors/stripe.js");
|
|
1335
|
-
const result = await handleStripeConnect(body);
|
|
1336
|
-
res.writeHead(result.status, {
|
|
1337
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1338
|
-
});
|
|
1339
|
-
res.end(result.body);
|
|
1340
|
-
})();
|
|
1341
|
-
});
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
if (parsedUrl.pathname === "/connections/stripe/test" &&
|
|
1345
|
-
req.method === "POST") {
|
|
1346
|
-
const { handleStripeTest } = await import("./connectors/stripe.js");
|
|
1347
|
-
const result = await handleStripeTest();
|
|
1348
|
-
res.writeHead(result.status, {
|
|
1349
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1350
|
-
});
|
|
1351
|
-
res.end(result.body);
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
if (parsedUrl.pathname === "/connections/stripe" &&
|
|
1355
|
-
req.method === "DELETE") {
|
|
1356
|
-
const { handleStripeDisconnect } = await import("./connectors/stripe.js");
|
|
1357
|
-
const result = handleStripeDisconnect();
|
|
1358
|
-
res.writeHead(result.status, {
|
|
1359
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1360
|
-
});
|
|
1361
|
-
res.end(result.body);
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
// ── Google Calendar routes ──────────────────────────────────────
|
|
1365
|
-
if (parsedUrl.pathname === "/connections/google-calendar/auth" &&
|
|
1366
|
-
req.method === "GET") {
|
|
1367
|
-
void (async () => {
|
|
1368
|
-
const { handleCalendarAuthRedirect } = await import("./connectors/googleCalendar.js");
|
|
1369
|
-
const result = handleCalendarAuthRedirect();
|
|
1370
|
-
if (result.redirect) {
|
|
1371
|
-
res.writeHead(302, { Location: result.redirect });
|
|
1372
|
-
res.end();
|
|
1373
|
-
}
|
|
1374
|
-
else {
|
|
1375
|
-
res.writeHead(result.status, {
|
|
1376
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1377
|
-
});
|
|
1378
|
-
res.end(result.body);
|
|
1379
|
-
}
|
|
1380
|
-
})();
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
if (parsedUrl.pathname === "/connections/google-calendar/callback" &&
|
|
1384
|
-
req.method === "GET") {
|
|
1385
|
-
void (async () => {
|
|
1386
|
-
const { handleCalendarCallback } = await import("./connectors/googleCalendar.js");
|
|
1387
|
-
const code = parsedUrl.searchParams.get("code");
|
|
1388
|
-
const state = parsedUrl.searchParams.get("state");
|
|
1389
|
-
const error = parsedUrl.searchParams.get("error");
|
|
1390
|
-
const result = await handleCalendarCallback(code, state, error);
|
|
1391
|
-
res.writeHead(result.status, {
|
|
1392
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1393
|
-
});
|
|
1394
|
-
res.end(result.body);
|
|
1395
|
-
})();
|
|
1396
|
-
return;
|
|
1397
|
-
}
|
|
1398
|
-
if (parsedUrl.pathname === "/connections/google-calendar/test" &&
|
|
1399
|
-
req.method === "POST") {
|
|
1400
|
-
void (async () => {
|
|
1401
|
-
const { handleCalendarTest } = await import("./connectors/googleCalendar.js");
|
|
1402
|
-
const result = await handleCalendarTest();
|
|
1403
|
-
res.writeHead(result.status, {
|
|
1404
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1405
|
-
});
|
|
1406
|
-
res.end(result.body);
|
|
1407
|
-
})();
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
if (parsedUrl.pathname === "/connections/google-calendar" &&
|
|
1411
|
-
req.method === "DELETE") {
|
|
1412
|
-
void (async () => {
|
|
1413
|
-
const { handleCalendarDisconnect } = await import("./connectors/googleCalendar.js");
|
|
1414
|
-
const result = await handleCalendarDisconnect();
|
|
1415
|
-
res.writeHead(result.status, {
|
|
1416
|
-
"Content-Type": result.contentType ?? "application/json",
|
|
1417
|
-
});
|
|
1418
|
-
res.end(result.body);
|
|
1419
|
-
})();
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
// ── Inbox routes ────────────────────────────────────────────────────
|
|
1423
|
-
if (parsedUrl.pathname === "/inbox" && req.method === "GET") {
|
|
1424
|
-
void (async () => {
|
|
1425
|
-
try {
|
|
1426
|
-
const { readdir, readFile, stat } = await import("node:fs/promises");
|
|
1427
|
-
const { existsSync } = await import("node:fs");
|
|
1428
|
-
const inboxDir = path.join(os.homedir(), ".patchwork", "inbox");
|
|
1429
|
-
if (!existsSync(inboxDir)) {
|
|
1430
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1431
|
-
res.end(JSON.stringify({ items: [] }));
|
|
1432
|
-
return;
|
|
1433
|
-
}
|
|
1434
|
-
const files = (await readdir(inboxDir)).filter((f) => f.endsWith(".md"));
|
|
1435
|
-
const items = await Promise.all(files.map(async (name) => {
|
|
1436
|
-
const filePath = path.join(inboxDir, name);
|
|
1437
|
-
const [content, stats] = await Promise.all([
|
|
1438
|
-
readFile(filePath, "utf8"),
|
|
1439
|
-
stat(filePath),
|
|
1440
|
-
]);
|
|
1441
|
-
const stripped = content
|
|
1442
|
-
.split("\n")
|
|
1443
|
-
.filter((l) => !l.startsWith("#"))
|
|
1444
|
-
.join("\n")
|
|
1445
|
-
.trim();
|
|
1446
|
-
return {
|
|
1447
|
-
name,
|
|
1448
|
-
path: filePath,
|
|
1449
|
-
modifiedAt: stats.mtime.toISOString(),
|
|
1450
|
-
preview: stripped.slice(0, 200),
|
|
1451
|
-
};
|
|
1452
|
-
}));
|
|
1453
|
-
items.sort((a, b) => new Date(b.modifiedAt).getTime() -
|
|
1454
|
-
new Date(a.modifiedAt).getTime());
|
|
1455
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1456
|
-
res.end(JSON.stringify({ items }));
|
|
1457
|
-
}
|
|
1458
|
-
catch (err) {
|
|
1459
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1460
|
-
res.end(JSON.stringify({
|
|
1461
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1462
|
-
}));
|
|
1463
|
-
}
|
|
1464
|
-
})();
|
|
1465
|
-
return;
|
|
1466
|
-
}
|
|
1467
|
-
const inboxFileMatch = parsedUrl.pathname?.match(/^\/inbox\/([^/]+\.md)$/);
|
|
1468
|
-
if (inboxFileMatch && req.method === "GET") {
|
|
1469
|
-
void (async () => {
|
|
1470
|
-
try {
|
|
1471
|
-
const { readFile, stat } = await import("node:fs/promises");
|
|
1472
|
-
const filename = decodeURIComponent(inboxFileMatch[1] ?? "");
|
|
1473
|
-
// Prevent path traversal — filename must not contain directory separators
|
|
1474
|
-
if (filename.includes("/") || filename.includes("\\")) {
|
|
1475
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1476
|
-
res.end(JSON.stringify({ error: "Invalid filename" }));
|
|
1477
|
-
return;
|
|
1478
|
-
}
|
|
1479
|
-
const filePath = path.join(os.homedir(), ".patchwork", "inbox", filename);
|
|
1480
|
-
const [content, stats] = await Promise.all([
|
|
1481
|
-
readFile(filePath, "utf8"),
|
|
1482
|
-
stat(filePath),
|
|
1483
|
-
]);
|
|
1484
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1485
|
-
res.end(JSON.stringify({
|
|
1486
|
-
name: filename,
|
|
1487
|
-
content,
|
|
1488
|
-
modifiedAt: stats.mtime.toISOString(),
|
|
1489
|
-
}));
|
|
1490
|
-
}
|
|
1491
|
-
catch (err) {
|
|
1492
|
-
const code = err.code;
|
|
1493
|
-
if (code === "ENOENT") {
|
|
1494
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1495
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
1496
|
-
}
|
|
1497
|
-
else {
|
|
1498
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1499
|
-
res.end(JSON.stringify({
|
|
1500
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1501
|
-
}));
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
})();
|
|
1505
|
-
return;
|
|
1506
|
-
}
|
|
1507
|
-
// ── End inbox routes ─────────────────────────────────────────────────
|
|
1508
|
-
const recipeNameRunMatch = req.method === "POST"
|
|
1509
|
-
? /^\/recipes\/([^/]+)\/run$/.exec(parsedUrl.pathname)
|
|
1510
|
-
: null;
|
|
1511
|
-
if (recipeNameRunMatch) {
|
|
1512
|
-
const nameFromPath = decodeURIComponent(recipeNameRunMatch[1] ?? "");
|
|
1513
|
-
const chunks = [];
|
|
1514
|
-
req.on("data", (c) => chunks.push(c));
|
|
1515
|
-
req.on("end", () => {
|
|
1516
|
-
void (async () => {
|
|
1517
|
-
try {
|
|
1518
|
-
const body = Buffer.concat(chunks).toString("utf-8");
|
|
1519
|
-
const parsed = body
|
|
1520
|
-
? JSON.parse(body)
|
|
1521
|
-
: {};
|
|
1522
|
-
const vars = parsed.vars &&
|
|
1523
|
-
typeof parsed.vars === "object" &&
|
|
1524
|
-
!Array.isArray(parsed.vars)
|
|
1525
|
-
? parsed.vars
|
|
1526
|
-
: undefined;
|
|
1527
|
-
if (!this.runRecipeFn) {
|
|
1528
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1529
|
-
res.end(JSON.stringify({
|
|
1530
|
-
ok: false,
|
|
1531
|
-
error: "Recipe execution unavailable — requires --claude-driver subprocess",
|
|
1532
|
-
}));
|
|
1533
|
-
return;
|
|
1534
|
-
}
|
|
1535
|
-
const result = await this.runRecipeFn(nameFromPath, vars);
|
|
1536
|
-
res.writeHead(result.ok ? 200 : 400, {
|
|
1537
|
-
"Content-Type": "application/json",
|
|
1538
|
-
});
|
|
1539
|
-
res.end(JSON.stringify(result));
|
|
1540
|
-
}
|
|
1541
|
-
catch {
|
|
1542
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1543
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
1544
|
-
}
|
|
1545
|
-
})();
|
|
1546
|
-
});
|
|
1547
|
-
return;
|
|
1548
|
-
}
|
|
1549
|
-
if (parsedUrl.pathname === "/recipes/run" && req.method === "POST") {
|
|
1550
|
-
const chunks = [];
|
|
1551
|
-
req.on("data", (c) => chunks.push(c));
|
|
1552
|
-
req.on("end", () => {
|
|
1553
|
-
void (async () => {
|
|
1554
|
-
try {
|
|
1555
|
-
const body = Buffer.concat(chunks).toString("utf-8");
|
|
1556
|
-
const parsed = JSON.parse(body || "{}");
|
|
1557
|
-
const name = parsed.name;
|
|
1558
|
-
const vars = parsed.vars &&
|
|
1559
|
-
typeof parsed.vars === "object" &&
|
|
1560
|
-
!Array.isArray(parsed.vars)
|
|
1561
|
-
? parsed.vars
|
|
1562
|
-
: undefined;
|
|
1563
|
-
if (typeof name !== "string" || !name) {
|
|
1564
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1565
|
-
res.end(JSON.stringify({ ok: false, error: "name required" }));
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
if (!this.runRecipeFn) {
|
|
1569
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1570
|
-
res.end(JSON.stringify({
|
|
1571
|
-
ok: false,
|
|
1572
|
-
error: "Recipe execution unavailable — requires --claude-driver subprocess",
|
|
1573
|
-
}));
|
|
1574
|
-
return;
|
|
1575
|
-
}
|
|
1576
|
-
const result = await this.runRecipeFn(name, vars);
|
|
1577
|
-
res.writeHead(result.ok ? 200 : 400, {
|
|
1578
|
-
"Content-Type": "application/json",
|
|
1579
|
-
});
|
|
1580
|
-
res.end(JSON.stringify(result));
|
|
1581
|
-
}
|
|
1582
|
-
catch {
|
|
1583
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1584
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
1585
|
-
}
|
|
1586
|
-
})();
|
|
1587
|
-
});
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
if (parsedUrl.pathname === "/activation-metrics" &&
|
|
1591
|
-
req.method === "GET") {
|
|
1592
|
-
try {
|
|
1593
|
-
const metrics = loadActivationMetrics();
|
|
1594
|
-
const summary = computeActivationSummary(metrics);
|
|
1595
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1596
|
-
res.end(JSON.stringify({ metrics, summary }));
|
|
1597
|
-
}
|
|
1598
|
-
catch (err) {
|
|
1599
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1600
|
-
res.end(JSON.stringify({
|
|
1601
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1602
|
-
}));
|
|
1603
|
-
}
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
if (parsedUrl.pathname === "/runs" && req.method === "GET") {
|
|
1607
|
-
try {
|
|
1608
|
-
const sp = parsedUrl.searchParams;
|
|
1609
|
-
const limitRaw = sp.get("limit");
|
|
1610
|
-
const afterRaw = sp.get("after");
|
|
1611
|
-
const trigger = sp.get("trigger");
|
|
1612
|
-
const status = sp.get("status");
|
|
1613
|
-
const recipe = sp.get("recipe");
|
|
1614
|
-
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
1615
|
-
const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
|
|
1616
|
-
const runs = this.runsFn?.({
|
|
1617
|
-
...(Number.isFinite(limit) && { limit }),
|
|
1618
|
-
...(trigger && { trigger }),
|
|
1619
|
-
...(status && { status }),
|
|
1620
|
-
...(recipe && { recipe }),
|
|
1621
|
-
...(Number.isFinite(after) && { after }),
|
|
1622
|
-
}) ?? [];
|
|
1623
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1624
|
-
res.end(JSON.stringify({ runs }));
|
|
1625
|
-
}
|
|
1626
|
-
catch (err) {
|
|
1627
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1628
|
-
res.end(JSON.stringify({
|
|
1629
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1630
|
-
}));
|
|
1631
|
-
}
|
|
1632
|
-
return;
|
|
1633
|
-
}
|
|
1634
|
-
// GET /runs/:seq — single run detail (includes stepResults if present)
|
|
1635
|
-
const runDetailMatch = req.method === "GET"
|
|
1636
|
-
? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname)
|
|
1637
|
-
: null;
|
|
1638
|
-
if (runDetailMatch?.[1]) {
|
|
1639
|
-
const seq = Number.parseInt(runDetailMatch[1], 10);
|
|
1640
|
-
try {
|
|
1641
|
-
const run = this.runDetailFn?.(seq) ?? null;
|
|
1642
|
-
if (!run) {
|
|
1643
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1644
|
-
res.end(JSON.stringify({ error: "not_found" }));
|
|
1645
|
-
}
|
|
1646
|
-
else {
|
|
1647
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1648
|
-
res.end(JSON.stringify({ run }));
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
catch (err) {
|
|
1652
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1653
|
-
res.end(JSON.stringify({
|
|
1654
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1655
|
-
}));
|
|
1656
|
-
}
|
|
1657
|
-
return;
|
|
1658
|
-
}
|
|
1659
|
-
// GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
|
|
1660
|
-
const runPlanMatch = req.method === "GET"
|
|
1661
|
-
? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
|
|
1662
|
-
: null;
|
|
1663
|
-
if (runPlanMatch?.[1]) {
|
|
1664
|
-
const seq = Number.parseInt(runPlanMatch[1], 10);
|
|
1665
|
-
try {
|
|
1666
|
-
const run = this.runDetailFn?.(seq) ?? null;
|
|
1667
|
-
if (!run) {
|
|
1668
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1669
|
-
res.end(JSON.stringify({ error: "run_not_found" }));
|
|
1670
|
-
return;
|
|
1671
|
-
}
|
|
1672
|
-
if (!this.runPlanFn) {
|
|
1673
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1674
|
-
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
1675
|
-
return;
|
|
1676
|
-
}
|
|
1677
|
-
const recipeName = run.recipeName;
|
|
1678
|
-
const plan = await this.runPlanFn(recipeName);
|
|
1679
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1680
|
-
res.end(JSON.stringify({ plan }));
|
|
1681
|
-
}
|
|
1682
|
-
catch (err) {
|
|
1683
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1684
|
-
const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
|
|
1685
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1686
|
-
res.end(JSON.stringify({ error: msg }));
|
|
1687
|
-
}
|
|
1688
|
-
return;
|
|
1689
|
-
}
|
|
1690
|
-
if (req.url === "/recipes" && req.method === "POST") {
|
|
1691
|
-
const chunks = [];
|
|
1692
|
-
req.on("data", (c) => chunks.push(c));
|
|
1693
|
-
req.on("end", () => {
|
|
1694
|
-
try {
|
|
1695
|
-
const body = Buffer.concat(chunks).toString("utf-8");
|
|
1696
|
-
const draft = JSON.parse(body || "{}");
|
|
1697
|
-
if (typeof draft.name !== "string" || !draft.name) {
|
|
1698
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1699
|
-
res.end(JSON.stringify({ ok: false, error: "name required" }));
|
|
1700
|
-
return;
|
|
1701
|
-
}
|
|
1702
|
-
if (!this.saveRecipeFn) {
|
|
1703
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1704
|
-
res.end(JSON.stringify({
|
|
1705
|
-
ok: false,
|
|
1706
|
-
error: "Recipe saving unavailable",
|
|
1707
|
-
}));
|
|
1708
|
-
return;
|
|
1709
|
-
}
|
|
1710
|
-
const result = this.saveRecipeFn(draft);
|
|
1711
|
-
res.writeHead(result.ok ? 201 : 400, {
|
|
1712
|
-
"Content-Type": "application/json",
|
|
1713
|
-
});
|
|
1714
|
-
res.end(JSON.stringify(result));
|
|
1715
|
-
}
|
|
1716
|
-
catch {
|
|
1717
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1718
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
1719
|
-
}
|
|
1720
|
-
});
|
|
1721
|
-
return;
|
|
1722
|
-
}
|
|
1723
|
-
const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
1724
|
-
if (recipePatchMatch && req.method === "PATCH") {
|
|
1725
|
-
const name = decodeURIComponent(recipePatchMatch[1]);
|
|
1726
|
-
const chunks = [];
|
|
1727
|
-
req.on("data", (c) => chunks.push(c));
|
|
1728
|
-
req.on("end", () => {
|
|
1729
|
-
try {
|
|
1730
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
1731
|
-
if (typeof body.enabled !== "boolean") {
|
|
1732
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1733
|
-
res.end(JSON.stringify({
|
|
1734
|
-
ok: false,
|
|
1735
|
-
error: "enabled (boolean) required",
|
|
1736
|
-
}));
|
|
1737
|
-
return;
|
|
1738
|
-
}
|
|
1739
|
-
if (!this.setRecipeEnabledFn) {
|
|
1740
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1741
|
-
res.end(JSON.stringify({ ok: false, error: "Not available" }));
|
|
1742
|
-
return;
|
|
1743
|
-
}
|
|
1744
|
-
const result = this.setRecipeEnabledFn(name, body.enabled);
|
|
1745
|
-
res.writeHead(result.ok ? 200 : 400, {
|
|
1746
|
-
"Content-Type": "application/json",
|
|
1747
|
-
});
|
|
1748
|
-
res.end(JSON.stringify(result));
|
|
1749
|
-
}
|
|
1750
|
-
catch {
|
|
1751
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1752
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
|
|
1753
|
-
}
|
|
1754
|
-
});
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
if (parsedUrl.pathname === "/recipes/lint" && req.method === "POST") {
|
|
1758
|
-
const chunks = [];
|
|
1759
|
-
req.on("data", (c) => chunks.push(c));
|
|
1760
|
-
req.on("end", () => {
|
|
1761
|
-
try {
|
|
1762
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
1763
|
-
if (typeof body?.content !== "string") {
|
|
1764
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1765
|
-
res.end(JSON.stringify({
|
|
1766
|
-
ok: false,
|
|
1767
|
-
error: "content (string) required",
|
|
1768
|
-
}));
|
|
1769
|
-
return;
|
|
1770
|
-
}
|
|
1771
|
-
if (!this.lintRecipeContentFn) {
|
|
1772
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1773
|
-
res.end(JSON.stringify({
|
|
1774
|
-
ok: false,
|
|
1775
|
-
error: "Recipe lint unavailable",
|
|
1776
|
-
}));
|
|
1777
|
-
return;
|
|
1778
|
-
}
|
|
1779
|
-
const result = this.lintRecipeContentFn(body.content);
|
|
1780
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1781
|
-
res.end(JSON.stringify(result));
|
|
1782
|
-
}
|
|
1783
|
-
catch {
|
|
1784
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1785
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
1786
|
-
}
|
|
1787
|
-
});
|
|
1788
|
-
return;
|
|
1789
|
-
}
|
|
1790
|
-
const recipeContentMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
1791
|
-
if (recipeContentMatch && req.method === "GET") {
|
|
1792
|
-
const name = decodeURIComponent(recipeContentMatch[1]);
|
|
1793
|
-
if (!this.loadRecipeContentFn) {
|
|
1794
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1795
|
-
res.end(JSON.stringify({ ok: false, error: "Recipe content unavailable" }));
|
|
1796
|
-
return;
|
|
1797
|
-
}
|
|
1798
|
-
const result = this.loadRecipeContentFn(name);
|
|
1799
|
-
if (!result) {
|
|
1800
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1801
|
-
res.end(JSON.stringify({ ok: false, error: "Recipe not found" }));
|
|
1802
|
-
return;
|
|
1803
|
-
}
|
|
1804
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1805
|
-
res.end(JSON.stringify(result));
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
if (recipeContentMatch && req.method === "PUT") {
|
|
1809
|
-
const name = decodeURIComponent(recipeContentMatch[1]);
|
|
1810
|
-
const chunks = [];
|
|
1811
|
-
req.on("data", (c) => chunks.push(c));
|
|
1812
|
-
req.on("end", () => {
|
|
1813
|
-
try {
|
|
1814
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
1815
|
-
if (typeof body.content !== "string") {
|
|
1816
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1817
|
-
res.end(JSON.stringify({
|
|
1818
|
-
ok: false,
|
|
1819
|
-
error: "content (string) required",
|
|
1820
|
-
}));
|
|
1821
|
-
return;
|
|
1822
|
-
}
|
|
1823
|
-
if (!this.saveRecipeContentFn) {
|
|
1824
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1825
|
-
res.end(JSON.stringify({
|
|
1826
|
-
ok: false,
|
|
1827
|
-
error: "Recipe content saving unavailable",
|
|
1828
|
-
}));
|
|
1829
|
-
return;
|
|
1830
|
-
}
|
|
1831
|
-
const result = this.saveRecipeContentFn(name, body.content);
|
|
1832
|
-
res.writeHead(result.ok ? 200 : 400, {
|
|
1833
|
-
"Content-Type": "application/json",
|
|
1834
|
-
});
|
|
1835
|
-
res.end(JSON.stringify(result));
|
|
1836
|
-
}
|
|
1837
|
-
catch {
|
|
1838
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1839
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
1840
|
-
}
|
|
1841
|
-
});
|
|
1842
|
-
return;
|
|
1843
|
-
}
|
|
1844
|
-
if (recipeContentMatch && req.method === "DELETE") {
|
|
1845
|
-
const name = decodeURIComponent(recipeContentMatch[1]);
|
|
1846
|
-
if (!this.deleteRecipeContentFn) {
|
|
1847
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1848
|
-
res.end(JSON.stringify({
|
|
1849
|
-
ok: false,
|
|
1850
|
-
error: "Recipe deletion unavailable",
|
|
1851
|
-
}));
|
|
1852
|
-
return;
|
|
1853
|
-
}
|
|
1854
|
-
const result = this.deleteRecipeContentFn(name);
|
|
1855
|
-
const status = result.ok
|
|
1856
|
-
? 200
|
|
1857
|
-
: result.error === "Recipe not found"
|
|
1858
|
-
? 404
|
|
1859
|
-
: 400;
|
|
1860
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1861
|
-
res.end(JSON.stringify(result));
|
|
1862
|
-
return;
|
|
1863
|
-
}
|
|
1864
|
-
if (req.url === "/recipes" && req.method === "GET") {
|
|
1865
|
-
try {
|
|
1866
|
-
const data = this.recipesFn?.() ?? { recipesDir: null, recipes: [] };
|
|
1867
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1868
|
-
res.end(JSON.stringify(data));
|
|
1869
|
-
}
|
|
1870
|
-
catch (err) {
|
|
1871
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1872
|
-
res.end(JSON.stringify({
|
|
1873
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1874
|
-
}));
|
|
1875
|
-
}
|
|
1876
|
-
return;
|
|
1877
|
-
}
|
|
1878
|
-
if (parsedUrl.pathname === "/templates" && req.method === "GET") {
|
|
1879
|
-
try {
|
|
1880
|
-
const now = Date.now();
|
|
1881
|
-
if (!this._templatesCache ||
|
|
1882
|
-
now - this._templatesCacheTs > 5 * 60 * 1000) {
|
|
1883
|
-
const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
|
|
1884
|
-
if (!ghRes.ok) {
|
|
1885
|
-
throw new Error(`GitHub returned ${ghRes.status}`);
|
|
1886
|
-
}
|
|
1887
|
-
this._templatesCache = (await ghRes.json());
|
|
1888
|
-
this._templatesCacheTs = now;
|
|
1889
|
-
}
|
|
1890
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1891
|
-
res.end(JSON.stringify(this._templatesCache));
|
|
1892
|
-
}
|
|
1893
|
-
catch (err) {
|
|
1894
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1895
|
-
res.end(JSON.stringify({
|
|
1896
|
-
ok: false,
|
|
1897
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1898
|
-
}));
|
|
1899
|
-
}
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
if (parsedUrl.pathname === "/recipes/install" && req.method === "POST") {
|
|
1903
|
-
let body = "";
|
|
1904
|
-
req.on("data", (chunk) => {
|
|
1905
|
-
body += chunk.toString();
|
|
1906
|
-
});
|
|
1907
|
-
req.on("end", async () => {
|
|
1908
|
-
try {
|
|
1909
|
-
const { source } = JSON.parse(body);
|
|
1910
|
-
if (!source) {
|
|
1911
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1912
|
-
res.end(JSON.stringify({ ok: false, error: "Missing source field" }));
|
|
1913
|
-
return;
|
|
1914
|
-
}
|
|
1915
|
-
const githubPrefix = "github:patchworkos/recipes/recipes/";
|
|
1916
|
-
let fetchUrl;
|
|
1917
|
-
let recipeName;
|
|
1918
|
-
if (source.startsWith(githubPrefix)) {
|
|
1919
|
-
recipeName = source.slice(githubPrefix.length);
|
|
1920
|
-
fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
|
|
1921
|
-
}
|
|
1922
|
-
else if (source.startsWith("https://")) {
|
|
1923
|
-
fetchUrl = source;
|
|
1924
|
-
const urlParts = fetchUrl.split("/");
|
|
1925
|
-
recipeName = (urlParts[urlParts.length - 1] ?? "recipe").replace(/\.ya?ml$/i, "");
|
|
1926
|
-
}
|
|
1927
|
-
else {
|
|
1928
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1929
|
-
res.end(JSON.stringify({
|
|
1930
|
-
ok: false,
|
|
1931
|
-
error: "Unsupported source format",
|
|
1932
|
-
}));
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
const yamlRes = await fetch(fetchUrl);
|
|
1936
|
-
if (!yamlRes.ok) {
|
|
1937
|
-
throw new Error(`Fetch failed: ${yamlRes.status} ${yamlRes.statusText}`);
|
|
1938
|
-
}
|
|
1939
|
-
const yamlText = await yamlRes.text();
|
|
1940
|
-
const tmpFile = path.join(os.tmpdir(), `patchwork-install-${Date.now()}-${recipeName}.yaml`);
|
|
1941
|
-
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1942
|
-
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1943
|
-
let result;
|
|
1944
|
-
try {
|
|
1945
|
-
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1946
|
-
mkdirSync(recipesDir, { recursive: true });
|
|
1947
|
-
const { installRecipeFromFile } = await import("./recipes/installer.js");
|
|
1948
|
-
const installResult = installRecipeFromFile(tmpFile, {
|
|
1949
|
-
recipesDir,
|
|
1950
|
-
});
|
|
1951
|
-
result = { action: installResult.action, name: recipeName };
|
|
1952
|
-
}
|
|
1953
|
-
finally {
|
|
1954
|
-
try {
|
|
1955
|
-
unlinkSync(tmpFile);
|
|
1956
|
-
}
|
|
1957
|
-
catch {
|
|
1958
|
-
// best-effort cleanup
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1962
|
-
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1963
|
-
}
|
|
1964
|
-
catch (err) {
|
|
1965
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1966
|
-
res.end(JSON.stringify({
|
|
1967
|
-
ok: false,
|
|
1968
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1969
|
-
}));
|
|
1970
|
-
}
|
|
1971
|
-
});
|
|
1972
|
-
return;
|
|
1973
|
-
}
|
|
1974
|
-
const sessionDetailMatch = /^\/sessions\/([A-Za-z0-9-]+)$/.exec(parsedUrl.pathname);
|
|
1975
|
-
if (sessionDetailMatch && req.method === "GET") {
|
|
1976
|
-
const id = sessionDetailMatch[1];
|
|
1977
|
-
try {
|
|
1978
|
-
const data = this.sessionDetailFn?.(id);
|
|
1979
|
-
if (!data?.summary) {
|
|
1980
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1981
|
-
res.end(JSON.stringify({ error: "unknown sessionId" }));
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1985
|
-
res.end(JSON.stringify(data));
|
|
1986
|
-
}
|
|
1987
|
-
catch (err) {
|
|
1988
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1989
|
-
res.end(JSON.stringify({
|
|
1990
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1991
|
-
}));
|
|
1992
|
-
}
|
|
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
|
+
}
|
|
1993
951
|
return;
|
|
1994
952
|
}
|
|
1995
953
|
if (parsedUrl.pathname === "/sessions" && req.method === "GET") {
|
|
@@ -2051,6 +1009,21 @@ export class Server extends EventEmitter {
|
|
|
2051
1009
|
cfg.approvalGate = gateRaw;
|
|
2052
1010
|
this.approvalGate = gateRaw;
|
|
2053
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
|
+
}
|
|
2054
1027
|
const driverRaw = body.driver;
|
|
2055
1028
|
if (driverRaw !== undefined) {
|
|
2056
1029
|
const validDrivers = [
|
|
@@ -2235,6 +1208,15 @@ export class Server extends EventEmitter {
|
|
|
2235
1208
|
pushServiceUrl: this.pushServiceUrl,
|
|
2236
1209
|
pushServiceToken: this.pushServiceToken,
|
|
2237
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,
|
|
2238
1220
|
});
|
|
2239
1221
|
res.writeHead(result.status, {
|
|
2240
1222
|
"Content-Type": "application/json",
|