mcp-scraper 0.33.4 → 0.33.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/api-server.cjs +1113 -306
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +3 -3
  5. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-install.js +1 -1
  11. package/dist/bin/mcp-stdio-server.cjs +12 -6
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +5 -5
  14. package/dist/bin/paa-harvest.cjs.map +1 -1
  15. package/dist/bin/paa-harvest.js +3 -3
  16. package/dist/{chunk-ACBNIIBU.js → chunk-6ZUK4H66.js} +10 -8
  17. package/dist/chunk-6ZUK4H66.js.map +1 -0
  18. package/dist/{chunk-ERORC7AP.js → chunk-A54ELKJK.js} +2 -2
  19. package/dist/{chunk-ICT7DDHL.js → chunk-AIK7WQD5.js} +29 -15
  20. package/dist/chunk-AIK7WQD5.js.map +1 -0
  21. package/dist/{chunk-SDHPQTFM.js → chunk-C2OD4ELT.js} +2 -2
  22. package/dist/chunk-F65IIUHI.js +7 -0
  23. package/dist/chunk-F65IIUHI.js.map +1 -0
  24. package/dist/{chunk-4767BA2O.js → chunk-LKIP7VDN.js} +17 -12
  25. package/dist/chunk-LKIP7VDN.js.map +1 -0
  26. package/dist/{chunk-MTSBI7ZH.js → chunk-WJJTGRK4.js} +2 -2
  27. package/dist/{chunk-3ZZOLQ6A.js → chunk-XL6YIWJ3.js} +3 -3
  28. package/dist/{db-WRILVOFY.js → db-5SZQUI7A.js} +2 -2
  29. package/dist/{extract-bundle-NFFVMUMT.js → extract-bundle-YNXTFCC3.js} +2 -2
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +3 -3
  32. package/dist/{server-LNCSY6ME.js → server-SHOYO52X.js} +1036 -269
  33. package/dist/server-SHOYO52X.js.map +1 -0
  34. package/dist/{site-extract-repository-O36SIFI2.js → site-extract-repository-ZDTO7ODZ.js} +4 -4
  35. package/dist/{worker-JU5FX3NE.js → worker-BJ64GCDE.js} +6 -6
  36. package/docs/mcp-tool-manifest.generated.json +3 -3
  37. package/package.json +1 -1
  38. package/dist/chunk-4767BA2O.js.map +0 -1
  39. package/dist/chunk-ACBNIIBU.js.map +0 -1
  40. package/dist/chunk-ICT7DDHL.js.map +0 -1
  41. package/dist/chunk-IQ742H22.js +0 -7
  42. package/dist/chunk-IQ742H22.js.map +0 -1
  43. package/dist/server-LNCSY6ME.js.map +0 -1
  44. /package/dist/{chunk-ERORC7AP.js.map → chunk-A54ELKJK.js.map} +0 -0
  45. /package/dist/{chunk-SDHPQTFM.js.map → chunk-C2OD4ELT.js.map} +0 -0
  46. /package/dist/{chunk-MTSBI7ZH.js.map → chunk-WJJTGRK4.js.map} +0 -0
  47. /package/dist/{chunk-3ZZOLQ6A.js.map → chunk-XL6YIWJ3.js.map} +0 -0
  48. /package/dist/{db-WRILVOFY.js.map → db-5SZQUI7A.js.map} +0 -0
  49. /package/dist/{extract-bundle-NFFVMUMT.js.map → extract-bundle-YNXTFCC3.js.map} +0 -0
  50. /package/dist/{site-extract-repository-O36SIFI2.js.map → site-extract-repository-ZDTO7ODZ.js.map} +0 -0
  51. /package/dist/{worker-JU5FX3NE.js.map → worker-BJ64GCDE.js.map} +0 -0
@@ -6,7 +6,7 @@ import {
6
6
  createHarvestAttemptRecorder,
7
7
  harvestProblemResponse,
8
8
  serializeHarvestProblem
9
- } from "./chunk-SDHPQTFM.js";
9
+ } from "./chunk-C2OD4ELT.js";
10
10
  import {
11
11
  BrowserDriver,
12
12
  MapsSelectors,
@@ -15,7 +15,7 @@ import {
15
15
  harvest,
16
16
  resolveKernelProxyId,
17
17
  serpArrivalUrl
18
- } from "./chunk-ERORC7AP.js";
18
+ } from "./chunk-A54ELKJK.js";
19
19
  import {
20
20
  HttpMcpToolExecutor,
21
21
  assessTranscriptSignal,
@@ -35,7 +35,7 @@ import {
35
35
  sanitizeAttempts,
36
36
  sanitizeHarvestResult,
37
37
  transcribeMediaUrl
38
- } from "./chunk-ACBNIIBU.js";
38
+ } from "./chunk-6ZUK4H66.js";
39
39
  import {
40
40
  auditImages,
41
41
  buildLinkReport,
@@ -52,7 +52,7 @@ import {
52
52
  recordKernelSession,
53
53
  recordVendorUsage,
54
54
  runWithCostContext
55
- } from "./chunk-MTSBI7ZH.js";
55
+ } from "./chunk-WJJTGRK4.js";
56
56
  import {
57
57
  csvRecords,
58
58
  listWorkflowDefinitions,
@@ -76,7 +76,7 @@ import {
76
76
  RawMapsOverviewSchema,
77
77
  RawMapsReviewStatsSchema
78
78
  } from "./chunk-V73MPRU6.js";
79
- import "./chunk-IQ742H22.js";
79
+ import "./chunk-F65IIUHI.js";
80
80
  import {
81
81
  completeExtractJob,
82
82
  countSuccessfulPages,
@@ -87,11 +87,12 @@ import {
87
87
  saveExtractPages,
88
88
  setExtractJobTotal,
89
89
  settleExtractJob
90
- } from "./chunk-3ZZOLQ6A.js";
90
+ } from "./chunk-XL6YIWJ3.js";
91
91
  import {
92
92
  BROWSER_OPEN_MIN_BALANCE_MC,
93
93
  CONCURRENCY_PRICE_ID,
94
- CONNECTED_ACTIVE_CONNECTION_MONTHLY_USD,
94
+ CONNECTED_ACTIVE_CONNECTION_MONTHLY_CREDITS,
95
+ CONNECTED_ACTIVE_CONNECTION_MONTHLY_MC,
95
96
  CONNECTED_COMPUTE_CREDITS_PER_SECOND,
96
97
  CONNECTED_FUNCTION_RUN_CREDITS,
97
98
  CONNECTED_FUNCTION_RUN_MC,
@@ -123,9 +124,10 @@ import {
123
124
  concurrencySlotBillingInfo,
124
125
  getPublicRatesPayload,
125
126
  insufficientBalanceResponse,
127
+ mcToCredits,
126
128
  serpActualCostMc,
127
129
  vendorCostUsdToMc
128
- } from "./chunk-4767BA2O.js";
130
+ } from "./chunk-LKIP7VDN.js";
129
131
  import {
130
132
  CaptchaError,
131
133
  ExtractionError,
@@ -211,7 +213,7 @@ import {
211
213
  settleDebitMcIdempotent,
212
214
  upsertPageSnapshot,
213
215
  verifyPassword
214
- } from "./chunk-ICT7DDHL.js";
216
+ } from "./chunk-AIK7WQD5.js";
215
217
 
216
218
  // src/blog/registry.ts
217
219
  var posts = [
@@ -7369,7 +7371,7 @@ var siteExtractFn = inngest.createFunction(
7369
7371
  return buildSeoAudit(pages, job.startUrl);
7370
7372
  }) : null;
7371
7373
  const artifacts = await step.run("finalize", async () => {
7372
- const { assembleExtractArtifacts } = await import("./extract-bundle-NFFVMUMT.js");
7374
+ const { assembleExtractArtifacts } = await import("./extract-bundle-YNXTFCC3.js");
7373
7375
  const stored = await assembleExtractArtifacts(job, { branding, imageAudit, seoAudit });
7374
7376
  await completeExtractJob(jobId, stored);
7375
7377
  return stored;
@@ -13079,15 +13081,115 @@ videoApp.post("/status", createApiKeyAuth(), async (c) => {
13079
13081
  // src/api/maps-routes.ts
13080
13082
  import { Hono as Hono11 } from "hono";
13081
13083
 
13084
+ // src/extractor/GoogleBusinessPanel.ts
13085
+ function clickNamedBusinessCardInBrowser(businessName) {
13086
+ const normalize = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
13087
+ const expected = normalize(businessName);
13088
+ const headings = Array.from(document.querySelectorAll('div[role="heading"], h3')).filter((heading) => normalize(heading.textContent) === expected);
13089
+ for (const heading of headings) {
13090
+ if (heading.closest('[role="dialog"], dialog')) continue;
13091
+ const directlyClickable = heading.closest(
13092
+ 'button, a, [role="button"], [role="link"], [jsaction]'
13093
+ );
13094
+ if (directlyClickable) {
13095
+ directlyClickable.click();
13096
+ return true;
13097
+ }
13098
+ let target = heading;
13099
+ for (let hop = 0; hop < 8; hop++) {
13100
+ const parent = target.parentElement;
13101
+ if (!parent || parent.querySelectorAll('div[role="heading"], h3').length > 1) break;
13102
+ target = parent;
13103
+ }
13104
+ target.click();
13105
+ return true;
13106
+ }
13107
+ return false;
13108
+ }
13109
+ function clickBusinessPanelRowInBrowser(input) {
13110
+ const normalize = (value) => (value ?? "").replace(/\s+/g, " ").trim();
13111
+ const expectedBusiness = normalize(input.businessName).toLowerCase();
13112
+ const visible = (element) => element.getAttribute("aria-hidden") !== "true" && (element.offsetParent !== null || element.getClientRects().length > 0);
13113
+ const dialogs = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).filter((dialog) => visible(dialog) && normalize(dialog.innerText).toLowerCase().includes(expectedBusiness)).reverse();
13114
+ for (const dialog of dialogs) {
13115
+ const row = Array.from(
13116
+ dialog.querySelectorAll('button, a, [role="button"]')
13117
+ ).find((element) => visible(element) && normalize(element.innerText).startsWith(input.prefix));
13118
+ if (row) {
13119
+ row.click();
13120
+ return true;
13121
+ }
13122
+ }
13123
+ return false;
13124
+ }
13125
+ function extractOpenBusinessPanelDialogItemsInBrowser(expectedHeading) {
13126
+ const normalize = (value) => (value ?? "").trim();
13127
+ const dialogs = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).filter((dialog) => dialog.getAttribute("aria-hidden") !== "true").reverse();
13128
+ const matchingDialog = dialogs.find((dialog) => {
13129
+ const heading2 = dialog.querySelector('h1, h2, [role="heading"]');
13130
+ return normalize(heading2?.innerText || heading2?.textContent).startsWith(expectedHeading);
13131
+ });
13132
+ if (!matchingDialog) return [];
13133
+ const heading = matchingDialog.querySelector('h1, h2, [role="heading"]');
13134
+ const headingLines = new Set(
13135
+ normalize(heading?.innerText || heading?.textContent).split("\n").map((value) => value.trim()).filter(Boolean)
13136
+ );
13137
+ const seen = /* @__PURE__ */ new Set();
13138
+ const items = [];
13139
+ matchingDialog.querySelectorAll("*").forEach((element) => {
13140
+ if (heading?.contains(element)) return;
13141
+ if (element.children.length > 0) return;
13142
+ const value = normalize(element.innerText || element.textContent);
13143
+ if (!value || headingLines.has(value) || seen.has(value)) return;
13144
+ if (/^(back|close|done|thanks)$/i.test(value)) return;
13145
+ if (/^serves\s.*following/i.test(value)) return;
13146
+ seen.add(value);
13147
+ items.push(value);
13148
+ });
13149
+ return items;
13150
+ }
13151
+ function closeOpenBusinessPanelDialogInBrowser(expectedHeading) {
13152
+ const dialogs = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).filter((dialog) => dialog.getAttribute("aria-hidden") !== "true").reverse();
13153
+ const matchingDialog = dialogs.find((dialog) => {
13154
+ const heading = dialog.querySelector('h1, h2, [role="heading"]');
13155
+ return (heading?.innerText || heading?.textContent || "").trim().startsWith(expectedHeading);
13156
+ });
13157
+ const close = matchingDialog?.querySelector(
13158
+ '[aria-label*="Close" i], button[aria-label*="close" i]'
13159
+ );
13160
+ close?.click();
13161
+ return Boolean(close);
13162
+ }
13163
+ async function clickPanelRowWithRetry(page, businessName, prefix) {
13164
+ const input = { businessName, prefix };
13165
+ if (await page.evaluate(clickBusinessPanelRowInBrowser, input)) return true;
13166
+ await page.waitForTimeout(1500);
13167
+ return page.evaluate(clickBusinessPanelRowInBrowser, input);
13168
+ }
13169
+ async function extractExpandedList(page, businessName, rowPrefix, dialogHeading) {
13170
+ if (!await clickPanelRowWithRetry(page, businessName, rowPrefix)) return [];
13171
+ await page.waitForTimeout(500);
13172
+ const items = await page.evaluate(
13173
+ extractOpenBusinessPanelDialogItemsInBrowser,
13174
+ dialogHeading
13175
+ );
13176
+ await page.evaluate(closeOpenBusinessPanelDialogInBrowser, dialogHeading);
13177
+ await page.waitForTimeout(250);
13178
+ return items;
13179
+ }
13180
+ async function extractExpandedGoogleBusinessPanelServices(page, businessName) {
13181
+ const services = await extractExpandedList(page, businessName, "Services:", "Services");
13182
+ const areasServed = await extractExpandedList(page, businessName, "Serves ", "Areas Served");
13183
+ return { services, areasServed };
13184
+ }
13185
+ function completeCollapsedPanelValues(values) {
13186
+ return values.filter((value) => !/(?:\.{3}|…)\s*$/.test(value.trim()));
13187
+ }
13188
+
13082
13189
  // src/extractor/MapsSearchExtractor.ts
13083
13190
  var LOCAL_RESULTS_PAGE_LIMIT = 8;
13084
13191
  var LOCAL_RESULTS_WAIT_MS = 1e3;
13085
13192
  var MAPS_REDIRECT_BASE = "https://serp-redirector.vercel.app";
13086
- function buildGoogleLocalResultsUrl(input) {
13087
- const query = [input.query, input.location].filter(Boolean).join(" ");
13088
- const params = new URLSearchParams({ q: query, udm: "1", gl: input.gl, hl: input.hl, pws: "0" });
13089
- return `https://www.google.com/search?${params.toString()}`;
13090
- }
13091
13193
  function buildGoogleOrganicSearchUrl(input) {
13092
13194
  const query = [input.query, input.location].filter(Boolean).join(" ");
13093
13195
  const params = new URLSearchParams({ q: query, gl: input.gl, hl: input.hl, pws: "0" });
@@ -13354,6 +13456,11 @@ var MapsSearchExtractor = class {
13354
13456
  areasServed
13355
13457
  };
13356
13458
  }, includeServices);
13459
+ if (includeServices) {
13460
+ const expanded = await extractExpandedGoogleBusinessPanelServices(page, card.name);
13461
+ details.services = expanded.services.length > 0 ? expanded.services : completeCollapsedPanelValues(details.services);
13462
+ details.areasServed = expanded.areasServed.length > 0 ? expanded.areasServed : completeCollapsedPanelValues(details.areasServed);
13463
+ }
13357
13464
  await this.closeDialog(page);
13358
13465
  return details;
13359
13466
  }
