@elitedcs/ghl-mcp 3.23.0 → 3.24.0

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
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.24.0 — Signed-attestation gate closes the credentials.json bypass
4
+
5
+ **Security fix. Tool count unchanged (212 across 43 modules).**
6
+
7
+ Through v3.23.0 the license gate trusted any `credentials.json` whose `verified_at` and `license_key` fields were non-empty strings. A technical user could hand-write the file with their own GHL API key and load all 163 core tools without paying. v3.24.0 replaces that trust with an Ed25519-signed attestation issued by `elitedcs.com/api/validate-license`. The MCP bundles only the public key, so it can verify but can't forge — hand-crafted credentials now fail on the next startup and the MCP falls into bootstrap mode.
8
+
9
+ **How it works.** Every successful online license validation now returns a `signed_attestation` token binding `{email, license_key, device_fingerprint, installs_max, expires_at}` together. The MCP stores this in `credentials.json` and verifies it on startup:
10
+
11
+ - **Valid + fresh** → boot normally.
12
+ - **Valid + nearing expiry (<7d)** → boot normally, refresh in the background.
13
+ - **Expired but within 14-day grace window** → boot normally for this session, attempt re-renew (covers transient license-server outages).
14
+ - **Past grace OR bad signature OR wrong device OR wrong email/license** → force online re-validate. If the server confirms the buyer, mint a new attestation. If not, drop into bootstrap mode and require `setup_ghl_mcp`.
15
+
16
+ **Migration is automatic.** Existing v3.20.0–v3.23.0 buyers have a `credentials.json` without `signed_attestation`. On first startup under v3.24.0 the MCP detects the missing field, calls `validate-license`, and writes the new signed token. Buyers see a one-time `[ghl-mcp] License gate: renewed-from-legacy` log line and nothing else changes. The transitional path also tolerates a license server that hasn't been upgraded yet — `setup_ghl_mcp` still works against an older server, the attestation just gets backfilled on the next restart after the server deploys.
17
+
18
+ **Funnel telemetry shipped on the server side (no MCP impact).** `validate-license`, `capture-lead`, and `stripe-webhook` now emit `[telemetry] {event, success, reason, email_hash, ...}` log lines that Cloudflare captures. Lets us measure the npm-install → setup → purchase funnel without storing PII.
19
+
20
+ **Pipeline automation shipped on the server side (no MCP impact).** A successful `validate-license` call now advances the buyer's GHL Command opportunity:
21
+
22
+ - Purchased → Onboarding on first install (installs_used 0 → 1).
23
+ - Onboarding → Active on second+ install or any reinstall.
24
+
25
+ Existing buyers backfilled manually based on their current `installs_used` count.
26
+
3
27
  ## 3.23.0 — `update_calendar` can assign team members again (Henry fix, part 2)
4
28
 
5
29
  **Critical fix. Tool count unchanged (212 across 43 modules). The `teamMembers` parameter is back on `update_calendar` — and actually persists this time — with a typed schema that surfaces GHL's strict validation up front.**
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "@elitedcs/ghl-mcp",
34
- version: "3.23.0",
34
+ version: "3.24.0",
35
35
  mcpName: "io.github.drjerryrelth/ghl-command",
36
36
  description: "GoHighLevel MCP Server for Claude. 212 tools \u2014 full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
37
37
  main: "dist/index.js",
@@ -290,7 +290,14 @@ var CredentialsSchema = import_zod.z.object({
290
290
  ghl_company_id: import_zod.z.string().optional(),
291
291
  ghl_user_id: import_zod.z.string().optional(),
292
292
  ghl_firebase_api_key: import_zod.z.string().optional(),
293
- ghl_firebase_refresh_token: import_zod.z.string().optional()
293
+ ghl_firebase_refresh_token: import_zod.z.string().optional(),
294
+ // Ed25519-signed attestation from elitedcs.com/api/validate-license.
295
+ // v3.24.0+ writes this on every successful online validation; index.ts
296
+ // verifies on startup and refuses to load tools without it (closing the
297
+ // hand-crafted-credentials.json bypass that existed through v3.23.0).
298
+ // Optional in the schema only so files written by earlier versions still
299
+ // parse — index.ts's gate forces a re-validate when the field is missing.
300
+ signed_attestation: import_zod.z.string().optional()
294
301
  });
