@elitedcs/ghl-mcp 3.83.6 → 3.83.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.83.7 — the cockpit answers only to its own address, and keeps its key to itself
6
+
7
+ **Security fix. Update to this version.**
8
+
9
+ The cockpit runs on your own computer and only accepts connections from it. Two
10
+ things it should have refused, it did not:
11
+
12
+ - **A design run's details were readable without the cockpit's key.** Anything
13
+ able to reach the cockpit on your computer could list the Design Studio runs it
14
+ had started: the brand, headline, points and audience you typed, the run's log,
15
+ and the folder it saved to, which includes your computer's user name.
16
+ - **The cockpit handed its key to a request that used a different address for
17
+ it.** A web page that tricks your browser into reaching the cockpit under
18
+ another name could have picked up that key, and with it read what the cockpit
19
+ reads for your clients: intake answers, uploaded intake documents, what each
20
+ account already has set up, and your snapshot list. Nothing could be changed
21
+ that way; every change the cockpit makes kept refusing such requests. No
22
+ GoHighLevel key was exposed by either path.
23
+
24
+ Now the cockpit refuses any request that is not addressed to `localhost` or
25
+ `127.0.0.1` on its own port, before it does anything else. It gives its key only
26
+ to its own pages opened on your computer, never to anything else. Design Studio's
27
+ run list and progress need the key, like the cockpit's other private reads. And a
28
+ request that spells a page's address in an unusual way is refused rather than
29
+ guessed at.
30
+
31
+ Nothing changes in how you open it: the address the cockpit opens for you is one of
32
+ the two it accepts.
33
+
5
34
  ## 3.83.6 — what a design run actually costs you, measured
6
35
 
7
36
  3.83.5 gave a running job a clock. This gives it an honest number on it.
