@canonry/canonry 4.177.3 → 4.178.1

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 (29) hide show
  1. package/assets/assets/{AuditHistoryPanel-YZvGU1la.js → AuditHistoryPanel-BCtMrEMF.js} +1 -1
  2. package/assets/assets/{BacklinksPage-CB2uJWWy.js → BacklinksPage-CIo2hA0i.js} +1 -1
  3. package/assets/assets/{ChartPrimitives-BoYe2M-i.js → ChartPrimitives-B_LDVvTy.js} +1 -1
  4. package/assets/assets/{HistoryPage-D9IxxbQ9.js → HistoryPage-D24U8cUG.js} +1 -1
  5. package/assets/assets/{MeasurementPropertyPage-CxgnnPdp.js → MeasurementPropertyPage-BKDdv-jJ.js} +1 -1
  6. package/assets/assets/ProjectPage-DrIs_ZuR.js +9 -0
  7. package/assets/assets/{RunRow-2KxOta8F.js → RunRow-B39IK7hK.js} +1 -1
  8. package/assets/assets/{RunsPage-ya8vTkyd.js → RunsPage-R4W1kD0B.js} +1 -1
  9. package/assets/assets/{SettingsPage-lTy3o2bo.js → SettingsPage-BAOGyt1F.js} +1 -1
  10. package/assets/assets/{SiteHealthSection-Cyu7MIv2.js → SiteHealthSection-Bx7yVNMQ.js} +3 -3
  11. package/assets/assets/{TrafficPage-DjhKfw57.js → TrafficPage-DL8RvM2R.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-Pwi-Dl9Q.js → TrafficSourceDetailPage-COzjakZa.js} +1 -1
  13. package/assets/assets/{extract-error-message-I1FVWHLH.js → extract-error-message-B3TERCQ-.js} +1 -1
  14. package/assets/assets/{index-DQVTRT9J.css → index-D0GbiVhZ.css} +1 -1
  15. package/assets/assets/index-xtxQtZRo.js +83 -0
  16. package/assets/assets/{react-sigma_core.esm.min-BX6mOEq5.js → react-sigma_core.esm.min-CbXAujub.js} +1 -1
  17. package/assets/index.html +2 -2
  18. package/dist/{chunk-5EH666EO.js → chunk-JKKHGDDC.js} +3048 -2058
  19. package/dist/{chunk-PQT3ECM3.js → chunk-OYDIND7Q.js} +178 -1
  20. package/dist/{chunk-6XUWDSLN.js → chunk-UTW6QOGP.js} +1356 -907
  21. package/dist/{chunk-QRE3DAAU.js → chunk-VIVLYBFX.js} +2 -2
  22. package/dist/{chunk-7ABFIVKO.js → chunk-WINVGL5B.js} +458 -137
  23. package/dist/cli.js +87 -5
  24. package/dist/index.js +4 -4
  25. package/dist/{intelligence-service-CHZGHFGS.js → intelligence-service-YSRPTRKP.js} +2 -2
  26. package/dist/mcp.js +5 -380
  27. package/package.json +10 -10
  28. package/assets/assets/ProjectPage-CPmaXzhL.js +0 -9
  29. package/assets/assets/index-BeLVKN3H.js +0 -83