295
302
  function appDataDir() {
296
303
  const home = os.homedir();
@@ -5843,7 +5850,11 @@ async function validateLicense(email, licenseKey) {
5843
5850
  });
5844
5851
  const data = await res.json().catch(() => ({}));
5845
5852
  if (res.ok && data.valid) {
5846
- return { ok: true, installs: `${data.installs_used}/${data.installs_max}` };
5853
+ return {
5854
+ ok: true,
5855
+ installs: `${data.installs_used}/${data.installs_max}`,
5856
+ signedAttestation: typeof data.signed_attestation === "string" ? data.signed_attestation : void 0
5857
+ };
5847
5858
  }
5848
5859
  return { ok: false, error: data.error || data.message || `License validation failed (HTTP ${res.status})` };
5849
5860
  } catch (err) {
@@ -5943,7 +5954,10 @@ Note: Firebase credentials rejected (${fb.error}). Saved without Workflow Builde
5943
5954
  ghl_company_id: args.ghl_company_id?.trim() || void 0,
5944
5955
  ghl_user_id: workflowBuilderEnabled ? args.ghl_user_id?.trim() : void 0,
5945
5956
  ghl_firebase_api_key: workflowBuilderEnabled ? args.ghl_firebase_api_key?.trim() : void 0,
5946
- ghl_firebase_refresh_token: workflowBuilderEnabled ? args.ghl_firebase_refresh_token?.trim() : void 0
5957
+ ghl_firebase_refresh_token: workflowBuilderEnabled ? args.ghl_firebase_refresh_token?.trim() : void 0,
5958
+ // v3.24.0+ attestation. Tied to email + license + device fingerprint;
5959
+ // verified on every MCP startup. Closes the hand-crafted creds bypass.
5960
+ signed_attestation: lic.signedAttestation
5947
5961
  });
5948
5962
  const toolCount = workflowBuilderEnabled ? "212" : "163";
5949
5963
  const wfLine = workflowBuilderEnabled ? "Workflow Builder: enabled." : "Workflow Builder: not configured (optional).";
@@ -8282,6 +8296,92 @@ function registerAllTools(server2, client, registry2, mcpVersion) {
8282
8296
  registerLocationSwitcherTools(server2, client, builderClient, registry2, mcpVersion);
8283
8297
  }
8284
8298
 