package/README.md CHANGED
@@ -897,7 +897,7 @@ Built by **[Elite DCs, LLC](https://elitedcs.com)** — a digital marketing and
897
897
 
898
898
  **Tech stack:** TypeScript, Node.js, esbuild, MCP SDK, Zod, GHL API v2, Firebase Auth
899
899
 
900
- **Version:** 3.83.6
900
+ **Version:** 3.83.7
901
901
 
902
902
  ---
903
903
 
package/dist/index.js CHANGED
@@ -28605,7 +28605,7 @@ var require_package = __commonJS({
28605
28605
  "package.json"(exports2, module2) {
28606
28606
  module2.exports = {
28607
28607
  name: "@elitedcs/ghl-mcp",
28608
- version: "3.83.6",
28608
+ version: "3.83.7",
28609
28609
  mcpName: "io.github.drjerryrelth/ghl-command",
28610
28610
  description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
28611
28611
  main: "dist/index.js",
@@ -32442,7 +32442,7 @@ document.getElementById("make").addEventListener("submit",async function(e){
32442
32442
  document.getElementById("mk-cancel").hidden=false;
32443
32443
  started=Date.now();passes=0;tick();ticker=setInterval(tick,1000);
32444
32444
  poll=setInterval(async function(){
32445
- var s=await (await fetch("/api/design/run?id="+encodeURIComponent(runId))).json();
32445
+ var s=await (await fetch("/api/design/run?id="+encodeURIComponent(runId),{headers:{"x-command-os-seat":KEY}})).json();
32446
32446
  var lines=s.tail||[];
32447
32447
  show(lines.join("\\n"));
32448
32448
  passes=lines.filter(function(l){return l.trim().slice(0,5)==="pass "}).length;
@@ -33985,9 +33985,11 @@ __export(dashboard_exports, {
33985
33985
  insideDir: () => insideDir,
33986
33986
  intakeInput: () => intakeInput,
33987
33987
  intakeRequestError: () => intakeRequestError,
33988
+ isAllowedCockpitHost: () => isAllowedCockpitHost,
33988
33989
  isLoopback: () => isLoopback,
33989
33990
  launcherAlreadyOffered: () => launcherAlreadyOffered,
33990
33991
  listClientDocs: () => listClientDocs,
33992
+ mayServeSeat: () => mayServeSeat,
33991
33993
  memberFacingError: () => memberFacingError,
33992
33994
  newClientFailure: () => newClientFailure,
33993
33995
  normalizedIntake: () => normalizedIntake,
@@ -35752,22 +35754,45 @@ function checkSeatToken(req, expected) {
35752
35754
  }
35753
35755
  return null;
35754
35756
  }
35755
- function checkMutationGuard(req, port) {
35756
- const method = (req.method || "GET").toUpperCase();
35757
- if (method === "GET" || method === "HEAD" || method === "OPTIONS") return null;
35758
- const header = (name) => {
35759
- const v = req.headers[name];
35760
- return (Array.isArray(v) ? v[0] : v ?? "").trim();
35761
- };
35762
- const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
35763
- if (!allowedHosts.includes(header("host").toLowerCase())) {
35764
- return { status: 403, error: "host not allowed" };
35757
+ function cockpitHeader(req, name) {
35758
+ const value = req.headers[name];
35759
+ return Array.isArray(value) ? value.join(",") : value ?? "";
35760
+ }
35761
+ function isAllowedCockpitHost(req, port) {
35762
+ const host = cockpitHeader(req, "host").toLowerCase();
35763
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
35764
+ }
35765
+ function mayServeSeat(req, port) {
35766
+ return isLoopback(req) && isAllowedCockpitHost(req, port);
35767
+ }
35768
+ function cockpitPath(req) {
35769
+ return (req.url || "/").split("?")[0];
35770
+ }
35771
+ function checkCockpitPath(req) {
35772
+ const pathname = cockpitPath(req);
35773
+ if (!pathname.startsWith("/") || /\/\/|\\|#|[\x00-\x20\x7f]|%(?:2e|2f|5c)|%(?![0-9a-f]{2})/i.test(pathname) || pathname.split("/").some((segment) => segment === "." || segment === "..")) {
35774
+ return { status: 400, error: "Use a canonical cockpit path." };
35765
35775
  }
35766
- const origin = header("origin").toLowerCase();
35767
- if (origin && !allowedHosts.some((h) => origin === `http://${h}`)) {
35776
+ return null;
35777
+ }
35778
+ function checkCockpitOrigin(req, port) {
35779
+ const origin = cockpitHeader(req, "origin").toLowerCase();
35780
+ if (req.headers.origin !== void 0 && origin !== `http://127.0.0.1:${port}` && origin !== `http://localhost:${port}`) {
35768
35781
  return { status: 403, error: "cross-origin request rejected" };
35769
35782
  }
35770
- if (header("content-type").split(";")[0].trim().toLowerCase() !== "application/json") {
35783
+ return null;
35784
+ }
35785
+ function checkMutationGuard(req, port) {
35786
+ if (!isAllowedCockpitHost(req, port)) {
35787
+ return { status: 403, error: "This Host is not allowed to access the cockpit." };
35788
+ }
35789
+ const pathDenied = checkCockpitPath(req);
35790
+ if (pathDenied) return pathDenied;
35791
+ const method = (req.method || "GET").toUpperCase();
35792
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") return null;
35793
+ const originDenied = checkCockpitOrigin(req, port);
35794
+ if (originDenied) return originDenied;
35795
+ if (cockpitHeader(req, "content-type").split(";")[0].trim().toLowerCase() !== "application/json") {
35771
35796
  return { status: 415, error: "expected application/json" };
35772
35797
  }
35773
35798
  return null;
@@ -35775,8 +35800,13 @@ function checkMutationGuard(req, port) {
35775
35800
  function cockpitGate(req, seatToken, port) {
35776
35801
  const denied = checkMutationGuard(req, port) ?? checkSeatToken(req, seatToken);
35777
35802
  if (denied) return denied;
35778
- const pathname = (req.url ?? "/").split("?")[0];
35779
- if (SEAT_GUARDED_READS.includes(pathname)) return checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
35803
+ const pathname = cockpitPath(req);
35804
+ if (SEAT_GUARDED_READS.includes(pathname) || pathname === "/api/design" || pathname.startsWith("/api/design/")) {
35805
+ if (cockpitHeader(req, "sec-fetch-site").toLowerCase() === "cross-site") {
35806
+ return { status: 403, error: "cross-site request rejected" };
35807
+ }
35808
+ return checkCockpitOrigin(req, port) ?? checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
35809
+ }
35780
35810
  return null;
35781
35811
  }
35782
35812
  function guarded(seatToken, port, routes) {
@@ -35901,7 +35931,7 @@ async function runDashboard(argv) {
35901
35931
  questions: (id) => composeQuestionSet({ industry: readCockpitState().accounts?.[id]?.industry, agency: readOverlay() })
35902
35932
  };
35903
35933
  const server2 = http.createServer(guarded(seatToken, port, (req, res) => {
35904
- const url = req.url || "/";
35934
+ const url = cockpitPath(req);
35905
35935
  if (req.method === "POST" && url === "/api/client-assessment") {
35906
35936
  readBody(req, res, 2e4, async (body) => {
35907
35937
  try {
@@ -35923,10 +35953,10 @@ async function runDashboard(argv) {
35923
35953
  return;
35924
35954
  }
35925
35955
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
35926
- res.end(html(currentRoster(), isLoopback(req) ? seatToken : "", "for", cap));
35956
+ res.end(html(currentRoster(), mayServeSeat(req, port) ? seatToken : "", "for", cap));
35927
35957
  return;
35928
35958
  }
35929
- if (req.method === "GET" && url.startsWith("/api/capability")) {
35959
+ if (req.method === "GET" && url === "/api/capability") {
35930
35960
  const u = new URL(req.url ?? "/", "http://127.0.0.1");
35931
35961
  const cap = capabilityByKey(u.searchParams.get("key") ?? "");
35932
35962
  const locationId2 = u.searchParams.get("locationId") ?? "";
@@ -36061,7 +36091,7 @@ async function runDashboard(argv) {
36061
36091
  });
36062
36092
  return;
36063
36093
  }
36064
- if (req.method === "GET" && url.startsWith("/api/intake-documents")) {
36094
+ if (req.method === "GET" && url === "/api/intake-documents") {
36065
36095
  const u = new URL(req.url ?? "/", "http://127.0.0.1");
36066
36096
  const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
36067
36097
  if (seat) {
@@ -36109,7 +36139,7 @@ async function runDashboard(argv) {
36109
36139
  const boardScreen = BOARD_ROUTES[url];
36110
36140
  if (req.method === "GET" && boardScreen) {
36111
36141
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
36112
- res.end(html(currentRoster(), isLoopback(req) ? seatToken : "", boardScreen));
36142
+ res.end(html(currentRoster(), mayServeSeat(req, port) ? seatToken : "", boardScreen));
36113
36143
  return;
36114
36144
  }
36115
36145
  if ((req.method === "GET" || req.method === "HEAD") && (url === "/assessment" || url === "/assessment.html")) {
@@ -36121,7 +36151,7 @@ async function runDashboard(argv) {
36121
36151
  "X-Robots-Tag": "noindex, nofollow"
36122
36152
  });
36123
36153
  const roster2 = currentRoster().map((r) => ({ locationId: r.locationId, name: r.name }));
36124
- const clientsTag = `<script>window.COMMAND_OS_CLIENTS=${scriptJson(roster2)};window.COMMAND_OS_SEAT=${scriptJson(isLoopback(req) ? seatToken : "")};</script>`;
36154
+ const clientsTag = `<script>window.COMMAND_OS_CLIENTS=${scriptJson(roster2)};window.COMMAND_OS_SEAT=${scriptJson(mayServeSeat(req, port) ? seatToken : "")};</script>`;
36125
36155
  res.end(req.method === "HEAD" ? void 0 : withCockpitShell(ASSESSMENT_HTML, "assessment", assessmentAgencyTag() + clientsTag));
36126
36156
  return;
36127
36157
  }
@@ -36164,14 +36194,14 @@ async function runDashboard(argv) {
36164
36194
  });
36165
36195
  return;
36166
36196
  }
36167
- if (req.method === "GET" && url.startsWith("/api/client-preflight?")) {
36197
+ if (req.method === "GET" && url === "/api/client-preflight") {
36168
36198
  const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
36169
36199
  if (seat) {
36170
36200
  res.writeHead(seat.status);
36171
36201
  res.end(JSON.stringify({ ok: false, error: seat.error }));
36172
36202
  return;
36173
36203
  }
36174
- const id = new URL(url, "http://x").searchParams.get("locationId") || "";
36204
+ const id = new URL(req.url || "/", "http://x").searchParams.get("locationId") || "";
36175
36205
  if (!readableAccount(id)) {
36176
36206
  res.writeHead(400);
36177
36207
  res.end(JSON.stringify({ ok: false, error: "Choose a registered client." }));
@@ -36327,8 +36357,8 @@ async function runDashboard(argv) {
36327
36357
  });
36328
36358
  return;
36329
36359
  }
36330
- if (req.method === "GET" && url.startsWith("/api/intake")) {
36331
- const industry = new URL(url, "http://x").searchParams.get("industry") || void 0;
36360
+ if (req.method === "GET" && url === "/api/intake") {
36361
+ const industry = new URL(req.url || "/", "http://x").searchParams.get("industry") || void 0;
36332
36362
  const agency = readOverlay();
36333
36363
  const composed = composeQuestionSet({ industry, agency });
36334
36364
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -36357,10 +36387,10 @@ async function runDashboard(argv) {
36357
36387
  });
36358
36388
  return;
36359
36389
  }
36360
- if (req.method === "GET" && url.startsWith("/api/client-intake")) {
36390
+ if (req.method === "GET" && url === "/api/client-intake") {
36361
36391
  (async () => {
36362
36392
  try {
36363
- const locationId2 = new URL(url, "http://x").searchParams.get("locationId") || "";
36393
+ const locationId2 = new URL(req.url || "/", "http://x").searchParams.get("locationId") || "";
36364
36394
  const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
36365
36395
  if (seat) {
36366
36396
  res.writeHead(seat.status, { "Content-Type": "application/json" });
@@ -36384,7 +36414,7 @@ async function runDashboard(argv) {
36384
36414
  res.end(JSON.stringify({ ok: true, ...snap, questions, industry, inputHash: hash, unansweredRequired: questions.filter((q2) => q2.required && !String(answers[q2.label] ?? answers[q2.key] ?? "").trim()).map((q2) => q2.label), costs: readRunCosts().filter((r) => r.locationId === locationId2 && (r.kind === "intake extraction" || r.kind === "engine-check")).slice(-10), submittedAnswers: snap.answers ?? {}, answers, savedAt: st.intakeDrafts?.[locationId2]?.savedAt, connectionPending: !c }));
36385
36415
  } catch (e) {
36386
36416
  res.writeHead(400, { "Content-Type": "application/json" });
36387
- logQuietly(`client-intake ${new URL(url, "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
36417
+ logQuietly(`client-intake ${new URL(req.url || "/", "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
36388
36418
  res.end(JSON.stringify({ ok: false, error: intakeRequestError(e) }));
36389
36419
  }
36390
36420
  })();
@@ -36508,12 +36538,12 @@ async function runDashboard(argv) {
36508
36538
  if (!rows.some((r) => r.locationId === locationId2)) rows.push({ locationId: locationId2, name: e.clientName || "(no account connected)", engagement: e });
36509
36539
  }
36510
36540
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
36511
- res.end(agencyProfilePageHtml(readAgencyProfile(), rows, isLoopback(req) ? seatToken : ""));
36541
+ res.end(agencyProfilePageHtml(readAgencyProfile(), rows, mayServeSeat(req, port) ? seatToken : ""));
36512
36542
  return;
36513
36543
  }
36514
36544
  if (req.method === "GET" && url === "/brands") {
36515
36545
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
36516
- res.end(brandsPageHtml(listBrands(), houseLooks(), listReferenceLooks(), isLoopback(req) ? seatToken : ""));
36546
+ res.end(brandsPageHtml(listBrands(), houseLooks(), listReferenceLooks(), mayServeSeat(req, port) ? seatToken : ""));
36517
36547
  return;
36518
36548
  }
36519
36549
  if (req.method === "GET" && /^\/brand\/[a-z0-9-]+\/screenshot\.png$/.test(url)) {
@@ -36534,7 +36564,7 @@ async function runDashboard(argv) {
36534
36564
  const dna = houseLooks().find((d) => d.slug === slug3) ?? readBrand(slug3);
36535
36565
  const dir = brandExists(slug3) ? brandDir(slug3) : void 0;
36536
36566
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
36537
- res.end(brandCardHtml(dna, { screenshot: dir ? fs27.existsSync(path26.join(dir, "screenshot.png")) : false, dir, seatToken: isLoopback(req) ? seatToken : "" }));
36567
+ res.end(brandCardHtml(dna, { screenshot: dir ? fs27.existsSync(path26.join(dir, "screenshot.png")) : false, dir, seatToken: mayServeSeat(req, port) ? seatToken : "" }));
36538
36568
  } catch (e) {
36539
36569
  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
36540
36570
  res.end(e.message);
@@ -36560,8 +36590,8 @@ async function runDashboard(argv) {
36560
36590
  });
36561
36591
  return;
36562
36592
  }
36563
- if (req.method === "GET" && url.startsWith("/api/design/run")) {
36564
- const id = new URL(url, "http://x").searchParams.get("id");
36593
+ if (req.method === "GET" && url === "/api/design/run") {
36594
+ const id = new URL(req.url || "/", "http://x").searchParams.get("id");
36565
36595
  const payload = id ? designRunStatus(id) : { runs: listDesignRuns() };
36566
36596
  res.writeHead(payload ? 200 : 404, { "Content-Type": "application/json", "Cache-Control": "no-store" });
36567
36597
  res.end(JSON.stringify(payload ?? { ok: false, error: "no such run" }));
@@ -36687,8 +36717,8 @@ async function runDashboard(argv) {
36687
36717
  });
36688
36718
  return;
36689
36719
  }
36690
- if (req.method === "GET" && url.startsWith("/handoff?")) {
36691
- const locationId2 = new URL(url, "http://x").searchParams.get("locationId") || "";
36720
+ if (req.method === "GET" && url === "/handoff") {
36721
+ const locationId2 = new URL(req.url || "/", "http://x").searchParams.get("locationId") || "";
36692
36722
  if (!readableAccount(locationId2)) {
36693
36723
  res.writeHead(400);
36694
36724
  res.end("Choose a registered client.");
@@ -36791,8 +36821,8 @@ async function runDashboard(argv) {
36791
36821
  });
36792
36822
  return;
36793
36823
  }
36794
- if (req.method === "GET" && url.startsWith("/blueprint")) {
36795
- const q2 = new URL(url, "http://x").searchParams;
36824
+ if (req.method === "GET" && url === "/blueprint") {
36825
+ const q2 = new URL(req.url || "/", "http://x").searchParams;
36796
36826
  const locationId2 = q2.get("locationId") || "";
36797
36827
  const st = readCockpitState();
36798
36828
  const review = st.reviews?.[locationId2];
@@ -36806,8 +36836,8 @@ async function runDashboard(argv) {
36806
36836
  res.end(reviewDocumentHtml(review, { assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: st.assessmentPurchases?.[locationId2], planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q2.get("agency") || st.agencyName, accent: q2.get("accent") || st.agencyAccent }));
36807
36837
  return;
36808
36838
  }
36809
- if (req.method === "GET" && url.startsWith("/api/review")) {
36810
- const locationId2 = new URL(url, "http://x").searchParams.get("locationId") || "";
36839
+ if (req.method === "GET" && url === "/api/review") {
36840
+ const locationId2 = new URL(req.url || "/", "http://x").searchParams.get("locationId") || "";
36811
36841
  res.writeHead(200, { "Content-Type": "application/json" });
36812
36842
  res.end(JSON.stringify(readCockpitState().reviews?.[locationId2] ?? { ok: false, error: "not generated yet" }));
36813
36843
  return;
@@ -37399,7 +37429,7 @@ var init_dashboard = __esm({
37399
37429
  anthropicKeyWrites = Promise.resolve();
37400
37430
  SEAT_TOKEN_HEADER = "x-command-os-seat";
37401
37431
  NOT_SIGNED_IN_MSG = "This browser isn't signed in to Command OS on this computer. Open Command OS on the machine it runs on, and use the page it opens for you.";
37402
- SEAT_GUARDED_READS = ["/api/capability", "/api/client-intake", "/api/intake-documents", "/api/snapshots"];
37432
+ SEAT_GUARDED_READS = ["/api/capability", "/api/client-intake", "/api/intake-documents", "/api/snapshots", "/api/agency-connection", "/api/client-preflight"];
37403
37433
  }
37404
37434
  });
37405
37435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.83.6",
3
+ "version": "3.83.7",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
5
  "description": "GoHighLevel MCP Server for Claude. 250 tools — full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",