@@ -13398,6 +13505,7 @@ var MapsSearchExtractor = class {
13398
13505
  };
13399
13506
 
13400
13507
  // src/extractor/MapsNavigator.ts
13508
+ var BUSINESS_PANEL_REDIRECT_BASE = "https://serp-redirector.vercel.app";
13401
13509
  var MapsNavigator = class {
13402
13510
  constructor(page) {
13403
13511
  this.page = page;
@@ -13455,26 +13563,67 @@ var MapsNavigator = class {
13455
13563
  await this.page.waitForSelector('[role="main"] h2', { timeout: 5e3 }).catch(() => {
13456
13564
  });
13457
13565
  }
13458
- async navigateToSerpBusinessPanel(businessName, location2, gl, hl) {
13459
- const searchUrl = buildGoogleLocalResultsUrl({ query: businessName, location: location2, gl, hl });
13460
- await this.page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 45e3 }).catch(() => {
13566
+ async navigateToSerpBusinessPanel(businessName, location2, gl, hl, category) {
13567
+ const searchUrl = buildGoogleOrganicSearchUrl({
13568
+ query: category?.trim() || businessName,
13569
+ location: location2,
13570
+ gl,
13571
+ hl
13461
13572
  });
13462
- await this.page.waitForTimeout(1e3);
13463
- const clicked = await this.page.evaluate((name) => {
13464
- const normalized = name.replace(/\s+/g, " ").trim().toLowerCase();
13465
- const card = Array.from(document.querySelectorAll("button")).find(
13466
- (button) => button.querySelector("h3")?.textContent?.replace(/\s+/g, " ").trim().toLowerCase() === normalized
13467
- );
13468
- if (card) {
13469
- card.click();
13470
- return true;
13471
- }
13472
- return false;
13473
- }, businessName);
13474
- if (!clicked) return false;
13475
- await this.page.waitForSelector('dialog, [role="dialog"]', { timeout: 5e3 }).catch(() => {
13573
+ const arrivalUrl = serpArrivalUrl(
13574
+ searchUrl,
13575
+ process.env.SERP_REDIRECT_BASE?.trim() || BUSINESS_PANEL_REDIRECT_BASE
13576
+ );
13577
+ await this.page.goto(arrivalUrl, { waitUntil: "domcontentloaded", timeout: 45e3 }).catch(() => {
13578
+ });
13579
+ await this.page.waitForTimeout(2e3);
13580
+ if (await this.isGoogleChallenge()) return false;
13581
+ await this.waitForNamedBusinessCard(businessName, 5e3);
13582
+ if (await this.page.evaluate(clickNamedBusinessCardInBrowser, businessName)) {
13583
+ return this.waitForBusinessDialog(businessName);
13584
+ }
13585
+ const moreBusinesses = this.page.getByRole("link", { name: /more (businesses|places|results)/i }).or(this.page.getByRole("button", { name: /more (businesses|places|results)/i })).first();
13586
+ if (!await moreBusinesses.isVisible({ timeout: 5e3 }).catch(() => false)) return false;
13587
+ await moreBusinesses.click({ timeout: 1e4 }).catch(() => {
13588
+ });
13589
+ await this.page.waitForTimeout(3e3);
13590
+ if (await this.isGoogleChallenge()) return false;
13591
+ await this.waitForNamedBusinessCard(businessName, 3e3);
13592
+ if (!await this.page.evaluate(clickNamedBusinessCardInBrowser, businessName)) {
13593
+ const searchBox = this.page.locator('textarea[name="q"]:visible, input[name="q"]:visible').first();
13594
+ if (!await searchBox.isVisible({ timeout: 5e3 }).catch(() => false)) return false;
13595
+ await searchBox.fill(`${businessName} ${location2}`);
13596
+ await searchBox.press("Enter");
13597
+ await this.page.waitForTimeout(3e3);
13598
+ if (await this.isGoogleChallenge()) return false;
13599
+ await this.waitForNamedBusinessCard(businessName, 1e4);
13600
+ if (!await this.page.evaluate(clickNamedBusinessCardInBrowser, businessName)) return false;
13601
+ }
13602
+ return this.waitForBusinessDialog(businessName);
13603
+ }
13604
+ async isGoogleChallenge() {
13605
+ return this.page.evaluate(() => {
13606
+ const text2 = (document.body?.innerText ?? "").slice(0, 2e3);
13607
+ return /unusual traffic|captcha|recaptcha|about this page/i.test(text2) || /\/sorry\//.test(location.href);
13608
+ }).catch(() => true);
13609
+ }
13610
+ async waitForNamedBusinessCard(businessName, timeout) {
13611
+ if (typeof this.page.waitForFunction !== "function") return;
13612
+ await this.page.waitForFunction(
13613
+ (expectedName) => {
13614
+ const normalize = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
13615
+ const expected = normalize(expectedName);
13616
+ return Array.from(document.querySelectorAll('div[role="heading"], h3')).some((element) => normalize(element.textContent ?? "") === expected);
13617
+ },
13618
+ businessName,
13619
+ { timeout }
13620
+ ).catch(() => void 0);
13621
+ }
13622
+ async waitForBusinessDialog(businessName) {
13623
+ const dialog = this.page.locator('dialog:visible, [role="dialog"]:visible').filter({ hasText: businessName }).first();
13624
+ await dialog.waitFor({ state: "visible", timeout: 1e4 }).catch(() => {
13476
13625
  });
13477
- return this.page.locator('dialog, [role="dialog"]').count().then((count) => count > 0);
13626
+ return dialog.isVisible().catch(() => false);
13478
13627
  }
13479
13628
  buildReviewsUrl(placeUrl) {
13480
13629
  const fid = placeUrl.match(/!1s(0x[0-9a-f]+:0x[0-9a-f]+)/i);
@@ -13519,6 +13668,71 @@ var MapsNavigator = class {
13519
13668
  }
13520
13669
  };
13521
13670
 
13671
+ // src/extractor/GoogleBusinessPanelRotation.ts
13672
+ async function extractGoogleBusinessPanelServicesWithRotation(options) {
13673
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 5);
13674
+ for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) {
13675
+ const driver = new BrowserDriver();
13676
+ const attemptNumber = attemptIndex + 1;
13677
+ const useDefaultProxy = attemptIndex < 2;
13678
+ const useHeadful = attemptIndex === maxAttempts - 1;
13679
+ try {
13680
+ await driver.launch({
13681
+ headless: useHeadful ? false : options.headless,
13682
+ headlessMode: useHeadful ? "headful" : options.headless ? "headless" : "headful",
13683
+ kernelApiKey: options.kernelApiKey,
13684
+ keepDefaultProxy: useDefaultProxy,
13685
+ proxyMode: "none",
13686
+ viewport: { width: 1280, height: 900 },
13687
+ locale: `${options.hl}-${options.gl.toUpperCase()}`
13688
+ });
13689
+ const page = driver.getPage();
13690
+ const opened = await new MapsNavigator(page).navigateToSerpBusinessPanel(
13691
+ options.businessName,
13692
+ options.location,
13693
+ options.gl,
13694
+ options.hl,
13695
+ options.category
13696
+ );
13697
+ if (!opened) {
13698
+ console.warn(JSON.stringify({
13699
+ event: "maps_place_services_panel_not_opened",
13700
+ attempt_number: attemptNumber,
13701
+ max_attempts: maxAttempts,
13702
+ keep_default_proxy: useDefaultProxy,
13703
+ headless_mode: useHeadful ? "headful" : options.headless ? "headless" : "headful",
13704
+ final_url: page.url()
13705
+ }));
13706
+ continue;
13707
+ }
13708
+ const result = await extractExpandedGoogleBusinessPanelServices(
13709
+ page,
13710
+ options.businessName
13711
+ );
13712
+ return {
13713
+ ...result,
13714
+ opened: true,
13715
+ attempts: attemptNumber
13716
+ };
13717
+ } catch (error) {
13718
+ console.warn(JSON.stringify({
13719
+ event: "maps_place_services_attempt_failed",
13720
+ attempt_number: attemptNumber,
13721
+ max_attempts: maxAttempts,
13722
+ message: error instanceof Error ? error.message : String(error)
13723
+ }));
13724
+ } finally {
13725
+ await driver.close().catch(() => void 0);
13726
+ }
13727
+ }
13728
+ return {
13729
+ services: [],
13730
+ areasServed: [],
13731
+ opened: false,
13732
+ attempts: maxAttempts
13733
+ };
13734
+ }
13735
+
13522
13736
  // src/extractor/MapsReviewCollector.ts
13523
13737
  var REVIEW_SCROLL_BUDGET_MS = 45e3;
13524
13738
  var REVIEW_SCROLL_STEP_MS = 1200;
@@ -13650,14 +13864,33 @@ var MapsExtractor = class {
13650
13864
  let servicesStatus = "not_requested";
13651
13865
  if (options.includeServices) {
13652
13866
  try {
13653
- const opened = await navigator.navigateToSerpBusinessPanel(options.businessName, options.location, options.gl, options.hl);
13867
+ let opened = await navigator.navigateToSerpBusinessPanel(
13868
+ options.businessName,
13869
+ options.location,
13870
+ options.gl,
13871
+ options.hl,
13872
+ overview.category ?? void 0
13873
+ );
13654
13874
  if (opened) {
13655
- const result = await this.extractServicesAndAreasServed(page);
13875
+ const result = await extractExpandedGoogleBusinessPanelServices(page, options.businessName);
13656
13876
  services = result.services;
13657
13877
  areasServed = result.areasServed;
13658
13878
  servicesStatus = services.length > 0 || areasServed.length > 0 ? "collected" : "none_exist";
13659
13879
  } else {
13660
- servicesStatus = "unavailable";
13880
+ await this.driver.close().catch(() => void 0);
13881
+ const result = await extractGoogleBusinessPanelServicesWithRotation({
13882
+ businessName: options.businessName,
13883
+ location: options.location,
13884
+ category: overview.category ?? void 0,
13885
+ gl: options.gl,
13886
+ hl: options.hl,
13887
+ headless: options.headless,
13888
+ kernelApiKey: options.kernelApiKey
13889
+ });
13890
+ opened = result.opened;
13891
+ services = result.services;
13892
+ areasServed = result.areasServed;
13893
+ servicesStatus = opened ? services.length > 0 || areasServed.length > 0 ? "collected" : "none_exist" : "unavailable";
13661
13894
  }
13662
13895
  } catch (err) {
13663
13896
  console.warn("[MapsExtractor] services/areas-served extraction failed:", err instanceof Error ? err.message : String(err));
@@ -13870,18 +14103,6 @@ var MapsExtractor = class {
13870
14103
  }
13871
14104
  return result.data;
13872
14105
  }
13873
- async extractServicesAndAreasServed(page) {
13874
- return page.evaluate(() => {
13875
- const visible = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).find((dialog) => dialog.offsetParent !== null);
13876
- if (!visible) return { services: [], areasServed: [] };
13877
- const values = Array.from(visible.querySelectorAll("button")).map((button) => button.innerText.replace(/\s+/g, " ").trim()).filter(Boolean);
13878
- const servicesText = values.find((value) => /^services\s*:/i.test(value));
13879
- const services = servicesText ? [...new Set(servicesText.replace(/^services\s*:\s*/i, "").split(/\s*[,·]\s*/).map((value) => value.trim()).filter(Boolean))] : [];
13880
- const served = values.find((value) => /^serves\b/i.test(value));
13881
- const areasServed = served ? [served.replace(/^serves\s*/i, "").trim()].filter(Boolean) : [];
13882
- return { services, areasServed };
13883
- });
13884
- }
13885
14106
  };
13886
14107
 
13887
14108
  // src/maps-search-rotation.ts
@@ -19574,13 +19795,11 @@ if (state.key) { refreshSessions(); if (state.current) selectSession(state.curre
19574
19795
  }
19575
19796
 
19576
19797
  // src/api/stripe-routes.ts
19577
- import Stripe2 from "stripe";
19798
+ import Stripe from "stripe";
19578
19799
  import { Hono as Hono19 } from "hono";
19579
19800
 
19580
19801
  // src/api/connected-account-billing.ts
19581
- import Stripe from "stripe";
19582
19802
  var DEFAULT_CONNECTED_ACCOUNT_PRICE_ID = "price_1TtYPAS8aAcsk3TGHOgrDZiQ";
19583
- var STRIPE_API_VERSION = "2026-02-25.clover";
19584
19803
  async function ensureConnectedAccountBillingSchema() {
19585
19804
  await getDb().execute(`CREATE TABLE IF NOT EXISTS connected_account_billing (
19586
19805
  user_id INTEGER PRIMARY KEY,
@@ -19634,14 +19853,16 @@ async function setConnectedAccountBillingState(input) {
19634
19853
  return state;
19635
19854
  }
19636
19855
  var ConnectedAccountBillingError = class extends Error {
19637
- constructor(message, code, status = 409) {
19856
+ constructor(message, code, status = 409, details) {
19638
19857
  super(message);
19639
19858
  this.code = code;
19640
19859
  this.status = status;
19860
+ this.details = details;
19641
19861
  this.name = "ConnectedAccountBillingError";
19642
19862
  }
19643
19863
  code;
19644
19864
  status;
19865
+ details;
19645
19866
  };
19646
19867
  function connectedAccountPriceId() {
19647
19868
  const configured = process.env.CONNECTED_ACCOUNT_PRICE_ID?.trim();
@@ -19652,12 +19873,12 @@ function connectedAccountBillingView(actualQuantity, state) {
19652
19873
  const quantity = Math.max(0, Math.round(actualQuantity));
19653
19874
  return {
19654
19875
  provider: "nango",
19655
- billingMode: "flat_recurring_usd_plus_credits",
19876
+ billingMode: "flat_recurring_credits",
19656
19877
  priceId: connectedAccountPriceId(),
19657
- unitAmountUsd: CONNECTED_ACTIVE_CONNECTION_MONTHLY_USD,
19878
+ unitAmountCredits: CONNECTED_ACTIVE_CONNECTION_MONTHLY_CREDITS,
19658
19879
  interval: "month",
19659
19880
  quantity,
19660
- projectedMonthlyUsd: quantity * CONNECTED_ACTIVE_CONNECTION_MONTHLY_USD,
19881
+ projectedMonthlyCredits: quantity * CONNECTED_ACTIVE_CONNECTION_MONTHLY_CREDITS,
19661
19882
  status: state?.status ?? "pending",
19662
19883
  lastErrorCode: state?.last_error_code ?? null,
19663
19884
  syncedAt: state?.synced_at ?? null,
@@ -19667,29 +19888,23 @@ function connectedAccountBillingView(actualQuantity, state) {
19667
19888
  function findBasePlanItem(subscription) {
19668
19889
  return subscription.items.data.find((item) => !!item.price?.id && item.price.id in SUBSCRIPTION_TIERS);
19669
19890
  }
19670
- function findConnectedAccountItem(subscription, priceId = connectedAccountPriceId()) {
19671
- return priceId ? subscription.items.data.find((item) => item.price?.id === priceId) : void 0;
19672
- }
19673
- function liveSubscription(subscription) {
19674
- return subscription.status === "active" || subscription.status === "trialing";
19675
- }
19676
- function safeStripeErrorCode(error) {
19891
+ function safeErrorCode(error) {
19677
19892
  if (error instanceof ConnectedAccountBillingError) return error.code;
19678
19893
  return "stripe_connection_billing_failed";
19679
19894
  }
19680
- async function persistFailure(user, actualQuantity, error, subscriptionItemId = null) {
19895
+ async function persistFailure(user, quantityToPersist, error) {
19681
19896
  await setConnectedAccountBillingState({
19682
19897
  userId: user.id,
19683
19898
  stripeSubscriptionId: user.subscription_id,
19684
- stripeSubscriptionItemId: subscriptionItemId,
19899
+ stripeSubscriptionItemId: null,
19685
19900
  priceId: connectedAccountPriceId(),
19686
- quantity: actualQuantity,
19901
+ quantity: quantityToPersist,
19687
19902
  status: error instanceof ConnectedAccountBillingError && error.code !== "stripe_connection_billing_failed" ? "blocked" : "error",
19688
- lastErrorCode: safeStripeErrorCode(error),
19903
+ lastErrorCode: safeErrorCode(error),
19689
19904
  synced: false
19690
19905
  });
19691
19906
  }
19692
- async function reconcileConnectedAccountBilling(user, actualQuantity, stripeClient) {
19907
+ async function reconcileConnectedAccountBilling(user, actualQuantity) {
19693
19908
  if (!Number.isSafeInteger(actualQuantity) || actualQuantity < 0) {
19694
19909
  throw new Error("actualQuantity must be a non-negative safe integer");
19695
19910
  }
@@ -19703,7 +19918,7 @@ async function reconcileConnectedAccountBilling(user, actualQuantity, stripeClie
19703
19918
  await persistFailure(user, actualQuantity, error);
19704
19919
  throw error;
19705
19920
  }
19706
- if (!user.subscription_id) {
19921
+ if (!user.subscription_id || !(user.subscription_tier ?? "") || !Object.values(SUBSCRIPTION_TIERS).some((t) => t.tier === user.subscription_tier)) {
19707
19922
  if (actualQuantity === 0) {
19708
19923
  const state = await setConnectedAccountBillingState({
19709
19924
  userId: user.id,
@@ -19725,48 +19940,38 @@ async function reconcileConnectedAccountBilling(user, actualQuantity, stripeClie
19725
19940
  await persistFailure(user, actualQuantity, error);
19726
19941
  throw error;
19727
19942
  }
19728
- const client2 = stripeClient ?? (() => {
19729
- const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
19730
- if (!secret2) {
19731
- throw new ConnectedAccountBillingError(
19732
- "Stripe is not configured.",
19733
- "connected_account_price_not_configured",
19734
- 503
19735
- );
19736
- }
19737
- return new Stripe(secret2, { apiVersion: STRIPE_API_VERSION });
19738
- })();
19739
- let currentItem;
19943
+ const existingState = await getConnectedAccountBillingState(user.id);
19944
+ const currentQuantity = Math.max(0, existingState?.quantity ?? 0);
19740
19945
  try {
19741
- const subscription = await client2.subscriptions.retrieve(user.subscription_id);
19742
- currentItem = findConnectedAccountItem(subscription, priceId);
19743
- if (!liveSubscription(subscription)) {
19744
- throw new ConnectedAccountBillingError(
19745
- "The plan subscription is not active.",
19746
- "subscription_not_active",
19747
- 402
19748
- );
19749
- }
19750
- if (!findBasePlanItem(subscription)) {
19751
- throw new ConnectedAccountBillingError(
19752
- "The base plan item could not be found on this subscription.",
19753
- "subscription_plan_item_missing",
19754
- 409
19946
+ if (actualQuantity > currentQuantity) {
19947
+ const newConnections = actualQuantity - currentQuantity;
19948
+ const feeMc = newConnections * CONNECTED_ACTIVE_CONNECTION_MONTHLY_MC;
19949
+ const debit = await debitMc(
19950
+ user.id,
19951
+ feeMc,
19952
+ LedgerOperation.CONNECTED_ACCOUNT_FEE,
19953
+ `${newConnections} connected account${newConnections === 1 ? "" : "s"} at ${CONNECTED_ACTIVE_CONNECTION_MONTHLY_CREDITS} credits/month`
19755
19954
  );
19756
- }
19757
- const currentQuantity = Math.max(0, currentItem?.quantity ?? 0);
19758
- let resultingItem = currentItem;
19759
- if (actualQuantity !== currentQuantity) {
19760
- const updated = await client2.subscriptions.update(subscription.id, {
19761
- items: actualQuantity === 0 ? currentItem ? [{ id: currentItem.id, deleted: true }] : [] : currentItem ? [{ id: currentItem.id, quantity: actualQuantity }] : [{ price: priceId, quantity: actualQuantity }],
19762
- proration_behavior: "create_prorations"
19763
- });
19764
- resultingItem = findConnectedAccountItem(updated, priceId);
19955
+ if (!debit.ok) {
19956
+ const requiredMc = feeMc;
19957
+ const error = new ConnectedAccountBillingError(
19958
+ `Insufficient credits to connect. Balance: ${mcToCredits(debit.balance_mc)} credits. Connecting requires ${mcToCredits(requiredMc)} credits (${mcToCredits(requiredMc - debit.balance_mc)} short). Upgrade your plan or wait for your credits to refresh.`,
19959
+ "insufficient_credits",
19960
+ 402,
19961
+ {
19962
+ balanceCredits: mcToCredits(debit.balance_mc),
19963
+ requiredCredits: mcToCredits(requiredMc),
19964
+ shortByCredits: mcToCredits(Math.max(0, requiredMc - debit.balance_mc))
19965
+ }
19966
+ );
19967
+ await persistFailure(user, currentQuantity, error);
19968
+ throw error;
19969
+ }
19765
19970
  }
19766
19971
  const state = await setConnectedAccountBillingState({
19767
19972
  userId: user.id,
19768
- stripeSubscriptionId: subscription.id,
19769
- stripeSubscriptionItemId: resultingItem?.id ?? null,
19973
+ stripeSubscriptionId: user.subscription_id,
19974
+ stripeSubscriptionItemId: null,
19770
19975
  priceId,
19771
19976
  quantity: actualQuantity,
19772
19977
  status: "synced",
@@ -19775,10 +19980,10 @@ async function reconcileConnectedAccountBilling(user, actualQuantity, stripeClie
19775
19980
  });
19776
19981
  return connectedAccountBillingView(actualQuantity, state);
19777
19982
  } catch (error) {
19778
- await persistFailure(user, actualQuantity, error, currentItem?.id ?? null);
19779
19983
  if (error instanceof ConnectedAccountBillingError) throw error;
19984
+ await persistFailure(user, currentQuantity, error);
19780
19985
  throw new ConnectedAccountBillingError(
19781
- "Unable to update connected-account billing in Stripe.",
19986
+ "Unable to update connected-account billing.",
19782
19987
  "stripe_connection_billing_failed",
19783
19988
  503
19784
19989
  );
@@ -19789,7 +19994,7 @@ async function currentConnectedAccountBillingView(userId, actualQuantity) {
19789
19994
  }
19790
19995
 
19791
19996
  // src/api/stripe-routes.ts
19792
- var stripe = new Stripe2(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-02-25.clover" });
19997
+ var stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-02-25.clover" });
19793
19998
  var stripeApp = new Hono19();
19794
19999
  stripeApp.post("/webhooks", async (c) => {
19795
20000
  const sig = c.req.header("stripe-signature");
@@ -19818,17 +20023,41 @@ stripeApp.post("/webhooks", async (c) => {
19818
20023
  if (tier) {
19819
20024
  const user = await resolveUser2(invoice.customer, invoice.customer_email ?? void 0);
19820
20025
  if (user && invoice.id && !await ledgerExistsForStripePI(invoice.id)) {
19821
- await creditMc(user.id, tier.credits_mc, LedgerOperation.SUBSCRIPTION, `${tier.label} subscription credits`, invoice.id);
20026
+ await creditMc(user.id, tier.credits_mc, LedgerOperation.SUBSCRIPTION, `${tier.label} subscription credits`, invoice.id, { neverExpires: tier.credits_never_expire });
19822
20027
  await setSubscriptionTier(user.id, tier.tier, tier.concurrency, subId ?? user.subscription_id);
20028
+ if (tier.includes_memory) {
20029
+ const subscriptionId = subId ?? user.subscription_id;
20030
+ await setMemoryPlan(user.id, "pro", subscriptionId ?? null);
20031
+ await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "pro" }));
20032
+ }
19823
20033
  const credentials = await syncScheduledActionCredentials(user);
19824
20034
  if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
20035
+ const connectionState = await getConnectedAccountBillingState(user.id);
20036
+ const activeConnections = Math.max(0, connectionState?.quantity ?? 0);
20037
+ if (activeConnections > 0) {
20038
+ const feeMc = activeConnections * CONNECTED_ACTIVE_CONNECTION_MONTHLY_MC;
20039
+ const feeDescription = `${activeConnections} connected account${activeConnections === 1 ? "" : "s"} renewal at ${CONNECTED_ACTIVE_CONNECTION_MONTHLY_CREDITS} credits/month`;
20040
+ const feeDebit = await debitMcIdempotent(user.id, feeMc, LedgerOperation.CONNECTED_ACCOUNT_FEE, feeDescription, `connection-fee:${invoice.id}`);
20041
+ if (!feeDebit.ok) {
20042
+ await setConnectedAccountBillingState({
20043
+ userId: user.id,
20044
+ stripeSubscriptionId: user.subscription_id,
20045
+ stripeSubscriptionItemId: null,
20046
+ priceId: connectionState?.price_id ?? null,
20047
+ quantity: activeConnections,
20048
+ status: "blocked",
20049
+ lastErrorCode: "insufficient_credits",
20050
+ synced: false
20051
+ });
20052
+ }
20053
+ }
19825
20054
  }
19826
20055
  }
19827
20056
  }
19828
20057
  }
19829
20058
  if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
19830
20059
  const sub = event.data.object;
19831
- const priceId = sub.items.data[0]?.price?.id;
20060
+ const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
19832
20061
  if (priceId && priceId in MEMORY_PLANS) {
19833
20062
  const mem = MEMORY_PLANS[priceId];
19834
20063
  const user2 = await resolveUser2(sub.customer, sub.customer_email);
@@ -19847,6 +20076,14 @@ stripeApp.post("/webhooks", async (c) => {
19847
20076
  const live = sub.status === "active" || sub.status === "trialing";
19848
20077
  const newTier = live ? tier.tier : null;
19849
20078
  await setSubscriptionTier(user.id, newTier, live ? tier.concurrency : 0, live ? sub.id : null);
20079
+ if (tier.includes_memory) {
20080
+ const newMemoryPlan = live ? "pro" : "free";
20081
+ await setMemoryPlan(user.id, newMemoryPlan, live ? sub.id : null);
20082
+ await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: newMemoryPlan }));
20083
+ } else if (user.memory_subscription_id === sub.id) {
20084
+ await setMemoryPlan(user.id, "free", null);
20085
+ await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "free" }));
20086
+ }
19850
20087
  if (live) {
19851
20088
  const credentials = await syncScheduledActionCredentials(user);
19852
20089
  if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
@@ -19858,7 +20095,19 @@ stripeApp.post("/webhooks", async (c) => {
19858
20095
  }
19859
20096
  if (event.type === "customer.subscription.deleted") {
19860
20097
  const sub = event.data.object;
19861
- const priceId = sub.items.data[0]?.price?.id;
20098
+ const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
20099
+ if (priceId && priceId in SUBSCRIPTION_TIERS) {
20100
+ const tier = SUBSCRIPTION_TIERS[priceId];
20101
+ const user2 = await getUserByStripeCustomerId(sub.customer);
20102
+ if (user2) {
20103
+ await setSubscriptionTier(user2.id, null, 0, null);
20104
+ if (tier.includes_memory || user2.memory_subscription_id === sub.id) {
20105
+ await setMemoryPlan(user2.id, "free", null);
20106
+ await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
20107
+ }
20108
+ }
20109
+ return c.json({ received: true });
20110
+ }
19862
20111
  if (priceId && priceId in MEMORY_PLANS || await resolveUserByMemorySubscription(sub.id)) {
19863
20112
  const user2 = await resolveUserByMemorySubscription(sub.id) ?? await getUserByStripeCustomerId(sub.customer);
19864
20113
  if (user2) {
@@ -19869,9 +20118,7 @@ stripeApp.post("/webhooks", async (c) => {
19869
20118
  }
19870
20119
  const user = await getUserByStripeCustomerId(sub.customer);
19871
20120
  if (!user) return c.json({ received: true });
19872
- if (priceId && priceId in SUBSCRIPTION_TIERS) {
19873
- await setSubscriptionTier(user.id, null, 0, null);
19874
- } else if (priceId === CONCURRENCY_PRICE_ID) {
20121
+ if (priceId === CONCURRENCY_PRICE_ID) {
19875
20122
  await setExtraConcurrencySlots(user.id, Math.max(0, user.extra_concurrency_slots - 1));
19876
20123
  await setConcurrencySubId(user.id, null);
19877
20124
  }
@@ -19964,9 +20211,9 @@ function negotiateScope(requested, user, resource) {
19964
20211
  const filtered = wanted.filter((s) => MEMORY_SCOPES.includes(s));
19965
20212
  const base = filtered.length > 0 ? filtered : ["memory.read", "memory.write"];
19966
20213
  const allowed = base.filter((s) => s === "memory.admin" ? operator : true);
19967
- const unique = [...new Set(allowed)];
19968
- if (operator && !unique.includes("memory.admin")) unique.push("memory.admin");
19969
- return unique.join(" ");
20214
+ const unique2 = [...new Set(allowed)];
20215
+ if (operator && !unique2.includes("memory.admin")) unique2.push("memory.admin");
20216
+ return unique2.join(" ");
19970
20217
  }
19971
20218
  async function sessionUser(token) {
19972
20219
  if (!token) return null;
@@ -21688,50 +21935,262 @@ loadActions();
21688
21935
  // src/api/resend-inbound-routes.ts
21689
21936
  import { Hono as Hono24 } from "hono";
21690
21937
  import { Resend } from "resend";
21691
- var SUPPORT_ADDRESS = "support@mcpscraper.dev";
21692
- var DMARC_ADDRESS = "dmarc@mcpscraper.dev";
21693
- var FORWARD_FROM = "MCP Scraper Support <support@updates.mcpscraper.dev>";
21694
- function classifyInboundRecipients(recipients, ...receivingDomains) {
21695
- const normalized = recipients.map((address) => address.trim().toLowerCase());
21696
- const domains = receivingDomains.map((domain) => domain?.trim().toLowerCase()).filter((domain) => Boolean(domain));
21697
- const supportAddresses = [SUPPORT_ADDRESS, ...domains.map((domain) => `support@${domain}`)];
21698
- const dmarcAddresses = [DMARC_ADDRESS, ...domains.map((domain) => `dmarc@${domain}`)];
21699
- if (supportAddresses.some((address) => normalized.includes(address))) return "support";
21700
- if (dmarcAddresses.some((address) => normalized.includes(address))) return "dmarc";
21701
- return "ignore";
21702
- }
21703
- function cleanLine(value, fallback) {
21704
- const clean2 = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
21705
- return (clean2 || fallback).slice(0, 240);
21938
+
21939
+ // src/api/resend-support-thread.ts
21940
+ import { createHash as createHash7 } from "crypto";
21941
+ var SUPPORT_TAGS = [
21942
+ {
21943
+ tag: "support",
21944
+ central: true,
21945
+ reusable: true,
21946
+ description: "Customer support work, context, and follow-up."
21947
+ },
21948
+ {
21949
+ tag: "customer-support",
21950
+ central: true,
21951
+ reusable: true,
21952
+ description: "Inbound and in-product MCP Scraper customer support requests."
21953
+ },
21954
+ {
21955
+ tag: "inbound-email",
21956
+ central: true,
21957
+ reusable: true,
21958
+ description: "Messages received through an inbound email integration."
21959
+ }
21960
+ ];
21961
+ var ISSUE_TAGS = [
21962
+ ...SUPPORT_TAGS,
21963
+ {
21964
+ tag: "product-feedback",
21965
+ central: true,
21966
+ reusable: true,
21967
+ description: "Customer feedback and reported product problems."
21968
+ }
21969
+ ];
21970
+ function cleanLine(value, fallback, max = 240) {
21971
+ const clean2 = String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
21972
+ return (clean2 || fallback).slice(0, max);
21706
21973
  }
21707
21974
  function quoteUntrusted(value) {
21708
- const clean2 = value.replace(/\0/g, "").trim().slice(0, 16e3);
21975
+ const clean2 = String(value ?? "").replace(/\0/g, "").trim().slice(0, 16e3);
21709
21976
  if (!clean2) return "> _(No plain-text body was available. Review the forwarded email.)_";
21710
21977
  return clean2.split("\n").map((line) => `> ${line}`).join("\n");
21711
21978
  }
21712
- function escapeHtml2(value) {
21713
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
21979
+ function shortHash(value, length = 24) {
21980
+ return createHash7("sha256").update(value).digest("hex").slice(0, length);
21714
21981
  }
21715
- function safeEmailId(value) {
21716
- return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 100) || "unknown-email";
21982
+ function safePathPart(value) {
21983
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "contact";
21717
21984
  }
21718
- function buildSupportIssueNote(email) {
21719
- const sender = cleanLine(email.from, "unknown sender");
21720
- const subject = cleanLine(email.subject, "(no subject)");
21721
- const receivedAt = cleanLine(email.created_at, (/* @__PURE__ */ new Date()).toISOString());
21722
- const attachments = email.attachments?.length ? email.attachments.map((item) => cleanLine(item.filename ?? "unnamed attachment", "unnamed attachment")).join(", ") : "None";
21723
- const datePrefix = receivedAt.slice(0, 10).replace(/[^0-9-]/g, "") || (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
21724
- const path5 = `support/inbound/${datePrefix}-${safeEmailId(email.id)}`;
21725
- const title = `Support email \u2014 ${subject} \u2014 ${sender}`.slice(0, 240);
21726
- const summary = `Inbound MCP Scraper support email from ${sender} about \u201C${subject}\u201D; awaiting review and response.`.slice(0, 500);
21985
+ function parseMailbox(value) {
21986
+ const raw = cleanLine(value, "", 500);
21987
+ if (!raw) return null;
21988
+ const angle = raw.match(/^(.*?)<([^<>]+)>$/);
21989
+ const candidate = (angle?.[2] ?? raw).trim().toLowerCase();
21990
+ const emailMatch = candidate.match(/[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?/i);
21991
+ if (!emailMatch) return null;
21992
+ const email = emailMatch[0].toLowerCase();
21993
+ const suppliedName = angle?.[1]?.trim().replace(/^["']|["']$/g, "");
21994
+ return { email, name: cleanLine(suppliedName, email) };
21995
+ }
21996
+ function supportSender(email) {
21997
+ return parseMailbox(email.reply_to?.[0]) ?? parseMailbox(email.from);
21998
+ }
21999
+ function normalizeSupportSubject(value) {
22000
+ let subject = cleanLine(value, "(no subject)", 240);
22001
+ for (let i = 0; i < 8; i += 1) {
22002
+ const next = subject.replace(/^\[(?:support|external)\]\s*/i, "").replace(/^(?:re|fw|fwd)\s*:\s*/i, "").trim();
22003
+ if (next === subject) break;
22004
+ subject = next;
22005
+ }
22006
+ return subject || "(no subject)";
22007
+ }
22008
+ function supportThreadToken(customerEmail, normalizedSubject) {
22009
+ return shortHash(`${customerEmail.trim().toLowerCase()}
22010
+ ${normalizeSupportSubject(normalizedSubject).toLowerCase()}`);
22011
+ }
22012
+ function supportThreadAddress(token, receivingDomain) {
22013
+ const safeToken = token.toLowerCase().replace(/[^a-f0-9]/g, "").slice(0, 24);
22014
+ const domain = receivingDomain.trim().toLowerCase();
22015
+ if (!safeToken || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) throw new Error("invalid support thread address");
22016
+ return `support+${safeToken}@${domain}`;
22017
+ }
22018
+ function isAutomatedMailbox(email) {
22019
+ const local = email.toLowerCase().split("@", 1)[0];
22020
+ return /^(?:no-?reply|do-?not-?reply|mailer-daemon|postmaster|dmarc|bounce|notifications?)$/.test(local);
22021
+ }
22022
+ function extractReplyText(value) {
22023
+ const text2 = String(value ?? "").replace(/\0/g, "").trim();
22024
+ if (!text2) return "";
22025
+ const lines = text2.split(/\r?\n/);
22026
+ const kept = [];
22027
+ for (const line of lines) {
22028
+ if (/^On .+ wrote:\s*$/i.test(line.trim())) break;
22029
+ if (/^-{2,}\s*Forwarded message\s*-{2,}$/i.test(line.trim())) break;
22030
+ if (/^From:\s+MCP Scraper Support\b/i.test(line.trim())) break;
22031
+ if (/^>\s?/.test(line)) break;
22032
+ kept.push(line);
22033
+ }
22034
+ return kept.join("\n").trim().slice(0, 16e3);
22035
+ }
22036
+ function personPath(identity) {
22037
+ const local = identity.email.split("@")[0];
22038
+ return `contacts/${safePathPart(identity.name === identity.email ? local : identity.name)}-${shortHash(identity.email, 12)}`;
22039
+ }
22040
+ function issuePath(token) {
22041
+ return `support/threads/${token}`;
22042
+ }
22043
+ function communicationPath(email) {
22044
+ const date = cleanLine(email.created_at, (/* @__PURE__ */ new Date()).toISOString()).slice(0, 10).replace(/[^0-9-]/g, "");
22045
+ const id = email.id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 100) || shortHash(email.message_id || email.id);
22046
+ return `Email/${date || (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${id}`;
22047
+ }
22048
+ function noteRef(vault, path5) {
22049
+ return `${vault}::${path5.replace(/\.md$/i, "")}.md`;
22050
+ }
22051
+ function unique(values) {
22052
+ return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
22053
+ }
22054
+ function searchPath(source) {
22055
+ if (!source?.startsWith("note:")) return null;
22056
+ const path5 = source.slice("note:".length).trim();
22057
+ return path5 || null;
22058
+ }
22059
+ async function getNote(memoryKey, vault, path5) {
22060
+ const result = await memoryCall("getTool", { vault, path: path5 }, memoryKey);
22061
+ return result.ok && result.note ? result.note : null;
22062
+ }
22063
+ async function inspectTagVocabulary(memoryKey) {
22064
+ const result = await memoryCall("listTagsTool", {}, memoryKey);
22065
+ if (!result.ok) throw new Error(result.error ?? "tag vocabulary could not be inspected");
22066
+ }
22067
+ async function captureNewNote(memoryKey, input) {
22068
+ const existing = await getNote(memoryKey, input.vault, input.path);
22069
+ if (existing) return existing;
22070
+ const prepared = await memoryCall("prepareMemoryWriteTool", {
22071
+ title: input.title,
22072
+ content: input.content,
22073
+ source: input.source,
22074
+ type: input.type,
22075
+ vault: input.vault,
22076
+ tagCandidates: input.tagCandidates,
22077
+ maxLinks: 4
22078
+ }, memoryKey);
22079
+ if (!prepared.ok) throw new Error(prepared.error ?? `${input.vault} memory preparation failed`);
22080
+ const resolvedTags = unique(
22081
+ (prepared.tagResolutions ?? []).filter((item) => item.action !== "omit").map((item) => item.tag ?? item.candidate)
22082
+ );
22083
+ const capture = await memoryCall("memoryCaptureTool", {
22084
+ vault: input.vault,
22085
+ path: input.path,
22086
+ title: input.title,
22087
+ content: input.content,
22088
+ props: { ...input.props, tags: resolvedTags },
22089
+ tagDecisions: input.tagCandidates
22090
+ }, memoryKey);
22091
+ if (!capture.ok && !capture.conflict) {
22092
+ const detail = JSON.stringify(capture);
22093
+ throw new Error(`${capture.error ?? `${input.vault} memory capture failed`}: ${detail.slice(0, 2e3)}`);
22094
+ }
22095
+ const stored = await getNote(memoryKey, input.vault, input.path);
22096
+ if (!stored) throw new Error(`${input.vault} memory capture could not be read back`);
22097
+ return stored;
22098
+ }
22099
+ async function findOrCreatePerson(memoryKey, customer, occurredAt) {
22100
+ const result = await memoryCall("searchTool", {
22101
+ vault: "People",
22102
+ query: customer.email,
22103
+ queries: [
22104
+ customer.email,
22105
+ `contact ${customer.email}`,
22106
+ `support relationship ${customer.email}`
22107
+ ],
22108
+ metadata: { email: customer.email },
22109
+ rerankTopN: 10
22110
+ }, memoryKey);
22111
+ if (!result.ok) throw new Error(result.error ?? "People lookup failed");
22112
+ for (const candidate of result.results ?? []) {
22113
+ const path6 = searchPath(candidate.source);
22114
+ if (!path6 || candidate.vault !== "People") continue;
22115
+ const note = await getNote(memoryKey, "People", path6);
22116
+ if (note && String(note.props?.email ?? "").toLowerCase() === customer.email) return note;
22117
+ }
22118
+ const path5 = personPath(customer);
22119
+ const existing = await getNote(memoryKey, "People", path5);
22120
+ if (existing) return existing;
22121
+ const title = customer.name === customer.email ? customer.email : `${customer.name} \u2014 ${customer.email}`;
22122
+ const summary = `MCP Scraper support contact identified by the verified sender email ${customer.email}.`;
21727
22123
  const content = [
22124
+ "## Identity",
22125
+ "",
22126
+ `- Name: ${customer.name}`,
22127
+ `- Email: ${customer.email}`,
22128
+ "",
22129
+ "## Relationship",
22130
+ "",
22131
+ "MCP Scraper support contact. The relationship is not classified beyond the verified email exchange.",
22132
+ "",
22133
+ "## Known Context",
22134
+ "",
22135
+ "This person contacted MCP Scraper support. Durable facts should be added only after review.",
22136
+ "",
22137
+ "## Interaction History",
22138
+ "",
22139
+ "Linked Communication records provide the message history.",
22140
+ "",
22141
+ "## Open Loops",
22142
+ "",
22143
+ "Review the linked support thread and respond if needed.",
22144
+ "",
22145
+ "## Tasks",
22146
+ "",
22147
+ "_No task was inferred automatically._",
22148
+ "",
22149
+ "## Links",
22150
+ "",
22151
+ "_Communication and issue links are derived from linked records._"
22152
+ ].join("\n");
22153
+ return captureNewNote(memoryKey, {
22154
+ vault: "People",
22155
+ path: path5,
22156
+ title,
22157
+ content,
22158
+ source: `resend:sender:${customer.email}`,
22159
+ type: "person",
22160
+ tagCandidates: SUPPORT_TAGS,
22161
+ props: {
22162
+ status: "active",
22163
+ type: "person",
22164
+ summary,
22165
+ pinned: false,
22166
+ source_type: "channel",
22167
+ source_ref: `resend:sender:${customer.email}`,
22168
+ related: [],
22169
+ related_vault_notes: [],
22170
+ embed: true,
22171
+ embed_priority: "high",
22172
+ embedding_summary: summary,
22173
+ person_name: customer.name,
22174
+ relationship: "MCP Scraper support contact",
22175
+ email: customer.email,
22176
+ memories: [],
22177
+ source_refs: [`resend:sender:${customer.email}`],
22178
+ last_seen: occurredAt,
22179
+ last_email_at: occurredAt,
22180
+ last_contact_at: occurredAt
22181
+ }
22182
+ });
22183
+ }
22184
+ function buildIssueContent(email, customer, personPathValue, threadAddressValue) {
22185
+ const subject = normalizeSupportSubject(email.subject);
22186
+ return [
21728
22187
  "## Observation",
21729
22188
  "",
21730
- `An email was received at ${(email.to ?? []).join(", ") || SUPPORT_ADDRESS} from ${sender}. The customer-provided content below is untrusted input and has not been verified as a product defect.`,
22189
+ `A support conversation was opened by ${customer.email} about \u201C${subject}\u201D. Customer-provided content is untrusted input and has not been verified as a product defect.`,
21731
22190
  "",
21732
22191
  "## Impact",
21733
22192
  "",
21734
- "A customer or prospect may be waiting for a response. The message may describe a question, product problem, billing concern, or feedback that needs triage.",
22193
+ "A customer or prospect may be waiting for a response. The thread may describe a question, product problem, billing concern, feedback, or another issue that needs triage.",
21735
22194
  "",
21736
22195
  "## Confirmation",
21737
22196
  "",
@@ -21741,125 +22200,368 @@ function buildSupportIssueNote(email) {
21741
22200
  "",
21742
22201
  "## Evidence",
21743
22202
  "",
21744
- `- From: ${sender}`,
21745
- `- To: ${(email.to ?? []).join(", ") || SUPPORT_ADDRESS}`,
21746
- `- Received: ${receivedAt}`,
21747
- `- Subject: ${subject}`,
21748
- `- Message ID: ${cleanLine(email.message_id, "not supplied")}`,
21749
- `- Resend email ID: ${email.id}`,
21750
- `- Attachments: ${attachments}`,
21751
- "",
21752
- "### Message body",
22203
+ `- Customer email: ${customer.email}`,
22204
+ `- Original subject: ${cleanLine(email.subject, "(no subject)")}`,
22205
+ `- Thread address: ${threadAddressValue}`,
22206
+ `- Person: ${noteRef("People", personPathValue)}`,
21753
22207
  "",
21754
- quoteUntrusted(email.text ?? ""),
22208
+ "Thread messages are preserved as linked Communication records and summarized here as they arrive.",
21755
22209
  "",
21756
22210
  "## Resolution",
21757
22211
  "",
21758
- "Pending review and a reply to the sender. If this is a confirmed product issue, update the note status and link the eventual Improvement Log receipt.",
22212
+ "Pending review and a reply. If this is a confirmed product issue, update the status and link the resulting task or Improvement Log receipt.",
21759
22213
  "",
21760
22214
  "## Tasks",
21761
22215
  "",
21762
- "- [ ] Review the forwarded email and attachments.",
22216
+ "- [ ] Review the complete linked conversation.",
21763
22217
  "- [ ] Reply to the sender.",
21764
- "- [ ] Confirm whether this is a product defect, question, billing concern, or feedback.",
21765
- "- [ ] Link any resulting task or Improvement Log receipt.",
22218
+ "- [ ] Classify the thread as a question, product defect, billing concern, feedback, or another issue.",
21766
22219
  "",
21767
22220
  "## Links",
21768
22221
  "",
21769
- "_No automatic links were added; support messages should be linked during human triage to avoid false relationships._"
22222
+ `- Person: ${noteRef("People", personPathValue)}`
21770
22223
  ].join("\n");
21771
- return { path: path5, title, content, summary };
21772
- }
21773
- async function captureSupportIssue(email, memoryKey) {
21774
- const note = buildSupportIssueNote(email);
21775
- const tagCandidates = [
21776
- { tag: "customer-support", central: true, reusable: true, description: "Inbound and in-product MCP Scraper customer support requests." },
21777
- { tag: "inbound-email", central: true, reusable: true, description: "Messages received through an inbound email integration." },
21778
- { tag: "product-feedback", central: true, reusable: true, description: "Customer feedback and reported product problems." }
21779
- ];
21780
- const prepared = await memoryCall("prepareMemoryWriteTool", {
21781
- title: note.title,
21782
- content: note.content,
21783
- source: `resend:email:${email.id}`,
21784
- type: "user_friction",
21785
- vault: "Issues",
21786
- tagCandidates,
21787
- maxLinks: 4
21788
- }, memoryKey);
21789
- if (!prepared.ok) throw new Error(prepared.error ?? "memory preparation failed");
21790
- const resolvedTags = (prepared.tagResolutions ?? []).filter((item) => item.action !== "omit").map((item) => item.tag ?? item.candidate).filter((tag) => Boolean(tag));
21791
- const capture = await memoryCall("memoryCaptureTool", {
22224
+ }
22225
+ async function findOrCreateIssue(memoryKey, email, customer, personPathValue, token, threadAddressValue) {
22226
+ const path5 = issuePath(token);
22227
+ const existing = await getNote(memoryKey, "Issues", path5);
22228
+ if (existing) return existing;
22229
+ const subject = normalizeSupportSubject(email.subject);
22230
+ const summary = `Support conversation with ${customer.email} about \u201C${subject}\u201D; full history is linked through Communication records.`;
22231
+ return captureNewNote(memoryKey, {
21792
22232
  vault: "Issues",
21793
- path: note.path,
21794
- title: note.title,
21795
- content: note.content,
22233
+ path: path5,
22234
+ title: `Support thread \u2014 ${subject} \u2014 ${customer.email}`.slice(0, 240),
22235
+ content: buildIssueContent(email, customer, personPathValue, threadAddressValue),
22236
+ source: `resend:thread:${token}`,
22237
+ type: "user_friction",
22238
+ tagCandidates: ISSUE_TAGS,
21796
22239
  props: {
21797
22240
  status: "ai_observed",
21798
- summary: note.summary,
21799
- tags: resolvedTags,
22241
+ type: "user_friction",
22242
+ summary,
21800
22243
  pinned: false,
21801
- source_type: "channel",
21802
- source_ref: `resend:email:${email.id}`,
22244
+ source_type: "thread",
22245
+ source_ref: `resend:thread:${token}`,
21803
22246
  related: [],
21804
- related_vault_notes: [],
22247
+ related_vault_notes: [noteRef("People", personPathValue)],
21805
22248
  embed: true,
21806
22249
  embed_priority: "high",
21807
- embedding_summary: note.summary,
21808
- type: "user_friction",
22250
+ embedding_summary: summary,
21809
22251
  severity: "untriaged",
21810
22252
  system: "MCP Scraper customer support",
21811
22253
  observed_at: email.created_at,
21812
22254
  detected_by: "resend-email-webhook",
21813
- reproducible: false
22255
+ reproducible: false,
22256
+ person_refs: [personPathValue],
22257
+ customer_email: customer.email,
22258
+ customer_name: customer.name,
22259
+ normalized_subject: subject,
22260
+ thread_token: token,
22261
+ thread_address: threadAddressValue,
22262
+ communication_refs: [],
22263
+ message_count: 0,
22264
+ attention_state: "needs_reply"
22265
+ }
22266
+ });
22267
+ }
22268
+ function buildCommunicationContent(email, customer, direction, personPathValue, issuePathValue, parentPath) {
22269
+ const sender = supportSender(email);
22270
+ const body = direction === "outbound" ? extractReplyText(email.text) : email.text;
22271
+ const participants = direction === "inbound" ? `- From: ${sender?.email ?? email.from}
22272
+ - To: MCP Scraper support` : `- From: MCP Scraper support
22273
+ - To: ${customer.email}`;
22274
+ return [
22275
+ "## Summary",
22276
+ "",
22277
+ `${direction === "inbound" ? "Inbound" : "Outbound"} support email in the \u201C${normalizeSupportSubject(email.subject)}\u201D thread.`,
22278
+ "",
22279
+ "## Participants",
22280
+ "",
22281
+ participants,
22282
+ `- Person: ${noteRef("People", personPathValue)}`,
22283
+ "",
22284
+ "## Message",
22285
+ "",
22286
+ quoteUntrusted(body),
22287
+ "",
22288
+ "## Decision",
22289
+ "",
22290
+ "_No decision was inferred automatically._",
22291
+ "",
22292
+ "## Follow Up Tasks",
22293
+ "",
22294
+ direction === "inbound" ? "- [ ] Review and reply." : "- [ ] Wait for the customer response or close the thread after review.",
22295
+ "",
22296
+ "## Links",
22297
+ "",
22298
+ `- Issue: ${noteRef("Issues", issuePathValue)}`,
22299
+ `- Person: ${noteRef("People", personPathValue)}`,
22300
+ ...parentPath ? [`- Previous message: [[${parentPath}]]`] : []
22301
+ ].join("\n");
22302
+ }
22303
+ async function captureCommunication(memoryKey, email, customer, direction, personPathValue, issuePathValue, token, parentPath) {
22304
+ const path5 = communicationPath(email);
22305
+ const existing = await getNote(memoryKey, "Communication", path5);
22306
+ if (existing) return existing;
22307
+ const subject = normalizeSupportSubject(email.subject);
22308
+ const summary = `${direction === "inbound" ? "Inbound message from" : "Outbound reply to"} ${customer.email} in the support thread about \u201C${subject}\u201D.`;
22309
+ const providerMessageRef = cleanLine(email.message_id, email.id, 500);
22310
+ return captureNewNote(memoryKey, {
22311
+ vault: "Communication",
22312
+ path: path5,
22313
+ title: `${direction === "inbound" ? "Inbound" : "Outbound"} email \u2014 ${subject} \u2014 ${customer.email}`.slice(0, 240),
22314
+ content: buildCommunicationContent(email, customer, direction, personPathValue, issuePathValue, parentPath),
22315
+ source: `resend:email:${email.id}`,
22316
+ type: "email",
22317
+ tagCandidates: SUPPORT_TAGS,
22318
+ props: {
22319
+ status: direction === "inbound" ? "inbox" : "responded",
22320
+ type: "email",
22321
+ summary,
22322
+ pinned: false,
22323
+ source_type: "channel",
22324
+ source_ref: `resend:email:${email.id}`,
22325
+ related: parentPath ? [`[[${parentPath}]]`] : [],
22326
+ related_vault_notes: [
22327
+ noteRef("People", personPathValue),
22328
+ noteRef("Issues", issuePathValue)
22329
+ ],
22330
+ embed: true,
22331
+ embed_priority: "high",
22332
+ embedding_summary: summary,
22333
+ channel: "email",
22334
+ participants: [customer.email, "support@updates.mcpscraper.dev"],
22335
+ person_refs: [personPathValue],
22336
+ occurred_at: email.created_at,
22337
+ direction,
22338
+ attention_state: direction === "inbound" ? "needs_reply" : "waiting",
22339
+ provider: "resend",
22340
+ provider_connection_ref: "resend:updates.mcpscraper.dev",
22341
+ provider_message_ref: providerMessageRef,
22342
+ provider_thread_ref: token,
22343
+ thread_ref: token,
22344
+ issue_ref: issuePathValue,
22345
+ sender_email: supportSender(email)?.email ?? email.from,
22346
+ recipient_emails: email.to ?? [],
22347
+ parentMessageId: parentPath,
22348
+ parent_communication_ref: parentPath
22349
+ }
22350
+ });
22351
+ }
22352
+ function appendIssueEvidence(content, email, communicationPathValue, direction) {
22353
+ const marker = `- Resend email ID: ${email.id}`;
22354
+ if (content.includes(marker)) return content;
22355
+ const block = [
22356
+ `### ${direction === "inbound" ? "Inbound" : "Outbound"} email \u2014 ${cleanLine(email.created_at, (/* @__PURE__ */ new Date()).toISOString())}`,
22357
+ "",
22358
+ `- Communication: ${noteRef("Communication", communicationPathValue)}`,
22359
+ `- Subject: ${cleanLine(email.subject, "(no subject)")}`,
22360
+ marker,
22361
+ `- Message ID: ${cleanLine(email.message_id, "not supplied", 500)}`,
22362
+ "",
22363
+ quoteUntrusted(direction === "outbound" ? extractReplyText(email.text) : email.text),
22364
+ ""
22365
+ ].join("\n");
22366
+ const anchor = "\n## Resolution";
22367
+ return content.includes(anchor) ? content.replace(anchor, `
22368
+ ${block}${anchor}`) : `${content}
22369
+
22370
+ ${block}`;
22371
+ }
22372
+ async function updateIssueThread(memoryKey, issuePathValue, email, communicationPathValue, direction, personPathValue) {
22373
+ for (let attempt = 0; attempt < 2; attempt += 1) {
22374
+ const current = await getNote(memoryKey, "Issues", issuePathValue);
22375
+ if (!current) throw new Error("support thread issue disappeared before update");
22376
+ const currentRefs = unique(Array.isArray(current.props?.communication_refs) ? current.props.communication_refs : []);
22377
+ const alreadyLinked = currentRefs.includes(communicationPathValue);
22378
+ const communicationRefs = alreadyLinked ? currentRefs : [...currentRefs, communicationPathValue];
22379
+ const relatedVaultNotes = unique([
22380
+ ...Array.isArray(current.props?.related_vault_notes) ? current.props.related_vault_notes : [],
22381
+ noteRef("People", personPathValue),
22382
+ ...communicationRefs.map((path5) => noteRef("Communication", path5))
22383
+ ]);
22384
+ const result = await memoryCall("putTool", {
22385
+ vault: "Issues",
22386
+ path: issuePathValue,
22387
+ title: current.title,
22388
+ content: appendIssueEvidence(current.content, email, communicationPathValue, direction),
22389
+ baseRevision: current.revision,
22390
+ props: {
22391
+ related_vault_notes: relatedVaultNotes,
22392
+ communication_refs: communicationRefs,
22393
+ last_communication_ref: communicationPathValue,
22394
+ last_message_at: email.created_at,
22395
+ message_count: communicationRefs.length,
22396
+ attention_state: direction === "inbound" ? "needs_reply" : "waiting"
22397
+ }
22398
+ }, memoryKey);
22399
+ if (result.ok) return;
22400
+ if (!result.conflict || attempt === 1) throw new Error(result.error ?? "support thread issue update failed");
22401
+ }
22402
+ }
22403
+ async function getSupportThreadByToken(memoryKey, token) {
22404
+ const safeToken = token.toLowerCase().replace(/[^a-f0-9]/g, "").slice(0, 24);
22405
+ if (safeToken.length !== 24) return null;
22406
+ const path5 = issuePath(safeToken);
22407
+ const issue = await getNote(memoryKey, "Issues", path5);
22408
+ if (!issue) return null;
22409
+ const customerEmail = parseMailbox(String(issue.props?.customer_email ?? ""));
22410
+ const personRefs = Array.isArray(issue.props?.person_refs) ? issue.props.person_refs : [];
22411
+ const personPathValue = personRefs.find((value) => typeof value === "string");
22412
+ const threadAddressValue = String(issue.props?.thread_address ?? "");
22413
+ if (!customerEmail || !personPathValue || !threadAddressValue) return null;
22414
+ return {
22415
+ issue,
22416
+ issuePath: path5,
22417
+ threadToken: safeToken,
22418
+ threadAddress: threadAddressValue,
22419
+ customer: {
22420
+ email: customerEmail.email,
22421
+ name: cleanLine(String(issue.props?.customer_name ?? ""), customerEmail.email)
21814
22422
  },
21815
- tagDecisions: tagCandidates
21816
- }, memoryKey);
21817
- if (capture.ok) return capture.note?.path ?? note.path;
21818
- if (capture.conflict) {
21819
- const existing = await memoryCall("getTool", { vault: "Issues", path: note.path }, memoryKey);
21820
- if (existing.ok) return note.path;
22423
+ personPath: personPathValue,
22424
+ normalizedSubject: cleanLine(String(issue.props?.normalized_subject ?? ""), "(no subject)")
22425
+ };
22426
+ }
22427
+ async function captureSupportMessage(memoryKey, email, options) {
22428
+ await inspectTagVocabulary(memoryKey);
22429
+ const customer = options.customer ?? supportSender(email);
22430
+ if (!customer) throw new Error("support sender does not contain a usable email address");
22431
+ const normalizedSubject = options.existingThread?.normalizedSubject ?? normalizeSupportSubject(email.subject);
22432
+ const token = options.existingThread?.threadToken ?? supportThreadToken(customer.email, normalizedSubject);
22433
+ const threadAddressValue = options.existingThread?.threadAddress ?? supportThreadAddress(token, options.receivingDomain);
22434
+ const person = options.existingThread ? await getNote(memoryKey, "People", options.existingThread.personPath) : await findOrCreatePerson(memoryKey, customer, email.created_at);
22435
+ if (!person) throw new Error("support person could not be resolved");
22436
+ const issue = options.existingThread?.issue ?? await findOrCreateIssue(
22437
+ memoryKey,
22438
+ email,
22439
+ customer,
22440
+ person.path,
22441
+ token,
22442
+ threadAddressValue
22443
+ );
22444
+ const parentPath = typeof issue.props?.last_communication_ref === "string" ? issue.props.last_communication_ref : void 0;
22445
+ const communication = await captureCommunication(
22446
+ memoryKey,
22447
+ email,
22448
+ customer,
22449
+ options.direction,
22450
+ person.path,
22451
+ issue.path,
22452
+ token,
22453
+ parentPath
22454
+ );
22455
+ await updateIssueThread(memoryKey, issue.path, email, communication.path, options.direction, person.path);
22456
+ return {
22457
+ issuePath: issue.path,
22458
+ communicationPath: communication.path,
22459
+ personPath: person.path,
22460
+ parentCommunicationPath: parentPath,
22461
+ threadToken: token,
22462
+ threadAddress: threadAddressValue,
22463
+ customerEmail: customer.email,
22464
+ normalizedSubject,
22465
+ direction: options.direction
22466
+ };
22467
+ }
22468
+
22469
+ // src/api/resend-inbound-routes.ts
22470
+ var SUPPORT_ADDRESS = "support@mcpscraper.dev";
22471
+ var DMARC_ADDRESS = "dmarc@mcpscraper.dev";
22472
+ var FORWARD_FROM = "MCP Scraper Support <support@updates.mcpscraper.dev>";
22473
+ function receivingDomains(values) {
22474
+ return values.map((domain) => domain?.trim().toLowerCase()).filter((domain) => Boolean(domain));
22475
+ }
22476
+ function extractSupportThreadToken(recipients, ...domainsInput) {
22477
+ const domains = receivingDomains(domainsInput);
22478
+ for (const recipient of recipients) {
22479
+ const address = parseMailbox(recipient)?.email ?? recipient.trim().toLowerCase();
22480
+ const match = address.match(/^support\+([a-f0-9]{24})@(.+)$/i);
22481
+ if (match && domains.includes(match[2].toLowerCase())) return match[1].toLowerCase();
21821
22482
  }
21822
- throw new Error(capture.error ?? "memory capture failed");
22483
+ return null;
22484
+ }
22485
+ function classifyInboundRecipients(recipients, ...domainsInput) {
22486
+ const normalized = recipients.map((address) => parseMailbox(address)?.email ?? address.trim().toLowerCase());
22487
+ const domains = receivingDomains(domainsInput);
22488
+ const supportAddresses = [SUPPORT_ADDRESS, ...domains.map((domain) => `support@${domain}`)];
22489
+ const dmarcAddresses = [DMARC_ADDRESS, ...domains.map((domain) => `dmarc@${domain}`)];
22490
+ if (dmarcAddresses.some((address) => normalized.includes(address))) return "dmarc";
22491
+ if (extractSupportThreadToken(recipients, ...domains)) return "support-thread";
22492
+ if (supportAddresses.some((address) => normalized.includes(address))) return "support";
22493
+ return "ignore";
21823
22494
  }
21824
- async function forwardReceivedEmail(resend, email, destination, label) {
22495
+ function escapeHtml2(value) {
22496
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
22497
+ }
22498
+ function safeEmailId(value) {
22499
+ return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 100) || "unknown-email";
22500
+ }
22501
+ async function receivedAttachments(resend, email) {
21825
22502
  const attachmentResult = await resend.emails.receiving.attachments.list({ emailId: email.id });
21826
22503
  if (attachmentResult.error) throw new Error(`attachment retrieval failed: ${attachmentResult.error.message}`);
21827
- const attachments = (attachmentResult.data?.data ?? []).filter((item) => item.content_disposition !== "inline").map((item) => ({
22504
+ return (attachmentResult.data?.data ?? []).filter((item) => item.content_disposition !== "inline").map((item) => ({
21828
22505
  path: item.download_url,
21829
22506
  filename: item.filename ?? "attachment",
21830
22507
  contentId: item.content_id
21831
22508
  }));
21832
- const sender = email.reply_to?.[0] || email.from;
21833
- const subject = cleanLine(email.subject, "(no subject)");
22509
+ }
22510
+ async function forwardSupportEmail(resend, email, destination, threadAddress) {
22511
+ const attachments = await receivedAttachments(resend, email);
22512
+ const sender = supportSender(email);
22513
+ const subject = normalizeSupportSubject(email.subject);
21834
22514
  const headerText = [
21835
- `${label} message received at ${(email.to ?? []).join(", ")}`,
22515
+ `Support message received at ${(email.to ?? []).join(", ")}`,
21836
22516
  `From: ${email.from}`,
21837
- `Reply to: ${sender}`,
22517
+ `Customer email: ${sender?.email ?? email.from}`,
21838
22518
  `Received: ${email.created_at}`,
22519
+ `Thread reply address: ${threadAddress}`,
21839
22520
  ""
21840
22521
  ].join("\n");
21841
22522
  const headerHtml = [
21842
22523
  '<div style="font-family:Arial,sans-serif;font-size:13px;line-height:1.5;color:#5f5a54;border-bottom:1px solid #e9e4db;padding-bottom:12px;margin-bottom:18px">',
21843
- `<strong>${label} message received</strong><br>`,
22524
+ "<strong>Support message received</strong><br>",
21844
22525
  `From: ${escapeHtml2(email.from)}<br>`,
21845
- `Reply to: ${escapeHtml2(sender)}<br>`,
21846
- `Received: ${escapeHtml2(email.created_at)}`,
22526
+ `Customer email: ${escapeHtml2(sender?.email ?? email.from)}<br>`,
22527
+ `Received: ${escapeHtml2(email.created_at)}<br>`,
22528
+ `Thread reply address: ${escapeHtml2(threadAddress)}`,
21847
22529
  "</div>"
21848
22530
  ].join("");
21849
22531
  const sent = await resend.emails.send({
21850
22532
  from: FORWARD_FROM,
21851
22533
  to: destination,
21852
- replyTo: sender,
21853
- subject: `[${label}] ${subject}`,
22534
+ replyTo: threadAddress,
22535
+ subject: `[Support] ${subject}`,
21854
22536
  text: `${headerText}${email.text || "(HTML-only message; view the HTML version.)"}`,
21855
22537
  html: email.html ? `${headerHtml}${email.html}` : void 0,
21856
22538
  attachments: attachments.length ? attachments : void 0,
22539
+ headers: { "X-MCP-Support-Thread": threadAddress },
21857
22540
  tags: [
21858
- { name: "category", value: label === "Support" ? "support_forward" : "dmarc_report" },
22541
+ { name: "category", value: "support_forward" },
21859
22542
  { name: "inbound_id", value: safeEmailId(email.id) }
21860
22543
  ]
21861
- }, { idempotencyKey: `mcp-inbound-${label.toLowerCase()}-${safeEmailId(email.id)}` });
21862
- if (sent.error) throw new Error(`forward failed: ${sent.error.message}`);
22544
+ }, { idempotencyKey: `mcp-inbound-support-${safeEmailId(email.id)}` });
22545
+ if (sent.error) throw new Error(`support forward failed: ${sent.error.message}`);
22546
+ return sent.data.id;
22547
+ }
22548
+ async function relayOperatorReply(resend, email, customerEmail, threadAddress, normalizedSubject) {
22549
+ const attachments = await receivedAttachments(resend, email);
22550
+ const replyText = extractReplyText(email.text) || "(No plain-text reply was available.)";
22551
+ const sent = await resend.emails.send({
22552
+ from: FORWARD_FROM,
22553
+ to: customerEmail,
22554
+ replyTo: threadAddress,
22555
+ subject: `Re: ${normalizeSupportSubject(normalizedSubject)}`,
22556
+ text: replyText,
22557
+ attachments: attachments.length ? attachments : void 0,
22558
+ headers: { "X-MCP-Support-Thread": threadAddress },
22559
+ tags: [
22560
+ { name: "category", value: "support_reply" },
22561
+ { name: "inbound_id", value: safeEmailId(email.id) }
22562
+ ]
22563
+ }, { idempotencyKey: `mcp-support-reply-${safeEmailId(email.id)}` });
22564
+ if (sent.error) throw new Error(`support reply relay failed: ${sent.error.message}`);
21863
22565
  return sent.data.id;
21864
22566
  }
21865
22567
  var resendInboundApp = new Hono24();
@@ -21885,32 +22587,99 @@ resendInboundApp.post("/webhooks", async (c) => {
21885
22587
  }
21886
22588
  if (event.type !== "email.received") return c.json({ ok: true, ignored: true });
21887
22589
  const receivedEvent = event;
21888
- const route = classifyInboundRecipients(
21889
- receivedEvent.data.to ?? [],
22590
+ const domains = [
21890
22591
  process.env.RESEND_RECEIVING_DOMAIN,
21891
22592
  process.env.RESEND_MANAGED_RECEIVING_DOMAIN
21892
- );
21893
- if (route === "ignore") return c.json({ ok: true, ignored: true });
22593
+ ];
22594
+ const route = classifyInboundRecipients(receivedEvent.data.to ?? [], ...domains);
22595
+ if (route === "ignore") return c.json({ ok: true, route, ignored: true });
22596
+ if (route === "dmarc") {
22597
+ return c.json({ ok: true, route, ignored: true, reason: "dmarc-report-suppressed" });
22598
+ }
21894
22599
  const received = await resend.emails.receiving.get(receivedEvent.data.email_id);
21895
22600
  if (received.error || !received.data) {
21896
22601
  console.error("[resend/inbound] could not retrieve received email", receivedEvent.data.email_id);
21897
22602
  return c.json({ error: "received email could not be retrieved" }, 502);
21898
22603
  }
22604
+ const destination = process.env.SUPPORT_FORWARD_TO?.trim();
22605
+ const memoryKey = process.env.SUPPORT_INBOX_MEMORY_KEY?.trim();
22606
+ const receivingDomain = process.env.RESEND_RECEIVING_DOMAIN?.trim();
22607
+ if (!destination || !memoryKey || !receivingDomain) {
22608
+ return c.json({ error: "support routing is not configured" }, 503);
22609
+ }
21899
22610
  try {
22611
+ const email = received.data;
22612
+ const sender = supportSender(email);
22613
+ if (!sender || isAutomatedMailbox(sender.email)) {
22614
+ return c.json({ ok: true, route, ignored: true, reason: "automated-or-unusable-sender" });
22615
+ }
21900
22616
  if (route === "support") {
21901
- const destination2 = process.env.SUPPORT_FORWARD_TO?.trim();
21902
- const memoryKey = process.env.SUPPORT_INBOX_MEMORY_KEY?.trim();
21903
- if (!destination2 || !memoryKey) return c.json({ error: "support routing is not configured" }, 503);
21904
- const [forwardedId2, memoryPath] = await Promise.all([
21905
- forwardReceivedEmail(resend, received.data, destination2, "Support"),
21906
- captureSupportIssue(received.data, memoryKey)
21907
- ]);
21908
- return c.json({ ok: true, route, forwardedId: forwardedId2, memoryPath });
22617
+ const captured = await captureSupportMessage(memoryKey, email, {
22618
+ receivingDomain,
22619
+ direction: "inbound",
22620
+ customer: sender
22621
+ });
22622
+ const forwardedId = await forwardSupportEmail(resend, received.data, destination, captured.threadAddress);
22623
+ return c.json({
22624
+ ok: true,
22625
+ route,
22626
+ direction: "inbound",
22627
+ forwardedId,
22628
+ issuePath: captured.issuePath,
22629
+ communicationPath: captured.communicationPath,
22630
+ personPath: captured.personPath,
22631
+ threadAddress: captured.threadAddress
22632
+ });
22633
+ }
22634
+ const token = extractSupportThreadToken(receivedEvent.data.to ?? [], ...domains);
22635
+ const thread = token ? await getSupportThreadByToken(memoryKey, token) : null;
22636
+ if (!thread) return c.json({ ok: true, route, ignored: true, reason: "unknown-support-thread" });
22637
+ const operator = parseMailbox(destination);
22638
+ if (operator && sender.email === operator.email) {
22639
+ const captured = await captureSupportMessage(memoryKey, email, {
22640
+ receivingDomain,
22641
+ direction: "outbound",
22642
+ customer: thread.customer,
22643
+ existingThread: thread
22644
+ });
22645
+ const relayedId = await relayOperatorReply(
22646
+ resend,
22647
+ received.data,
22648
+ thread.customer.email,
22649
+ captured.threadAddress,
22650
+ thread.normalizedSubject
22651
+ );
22652
+ return c.json({
22653
+ ok: true,
22654
+ route,
22655
+ direction: "outbound",
22656
+ relayedId,
22657
+ issuePath: captured.issuePath,
22658
+ communicationPath: captured.communicationPath,
22659
+ personPath: captured.personPath,
22660
+ threadAddress: captured.threadAddress
22661
+ });
22662
+ }
22663
+ if (sender.email === thread.customer.email) {
22664
+ const captured = await captureSupportMessage(memoryKey, email, {
22665
+ receivingDomain,
22666
+ direction: "inbound",
22667
+ customer: thread.customer,
22668
+ existingThread: thread
22669
+ });
22670
+ const forwardedId = await forwardSupportEmail(resend, received.data, destination, captured.threadAddress);
22671
+ return c.json({
22672
+ ok: true,
22673
+ route,
22674
+ direction: "inbound",
22675
+ forwardedId,
22676
+ issuePath: captured.issuePath,
22677
+ communicationPath: captured.communicationPath,
22678
+ personPath: captured.personPath,
22679
+ threadAddress: captured.threadAddress
22680
+ });
21909
22681
  }
21910
- const destination = process.env.DMARC_FORWARD_TO?.trim() || process.env.SUPPORT_FORWARD_TO?.trim();
21911
- if (!destination) return c.json({ error: "DMARC routing is not configured" }, 503);
21912
- const forwardedId = await forwardReceivedEmail(resend, received.data, destination, "DMARC");
21913
- return c.json({ ok: true, route, forwardedId });
22682
+ return c.json({ ok: true, route, ignored: true, reason: "unrecognized-thread-sender" });
21914
22683
  } catch (err) {
21915
22684
  console.error("[resend/inbound] processing failed", receivedEvent.data.email_id, err instanceof Error ? err.message : String(err));
21916
22685
  return c.json({ error: "inbound email processing failed" }, 502);
@@ -21953,16 +22722,16 @@ function startSiteAuditWorker() {
21953
22722
  // src/api/server.ts
21954
22723
  import { createMiddleware as createMiddleware3 } from "hono/factory";
21955
22724
  import { getCookie as getCookie2, setCookie as setCookie2, deleteCookie } from "hono/cookie";
21956
- import Stripe3 from "stripe";
22725
+ import Stripe2 from "stripe";
21957
22726
 
21958
22727
  // src/api/page-diff.ts
21959
- import { createHash as createHash7 } from "crypto";
22728
+ import { createHash as createHash8 } from "crypto";
21960
22729
  import { diffLines } from "diff";
21961
22730
  var MAX_SNAPSHOT_CONTENT_CHARS = 25e4;
21962
22731
  var MAX_DIFF_HUNKS = 200;
21963
22732
  var MAX_DIFF_LINES_PER_RESPONSE = 2e3;
21964
22733
  function sha256Hex(value) {
21965
- return createHash7("sha256").update(value).digest("hex");
22734
+ return createHash8("sha256").update(value).digest("hex");
21966
22735
  }
21967
22736
  function truncateForStorage(value, maxChars = MAX_SNAPSHOT_CONTENT_CHARS) {
21968
22737
  if (value.length <= maxChars) return { value, truncated: false };
@@ -22069,7 +22838,7 @@ async function persistScrapeBody(user, opts) {
22069
22838
  }
22070
22839
 
22071
22840
  // src/api/connection-memory-import.ts
22072
- import { createHash as createHash8 } from "crypto";
22841
+ import { createHash as createHash9 } from "crypto";
22073
22842
  var CONNECTION_MEMORY_IMPORT_MAX_ARGS_BYTES = 64 * 1024;
22074
22843
  var CONNECTION_MEMORY_IMPORT_MAX_RESULT_BYTES = 1e6;
22075
22844
  var CONNECTION_MEMORY_IMPORT_MAX_STRING_CHARS = 256e3;
@@ -22099,7 +22868,7 @@ function canonicalJson(value) {
22099
22868
  return JSON.stringify(value);
22100
22869
  }
22101
22870
  function sha2562(value) {
22102
- return createHash8("sha256").update(value).digest("hex");
22871
+ return createHash9("sha256").update(value).digest("hex");
22103
22872
  }
22104
22873
  function sensitiveKey(key) {
22105
22874
  const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -22941,10 +23710,10 @@ async function dbUsage(identity, plan) {
22941
23710
  }
22942
23711
 
22943
23712
  // src/api/nango-control.ts
22944
- import { createHash as createHash10, randomUUID as randomUUID12 } from "crypto";
23713
+ import { createHash as createHash11, randomUUID as randomUUID12 } from "crypto";
22945
23714
 
22946
23715
  // src/api/main-nango-transport.ts
22947
- import { createHash as createHash9, randomUUID as randomUUID11 } from "crypto";
23716
+ import { createHash as createHash10, randomUUID as randomUUID11 } from "crypto";
22948
23717
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22949
23718
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
22950
23719
 
@@ -23536,7 +24305,7 @@ async function listNangoToolsDirect(identity, connectionId) {
23536
24305
  });
23537
24306
  const readTools = tools.filter((tool) => classifyTool(tool) === "read").map((tool) => tool.name);
23538
24307
  const actionTools = tools.filter((tool) => classifyTool(tool) === "action").map((tool) => tool.name);
23539
- const revision = createHash9("sha256").update(JSON.stringify(tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })))).digest("hex");
24308
+ const revision = createHash10("sha256").update(JSON.stringify(tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })))).digest("hex");
23540
24309
  await updateServiceConnectionTools(connection.id, readTools, actionTools, revision);
23541
24310
  const refreshed = await getOwnedServiceConnection(identity, connection.id);
23542
24311
  return { connection: refreshed ?? { ...connection, readTools, actionTools, toolRevision: revision }, tools };
@@ -23557,7 +24326,7 @@ async function callNangoToolDirect(args) {
23557
24326
  ratePolicyVersion: CONNECTED_USAGE_RATE_POLICY_VERSION
23558
24327
  });
23559
24328
  const requestId = args.requestId?.trim() || randomUUID11();
23560
- const idempotencyKey = `main-nango:${createHash9("sha256").update(args.identity.toLowerCase()).update("\0").update(connection.id).update("\0").update(args.tool).update("\0").update(requestId).digest("hex")}`;
24329
+ const idempotencyKey = `main-nango:${createHash10("sha256").update(args.identity.toLowerCase()).update("\0").update(connection.id).update("\0").update(args.tool).update("\0").update(requestId).digest("hex")}`;
23561
24330
  const startedAt = /* @__PURE__ */ new Date();
23562
24331
  const started = performance.now();
23563
24332
  let result;
@@ -23582,7 +24351,7 @@ async function callNangoToolDirect(args) {
23582
24351
  toolName: args.tool,
23583
24352
  operationKind: args.operationKind ?? args.classification,
23584
24353
  outcome: providerError ? "error" : "partial",
23585
- requestId: requestId.length <= 200 ? requestId : createHash9("sha256").update(requestId).digest("hex"),
24354
+ requestId: requestId.length <= 200 ? requestId : createHash10("sha256").update(requestId).digest("hex"),
23586
24355
  startedAt: startedAt.toISOString(),
23587
24356
  completedAt: completedAt.toISOString()
23588
24357
  }
@@ -23619,7 +24388,7 @@ async function describeNangoToolDirect(identity, connectionId, toolName) {
23619
24388
  providerContractHash: MAIN_INTEGRATION_CONTRACT_HASH,
23620
24389
  protocolVersion: null,
23621
24390
  schemaSource: "live_tools_list",
23622
- schemaHash: createHash9("sha256").update(JSON.stringify(projected)).digest("hex"),
24391
+ schemaHash: createHash10("sha256").update(JSON.stringify(projected)).digest("hex"),
23623
24392
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
23624
24393
  };
23625
24394
  }
@@ -24344,7 +25113,7 @@ async function setScheduleConnectionActionsEnabled(identity, connectionId, enabl
24344
25113
  return data.connection.actionsEnabled === true;
24345
25114
  }
24346
25115
  async function callScheduleConnectionAction(identity, connectionId, input, tool, idempotencyKey) {
24347
- const requestId = `main-connected-action:${createHash10("sha256").update(identity).update("\0").update(idempotencyKey?.trim() || randomUUID12()).digest("hex")}`;
25116
+ const requestId = `main-connected-action:${createHash11("sha256").update(identity).update("\0").update(idempotencyKey?.trim() || randomUUID12()).digest("hex")}`;
24348
25117
  if (mainOwnsIntegrations()) {
24349
25118
  const selectedTool = tool?.trim();
24350
25119
  if (!selectedTool) throw new NangoControlError("An action tool is required.", 400, "invalid_request", false);
@@ -24486,7 +25255,7 @@ function canonicalJson2(value) {
24486
25255
  return JSON.stringify(value);
24487
25256
  }
24488
25257
  function projectedToolSchemaHash(tool) {
24489
- return createHash10("sha256").update(canonicalJson2(tool)).digest("hex");
25258
+ return createHash11("sha256").update(canonicalJson2(tool)).digest("hex");
24490
25259
  }
24491
25260
  async function describeNangoTool(identity, connectionId, tool, fresh) {
24492
25261
  if (mainOwnsIntegrations()) {
@@ -25072,7 +25841,7 @@ async function callResendExportPage(identity, input) {
25072
25841
  }
25073
25842
 
25074
25843
  // src/api/scheduler-integration-auth.ts
25075
- import { createHash as createHash11, createHmac as createHmac4, timingSafeEqual as timingSafeEqual3 } from "crypto";
25844
+ import { createHash as createHash12, createHmac as createHmac4, timingSafeEqual as timingSafeEqual3 } from "crypto";
25076
25845
  var MAX_CLOCK_SKEW_MS = 5 * 60 * 1e3;
25077
25846
  var SchedulerIntegrationAuthError = class extends Error {
25078
25847
  code;
@@ -25088,7 +25857,7 @@ function signingSecret() {
25088
25857
  return secret2;
25089
25858
  }
25090
25859
  function schedulerIntegrationSignature(args) {
25091
- const bodyHash = createHash11("sha256").update(args.body).digest("hex");
25860
+ const bodyHash = createHash12("sha256").update(args.body).digest("hex");
25092
25861
  return createHmac4("sha256", args.secret).update(`${args.method.toUpperCase()}
25093
25862
  ${args.path}
25094
25863
  ${args.timestamp}
@@ -25237,7 +26006,7 @@ var requirePaidSchedulingTier = createMiddleware3(async (c, next) => {
25237
26006
  return next();
25238
26007
  });
25239
26008
  var app = new Hono25();
25240
- var STRIPE_API_VERSION2 = "2026-02-25.clover";
26009
+ var STRIPE_API_VERSION = "2026-02-25.clover";
25241
26010
  function requireStripeSecret() {
25242
26011
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
25243
26012
  if (secret2) return secret2;
@@ -25247,7 +26016,7 @@ function requireStripeSecret() {
25247
26016
  async function createSignupStripeCustomer(email) {
25248
26017
  const secret2 = requireStripeSecret();
25249
26018
  if (!secret2) return null;
25250
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
26019
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
25251
26020
  const customer = await stripeClient.customers.create({
25252
26021
  email,
25253
26022
  description: "MCP Scraper free account",
@@ -25319,7 +26088,7 @@ app.post("/auth/register", requireAllowedOrigin, async (c) => {
25319
26088
  const user = await createUser(normalizedEmail, void 0, password, stripeCustomerId ?? void 0);
25320
26089
  if (stripeCustomerId) {
25321
26090
  try {
25322
- const stripeClient = new Stripe3(process.env.STRIPE_SECRET_KEY, { apiVersion: STRIPE_API_VERSION2 });
26091
+ const stripeClient = new Stripe2(process.env.STRIPE_SECRET_KEY, { apiVersion: STRIPE_API_VERSION });
25323
26092
  await stripeClient.customers.update(stripeCustomerId, {
25324
26093
  metadata: {
25325
26094
  app: "mcp-scraper",
@@ -25387,7 +26156,7 @@ app.post("/account/delete", requireAllowedOrigin, sessionAuth, async (c) => {
25387
26156
  }
25388
26157
  const secret2 = requireStripeSecret();
25389
26158
  if (secret2 && user.stripe_customer_id) {
25390
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
26159
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
25391
26160
  const subs = await stripeClient.subscriptions.list({ customer: user.stripe_customer_id, status: "all", limit: 100 });
25392
26161
  for (const sub of subs.data) {
25393
26162
  if (sub.status === "active" || sub.status === "trialing" || sub.status === "past_due") {
@@ -25712,7 +26481,7 @@ app.post("/memory/checkout", auth2, async (c) => {
25712
26481
  if (!priceId || !MEMORY_PLANS[priceId]) return c.json({ error: "Invalid priceId \u2014 must be a memory plan price." }, 400);
25713
26482
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
25714
26483
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
25715
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
26484
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
25716
26485
  let customerId = user.stripe_customer_id;
25717
26486
  if (!customerId) {
25718
26487
  const customer = await stripeClient.customers.create({ email: user.email });
@@ -25747,7 +26516,7 @@ app.post("/memory/portal", auth2, async (c) => {
25747
26516
  if (!user.stripe_customer_id) return c.json({ error: "No billing account yet \u2014 subscribe first." }, 409);
25748
26517
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
25749
26518
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
25750
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
26519
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
25751
26520
  const session = await stripeClient.billingPortal.sessions.create({
25752
26521
  customer: user.stripe_customer_id,
25753
26522
  return_url: `${appOrigin()}/memory`
@@ -25965,10 +26734,8 @@ app.post("/schedule-connections/session", auth2, requireIntegrationsTier, async
25965
26734
  if (!providerConfigKey) return c.json({ ok: false, error: "providerConfigKey is required." }, 400);
25966
26735
  try {
25967
26736
  if (providerConfigKey !== "resend") {
25968
- await reconcileConnectedAccountBilling(
25969
- user,
25970
- billableNangoConnectionCount(await getNangoConnections(user.email, { summaryOnly: true }))
25971
- );
26737
+ const existingCount = billableNangoConnectionCount(await getNangoConnections(user.email, { summaryOnly: true }));
26738
+ await reconcileConnectedAccountBilling(user, existingCount + 1);
25972
26739
  }
25973
26740
  const session = providerConfigKey === "resend" ? await createResendConnectSession(user.email, resendOAuthCallbackUrl()) : await createNangoConnectSession(user.email, providerConfigKey);
25974
26741
  return c.json({ ok: true, ...session });
@@ -27266,7 +28033,7 @@ app.post("/billing/concurrency/checkout", requireAllowedOrigin, sessionAuth, asy
27266
28033
  if (user.concurrency_stripe_sub_id) return c.json({ error: "Already subscribed \u2014 cancel existing slot first to change." }, 409);
27267
28034
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
27268
28035
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
27269
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
28036
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
27270
28037
  let customerId = user.stripe_customer_id;
27271
28038
  if (!customerId) {
27272
28039
  const customer = await stripeClient.customers.create({ email: user.email });
@@ -27312,10 +28079,10 @@ app.post("/billing/subscribe", requireAllowedOrigin, sessionAuth, async (c) => {
27312
28079
  const user = c.get("sessionUser");
27313
28080
  const raw = await c.req.json().catch(() => ({}));
27314
28081
  const tier = SUBSCRIPTION_TIER_BY_KEY[String(raw.tier ?? "")];
27315
- if (!tier) return c.json({ error: "Invalid tier. Choose starter, growth, or scale." }, 400);
28082
+ if (!tier) return c.json({ error: "Invalid subscription tier." }, 400);
27316
28083
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
27317
28084
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
27318
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
28085
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
27319
28086
  let customerId = user.stripe_customer_id;
27320
28087
  if (!customerId) {
27321
28088
  const customer = await stripeClient.customers.create({ email: user.email });
@@ -27367,7 +28134,7 @@ app.post("/billing/portal", requireAllowedOrigin, sessionAuth, async (c) => {
27367
28134
  if (!user.stripe_customer_id) return c.json({ error: "No billing account yet \u2014 subscribe first." }, 409);
27368
28135
  const secret2 = requireStripeSecret();
27369
28136
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
27370
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
28137
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
27371
28138
  const session = await stripeClient.billingPortal.sessions.create({
27372
28139
  customer: user.stripe_customer_id,
27373
28140
  return_url: `${appOrigin()}/billing`
@@ -27397,7 +28164,7 @@ app.post("/billing/concurrency/terminal-checkout", auth2, async (c) => {
27397
28164
  }
27398
28165
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
27399
28166
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
27400
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
28167
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
27401
28168
  let customerId = user.stripe_customer_id;
27402
28169
  if (!customerId) {
27403
28170
  const customer = await stripeClient.customers.create({ email: user.email });
@@ -27441,7 +28208,7 @@ app.post("/billing/subscribe/terminal-checkout", auth2, async (c) => {
27441
28208
  if (!tier) return c.json({ error: "Invalid tier. Choose starter, growth, or scale.", tiers: Object.keys(SUBSCRIPTION_TIER_BY_KEY) }, 400);
27442
28209
  const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
27443
28210
  if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
27444
- const stripeClient = new Stripe3(secret2, { apiVersion: STRIPE_API_VERSION2 });
28211
+ const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
27445
28212
  let customerId = user.stripe_customer_id;
27446
28213
  if (!customerId) {
27447
28214
  const customer = await stripeClient.customers.create({ email: user.email });
@@ -27502,7 +28269,7 @@ app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async
27502
28269
  if (!user.concurrency_stripe_sub_id) return c.json({ error: "No active concurrency subscription." }, 404);
27503
28270
  const stripeSecret = process.env.STRIPE_SECRET_KEY?.trim();
27504
28271
  if (!stripeSecret) return c.json({ error: "Stripe is not configured." }, 503);
27505
- const stripeClient = new Stripe3(stripeSecret, { apiVersion: STRIPE_API_VERSION2 });
28272
+ const stripeClient = new Stripe2(stripeSecret, { apiVersion: STRIPE_API_VERSION });
27506
28273
  await stripeClient.subscriptions.cancel(user.concurrency_stripe_sub_id);
27507
28274
  const nextSlots = Math.max(0, user.extra_concurrency_slots - 1);
27508
28275
  await setExtraConcurrencySlots(user.id, nextSlots);
@@ -27564,7 +28331,7 @@ app.get("/cron/tick", async (c) => {
27564
28331
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
27565
28332
  return c.json({ error: "Unauthorized" }, 401);
27566
28333
  }
27567
- const { drainQueue } = await import("./worker-JU5FX3NE.js");
28334
+ const { drainQueue } = await import("./worker-BJ64GCDE.js");
27568
28335
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
27569
28336
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
27570
28337
  const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup] = await Promise.all([
@@ -27592,15 +28359,15 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
27592
28359
  return c.json({ error: "Unauthorized" }, 401);
27593
28360
  }
27594
28361
  const jobId = c.req.param("id");
27595
- const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-O36SIFI2.js");
27596
- const { assembleExtractArtifacts } = await import("./extract-bundle-NFFVMUMT.js");
28362
+ const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-ZDTO7ODZ.js");
28363
+ const { assembleExtractArtifacts } = await import("./extract-bundle-YNXTFCC3.js");
27597
28364
  const job = await getExtractJob2(jobId);
27598
28365
  if (!job) return c.json({ error: "job not found" }, 404);
27599
28366
  const stored = await assembleExtractArtifacts(job, {});
27600
28367
  await completeExtractJob2(jobId, stored);
27601
28368
  let settlement = "already_settled_or_refunded";
27602
28369
  if (job.billedMc == null && job.userId != null) {
27603
- const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-O36SIFI2.js");
28370
+ const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-ZDTO7ODZ.js");
27604
28371
  const heldMc = Number(job.options.heldMc ?? 0);
27605
28372
  const successful = await countSuccessfulPages2(jobId);
27606
28373
  const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
@@ -27767,4 +28534,4 @@ app.get("/blog/:slug/", (c) => {
27767
28534
  export {
27768
28535
  app
27769
28536
  };
27770
- //# sourceMappingURL=server-LNCSY6ME.js.map
28537
+ //# sourceMappingURL=server-SHOYO52X.js.map