8299
+ // src/attestation.ts
8300
+ var import_node_crypto = require("node:crypto");
8301
+ var ATTESTATION_PUBLIC_KEY_B64 = "8KJo8V3Z7ngeToIQfOXpKdlr/EA34hXJVEIpW0A7wqs=";
8302
+ var ATTESTATION_VERSION = 1;
8303
+ var ATTESTATION_GRACE_DAYS = 14;
8304
+ function deviceFingerprint2() {
8305
+ const os3 = require("node:os");
8306
+ const crypto4 = require("node:crypto");
8307
+ const raw = `${os3.hostname()}:${os3.userInfo().username}:${os3.platform()}:${os3.arch()}`;
8308
+ return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
8309
+ }
8310
+ function base64urlDecode(s) {
8311
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
8312
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
8313
+ return new Uint8Array(Buffer.from(padded, "base64"));
8314
+ }
8315
+ var cachedPublicKey = null;
8316
+ async function getPublicKey() {
8317
+ if (cachedPublicKey) return cachedPublicKey;
8318
+ const raw = Buffer.from(ATTESTATION_PUBLIC_KEY_B64, "base64");
8319
+ cachedPublicKey = await import_node_crypto.webcrypto.subtle.importKey(
8320
+ "raw",
8321
+ raw,
8322
+ { name: "Ed25519" },
8323
+ false,
8324
+ ["verify"]
8325
+ );
8326
+ return cachedPublicKey;
8327
+ }
8328
+ async function verifyAttestation(token, bindings, now = /* @__PURE__ */ new Date()) {
8329
+ if (typeof token !== "string" || !token) return { ok: false, reason: "malformed" };
8330
+ const dot = token.indexOf(".");
8331
+ if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: "malformed" };
8332
+ let payloadBytes;
8333
+ let signatureBytes;
8334
+ try {
8335
+ payloadBytes = base64urlDecode(token.slice(0, dot));
8336
+ signatureBytes = base64urlDecode(token.slice(dot + 1));
8337
+ } catch {
8338
+ return { ok: false, reason: "malformed" };
8339
+ }
8340
+ let payload;
8341
+ try {
8342
+ const parsed = JSON.parse(new TextDecoder().decode(payloadBytes));
8343
+ if (typeof parsed.v !== "number") return { ok: false, reason: "malformed" };
8344
+ if (parsed.v !== ATTESTATION_VERSION) return { ok: false, reason: "unsupported-version" };
8345
+ payload = parsed;
8346
+ } catch {
8347
+ return { ok: false, reason: "malformed" };
8348
+ }
8349
+ let signatureValid;
8350
+ try {
8351
+ const key = await getPublicKey();
8352
+ signatureValid = await import_node_crypto.webcrypto.subtle.verify({ name: "Ed25519" }, key, signatureBytes, payloadBytes);
8353
+ } catch {
8354
+ return { ok: false, reason: "bad-signature" };
8355
+ }
8356
+ if (!signatureValid) return { ok: false, reason: "bad-signature" };
8357
+ if (payload.email.toLowerCase() !== bindings.email.toLowerCase()) {
8358
+ return { ok: false, reason: "wrong-email" };
8359
+ }
8360
+ if (payload.license_key !== bindings.license_key) {
8361
+ return { ok: false, reason: "wrong-license" };
8362
+ }
8363
+ const fingerprint = bindings.expectedFingerprint ?? deviceFingerprint2();
8364
+ if (payload.device_fingerprint !== fingerprint) {
8365
+ return { ok: false, reason: "wrong-device" };
8366
+ }
8367
+ const expiry = Date.parse(payload.expires_at);
8368
+ if (!Number.isFinite(expiry)) return { ok: false, reason: "malformed" };
8369
+ const graceDeadline = expiry + ATTESTATION_GRACE_DAYS * 24 * 60 * 60 * 1e3;
8370
+ if (now.getTime() <= expiry) {
8371
+ return { ok: true, payload, reason: "valid" };
8372
+ }
8373
+ if (now.getTime() <= graceDeadline) {
8374
+ return { ok: true, payload, reason: "in-grace" };
8375
+ }
8376
+ return { ok: false, reason: "expired" };
8377
+ }
8378
+ function shouldRenew(payload, now = /* @__PURE__ */ new Date()) {
8379
+ const expiry = Date.parse(payload.expires_at);
8380
+ if (!Number.isFinite(expiry)) return true;
8381
+ const remainingMs = expiry - now.getTime();
8382
+ return remainingMs < 7 * 24 * 60 * 60 * 1e3;
8383
+ }
8384
+
8285
8385
  // src/tools/meta.ts