@@ -1,15 +1,17 @@
1
1
  import {
2
2
  ApiClient,
3
+ CANONRY_MCP_TOOLKIT_NAMES,
3
4
  CanonryMcpToolNames,
4
5
  canonryMcpTools,
5
6
  configExists,
7
+ createCanonryMcpServer,
6
8
  getBundledSkillSnapshots,
7
9
  getConfigPath,
8
10
  isMachineFormat,
9
11
  loadConfig,
10
12
  loadConfigRaw,
11
13
  saveConfigPatch
12
- } from "./chunk-6XUWDSLN.js";
14
+ } from "./chunk-UTW6QOGP.js";
13
15
  import {
14
16
  CC_CACHE_DIR,
15
17
  DUCKDB_SPEC,
@@ -18,6 +20,7 @@ import {
18
20
  GSC_REPORTING_TIME_ZONE,
19
21
  IntelligenceService,
20
22
  PLUGIN_DIR,
23
+ USER_SESSION_COOKIE_NAME,
21
24
  adsAdGroups,
22
25
  adsAds,
23
26
  adsCampaigns,
@@ -48,7 +51,9 @@ import {
48
51
  countPopulatedGroups,
49
52
  crawlerEventsHourly,
50
53
  createClient,
54
+ createCredentialChecker,
51
55
  createLogger,
56
+ createUserSession,
52
57
  deliverWebhook,
53
58
  determineCitationState,
54
59
  discoverySessions,
@@ -90,9 +95,11 @@ import {
90
95
  gscUrlInspections,
91
96
  gtmConnections,
92
97
  gtmRawSnapshots,
98
+ hashApiKey,
93
99
  hashAttributes,
94
100
  hashLodging,
95
101
  hashPlaceDetails,
102
+ insightNotifyState,
96
103
  insights,
97
104
  inspectUrl,
98
105
  installDuckdb,
@@ -110,6 +117,7 @@ import {
110
117
  migrate,
111
118
  nextRunFromCron,
112
119
  notifications,
120
+ parseCookieHeader,
113
121
  parseJsonColumn,
114
122
  probeLatestRelease,
115
123
  projects,
@@ -122,15 +130,19 @@ import {
122
130
  readInstalledVersion,
123
131
  redactNotificationUrl,
124
132
  refreshAccessToken,
133
+ registerOAuthAdminRoutes,
134
+ registerOAuthRoutes,
125
135
  researchRunQueries,
126
136
  researchRuns,
127
137
  resolveDestination,
128
138
  resolveMeasurementSitemapTarget,
129
139
  resolveTrustProxy,
140
+ resolveUserSession,
130
141
  resolveVercelSyncDeadlineMs,
131
142
  resolveWebhookTarget,
132
143
  runs,
133
144
  schedules,
145
+ serializeUserSessionCookie,
134
146
  siteAuditPages,
135
147
  siteAuditSnapshots,
136
148
  siteCrawlAttempts,
@@ -145,7 +157,7 @@ import {
145
157
  siteCrawlSnapshots,
146
158
  toAlertView,
147
159
  usageCounters
148
- } from "./chunk-5EH666EO.js";
160
+ } from "./chunk-JKKHGDDC.js";
149
161
  import {
150
162
  AGENT_MEMORY_VALUE_MAX_BYTES,
151
163
  AGENT_PROVIDER_IDS,
@@ -208,6 +220,7 @@ import {
208
220
  canonicalizeGtmAccountId,
209
221
  canonicalizeGtmContainerId,
210
222
  canonicalizeGtmResourceSelection,
223
+ classifyProviderErrorMessages,
211
224
  classifyTemplateLinkEdge,
212
225
  cloudflareQueueNameSchema,
213
226
  computeBacklinkSummaryMetrics,
@@ -249,6 +262,7 @@ import {
249
262
  isAgentProviderId,
250
263
  isBrowserProvider,
251
264
  isGhostTelemetryEvent,
265
+ isReadOnlyKey,
252
266
  isRetryableHttpError,
253
267
  isSelfLink,
254
268
  isVertexGroundingRedirect,
@@ -282,7 +296,7 @@ import {
282
296
  validationError,
283
297
  winnabilityClassLabel,
284
298
  withRetry
285
- } from "./chunk-PQT3ECM3.js";
299
+ } from "./chunk-OYDIND7Q.js";
286
300
 
287
301
  // src/telemetry.ts
288
302
  import crypto from "crypto";
@@ -849,9 +863,11 @@ function dailyMetricClauses(options) {
849
863
  );
850
864
  }
851
865
  const filters = [
852
- `segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'`,
853
- "campaign.status != 'REMOVED'"
866
+ `segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'`
854
867
  ];
868
+ if (options.campaignIds === void 0) {
869
+ filters.push("campaign.status != 'REMOVED'");
870
+ }
855
871
  if (options.campaignIds !== void 0) {
856
872
  if (!Array.isArray(options.campaignIds) || options.campaignIds.length === 0) {
857
873
  throw new GoogleAdsApiError("Campaign IDs must be a non-empty array when provided", 400);
@@ -878,6 +894,22 @@ function dailyMetricClauses(options) {
878
894
  `LIMIT ${validatedLimit(options.limit)}`
879
895
  ];
880
896
  }
897
+ function buildCampaignSpendRankingQuery(options) {
898
+ const start = parseIsoDate(options.startDate, "Start date");
899
+ const end = parseIsoDate(options.endDate, "End date");
900
+ if (start > end) {
901
+ throw new GoogleAdsApiError("Start date must be on or before end date", 400);
902
+ }
903
+ return selectQuery([
904
+ "campaign.id",
905
+ "campaign.status",
906
+ "metrics.cost_micros"
907
+ ], "campaign", [
908
+ `WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'`,
909
+ "ORDER BY metrics.cost_micros DESC",
910
+ `LIMIT ${validatedLimit(options.limit)}`
911
+ ]);
912
+ }
881
913
  function buildCampaignsQuery(options = {}) {
882
914
  const clauses = [];
883
915
  if (!options.includeRemoved) clauses.push("WHERE campaign.status != 'REMOVED'");
@@ -1317,6 +1349,18 @@ var GoogleAdsClient = class {
1317
1349
  }
1318
1350
  };
1319
1351
  }
1352
+ /**
1353
+ * Campaigns ranked by spend over a window, removed ones included, so the
1354
+ * bounded daily-metrics query can be scoped to where the money actually went
1355
+ * rather than to the lowest campaign ids.
1356
+ */
1357
+ async getCampaignSpendRanking(customerId, options) {
1358
+ return this.#searchStream(
1359
+ customerId,
1360
+ "campaign-spend-ranking",
1361
+ buildCampaignSpendRankingQuery(options)
1362
+ );
1363
+ }
1320
1364
  async getDailyCampaignMetrics(customerId, options) {
1321
1365
  return this.#searchStream(
1322
1366
  customerId,
@@ -2816,19 +2860,21 @@ function campaignMetricDto(row) {
2816
2860
  conversionValueMicros: conversionValueMicros(row.metrics)
2817
2861
  };
2818
2862
  }
2819
- function defaultMetricsQuery(now, campaigns, timeZone) {
2820
- const campaignIds = campaigns.map((campaign) => campaign.id).sort((left, right) => left.localeCompare(right));
2821
- const selected = campaignIds.slice(0, GOOGLE_ADS_CAMPAIGN_METRICS_MAX_CAMPAIGNS);
2822
- if (selected.length === 0) return { query: null, inventoryTruncated: false };
2863
+ function defaultMetricsWindow(now, timeZone) {
2823
2864
  const customerTimeZone = typeof timeZone === "string" && timeZone.trim() !== "" ? timeZone : "UTC";
2824
2865
  const endDate = formatIsoDateInTimeZone(now.toISOString(), customerTimeZone);
2866
+ return { startDate: shiftIsoCalendarDate(endDate, -(GOOGLE_ADS_CAMPAIGN_METRICS_MAX_DAYS - 1)), endDate };
2867
+ }
2868
+ function defaultMetricsQuery(now, campaigns, timeZone, rankedCampaignIds) {
2869
+ const inventoryIds = campaigns.map((campaign) => campaign.id).sort((left, right) => left.localeCompare(right));
2870
+ const ranked = rankedCampaignIds ?? [];
2871
+ const ordered = ranked.length > 0 ? [...ranked, ...inventoryIds.filter((id) => !ranked.includes(id))] : inventoryIds;
2872
+ const selected = ordered.slice(0, GOOGLE_ADS_CAMPAIGN_METRICS_MAX_CAMPAIGNS);
2873
+ if (selected.length === 0) return { query: null, inventoryTruncated: false };
2874
+ const { startDate, endDate } = defaultMetricsWindow(now, timeZone);
2825
2875
  return {
2826
- query: googleAdsCampaignMetricsQuerySchema.parse({
2827
- campaignIds: selected,
2828
- startDate: shiftIsoCalendarDate(endDate, -(GOOGLE_ADS_CAMPAIGN_METRICS_MAX_DAYS - 1)),
2829
- endDate
2830
- }),
2831
- inventoryTruncated: campaignIds.length > selected.length
2876
+ query: googleAdsCampaignMetricsQuerySchema.parse({ campaignIds: selected, startDate, endDate }),
2877
+ inventoryTruncated: ordered.length > selected.length
2832
2878
  };
2833
2879
  }
2834
2880
  function countRedactedProviderValues(value) {
@@ -3131,7 +3177,21 @@ var DefaultGoogleMarketingRuntime = class {
3131
3177
  capturedAt
3132
3178
  );
3133
3179
  const effectiveGoalGraph = deriveGoogleAdsEffectiveGoalGraph(inventory);
3134
- const defaults = defaultMetricsQuery(this.#now(), inventory.campaigns, customerResult.data.timeZone);
3180
+ const rankingWindow = defaultMetricsWindow(this.#now(), customerResult.data.timeZone);
3181
+ const ranking = await client.getCampaignSpendRanking(customerId, {
3182
+ ...rankingWindow,
3183
+ limit: GOOGLE_ADS_CAMPAIGN_METRICS_MAX_CAMPAIGNS + 1
3184
+ }).then((result) => ({
3185
+ ids: result.data.map((row) => String(row.campaign.id)),
3186
+ failed: false
3187
+ })).catch(() => ({ ids: [], failed: true }));
3188
+ const rankingTruncated = ranking.ids.length > GOOGLE_ADS_CAMPAIGN_METRICS_MAX_CAMPAIGNS;
3189
+ const defaults = defaultMetricsQuery(
3190
+ this.#now(),
3191
+ inventory.campaigns,
3192
+ customerResult.data.timeZone,
3193
+ ranking.failed ? void 0 : ranking.ids.slice(0, GOOGLE_ADS_CAMPAIGN_METRICS_MAX_CAMPAIGNS)
3194
+ );
3135
3195
  const metricsQuery = suppliedMetricsQuery ?? defaults.query;
3136
3196
  if (!metricsQuery) {
3137
3197
  return {
@@ -3149,7 +3209,10 @@ var DefaultGoogleMarketingRuntime = class {
3149
3209
  const response = {
3150
3210
  query: metricsQuery,
3151
3211
  rows: metricRows.slice(0, GOOGLE_ADS_CAMPAIGN_METRICS_MAX_ROWS),
3152
- truncated: metricRows.length > GOOGLE_ADS_CAMPAIGN_METRICS_MAX_ROWS || suppliedMetricsQuery === void 0 && defaults.inventoryTruncated,
3212
+ truncated: metricRows.length > GOOGLE_ADS_CAMPAIGN_METRICS_MAX_ROWS || suppliedMetricsQuery === void 0 && defaults.inventoryTruncated || // Either the ranking could not be read (so selection fell back to an
3213
+ // order that omits removed campaigns) or it reported more campaigns
3214
+ // than the cap. Both mean these totals are a subset of the account.
3215
+ suppliedMetricsQuery === void 0 && (ranking.failed || rankingTruncated),
3153
3216
  fetchedAt: this.#now().toISOString()
3154
3217
  };
3155
3218
  const metricsPayload = {
@@ -3330,11 +3393,11 @@ function createGoogleMarketingRuntime(options) {
3330
3393
 
3331
3394
  // src/server.ts
3332
3395
  import { createRequire as createRequire4 } from "module";
3333
- import crypto26 from "crypto";
3396
+ import crypto27 from "crypto";
3334
3397
  import fs7 from "fs";
3335
3398
  import path8 from "path";
3336
3399
  import { fileURLToPath as fileURLToPath2 } from "url";
3337
- import { and as and19, eq as eq25 } from "drizzle-orm";
3400
+ import { and as and19, eq as eq26 } from "drizzle-orm";
3338
3401
  import Fastify from "fastify";
3339
3402
  import os4 from "os";
3340
3403
 
@@ -6349,40 +6412,7 @@ function classifyRunAbortReason(message) {
6349
6412
  return void 0;
6350
6413
  }
6351
6414
  function classifyProviderErrors(errors) {
6352
- const codes = /* @__PURE__ */ new Set();
6353
- for (const message of errors.values()) {
6354
- codes.add(classifyOneProviderError(message));
6355
- }
6356
- const priority = [
6357
- "PROVIDER_AUTH",
6358
- "RATE_LIMITED",
6359
- "TIMEOUT",
6360
- "NETWORK",
6361
- "PARSE_ERROR",
6362
- "UNKNOWN"
6363
- ];
6364
- for (const code of priority) {
6365
- if (codes.has(code)) return code;
6366
- }
6367
- return "UNKNOWN";
6368
- }
6369
- function classifyOneProviderError(message) {
6370
- if (/\b401\b|\b403\b|unauthorized|forbidden|invalid[_ -]?api[_ -]?key|missing[_ -]?api[_ -]?key|authentication/i.test(message)) {
6371
- return "PROVIDER_AUTH";
6372
- }
6373
- if (/\b429\b|rate[_ -]?limit|too many requests|quota[_ -]?exceeded/i.test(message)) {
6374
- return "RATE_LIMITED";
6375
- }
6376
- if (/timeout|timed out|ETIMEDOUT/i.test(message)) {
6377
- return "TIMEOUT";
6378
- }
6379
- if (/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network|fetch failed|socket hang up/i.test(message)) {
6380
- return "NETWORK";
6381
- }
6382
- if (/parse|unexpected token|invalid json|malformed|JSON\.parse/i.test(message)) {
6383
- return "PARSE_ERROR";
6384
- }
6385
- return "UNKNOWN";
6415
+ return classifyProviderErrorMessages(errors.values());
6386
6416
  }
6387
6417
  var JobRunner = class {
6388
6418
  /**
@@ -10855,6 +10885,31 @@ function partitionDeadLinks(deadLinks) {
10855
10885
  ];
10856
10886
  return { dead, unverified };
10857
10887
  }
10888
+ function reportedTermination(summary) {
10889
+ const limits = summary.limits;
10890
+ if (limits) {
10891
+ if (limits.maxBytes && (summary.bytesRead ?? 0) >= limits.maxBytes) return "max-bytes";
10892
+ if (limits.maxDurationMs && (summary.elapsedMs ?? 0) >= limits.maxDurationMs) return "max-duration";
10893
+ if (limits.maxFetches && (summary.fetchesStarted ?? 0) >= limits.maxFetches) return "max-fetches";
10894
+ }
10895
+ return summary.terminationReason ?? "complete";
10896
+ }
10897
+ function crawlBudgetsFor(maxPages) {
10898
+ return {
10899
+ // 2x the ~745 KB/page measured on a media-heavy production site, so the
10900
+ // byte cap is headroom rather than the thing that stops the crawl.
10901
+ maxBytes: maxPages * 15e5,
10902
+ // ~5 pages/s at the engine's default concurrency of 5, doubled for slow
10903
+ // origins, and never below the engine's own 2-minute floor.
10904
+ maxDurationMs: Math.max(12e4, Math.ceil(maxPages / 5 * 1e3 * 2)),
10905
+ // Redirects and dead-link probes cost fetches without producing pages.
10906
+ maxFetches: Math.ceil(maxPages * 1.5),
10907
+ // Raised off the default of 10 because it is an ADMISSION limit: a sitemap
10908
+ // with many query-parameter variants trips it during seeding and latches a
10909
+ // soft reason that then masks whatever really stops the crawl.
10910
+ maxQueryVariants: Math.max(50, Math.ceil(maxPages / 10))
10911
+ };
10912
+ }
10858
10913
  async function executeSiteAudit(db, runId, projectId, opts = {}) {
10859
10914
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10860
10915
  const claim = db.update(runs).set({ status: "running", startedAt }).where(and12(eq15(runs.id, runId), eq15(runs.projectId, projectId), eq15(runs.status, "queued"))).run();
@@ -11155,6 +11210,7 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
11155
11210
  maxEdges,
11156
11211
  maxDepth: opts.maxDepth ?? null
11157
11212
  });
11213
+ const budgets = crawlBudgetsFor(maxPages);
11158
11214
  const report = await runSiteCrawl(crawlRootUrl, {
11159
11215
  mode: "summary",
11160
11216
  sitemapUrl: opts.sitemapUrl,
@@ -11162,6 +11218,7 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
11162
11218
  maxEdges,
11163
11219
  maxDepth: opts.maxDepth,
11164
11220
  checkDeadLinks: opts.checkDeadLinks ?? false,
11221
+ ...budgets,
11165
11222
  signal: opts.signal,
11166
11223
  onEvent: persistEvent
11167
11224
  });
@@ -11288,7 +11345,7 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
11288
11345
  maxDepth: opts.maxDepth ?? null,
11289
11346
  checkDeadLinks: opts.checkDeadLinks ?? false,
11290
11347
  complete: crawlSummary.complete,
11291
- termination: crawlSummary.terminationReason ?? "complete",
11348
+ termination: reportedTermination(crawlSummary),
11292
11349
  detailsAvailable: true,
11293
11350
  pagesDiscovered: crawlSummary.pagesDiscovered,
11294
11351
  pagesFetched: crawlSummary.pagesFetched,
@@ -11839,7 +11896,7 @@ function readStoredGroundingSources(rawResponse) {
11839
11896
  return result;
11840
11897
  }
11841
11898
  async function backfillInsightsCommand(project, opts) {
11842
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-CHZGHFGS.js");
11899
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-YSRPTRKP.js");
11843
11900
  const config = loadConfig();
11844
11901
  const db = createClient(config.database);
11845
11902
  migrate(db);
@@ -12829,6 +12886,21 @@ async function refreshAllIntegrations(client, projectName) {
12829
12886
  import { eq as eq18, desc as desc7, and as and15, inArray as inArray7, or as or2 } from "drizzle-orm";
12830
12887
  import crypto21 from "crypto";
12831
12888
  var log16 = createLogger("Notifier");
12889
+ var MATERIAL_MAGNITUDE_DELTA = 20;
12890
+ var SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
12891
+ function severityRank(severity) {
12892
+ return SEVERITY_RANK[severity] ?? 0;
12893
+ }
12894
+ function insightFingerprint(insight) {
12895
+ return insight.title.replace(/\b\d+(?:\.\d+)?%/g, "N%");
12896
+ }
12897
+ function insightMagnitude(insight) {
12898
+ const match = /\b(\d+(?:\.\d+)?)%/.exec(insight.title);
12899
+ return match?.[1] === void 0 ? null : Math.round(Number(match[1]));
12900
+ }
12901
+ function insightNotifyKey(projectId, insight) {
12902
+ return `${projectId}:${insight.type}:${insight.query}:${insightFingerprint(insight)}`;
12903
+ }
12832
12904
  var Notifier = class {
12833
12905
  db;
12834
12906
  serverUrl;
@@ -12984,11 +13056,59 @@ var Notifier = class {
12984
13056
  log16.info("health.notified", { projectId, event, status, code, subscribers: notifs.length, delivered });
12985
13057
  return event;
12986
13058
  }
13059
+ /**
13060
+ * Has this finding already been said?
13061
+ *
13062
+ * The identity of a finding is its type, its subject and its wording with the
13063
+ * magnitude neutralised. So the same drop reported at 79% and at 80% over the
13064
+ * same window is one piece of news, while the same drop over an ADVANCED
13065
+ * window is a new one.
13066
+ *
13067
+ * Re-notifies on three things and nothing else:
13068
+ * - never sent before
13069
+ * - severity got worse (medium -> high -> critical)
13070
+ * - magnitude moved by more than MATERIAL_MAGNITUDE_DELTA points
13071
+ *
13072
+ * Deliberately NOT time-based. A daily repeat is exactly the behaviour this
13073
+ * exists to stop, and "re-alert after N days" is that behaviour with a delay.
13074
+ */
13075
+ isNewInsightNews(projectId, insight) {
13076
+ const key = insightNotifyKey(projectId, insight);
13077
+ const previous = this.db.select().from(insightNotifyState).where(eq18(insightNotifyState.key, key)).get();
13078
+ if (!previous) return true;
13079
+ if (severityRank(insight.severity) > severityRank(previous.severity)) return true;
13080
+ const magnitude = insightMagnitude(insight);
13081
+ if (magnitude !== null && previous.magnitude !== null && previous.magnitude !== void 0) {
13082
+ if (Math.abs(magnitude - previous.magnitude) > MATERIAL_MAGNITUDE_DELTA) return true;
13083
+ }
13084
+ return false;
13085
+ }
13086
+ /** Record a delivered insight so it stops being news. */
13087
+ rememberInsightNotified(projectId, insight) {
13088
+ const key = insightNotifyKey(projectId, insight);
13089
+ const row = {
13090
+ key,
13091
+ projectId,
13092
+ type: insight.type,
13093
+ subject: insight.query,
13094
+ fingerprint: insightFingerprint(insight),
13095
+ severity: insight.severity,
13096
+ magnitude: insightMagnitude(insight),
13097
+ notifiedAt: (/* @__PURE__ */ new Date()).toISOString()
13098
+ };
13099
+ const existing = this.db.select().from(insightNotifyState).where(eq18(insightNotifyState.key, key)).get();
13100
+ if (existing) {
13101
+ this.db.update(insightNotifyState).set(row).where(eq18(insightNotifyState.key, key)).run();
13102
+ } else {
13103
+ this.db.insert(insightNotifyState).values(row).run();
13104
+ }
13105
+ }
12987
13106
  /** Dispatch insight webhooks for critical/high severity insights after a run. */
12988
13107
  async dispatchInsightWebhooks(runId, projectId, result) {
12989
13108
  const insightEvents = [];
12990
- const criticalInsights = result.insights.filter((i) => i.severity === "critical");
12991
- const highInsights = result.insights.filter((i) => i.severity === "high");
13109
+ const unsent = result.insights.filter((i) => this.isNewInsightNews(projectId, i));
13110
+ const criticalInsights = unsent.filter((i) => i.severity === "critical");
13111
+ const highInsights = unsent.filter((i) => i.severity === "high");
12992
13112
  if (criticalInsights.length > 0) insightEvents.push("insight.critical");
12993
13113
  if (highInsights.length > 0) insightEvents.push("insight.high");
12994
13114
  if (insightEvents.length === 0) return;
@@ -13022,6 +13142,7 @@ var Notifier = class {
13022
13142
  dashboardUrl: `${this.serverUrl}/projects/${project.name}`
13023
13143
  };
13024
13144
  await this.sendWebhook(config.url, payload, notif.id, projectId, notif.webhookSecret ?? null);
13145
+ for (const insight of relevantInsights) this.rememberInsightNotified(projectId, insight);
13025
13146
  }
13026
13147
  }
13027
13148
  }
@@ -15185,10 +15306,159 @@ ${lines.join("\n")}
15185
15306
  }
15186
15307
  };
15187
15308
 
15188
- // src/agent/agent-routes.ts
15309
+ // src/mcp-http.ts
15310
+ import crypto26 from "crypto";
15189
15311
  import { eq as eq22 } from "drizzle-orm";
15312
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15313
+ var SESSION_IDLE_MS = 10 * 60 * 1e3;
15314
+ var SWEEP_INTERVAL_MS = 60 * 1e3;
15315
+ function mintSessionKey(db, scopes, userId) {
15316
+ const raw = `cnry_${crypto26.randomBytes(24).toString("hex")}`;
15317
+ const id = crypto26.randomUUID();
15318
+ db.insert(apiKeys).values({
15319
+ id,
15320
+ name: `mcp-session:${userId}`,
15321
+ keyHash: hashApiKey(raw),
15322
+ keyPrefix: raw.slice(0, 9),
15323
+ scopes: [...scopes],
15324
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
15325
+ }).run();
15326
+ return { id, raw };
15327
+ }
15328
+ function revokeSessionKey(db, id) {
15329
+ try {
15330
+ db.update(apiKeys).set({ revokedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq22(apiKeys.id, id)).run();
15331
+ } catch {
15332
+ }
15333
+ }
15334
+ function mcpTransportPaths() {
15335
+ const paths = ["/mcp", "/mcp/readonly"];
15336
+ for (const toolkit of CANONRY_MCP_TOOLKIT_NAMES) {
15337
+ paths.push(`/mcp/x/${toolkit}`, `/mcp/x/${toolkit}/readonly`);
15338
+ }
15339
+ return paths;
15340
+ }
15341
+ function registerMcpHttpRoutes(scope, opts) {
15342
+ const sessions = /* @__PURE__ */ new Map();
15343
+ const now = opts.now ?? (() => Date.now());
15344
+ async function dropSession(id) {
15345
+ const session = sessions.get(id);
15346
+ if (!session) return;
15347
+ sessions.delete(id);
15348
+ await session.close().catch(() => {
15349
+ });
15350
+ }
15351
+ const sweep = setInterval(() => {
15352
+ const cutoff = now() - SESSION_IDLE_MS;
15353
+ for (const [id, session] of sessions) {
15354
+ if (session.lastSeenAt < cutoff) void dropSession(id);
15355
+ }
15356
+ }, SWEEP_INTERVAL_MS);
15357
+ sweep.unref();
15358
+ if (opts.issuer) {
15359
+ scope.addHook("onSend", async (request, reply, payload) => {
15360
+ const isTransport = request.routeOptions.config?.transportEnvelope === true;
15361
+ if (isTransport && reply.statusCode === 401 && !reply.getHeader("WWW-Authenticate")) {
15362
+ const metadata = `${opts.issuer}/.well-known/oauth-protected-resource${request.routeOptions.url ?? ""}`;
15363
+ void reply.header("WWW-Authenticate", `Bearer resource_metadata="${metadata}"`);
15364
+ }
15365
+ return payload;
15366
+ });
15367
+ }
15368
+ scope.addHook("onClose", async () => {
15369
+ clearInterval(sweep);
15370
+ await Promise.all([...sessions.keys()].map((id) => dropSession(id)));
15371
+ });
15372
+ function callerBearer(request) {
15373
+ const header = request.headers.authorization;
15374
+ if (typeof header !== "string") return null;
15375
+ const trimmed = header.trim();
15376
+ const prefix = "bearer ";
15377
+ if (trimmed.length <= prefix.length) return null;
15378
+ if (trimmed.slice(0, prefix.length).toLowerCase() !== prefix) return null;
15379
+ return trimmed.slice(prefix.length).trim() || null;
15380
+ }
15381
+ async function openSession(request, keyId, segment) {
15382
+ const bearer = callerBearer(request);
15383
+ if (!bearer) return null;
15384
+ const scopes = request.principal?.scopes ?? request.apiKey?.scopes ?? [];
15385
+ let sessionKey = null;
15386
+ if (!request.apiKey && request.principal?.kind === "user") {
15387
+ sessionKey = mintSessionKey(opts.db, scopes, request.principal.id);
15388
+ }
15389
+ const client = new ApiClient(opts.selfApiUrl, sessionKey?.raw ?? bearer, { skipProbe: true });
15390
+ const server = createCanonryMcpServer({
15391
+ scope: segment.readOnly || isReadOnlyKey(scopes) ? "read-only" : "all",
15392
+ tiers: segment.tiers,
15393
+ clientFactory: () => client
15394
+ });
15395
+ const transport = new StreamableHTTPServerTransport({
15396
+ sessionIdGenerator: () => crypto26.randomUUID(),
15397
+ onsessioninitialized: (id) => {
15398
+ sessions.set(id, { transport, close, keyId, segmentId: segment.id, lastSeenAt: now() });
15399
+ }
15400
+ });
15401
+ async function close() {
15402
+ await transport.close();
15403
+ await server.close();
15404
+ if (sessionKey) revokeSessionKey(opts.db, sessionKey.id);
15405
+ }
15406
+ transport.onclose = () => {
15407
+ const id = transport.sessionId;
15408
+ if (id) sessions.delete(id);
15409
+ if (sessionKey) revokeSessionKey(opts.db, sessionKey.id);
15410
+ };
15411
+ await server.connect(transport);
15412
+ return { transport, close, keyId, segmentId: segment.id, lastSeenAt: now() };
15413
+ }
15414
+ async function handle(request, reply, segment) {
15415
+ const keyId = request.principal?.id ?? request.apiKey?.id;
15416
+ if (!keyId) {
15417
+ await reply.status(401).send({ error: { code: "AUTH_REQUIRED", message: "Authentication required." } });
15418
+ return;
15419
+ }
15420
+ const sessionId = request.headers["mcp-session-id"];
15421
+ const existing = typeof sessionId === "string" ? sessions.get(sessionId) : void 0;
15422
+ if (existing) {
15423
+ if (existing.keyId !== keyId || existing.segmentId !== segment.id) {
15424
+ await reply.status(404).send({ error: { code: "NOT_FOUND", message: "Unknown MCP session." } });
15425
+ return;
15426
+ }
15427
+ existing.lastSeenAt = now();
15428
+ await existing.transport.handleRequest(request.raw, reply.raw, request.body);
15429
+ return;
15430
+ }
15431
+ if (typeof sessionId === "string") {
15432
+ await reply.status(404).send({ error: { code: "NOT_FOUND", message: "Unknown MCP session." } });
15433
+ return;
15434
+ }
15435
+ const opened = await openSession(request, keyId, segment);
15436
+ if (!opened) {
15437
+ await reply.status(401).send({ error: { code: "AUTH_REQUIRED", message: "Authentication required." } });
15438
+ return;
15439
+ }
15440
+ await opened.transport.handleRequest(request.raw, reply.raw, request.body);
15441
+ }
15442
+ const config = { transportEnvelope: true };
15443
+ function mount(pathname, segment) {
15444
+ const run = (request, reply) => handle(request, reply, segment);
15445
+ scope.post(pathname, { config }, run);
15446
+ scope.get(pathname, { config }, run);
15447
+ scope.delete(pathname, { config }, run);
15448
+ }
15449
+ mount("/mcp", { id: "core", tiers: ["core"], readOnly: false });
15450
+ mount("/mcp/readonly", { id: "core:ro", tiers: ["core"], readOnly: true });
15451
+ for (const toolkit of CANONRY_MCP_TOOLKIT_NAMES) {
15452
+ const tiers = ["core", toolkit];
15453
+ mount(`/mcp/x/${toolkit}`, { id: toolkit, tiers, readOnly: false });
15454
+ mount(`/mcp/x/${toolkit}/readonly`, { id: `${toolkit}:ro`, tiers, readOnly: true });
15455
+ }
15456
+ }
15457
+
15458
+ // src/agent/agent-routes.ts
15459
+ import { eq as eq23 } from "drizzle-orm";
15190
15460
  function resolveProject(db, name) {
15191
- const row = db.select({ id: projects.id, name: projects.name }).from(projects).where(eq22(projects.name, name)).get();
15461
+ const row = db.select({ id: projects.id, name: projects.name }).from(projects).where(eq23(projects.name, name)).get();
15192
15462
  if (!row) throw notFound("project", name);
15193
15463
  return row;
15194
15464
  }
@@ -15197,7 +15467,7 @@ function registerAgentRoutes(app, opts) {
15197
15467
  "/projects/:name/agent/transcript",
15198
15468
  async (request) => {
15199
15469
  const project = resolveProject(opts.db, request.params.name);
15200
- const row = opts.db.select().from(agentSessions).where(eq22(agentSessions.projectId, project.id)).get();
15470
+ const row = opts.db.select().from(agentSessions).where(eq23(agentSessions.projectId, project.id)).get();
15201
15471
  if (!row) {
15202
15472
  return { messages: [], modelProvider: null, modelId: null, updatedAt: null };
15203
15473
  }
@@ -15221,7 +15491,7 @@ function registerAgentRoutes(app, opts) {
15221
15491
  async (request) => {
15222
15492
  const project = resolveProject(opts.db, request.params.name);
15223
15493
  opts.sessionRegistry.reset(project.name);
15224
- opts.db.update(agentSessions).set({ messages: "[]", followUpQueue: "[]", updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq22(agentSessions.projectId, project.id)).run();
15494
+ opts.db.update(agentSessions).set({ messages: "[]", followUpQueue: "[]", updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq23(agentSessions.projectId, project.id)).run();
15225
15495
  return { status: "reset" };
15226
15496
  }
15227
15497
  );
@@ -16164,20 +16434,20 @@ function clipText(value, length) {
16164
16434
  }
16165
16435
 
16166
16436
  // src/research-runner.ts
16167
- import { and as and17, eq as eq23, inArray as inArray8, sql as sql10 } from "drizzle-orm";
16437
+ import { and as and17, eq as eq24, inArray as inArray8, sql as sql10 } from "drizzle-orm";
16168
16438
  var unfinishedResearchQueryStatuses = [ResearchQueryStatuses.queued, ResearchQueryStatuses.running];
16169
16439
  function finalResearchRunStatus(completed, failed) {
16170
16440
  if (failed === 0) return ResearchRunStatuses.completed;
16171
16441
  return completed > 0 ? ResearchRunStatuses.partial : ResearchRunStatuses.failed;
16172
16442
  }
16173
16443
  async function executeResearchRun(db, registry, runId, projectId) {
16174
- const run = db.select().from(researchRuns).where(and17(eq23(researchRuns.id, runId), eq23(researchRuns.projectId, projectId))).get();
16444
+ const run = db.select().from(researchRuns).where(and17(eq24(researchRuns.id, runId), eq24(researchRuns.projectId, projectId))).get();
16175
16445
  if (!run || run.status !== ResearchRunStatuses.queued) return;
16176
- const project = db.select().from(projects).where(eq23(projects.id, projectId)).get();
16446
+ const project = db.select().from(projects).where(eq24(projects.id, projectId)).get();
16177
16447
  if (!project) return;
16178
16448
  const provider = registry.get(run.provider);
16179
16449
  const now = (/* @__PURE__ */ new Date()).toISOString();
16180
- const claim = db.update(researchRuns).set({ status: ResearchRunStatuses.running, startedAt: now }).where(and17(eq23(researchRuns.id, runId), eq23(researchRuns.status, ResearchRunStatuses.queued))).run();
16450
+ const claim = db.update(researchRuns).set({ status: ResearchRunStatuses.running, startedAt: now }).where(and17(eq24(researchRuns.id, runId), eq24(researchRuns.status, ResearchRunStatuses.queued))).run();
16181
16451
  if (claim.changes !== 1) return;
16182
16452
  let reserved = 0;
16183
16453
  let dispatched = 0;
@@ -16201,8 +16471,8 @@ async function executeResearchRun(db, registry, runId, projectId) {
16201
16471
  }
16202
16472
  reserved = run.totalQueries;
16203
16473
  reservation = { scope, period };
16204
- const rows = db.select().from(researchRunQueries).where(eq23(researchRunQueries.researchRunId, runId)).orderBy(researchRunQueries.position).all();
16205
- const competitorDomains = db.select({ domain: competitors.domain }).from(competitors).where(eq23(competitors.projectId, projectId)).all().map((row) => row.domain);
16474
+ const rows = db.select().from(researchRunQueries).where(eq24(researchRunQueries.researchRunId, runId)).orderBy(researchRunQueries.position).all();
16475
+ const competitorDomains = db.select({ domain: competitors.domain }).from(competitors).where(eq24(competitors.projectId, projectId)).all().map((row) => row.domain);
16206
16476
  const domains = effectiveDomains(project);
16207
16477
  const brands = effectiveBrandNames(project);
16208
16478
  const config = { ...provider.config, model: run.resolvedModel };
@@ -16210,7 +16480,7 @@ async function executeResearchRun(db, registry, runId, projectId) {
16210
16480
  await mapWithConcurrency(rows, Math.max(1, provider.config.quotaPolicy.maxConcurrency), async (row) => {
16211
16481
  try {
16212
16482
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
16213
- db.update(researchRunQueries).set({ status: ResearchQueryStatuses.running, startedAt }).where(and17(eq23(researchRunQueries.id, row.id), eq23(researchRunQueries.status, ResearchQueryStatuses.queued))).run();
16483
+ db.update(researchRunQueries).set({ status: ResearchQueryStatuses.running, startedAt }).where(and17(eq24(researchRunQueries.id, row.id), eq24(researchRunQueries.status, ResearchQueryStatuses.queued))).run();
16214
16484
  const raw = await gate.run(async () => {
16215
16485
  dispatched++;
16216
16486
  return provider.adapter.executeTrackedQuery({ query: row.queryText, canonicalDomains: domains, competitorDomains, ...run.location ? { location: run.location } : {} }, config);
@@ -16237,7 +16507,7 @@ async function executeResearchRun(db, registry, runId, projectId) {
16237
16507
  citationState: determineCitationState(normalized, domains),
16238
16508
  rawResponse: raw.rawResponse,
16239
16509
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
16240
- }).where(and17(eq23(researchRunQueries.id, row.id), eq23(researchRunQueries.status, ResearchQueryStatuses.running))).run();
16510
+ }).where(and17(eq24(researchRunQueries.id, row.id), eq24(researchRunQueries.status, ResearchQueryStatuses.running))).run();
16241
16511
  if (completed.changes === 1) incrementResearchProgress(db, runId, "completedQueries");
16242
16512
  } catch (error) {
16243
16513
  try {
@@ -16261,18 +16531,18 @@ async function executeResearchRun(db, registry, runId, projectId) {
16261
16531
  }
16262
16532
  }
16263
16533
  function incrementResearchProgress(db, runId, column) {
16264
- db.update(researchRuns).set({ [column]: sql10`${researchRuns[column]} + 1` }).where(eq23(researchRuns.id, runId)).run();
16534
+ db.update(researchRuns).set({ [column]: sql10`${researchRuns[column]} + 1` }).where(eq24(researchRuns.id, runId)).run();
16265
16535
  }
16266
16536
  function markResearchQueryFailed(db, runId, queryId, error) {
16267
16537
  const message = describeError(error);
16268
- const failed = db.update(researchRunQueries).set({ status: ResearchQueryStatuses.failed, error: message, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and17(eq23(researchRunQueries.id, queryId), inArray8(researchRunQueries.status, unfinishedResearchQueryStatuses))).run();
16538
+ const failed = db.update(researchRunQueries).set({ status: ResearchQueryStatuses.failed, error: message, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and17(eq24(researchRunQueries.id, queryId), inArray8(researchRunQueries.status, unfinishedResearchQueryStatuses))).run();
16269
16539
  if (failed.changes === 1) incrementResearchProgress(db, runId, "failedQueries");
16270
16540
  }
16271
16541
  function markUnfinishedResearchQueriesFailed(db, runId, error) {
16272
- db.update(researchRunQueries).set({ status: ResearchQueryStatuses.failed, error, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and17(eq23(researchRunQueries.researchRunId, runId), inArray8(researchRunQueries.status, unfinishedResearchQueryStatuses))).run();
16542
+ db.update(researchRunQueries).set({ status: ResearchQueryStatuses.failed, error, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and17(eq24(researchRunQueries.researchRunId, runId), inArray8(researchRunQueries.status, unfinishedResearchQueryStatuses))).run();
16273
16543
  }
16274
16544
  function finalizeResearchRun(db, runId, fatalError) {
16275
- const rows = db.select({ status: researchRunQueries.status }).from(researchRunQueries).where(eq23(researchRunQueries.researchRunId, runId)).all();
16545
+ const rows = db.select({ status: researchRunQueries.status }).from(researchRunQueries).where(eq24(researchRunQueries.researchRunId, runId)).all();
16276
16546
  const completed = rows.filter((row) => row.status === ResearchQueryStatuses.completed).length;
16277
16547
  const failed = rows.filter((row) => row.status === ResearchQueryStatuses.failed).length;
16278
16548
  db.update(researchRuns).set({
@@ -16281,11 +16551,11 @@ function finalizeResearchRun(db, runId, fatalError) {
16281
16551
  failedQueries: failed,
16282
16552
  error: fatalError ?? (failed === rows.length ? "Every research query failed." : null),
16283
16553
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
16284
- }).where(eq23(researchRuns.id, runId)).run();
16554
+ }).where(eq24(researchRuns.id, runId)).run();
16285
16555
  }
16286
16556
 
16287
16557
  // src/google-marketing-sync.ts
16288
- import { and as and18, eq as eq24, isNull as isNull3 } from "drizzle-orm";
16558
+ import { and as and18, eq as eq25, isNull as isNull3 } from "drizzle-orm";
16289
16559
  function safeErrorMessage(error) {
16290
16560
  const raw = describeError(error);
16291
16561
  return raw.replace(/(["']?authorization["']?\s*[:=]\s*["']?bearer\s+)[^\s,"'}]+/gi, "$1[redacted]").replace(/(["']?(?:developer|access|refresh|client)[_-]?(?:token|secret)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, "$1[redacted]").replace(/[\r\n\t]+/g, " ").slice(0, 1e3);
@@ -16309,9 +16579,9 @@ function assertSnapshotContext(metadata, expected) {
16309
16579
  }
16310
16580
  function requireSyncRun(db, runId, projectId, kind) {
16311
16581
  const run = db.select().from(runs).where(and18(
16312
- eq24(runs.id, runId),
16313
- eq24(runs.projectId, projectId),
16314
- eq24(runs.kind, kind)
16582
+ eq25(runs.id, runId),
16583
+ eq25(runs.projectId, projectId),
16584
+ eq25(runs.kind, kind)
16315
16585
  )).get();
16316
16586
  if (!run) throw new Error(`Run ${runId} is not a ${kind} run for this project.`);
16317
16587
  if (run.status !== RunStatuses.queued) {
@@ -16325,18 +16595,18 @@ function markFailed(db, runId, projectId, kind, expectedStatus, error) {
16325
16595
  error: serializeRunError({ message: safeErrorMessage(error) }),
16326
16596
  finishedAt: (/* @__PURE__ */ new Date()).toISOString()
16327
16597
  }).where(and18(
16328
- eq24(runs.id, runId),
16329
- eq24(runs.projectId, projectId),
16330
- eq24(runs.kind, kind),
16331
- eq24(runs.status, expectedStatus)
16598
+ eq25(runs.id, runId),
16599
+ eq25(runs.projectId, projectId),
16600
+ eq25(runs.kind, kind),
16601
+ eq25(runs.status, expectedStatus)
16332
16602
  )).run();
16333
16603
  }
16334
16604
  function claimSyncRun(db, runId, projectId, kind, startedAt) {
16335
16605
  const claim = db.update(runs).set({ status: RunStatuses.running, startedAt, error: null }).where(and18(
16336
- eq24(runs.id, runId),
16337
- eq24(runs.projectId, projectId),
16338
- eq24(runs.kind, kind),
16339
- eq24(runs.status, RunStatuses.queued)
16606
+ eq25(runs.id, runId),
16607
+ eq25(runs.projectId, projectId),
16608
+ eq25(runs.kind, kind),
16609
+ eq25(runs.status, RunStatuses.queued)
16340
16610
  )).run();
16341
16611
  return claim.changes === 1;
16342
16612
  }
@@ -16344,9 +16614,9 @@ async function executeGoogleAdsMarketingSync(db, runtime, runId, projectId) {
16344
16614
  let claimed = false;
16345
16615
  try {
16346
16616
  requireSyncRun(db, runId, projectId, RunKinds["google-ads-sync"]);
16347
- const project = db.select().from(projects).where(eq24(projects.id, projectId)).get();
16617
+ const project = db.select().from(projects).where(eq25(projects.id, projectId)).get();
16348
16618
  if (!project) throw new Error(`Project ${projectId} was not found.`);
16349
- const connection = db.select().from(googleAdsConnections).where(eq24(googleAdsConnections.projectId, projectId)).get();
16619
+ const connection = db.select().from(googleAdsConnections).where(eq25(googleAdsConnections.projectId, projectId)).get();
16350
16620
  if (!connection?.selectedCustomerId) {
16351
16621
  throw new Error("Google Ads needs a selected customer before it can sync.");
16352
16622
  }
@@ -16398,10 +16668,10 @@ async function executeGoogleAdsMarketingSync(db, runtime, runId, projectId) {
16398
16668
  finishedAt,
16399
16669
  error: null
16400
16670
  }).where(and18(
16401
- eq24(runs.id, runId),
16402
- eq24(runs.projectId, projectId),
16403
- eq24(runs.kind, RunKinds["google-ads-sync"]),
16404
- eq24(runs.status, RunStatuses.running)
16671
+ eq25(runs.id, runId),
16672
+ eq25(runs.projectId, projectId),
16673
+ eq25(runs.kind, RunKinds["google-ads-sync"]),
16674
+ eq25(runs.status, RunStatuses.running)
16405
16675
  )).run();
16406
16676
  if (terminalClaim.changes !== 1) {
16407
16677
  throw new Error(`Run ${runId} was no longer running when Google Ads evidence was ready.`);
@@ -16419,11 +16689,11 @@ async function executeGoogleAdsMarketingSync(db, runtime, runId, projectId) {
16419
16689
  lastMetricsSnapshotId: result.metrics?.metadata.id ?? connection.lastMetricsSnapshotId,
16420
16690
  updatedAt: finishedAt
16421
16691
  }).where(and18(
16422
- eq24(googleAdsConnections.id, connection.id),
16423
- eq24(googleAdsConnections.projectId, projectId),
16424
- eq24(googleAdsConnections.selectedCustomerId, connection.selectedCustomerId),
16425
- connection.selectedLoginCustomerId === null ? isNull3(googleAdsConnections.selectedLoginCustomerId) : eq24(googleAdsConnections.selectedLoginCustomerId, connection.selectedLoginCustomerId),
16426
- eq24(googleAdsConnections.selectionGeneration, connection.selectionGeneration)
16692
+ eq25(googleAdsConnections.id, connection.id),
16693
+ eq25(googleAdsConnections.projectId, projectId),
16694
+ eq25(googleAdsConnections.selectedCustomerId, connection.selectedCustomerId),
16695
+ connection.selectedLoginCustomerId === null ? isNull3(googleAdsConnections.selectedLoginCustomerId) : eq25(googleAdsConnections.selectedLoginCustomerId, connection.selectedLoginCustomerId),
16696
+ eq25(googleAdsConnections.selectionGeneration, connection.selectionGeneration)
16427
16697
  )).run();
16428
16698
  if (connectionClaim.changes !== 1) {
16429
16699
  throw new Error("Google Ads selection changed while its evidence was being collected.");
@@ -16450,9 +16720,9 @@ async function executeGtmMarketingSync(db, runtime, runId, projectId) {
16450
16720
  let claimed = false;
16451
16721
  try {
16452
16722
  requireSyncRun(db, runId, projectId, RunKinds["gtm-sync"]);
16453
- const project = db.select().from(projects).where(eq24(projects.id, projectId)).get();
16723
+ const project = db.select().from(projects).where(eq25(projects.id, projectId)).get();
16454
16724
  if (!project) throw new Error(`Project ${projectId} was not found.`);
16455
- const connection = db.select().from(gtmConnections).where(eq24(gtmConnections.projectId, projectId)).get();
16725
+ const connection = db.select().from(gtmConnections).where(eq25(gtmConnections.projectId, projectId)).get();
16456
16726
  if (!connection?.selectedAccountId || !connection.selectedContainerId) {
16457
16727
  throw new Error("GTM needs a selected account and container before it can sync.");
16458
16728
  }
@@ -16488,10 +16758,10 @@ async function executeGtmMarketingSync(db, runtime, runId, projectId) {
16488
16758
  finishedAt,
16489
16759
  error: null
16490
16760
  }).where(and18(
16491
- eq24(runs.id, runId),
16492
- eq24(runs.projectId, projectId),
16493
- eq24(runs.kind, RunKinds["gtm-sync"]),
16494
- eq24(runs.status, RunStatuses.running)
16761
+ eq25(runs.id, runId),
16762
+ eq25(runs.projectId, projectId),
16763
+ eq25(runs.kind, RunKinds["gtm-sync"]),
16764
+ eq25(runs.status, RunStatuses.running)
16495
16765
  )).run();
16496
16766
  if (terminalClaim.changes !== 1) {
16497
16767
  throw new Error(`Run ${runId} was no longer running when GTM evidence was ready.`);
@@ -16506,12 +16776,12 @@ async function executeGtmMarketingSync(db, runtime, runId, projectId) {
16506
16776
  lastSnapshotId: snapshot.metadata.id,
16507
16777
  updatedAt: finishedAt
16508
16778
  }).where(and18(
16509
- eq24(gtmConnections.id, connection.id),
16510
- eq24(gtmConnections.projectId, projectId),
16511
- eq24(gtmConnections.selectedAccountId, connection.selectedAccountId),
16512
- eq24(gtmConnections.selectedContainerId, connection.selectedContainerId),
16513
- connection.selectedWorkspaceId === null ? isNull3(gtmConnections.selectedWorkspaceId) : eq24(gtmConnections.selectedWorkspaceId, connection.selectedWorkspaceId),
16514
- eq24(gtmConnections.selectionGeneration, connection.selectionGeneration)
16779
+ eq25(gtmConnections.id, connection.id),
16780
+ eq25(gtmConnections.projectId, projectId),
16781
+ eq25(gtmConnections.selectedAccountId, connection.selectedAccountId),
16782
+ eq25(gtmConnections.selectedContainerId, connection.selectedContainerId),
16783
+ connection.selectedWorkspaceId === null ? isNull3(gtmConnections.selectedWorkspaceId) : eq25(gtmConnections.selectedWorkspaceId, connection.selectedWorkspaceId),
16784
+ eq25(gtmConnections.selectionGeneration, connection.selectionGeneration)
16515
16785
  )).run();
16516
16786
  if (connectionClaim.changes !== 1) {
16517
16787
  throw new Error("GTM selection changed while its evidence was being collected.");
@@ -16597,15 +16867,15 @@ function summarizeProviderConfig(config) {
16597
16867
  quota: { ...config?.quota ?? DEFAULT_QUOTA }
16598
16868
  };
16599
16869
  }
16600
- function hashApiKey(key) {
16601
- return crypto26.createHash("sha256").update(key).digest("hex");
16870
+ function hashApiKey2(key) {
16871
+ return crypto27.createHash("sha256").update(key).digest("hex");
16602
16872
  }
16603
16873
  var DASHBOARD_SCRYPT_KEYLEN = 64;
16604
16874
  var DASHBOARD_SCRYPT_COST = 1 << 15;
16605
16875
  var DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024;
16606
16876
  function hashDashboardPassword(password) {
16607
- const salt = crypto26.randomBytes(16);
16608
- const derived = crypto26.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, {
16877
+ const salt = crypto27.randomBytes(16);
16878
+ const derived = crypto27.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, {
16609
16879
  N: DASHBOARD_SCRYPT_COST,
16610
16880
  maxmem: DASHBOARD_SCRYPT_MAXMEM
16611
16881
  });
@@ -16626,23 +16896,23 @@ function verifyDashboardPassword(password, storedHash) {
16626
16896
  } catch {
16627
16897
  return { ok: false, needsRehash: false };
16628
16898
  }
16629
- const derived = crypto26.scryptSync(password, salt, expected.length, {
16899
+ const derived = crypto27.scryptSync(password, salt, expected.length, {
16630
16900
  N: DASHBOARD_SCRYPT_COST,
16631
16901
  maxmem: DASHBOARD_SCRYPT_MAXMEM
16632
16902
  });
16633
16903
  if (derived.length !== expected.length)
16634
16904
  return { ok: false, needsRehash: false };
16635
16905
  return {
16636
- ok: crypto26.timingSafeEqual(derived, expected),
16906
+ ok: crypto27.timingSafeEqual(derived, expected),
16637
16907
  needsRehash: false
16638
16908
  };
16639
16909
  }
16640
16910
  if (/^[a-f0-9]{64}$/i.test(storedHash)) {
16641
- const candidate = Buffer.from(hashApiKey(password), "hex");
16911
+ const candidate = Buffer.from(hashApiKey2(password), "hex");
16642
16912
  const expected = Buffer.from(storedHash, "hex");
16643
16913
  if (candidate.length !== expected.length)
16644
16914
  return { ok: false, needsRehash: false };
16645
- const ok = crypto26.timingSafeEqual(candidate, expected);
16915
+ const ok = crypto27.timingSafeEqual(candidate, expected);
16646
16916
  return { ok, needsRehash: ok };
16647
16917
  }
16648
16918
  return { ok: false, needsRehash: false };
@@ -16778,7 +17048,7 @@ function cloneGtmConfig(config) {
16778
17048
  function createGoogleMarketingConfigCredentialStore(input) {
16779
17049
  const persistConfigPatch = input.saveConfigPatch ?? saveConfigPatch;
16780
17050
  const env = input.env ?? process.env;
16781
- const randomUUID = input.randomUUID ?? crypto26.randomUUID;
17051
+ const randomUUID = input.randomUUID ?? crypto27.randomUUID;
16782
17052
  const snapshot = () => ({
16783
17053
  googleAds: cloneGoogleAdsConfig(input.config.googleAds),
16784
17054
  gtm: cloneGtmConfig(input.config.gtm)
@@ -16970,7 +17240,7 @@ async function createServer(opts) {
16970
17240
  (runId, projectId, result) => notifier.dispatchInsightWebhooks(runId, projectId, result),
16971
17241
  async (ctx) => {
16972
17242
  if (!sessionRegistry) return;
16973
- const project = opts.db.select({ name: projects.name }).from(projects).where(eq25(projects.id, ctx.projectId)).get();
17243
+ const project = opts.db.select({ name: projects.name }).from(projects).where(eq26(projects.id, ctx.projectId)).get();
16974
17244
  if (!project) return;
16975
17245
  let content;
16976
17246
  if (ctx.kind === RunKinds["aeo-discover-probe"]) {
@@ -17413,7 +17683,7 @@ async function createServer(opts) {
17413
17683
  void (async () => {
17414
17684
  try {
17415
17685
  const report = await aeroClient.runDoctor({ project: projectName });
17416
- const project = opts.db.select().from(projects).where(eq25(projects.name, projectName)).get();
17686
+ const project = opts.db.select().from(projects).where(eq26(projects.name, projectName)).get();
17417
17687
  if (!project) {
17418
17688
  app.log.warn({ projectName }, "doctor schedule fired for an unknown project");
17419
17689
  return;
@@ -17442,8 +17712,8 @@ async function createServer(opts) {
17442
17712
  if (!probed) return;
17443
17713
  const alreadySynced = opts.db.select().from(ccReleaseSyncs).where(
17444
17714
  and19(
17445
- eq25(ccReleaseSyncs.release, probed.release),
17446
- eq25(ccReleaseSyncs.status, CcReleaseSyncStatuses.ready)
17715
+ eq26(ccReleaseSyncs.release, probed.release),
17716
+ eq26(ccReleaseSyncs.status, CcReleaseSyncStatuses.ready)
17447
17717
  )
17448
17718
  ).limit(1).get();
17449
17719
  if (alreadySynced) {
@@ -17624,7 +17894,7 @@ async function createServer(opts) {
17624
17894
  return removed;
17625
17895
  }
17626
17896
  };
17627
- const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto26.randomBytes(32).toString("hex");
17897
+ const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto27.randomBytes(32).toString("hex");
17628
17898
  const googleConnectionStore = {
17629
17899
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
17630
17900
  getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
@@ -17697,14 +17967,26 @@ async function createServer(opts) {
17697
17967
  "Dashboard password gate resolved"
17698
17968
  );
17699
17969
  const apiPrefix = basePath ? `${basePath}api/v1` : "/api/v1";
17970
+ const credentialChecker = createCredentialChecker({
17971
+ db: opts.db,
17972
+ trustProxyConfigured: trustProxy !== false
17973
+ });
17700
17974
  const googlePublicUrl = resolveGooglePublicUrl(opts.config, basePath);
17975
+ const publicOrigin = (() => {
17976
+ if (!googlePublicUrl) return void 0;
17977
+ try {
17978
+ return new URL(googlePublicUrl).origin;
17979
+ } catch {
17980
+ return void 0;
17981
+ }
17982
+ })();
17701
17983
  if (opts.config.apiKey) {
17702
- const keyHash = hashApiKey(opts.config.apiKey);
17703
- const existing = opts.db.select().from(apiKeys).where(eq25(apiKeys.keyHash, keyHash)).get();
17984
+ const keyHash = hashApiKey2(opts.config.apiKey);
17985
+ const existing = opts.db.select().from(apiKeys).where(eq26(apiKeys.keyHash, keyHash)).get();
17704
17986
  if (!existing) {
17705
17987
  const prefix = opts.config.apiKey.slice(0, 12);
17706
17988
  opts.db.insert(apiKeys).values({
17707
- id: `key_${crypto26.randomBytes(8).toString("hex")}`,
17989
+ id: `key_${crypto27.randomBytes(8).toString("hex")}`,
17708
17990
  name: "default",
17709
17991
  keyHash,
17710
17992
  keyPrefix: prefix,
@@ -17728,7 +18010,7 @@ async function createServer(opts) {
17728
18010
  };
17729
18011
  const createSession = (apiKeyId) => {
17730
18012
  pruneExpiredSessions();
17731
- const sessionId = crypto26.randomBytes(32).toString("hex");
18013
+ const sessionId = crypto27.randomBytes(32).toString("hex");
17732
18014
  sessions.set(sessionId, {
17733
18015
  apiKeyId,
17734
18016
  expiresAt: Date.now() + SESSION_TTL_MS
@@ -17752,7 +18034,7 @@ async function createServer(opts) {
17752
18034
  };
17753
18035
  const getDefaultApiKey = () => {
17754
18036
  if (!opts.config.apiKey) return void 0;
17755
- return opts.db.select().from(apiKeys).where(eq25(apiKeys.keyHash, hashApiKey(opts.config.apiKey))).get();
18037
+ return opts.db.select().from(apiKeys).where(eq26(apiKeys.keyHash, hashApiKey2(opts.config.apiKey))).get();
17756
18038
  };
17757
18039
  const createPasswordSession = (reply) => {
17758
18040
  const key = getDefaultApiKey();
@@ -17776,7 +18058,7 @@ async function createServer(opts) {
17776
18058
  if (!header) return false;
17777
18059
  const parts = header.split(" ");
17778
18060
  if (parts.length !== 2 || parts[0] !== "Bearer") return false;
17779
- const key = opts.db.select().from(apiKeys).where(eq25(apiKeys.keyHash, hashApiKey(parts[1]))).get();
18061
+ const key = opts.db.select().from(apiKeys).where(eq26(apiKeys.keyHash, hashApiKey2(parts[1]))).get();
17780
18062
  return Boolean(key && !key.revokedAt);
17781
18063
  };
17782
18064
  const namedAccountsInUse = () => anyUsersExist(opts.db);
@@ -17866,12 +18148,12 @@ async function createServer(opts) {
17866
18148
  return reply.send({ authenticated: true });
17867
18149
  }
17868
18150
  if (apiKey) {
17869
- const key = opts.db.select().from(apiKeys).where(eq25(apiKeys.keyHash, hashApiKey(apiKey))).get();
18151
+ const key = opts.db.select().from(apiKeys).where(eq26(apiKeys.keyHash, hashApiKey2(apiKey))).get();
17870
18152
  if (!key || key.revokedAt) {
17871
18153
  const err2 = authInvalid();
17872
18154
  return reply.status(err2.statusCode).send(err2.toJSON());
17873
18155
  }
17874
- opts.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq25(apiKeys.id, key.id)).run();
18156
+ opts.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq26(apiKeys.id, key.id)).run();
17875
18157
  const sessionId = createSession(key.id);
17876
18158
  reply.header(
17877
18159
  "set-cookie",
@@ -18004,7 +18286,11 @@ async function createServer(opts) {
18004
18286
  vercelSyncDeadlineMs: resolveVercelSyncDeadlineMs(process.env),
18005
18287
  // Local-only Aero agent routes. Registered here so they inherit api-routes'
18006
18288
  // auth plugin — bare `registerAgentRoutes(app, ...)` would skip auth.
18289
+ credentials: credentialChecker,
18290
+ oauthResourceUrl: publicOrigin ? `${publicOrigin}${`${basePath ?? "/"}api/v1/mcp`.replace("//", "/")}` : void 0,
18007
18291
  registerAuthenticatedRoutes: async (scope) => {
18292
+ registerMcpHttpRoutes(scope, { selfApiUrl: opts.config.apiUrl, issuer: publicOrigin, db: opts.db });
18293
+ registerOAuthAdminRoutes(scope, { db: opts.db });
18008
18294
  if (!sessionRegistry) return;
18009
18295
  registerAgentRoutes(scope, { db: opts.db, sessionRegistry });
18010
18296
  },
@@ -18015,8 +18301,8 @@ async function createServer(opts) {
18015
18301
  getAgentProviderSummary: () => buildAgentProvidersResponse(opts.config).providers,
18016
18302
  getGoogleMarketingDoctorInput: (ctx) => {
18017
18303
  if (!ctx.project) return null;
18018
- const googleAds = opts.db.select().from(googleAdsConnections).where(eq25(googleAdsConnections.projectId, ctx.project.id)).get();
18019
- const gtm = opts.db.select().from(gtmConnections).where(eq25(gtmConnections.projectId, ctx.project.id)).get();
18304
+ const googleAds = opts.db.select().from(googleAdsConnections).where(eq26(googleAdsConnections.projectId, ctx.project.id)).get();
18305
+ const gtm = opts.db.select().from(gtmConnections).where(eq26(gtmConnections.projectId, ctx.project.id)).get();
18020
18306
  const googleAdsCredential = getGoogleAdsConnection(opts.config, ctx.project.id);
18021
18307
  const gtmCredential = getGtmConnection(opts.config, ctx.project.id);
18022
18308
  const googleAdsConnected = Boolean(googleAdsCredential?.accessToken);
@@ -18140,7 +18426,7 @@ async function createServer(opts) {
18140
18426
  deps: {
18141
18427
  enqueueAutoExtract: ({ projectId, release: r }) => {
18142
18428
  const now = (/* @__PURE__ */ new Date()).toISOString();
18143
- const runId = crypto26.randomUUID();
18429
+ const runId = crypto27.randomUUID();
18144
18430
  opts.db.insert(runs).values({
18145
18431
  id: runId,
18146
18432
  projectId,
@@ -18265,7 +18551,7 @@ async function createServer(opts) {
18265
18551
  ...inspectOpts,
18266
18552
  config: opts.config
18267
18553
  }).then(() => {
18268
- const finished = opts.db.select({ status: runs.status }).from(runs).where(eq25(runs.id, runId)).get();
18554
+ const finished = opts.db.select({ status: runs.status }).from(runs).where(eq26(runs.id, runId)).get();
18269
18555
  if (finished?.status === RunStatuses.completed || finished?.status === RunStatuses.partial) {
18270
18556
  return maybeRefreshGscCoverage(
18271
18557
  opts.db,
@@ -18375,7 +18661,7 @@ async function createServer(opts) {
18375
18661
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
18376
18662
  opts.db.insert(auditLog).values(
18377
18663
  targetProjectIds.map((projectId) => ({
18378
- id: crypto26.randomUUID(),
18664
+ id: crypto27.randomUUID(),
18379
18665
  projectId,
18380
18666
  actor: "api",
18381
18667
  action: existing ? "provider.updated" : "provider.created",
@@ -18629,6 +18915,16 @@ async function createServer(opts) {
18629
18915
  const error = notFound("API route", url);
18630
18916
  return reply.status(error.statusCode).send(error.toJSON());
18631
18917
  }
18918
+ const hasDotSegment = (candidate) => candidate.split("/").some((segment) => segment.startsWith("."));
18919
+ let decodedUrl = url;
18920
+ try {
18921
+ decodedUrl = decodeURIComponent(url);
18922
+ } catch {
18923
+ }
18924
+ if (hasDotSegment(url) || hasDotSegment(decodedUrl)) {
18925
+ const error = notFound("Route", url);
18926
+ return reply.status(error.statusCode).send(error.toJSON());
18927
+ }
18632
18928
  if (basePath && !url.startsWith(basePath)) {
18633
18929
  return reply.status(404).send({ error: "Not found", path: request.url });
18634
18930
  }
@@ -18649,13 +18945,38 @@ async function createServer(opts) {
18649
18945
  ...update ? { updateAvailable: update } : {}
18650
18946
  };
18651
18947
  };
18948
+ if (publicOrigin) {
18949
+ registerOAuthRoutes(app, {
18950
+ db: opts.db,
18951
+ issuer: publicOrigin,
18952
+ resourcePaths: mcpTransportPaths().map(
18953
+ (suffix) => `${basePath ?? "/"}api/v1${suffix}`.replace("//", "/")
18954
+ ),
18955
+ resolveUser: (request) => {
18956
+ const cookies = parseCookieHeader(request.headers.cookie);
18957
+ const token = cookies[USER_SESSION_COOKIE_NAME];
18958
+ if (!token) return null;
18959
+ const resolved = resolveUserSession(opts.db, token);
18960
+ return resolved ? { id: resolved.user.id, name: resolved.user.name } : null;
18961
+ },
18962
+ credentials: credentialChecker,
18963
+ startSession: (userId) => {
18964
+ const token = createUserSession(opts.db, userId);
18965
+ return serializeUserSessionCookie({
18966
+ value: token,
18967
+ path: basePath ?? "/",
18968
+ secure: opts.config.publicUrl?.startsWith("https://") ?? false
18969
+ });
18970
+ }
18971
+ });
18972
+ }
18652
18973
  app.get("/health", healthHandler);
18653
18974
  if (basePath) {
18654
18975
  app.get(`${basePath}health`, healthHandler);
18655
18976
  }
18656
18977
  checkLatestVersionForServer();
18657
18978
  scheduler.start();
18658
- for (const run of opts.db.select({ id: researchRuns.id, projectId: researchRuns.projectId }).from(researchRuns).where(eq25(researchRuns.status, ResearchRunStatuses.queued)).all()) {
18979
+ for (const run of opts.db.select({ id: researchRuns.id, projectId: researchRuns.projectId }).from(researchRuns).where(eq26(researchRuns.status, ResearchRunStatuses.queued)).all()) {
18659
18980
  dispatchResearchRun(run.id, run.projectId);
18660
18981
  }
18661
18982
  app.addHook("onClose", async () => {