8286
8386
  function registerMetaTools(server2, installedVersion) {
8287
8387
  server2.tool(
@@ -8364,8 +8464,70 @@ var server = new import_mcp.McpServer({
8364
8464
  });
8365
8465
  registerMetaTools(server, pkg.version);
8366
8466
  var inBootstrapMode = true;
8467
+ async function renewAttestation(creds) {
8468
+ if (!creds.email || !creds.license_key) return false;
8469
+ try {
8470
+ const lic = await validateLicense(creds.email, creds.license_key);
8471
+ if (!lic.ok) {
8472
+ process.stderr.write(`[ghl-mcp] Re-validate failed: ${lic.error}
8473
+ `);
8474
+ return false;
8475
+ }
8476
+ try {
8477
+ writeCredentials({
8478
+ ...creds,
8479
+ verified_at: (/* @__PURE__ */ new Date()).toISOString(),
8480
+ ...lic.signedAttestation ? { signed_attestation: lic.signedAttestation } : {}
8481
+ });
8482
+ } catch {
8483
+ }
8484
+ if (!lic.signedAttestation) {
8485
+ process.stderr.write("[ghl-mcp] License re-validated, but the license server did not return a signed attestation. Falling back to legacy trust for this session.\n");
8486
+ }
8487
+ return true;
8488
+ } catch {
8489
+ return false;
8490
+ }
8491
+ }
8492
+ function renewAttestationInBackground(creds) {
8493
+ return renewAttestation(creds).then(() => void 0, () => void 0);
8494
+ }
8367
8495
  async function resolveAccessAndRegister() {
8368
- let licenseVerified = Boolean(fileCreds?.verified_at && fileCreds?.license_key);
8496
+ let licenseVerified = false;
8497
+ let attestationStatus = "no-creds";
8498
+ if (fileCreds?.email && fileCreds?.license_key && fileCreds.signed_attestation) {
8499
+ const verify = await verifyAttestation(fileCreds.signed_attestation, {
8500
+ email: fileCreds.email,
8501
+ license_key: fileCreds.license_key
8502
+ });
8503
+ if (verify.ok) {
8504
+ licenseVerified = true;
8505
+ attestationStatus = verify.reason;
8506
+ if (verify.reason === "in-grace" || shouldRenew(verify.payload)) {
8507
+ void renewAttestationInBackground(fileCreds);
8508
+ }
8509
+ } else {
8510
+ process.stderr.write(`[ghl-mcp] Stored attestation rejected: ${verify.reason}. Re-validating online.
8511
+ `);
8512
+ const renewed = await renewAttestation(fileCreds);
8513
+ if (renewed) {
8514
+ licenseVerified = true;
8515
+ attestationStatus = "renewed-after-tamper";
8516
+ } else {
8517
+ attestationStatus = "needs-reset";
8518
+ }
8519
+ }
8520
+ } else if (fileCreds?.email && fileCreds?.license_key) {
8521
+ process.stderr.write("[ghl-mcp] Upgrading legacy credentials.json to signed attestation.\n");
8522
+ const renewed = await renewAttestation(fileCreds);
8523
+ if (renewed) {
8524
+ licenseVerified = true;
8525
+ attestationStatus = "renewed-from-legacy";
8526
+ } else {
8527
+ attestationStatus = "needs-reset";
8528
+ process.stderr.write("[ghl-mcp] Could not re-validate. Run setup_ghl_mcp again.\n");
8529
+ }
8530
+ }
8369
8531
  if (!licenseVerified && apiKey && locationId) {
8370
8532
  const licEmail = (process.env.GHL_LICENSE_EMAIL || fileCreds?.email)?.trim();
8371
8533
  const licKey = (process.env.GHL_LICENSE_KEY || fileCreds?.license_key)?.trim();
@@ -8373,6 +8535,7 @@ async function resolveAccessAndRegister() {
8373
8535
  const lic = await validateLicense(licEmail, licKey);
8374
8536
  if (lic.ok) {
8375
8537
  licenseVerified = true;
8538
+ attestationStatus = "renewed";
8376
8539
  try {
8377
8540
  writeCredentials({
8378
8541
  license_key: licKey,
@@ -8383,7 +8546,8 @@ async function resolveAccessAndRegister() {
8383
8546
  ...process.env.GHL_COMPANY_ID ? { ghl_company_id: process.env.GHL_COMPANY_ID } : {},
8384
8547
  ...process.env.GHL_USER_ID ? { ghl_user_id: process.env.GHL_USER_ID } : {},
8385
8548
  ...process.env.GHL_FIREBASE_API_KEY ? { ghl_firebase_api_key: process.env.GHL_FIREBASE_API_KEY } : {},
8386
- ...process.env.GHL_FIREBASE_REFRESH_TOKEN ? { ghl_firebase_refresh_token: process.env.GHL_FIREBASE_REFRESH_TOKEN } : {}
8549
+ ...process.env.GHL_FIREBASE_REFRESH_TOKEN ? { ghl_firebase_refresh_token: process.env.GHL_FIREBASE_REFRESH_TOKEN } : {},
8550
+ ...lic.signedAttestation ? { signed_attestation: lic.signedAttestation } : {}
8387
8551
  });
8388
8552
  process.stderr.write("[ghl-mcp] License validated and cached.\n");
8389
8553
  } catch {
@@ -8398,6 +8562,10 @@ async function resolveAccessAndRegister() {
8398
8562
  );
8399
8563
  }
8400
8564
  }
8565
+ if (licenseVerified) {
8566
+ process.stderr.write(`[ghl-mcp] License gate: ${attestationStatus}.
8567
+ `);
8568
+ }
8401
8569
  inBootstrapMode = !apiKey || !locationId || !licenseVerified;
8402
8570
  if (inBootstrapMode) {
8403
8571
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.23.0",
3
+ "version": "3.24.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
5
  "description": "GoHighLevel MCP Server for Claude. 212 tools — full CRM, automation, marketing control, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",