@ainyc/canonry 4.54.0 → 4.55.3

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 (27) hide show
  1. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +9 -4
  2. package/assets/assets/{BacklinksPage-BXFT4pLI.js → BacklinksPage-buvZ4ZOd.js} +1 -1
  3. package/assets/assets/{ProjectPage-DAtd9Vay.js → ProjectPage-D0UqSqe7.js} +4 -4
  4. package/assets/assets/{RunRow-38dDceGl.js → RunRow-D-DTu1PA.js} +1 -1
  5. package/assets/assets/{RunsPage-AJnFLtaE.js → RunsPage-CrBpgwkO.js} +1 -1
  6. package/assets/assets/{SettingsPage-FT9ZAvFH.js → SettingsPage-Bgsi9tZ2.js} +1 -1
  7. package/assets/assets/TrafficPage-DAHXrzqz.js +1 -0
  8. package/assets/assets/TrafficSourceDetailPage-DCcDN3VD.js +1 -0
  9. package/assets/assets/extract-error-message-BGhWiJPr.js +1 -0
  10. package/assets/assets/{index-DLPKqyhx.js → index-CbDkoDBH.js} +62 -62
  11. package/assets/assets/{index-Bm3JQsW0.css → index-dxdJhCQO.css} +1 -1
  12. package/assets/assets/{server-traffic-GqiQYm6x.js → server-traffic-3xxyOEIX.js} +1 -1
  13. package/assets/assets/{trash-2-BwPzJ8NI.js → trash-2-dppRdHYI.js} +1 -1
  14. package/assets/index.html +2 -2
  15. package/dist/{chunk-CRO6Q25G.js → chunk-5EAGNVCJ.js} +423 -250
  16. package/dist/{chunk-J7MX3YOH.js → chunk-UOQ62KDD.js} +8 -3
  17. package/dist/{chunk-JHAHNKSN.js → chunk-XB6Y63NI.js} +260 -5
  18. package/dist/{chunk-VZPDBHBW.js → chunk-XHU35P3S.js} +367 -365
  19. package/dist/cli.js +14 -12
  20. package/dist/index.d.ts +13 -0
  21. package/dist/index.js +4 -4
  22. package/dist/{intelligence-service-OCREQUCQ.js → intelligence-service-4PT22FED.js} +2 -2
  23. package/dist/mcp.js +2 -2
  24. package/package.json +15 -14
  25. package/assets/assets/TrafficPage-B4A3oO8M.js +0 -1
  26. package/assets/assets/TrafficSourceDetailPage-8NYU1TA6.js +0 -1
  27. package/assets/assets/arrow-left-DgI0X1Q1.js +0 -1
@@ -6,7 +6,7 @@ import {
6
6
  loadConfig,
7
7
  loadConfigRaw,
8
8
  saveConfigPatch
9
- } from "./chunk-J7MX3YOH.js";
9
+ } from "./chunk-UOQ62KDD.js";
10
10
  import {
11
11
  DEFAULT_RUN_HISTORY_LIMIT,
12
12
  IntelligenceService,
@@ -45,14 +45,17 @@ import {
45
45
  categorizeQueryByIntent,
46
46
  ccReleaseSyncs,
47
47
  competitors,
48
+ computeCompetitorOverlap,
48
49
  contentTargetDismissals,
49
50
  crawlerEventsHourly,
50
51
  createClient,
51
52
  createLogger,
53
+ determineCitationState,
52
54
  discoveryProbes,
53
55
  discoverySessions,
54
56
  dropLegacyCredentialColumns,
55
57
  extractLegacyCredentials,
58
+ extractRecommendedCompetitors,
56
59
  filterTrackedSnapshots,
57
60
  gaAiReferrals,
58
61
  gaSocialReferrals,
@@ -84,7 +87,7 @@ import {
84
87
  smoothedRunDelta,
85
88
  trafficSources,
86
89
  usageCounters
87
- } from "./chunk-JHAHNKSN.js";
90
+ } from "./chunk-XB6Y63NI.js";
88
91
  import {
89
92
  AGENT_MEMORY_VALUE_MAX_BYTES,
90
93
  AGENT_PROVIDER_IDS,
@@ -144,7 +147,6 @@ import {
144
147
  bingSitesResponseDtoSchema,
145
148
  bingStatusDtoSchema,
146
149
  bingUrlInspectionDtoSchema,
147
- brandKeyFromText,
148
150
  brandLabelFromDomain,
149
151
  buildRunErrorFromMessages,
150
152
  categorizeSource,
@@ -185,6 +187,7 @@ import {
185
187
  emptyCitationVisibility,
186
188
  extractAnswerMentions,
187
189
  findDuplicateLocationLabels,
190
+ forbidden,
188
191
  formatDate,
189
192
  formatDateRange,
190
193
  formatDeltaCopy,
@@ -285,7 +288,7 @@ import {
285
288
  wordpressSchemaDeployResultDtoSchema,
286
289
  wordpressSchemaStatusResultDtoSchema,
287
290
  wordpressStatusDtoSchema
288
- } from "./chunk-VZPDBHBW.js";
291
+ } from "./chunk-XHU35P3S.js";
289
292
 
290
293
  // src/telemetry.ts
291
294
  import crypto from "crypto";
@@ -586,6 +589,12 @@ import fs8 from "fs";
586
589
  // ../api-routes/src/auth.ts
587
590
  import crypto2 from "crypto";
588
591
  import { eq } from "drizzle-orm";
592
+ function requireScope(request, scope) {
593
+ const key = request.apiKey;
594
+ if (!key) return;
595
+ if (key.scopes.includes("*") || key.scopes.includes(scope)) return;
596
+ throw forbidden(`This action requires the "${scope}" scope on your API key.`);
597
+ }
589
598
  function hashKey(key) {
590
599
  return crypto2.createHash("sha256").update(key).digest("hex");
591
600
  }
@@ -645,6 +654,8 @@ async function authPlugin(app, opts = {}) {
645
654
  throw authRequired();
646
655
  }
647
656
  app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
657
+ const scopes = Array.isArray(key.scopes) ? key.scopes : [];
658
+ request.apiKey = { id: key.id, name: key.name, scopes };
648
659
  });
649
660
  }
650
661
 
@@ -1648,14 +1659,16 @@ async function runRoutes(app, opts) {
1648
1659
  const project = resolveProject(app.db, request.params.name);
1649
1660
  const parsedLimit = parseInt(request.query.limit ?? "", 10);
1650
1661
  const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
1651
- const rows = limit == null ? app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
1662
+ const kind = parseListKind(request.query.kind);
1663
+ const where = kind ? and2(eq7(runs.projectId, project.id), eq7(runs.kind, kind)) : eq7(runs.projectId, project.id);
1664
+ const rows = limit == null ? app.db.select().from(runs).where(where).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(where).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
1652
1665
  return reply.send(rows.map(formatRun));
1653
1666
  });
1654
1667
  app.get("/projects/:name/runs/latest", async (request, reply) => {
1655
1668
  const project = resolveProject(app.db, request.params.name);
1656
- const countRow = app.db.select({ count: sql4`count(*)` }).from(runs).where(eq7(runs.projectId, project.id)).get();
1669
+ const countRow = app.db.select({ count: sql4`count(*)` }).from(runs).where(and2(eq7(runs.projectId, project.id), notProbeRun())).get();
1657
1670
  const totalRuns = countRow?.count ?? 0;
1658
- const latestRun = app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt), desc(runs.id)).limit(1).get();
1671
+ const latestRun = app.db.select().from(runs).where(and2(eq7(runs.projectId, project.id), notProbeRun())).orderBy(desc(runs.createdAt), desc(runs.id)).limit(1).get();
1659
1672
  if (!latestRun) {
1660
1673
  return reply.send({ totalRuns: 0, run: null });
1661
1674
  }
@@ -3816,6 +3829,14 @@ var COLORS = {
3816
3829
  function escapeHtml(value) {
3817
3830
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3818
3831
  }
3832
+ function safeHref(value) {
3833
+ const trimmed = (value ?? "").trim();
3834
+ if (!trimmed) return "#";
3835
+ if (trimmed.startsWith("/")) return escapeHtml(trimmed);
3836
+ if (/^https?:\/\//i.test(trimmed)) return escapeHtml(trimmed);
3837
+ if (/^mailto:/i.test(trimmed)) return escapeHtml(trimmed);
3838
+ return "#";
3839
+ }
3819
3840
  function summarizeQueryParams(params) {
3820
3841
  const keys = Array.from(params.keys());
3821
3842
  const total = keys.length;
@@ -5075,7 +5096,7 @@ function renderCompetitorLandscape(report) {
5075
5096
  const mentionCount = mention?.mentionCount ?? 0;
5076
5097
  const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
5077
5098
  const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
5078
- <ul>${c.theirCitedPages.map((p) => `<li><a href="${escapeHtml(p.url)}">${escapeHtml(p.url)}</a> <span class="cited-for">${escapeHtml(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
5099
+ <ul>${c.theirCitedPages.map((p) => `<li><a href="${safeHref(p.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(p.url)}</a> <span class="cited-for">${escapeHtml(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
5079
5100
  </details>` : "";
5080
5101
  return `<tr>
5081
5102
  <td>${escapeHtml(c.domain)}</td>
@@ -5490,7 +5511,7 @@ function renderServerActivity(report, audience) {
5490
5511
  <thead><tr><th>AI tool</th><th class="numeric">Bot requests (7d)</th><th class="numeric">User fetches (7d)</th><th class="numeric">Referral sessions</th></tr></thead>
5491
5512
  <tbody>${clientOperatorRows}</tbody>
5492
5513
  </table>
5493
- <p class="meta">Bot requests are bulk crawl (GPTBot, PerplexityBot, \u2026). User fetches are on-demand reads triggered by real users inside an AI surface (ChatGPT-User, Perplexity-User, \u2026). Verified requests are reverse-DNS confirmed; unverified requests are UA claims shown separately in agency diagnostics.</p>
5514
+ <p class="meta">Bot requests are bulk crawl (GPTBot, PerplexityBot, \u2026). User fetches are on-demand reads triggered by real users inside an AI surface (ChatGPT-User, Perplexity-User, \u2026). Verified means the request came from an IP the operator publishes as its own; unverified means the user-agent matched but the IP is not in a published range. User-fetch totals count both, since many genuine user fetches come from outside any published range.</p>
5494
5515
  </div>` : ""}`
5495
5516
  );
5496
5517
  }
@@ -5556,7 +5577,7 @@ function renderServerActivity(report, audience) {
5556
5577
  </div>
5557
5578
  ${trendChart}
5558
5579
  ${operatorRows ? `<div class="chart-card"><h3>Per AI operator</h3>
5559
- <p class="meta">Verified means rDNS-confirmed. Unverified bots claim the user-agent but couldn't be verified \u2014 could be the real bot or an imitator. User fetches are on-demand reads from an AI surface on behalf of a real user (ChatGPT-User, Perplexity-User, \u2026) \u2014 disjoint from bulk crawl.</p>
5580
+ <p class="meta">Verified means the request's source IP falls inside the operator's published range. Unverified bots claim the user-agent but the IP is not in a published range, so it could be the real bot or an imitator. User fetches are on-demand reads from an AI surface on behalf of a real user (ChatGPT-User, Perplexity-User, \u2026), disjoint from bulk crawl and counted whether or not the IP can be verified.</p>
5560
5581
  <table class="report-table">
5561
5582
  <thead><tr><th>Operator</th><th class="numeric">Verified hits</th><th class="numeric">Unverified</th><th class="numeric">User fetches</th><th class="numeric">Referral sessions</th><th class="numeric">7d delta</th></tr></thead>
5562
5583
  <tbody>${operatorRows}</tbody>
@@ -5707,8 +5728,8 @@ function renderOpportunities(report) {
5707
5728
  </article>`).join("")}
5708
5729
  </div>`;
5709
5730
  const rows = opps.slice(0, 10).map((o) => {
5710
- const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
5711
- const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
5731
+ const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
5732
+ const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
5712
5733
  const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
5713
5734
  return `<tr>
5714
5735
  <td>${escapeHtml(o.query)}</td>
@@ -6026,10 +6047,12 @@ function renderReportHtml(report, opts = {}) {
6026
6047
  renderRecommendedNextSteps(report)
6027
6048
  ].join("\n");
6028
6049
  const json = escapeJsonForScript(JSON.stringify(report));
6050
+ const csp = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'none'; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
6029
6051
  return `<!DOCTYPE html>
6030
6052
  <html lang="en">
6031
6053
  <head>
6032
6054
  <meta charset="utf-8" />
6055
+ <meta http-equiv="Content-Security-Policy" content="${csp}" />
6033
6056
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6034
6057
  <title>${escapeHtml(title)}</title>
6035
6058
  <style>${STYLE}</style>
@@ -9099,7 +9122,7 @@ var routeCatalog = [
9099
9122
  path: "/api/v1/projects/{name}/runs",
9100
9123
  summary: "List project runs",
9101
9124
  tags: ["runs"],
9102
- parameters: [nameParameter, limitQueryParameter],
9125
+ parameters: [nameParameter, limitQueryParameter, runsListKindQueryParameter],
9103
9126
  responses: {
9104
9127
  200: jsonArrayResponse("Runs returned.", "RunDto")
9105
9128
  }
@@ -11392,7 +11415,7 @@ var routeCatalog = [
11392
11415
  method: "post",
11393
11416
  path: "/api/v1/projects/{name}/traffic/connect/vercel",
11394
11417
  summary: "Connect a Vercel traffic source",
11395
- description: "Probes Vercel's internal `request-logs` endpoint with the supplied API token (single page, 60-minute window) before persisting. On success, stores the token in `~/.canonry/config.yaml` and creates / updates the project's active Vercel `traffic_sources` row. A probe failure (bad token, wrong project / team id, unreachable host) surfaces as 502 with the upstream status in the message so the caller learns about it up front instead of at the first sync. The project id, team id, and environment are stored as non-secret config on the row; only the API token lives in the credential file.",
11418
+ description: "Probes Vercel's internal `request-logs` endpoint with the supplied personal access token (single page, 60-minute window) before persisting. On success, stores the token in `~/.canonry/config.yaml` and creates / updates the project's active Vercel `traffic_sources` row. A probe failure (bad token, wrong project / team id, unreachable host) surfaces as 502 with the upstream status in the message so the caller learns about it up front instead of at the first sync. The project id, team id, and environment are stored as non-secret config on the row; only the personal access token lives in the credential file.",
11396
11419
  tags: ["traffic"],
11397
11420
  parameters: [nameParameter],
11398
11421
  requestBody: {
@@ -11404,8 +11427,8 @@ var routeCatalog = [
11404
11427
  required: ["projectId", "teamId", "token"],
11405
11428
  properties: {
11406
11429
  projectId: { ...stringSchema, description: "Vercel project id (e.g. `prj_...`) \u2014 from the Vercel dashboard or `.vercel/project.json`." },
11407
- teamId: { ...stringSchema, description: "Vercel team / owner id (e.g. `team_...`)." },
11408
- token: { ...stringSchema, description: "Vercel API token (personal access token). Stored in `~/.canonry/config.yaml`, never the DB or response." },
11430
+ teamId: { ...stringSchema, description: "Vercel team or account id: the org that owns the project (`orgId` in `.vercel/project.json`)." },
11431
+ token: { ...stringSchema, description: "Vercel personal access token. Stored in `~/.canonry/config.yaml`, never the DB or response." },
11409
11432
  environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
11410
11433
  displayName: stringSchema
11411
11434
  }
@@ -11454,7 +11477,7 @@ var routeCatalog = [
11454
11477
  method: "post",
11455
11478
  path: "/api/v1/projects/{name}/traffic/sources/{id}/backfill",
11456
11479
  summary: "Reclassify historical traffic-source logs",
11457
- description: 'Async one-shot backfill: pulls the last `days` of events (clamped server-side to the upstream retention ceiling \u2014 30d for Cloud Logging `_Default`; the WordPress plugin honours the same window via `since`/`until` query params), classifies them with the current rules, and replaces the hourly rollup buckets + sample slice in the window inside one transaction. Returns immediately with `{ runId, status: "running" }`; poll `GET /runs/{id}` for completion. lastSyncedAt only advances forward, so a backfill never undoes incremental sync progress that ran ahead of it. Supported source types: `cloud-run`, `wordpress`.',
11480
+ description: 'Async one-shot backfill: pulls the last `days` of events (clamped server-side to the upstream retention ceiling \u2014 30d for Cloud Logging `_Default`; the WordPress plugin honours the same window via `since`/`until` query params), classifies them with the current rules, and replaces the hourly rollup buckets + sample slice in the window inside one transaction. Returns immediately with `{ runId, status: "running" }`; poll `GET /runs/{id}` for completion. lastSyncedAt only advances forward, so a backfill never undoes incremental sync progress that ran ahead of it. Supported source types: `cloud-run`, `wordpress`, `vercel`.',
11458
11481
  tags: ["traffic"],
11459
11482
  parameters: [
11460
11483
  nameParameter,
@@ -11885,6 +11908,7 @@ function buildOperationId(method, path16) {
11885
11908
  }
11886
11909
 
11887
11910
  // ../api-routes/src/settings.ts
11911
+ var SETTINGS_WRITE_SCOPE = "settings.write";
11888
11912
  async function settingsRoutes(app, opts) {
11889
11913
  app.get("/settings", async () => ({
11890
11914
  providers: opts.providerSummary ?? [],
@@ -11892,6 +11916,7 @@ async function settingsRoutes(app, opts) {
11892
11916
  bing: opts.bing ?? { configured: false }
11893
11917
  }));
11894
11918
  app.put("/settings/providers/:name", async (request) => {
11919
+ requireScope(request, SETTINGS_WRITE_SCOPE);
11895
11920
  const { apiKey, baseUrl, model, quota } = request.body ?? {};
11896
11921
  const name = request.params.name;
11897
11922
  const adapters = opts.providerAdapters ?? [];
@@ -11950,6 +11975,7 @@ async function settingsRoutes(app, opts) {
11950
11975
  return result;
11951
11976
  });
11952
11977
  app.put("/settings/google", async (request) => {
11978
+ requireScope(request, SETTINGS_WRITE_SCOPE);
11953
11979
  const { clientId, clientSecret } = request.body ?? {};
11954
11980
  if (!clientId || typeof clientId !== "string" || !clientSecret || typeof clientSecret !== "string") {
11955
11981
  throw validationError("clientId and clientSecret are required");
@@ -11964,6 +11990,7 @@ async function settingsRoutes(app, opts) {
11964
11990
  return result;
11965
11991
  });
11966
11992
  app.put("/settings/bing", async (request) => {
11993
+ requireScope(request, SETTINGS_WRITE_SCOPE);
11967
11994
  const { apiKey } = request.body ?? {};
11968
11995
  if (!apiKey || typeof apiKey !== "string") {
11969
11996
  throw validationError("apiKey is required");
@@ -13482,7 +13509,14 @@ async function googleRoutes(app, opts) {
13482
13509
  }
13483
13510
  const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
13484
13511
  const stateEncoded = buildSignedState(
13485
- { domain: project.canonicalDomain, type, propertyId, redirectUri },
13512
+ {
13513
+ projectId: project.id,
13514
+ projectName: project.name,
13515
+ domain: project.canonicalDomain,
13516
+ type,
13517
+ propertyId,
13518
+ redirectUri
13519
+ },
13486
13520
  stateSecret
13487
13521
  );
13488
13522
  const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
@@ -13529,7 +13563,19 @@ async function googleRoutes(app, opts) {
13529
13563
  if (!stateData) {
13530
13564
  return reply.status(400).send("Invalid or tampered state parameter");
13531
13565
  }
13532
- const { domain, type, propertyId, redirectUri } = stateData;
13566
+ const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
13567
+ if (!projectId) {
13568
+ return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
13569
+ }
13570
+ const project = app.db.select().from(projects).where(eq19(projects.id, projectId)).get();
13571
+ if (!project) {
13572
+ return reply.status(400).send("Project no longer exists. Restart the connect flow.");
13573
+ }
13574
+ if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
13575
+ return reply.status(400).send(
13576
+ `Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
13577
+ );
13578
+ }
13533
13579
  let tokens;
13534
13580
  try {
13535
13581
  tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
@@ -13550,6 +13596,11 @@ async function googleRoutes(app, opts) {
13550
13596
  const now = (/* @__PURE__ */ new Date()).toISOString();
13551
13597
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
13552
13598
  const existing = store.getConnection(domain, type);
13599
+ if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
13600
+ return reply.status(403).send(
13601
+ `This domain already has a Google ${String(type).toUpperCase()} connection owned by another project. Disconnect it from that project first (DELETE /api/v1/projects/<owner>/google/connections/${escapeHtml2(String(type))}) before re-connecting from "${escapeHtml2(projectName ?? "")}".`
13602
+ );
13603
+ }
13553
13604
  store.upsertConnection({
13554
13605
  domain,
13555
13606
  connectionType: type,
@@ -13558,6 +13609,9 @@ async function googleRoutes(app, opts) {
13558
13609
  refreshToken: tokens.refresh_token ?? existing?.refreshToken ?? null,
13559
13610
  tokenExpiresAt: expiresAt,
13560
13611
  scopes: tokens.scope?.split(" ") ?? [],
13612
+ // Stamp ownership on first write; subsequent same-project re-connects
13613
+ // preserve it.
13614
+ createdByProjectId: existing?.createdByProjectId ?? projectId,
13561
13615
  createdAt: existing?.createdAt ?? now,
13562
13616
  updatedAt: now
13563
13617
  });
@@ -13586,16 +13640,26 @@ async function googleRoutes(app, opts) {
13586
13640
  app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
13587
13641
  const store = requireConnectionStore();
13588
13642
  const project = resolveProject(app.db, request.params.name);
13589
- const deleted = store.deleteConnection(project.canonicalDomain, request.params.type);
13643
+ const type = request.params.type;
13644
+ const existing = store.getConnection(project.canonicalDomain, type);
13645
+ if (!existing) {
13646
+ throw notFound("Google connection", type);
13647
+ }
13648
+ if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
13649
+ throw validationError(
13650
+ `This Google ${type.toUpperCase()} connection is owned by a different project. Disconnect from the owning project instead.`
13651
+ );
13652
+ }
13653
+ const deleted = store.deleteConnection(project.canonicalDomain, type);
13590
13654
  if (!deleted) {
13591
- throw notFound("Google connection", request.params.type);
13655
+ throw notFound("Google connection", type);
13592
13656
  }
13593
13657
  writeAuditLog(app.db, {
13594
13658
  projectId: project.id,
13595
13659
  actor: "api",
13596
13660
  action: "google.disconnected",
13597
13661
  entityType: "google_connection",
13598
- entityId: request.params.type
13662
+ entityId: type
13599
13663
  });
13600
13664
  return reply.status(204).send();
13601
13665
  });
@@ -14342,10 +14406,16 @@ async function bingRoutes(app, opts) {
14342
14406
  }
14343
14407
  const now = (/* @__PURE__ */ new Date()).toISOString();
14344
14408
  const existing = store.getConnection(project.canonicalDomain);
14409
+ if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
14410
+ throw validationError(
14411
+ `This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
14412
+ );
14413
+ }
14345
14414
  store.upsertConnection({
14346
14415
  domain: project.canonicalDomain,
14347
14416
  apiKey,
14348
14417
  siteUrl: existing?.siteUrl ?? null,
14418
+ createdByProjectId: existing?.createdByProjectId ?? project.id,
14349
14419
  createdAt: existing?.createdAt ?? now,
14350
14420
  updatedAt: now
14351
14421
  });
@@ -14366,6 +14436,15 @@ async function bingRoutes(app, opts) {
14366
14436
  app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
14367
14437
  const store = requireConnectionStore();
14368
14438
  const project = resolveProject(app.db, request.params.name);
14439
+ const existing = store.getConnection(project.canonicalDomain);
14440
+ if (!existing) {
14441
+ throw notFound("Bing connection", project.canonicalDomain);
14442
+ }
14443
+ if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
14444
+ throw validationError(
14445
+ `This Bing connection is owned by a different project. Disconnect from the owning project instead.`
14446
+ );
14447
+ }
14369
14448
  const deleted = store.deleteConnection(project.canonicalDomain);
14370
14449
  if (!deleted) {
14371
14450
  throw notFound("Bing connection", project.canonicalDomain);
@@ -14775,6 +14854,7 @@ async function cdpRoutes(app, opts) {
14775
14854
  return reply.type("image/png").send(stream);
14776
14855
  });
14777
14856
  app.put("/settings/cdp", async (request, reply) => {
14857
+ requireScope(request, SETTINGS_WRITE_SCOPE);
14778
14858
  if (!opts.onCdpConfigure) {
14779
14859
  const err = notImplemented("CDP configuration not supported in this deployment");
14780
14860
  return reply.code(err.statusCode).send(err.toJSON());
@@ -18085,6 +18165,7 @@ async function backlinksRoutes(app, opts) {
18085
18165
 
18086
18166
  // ../api-routes/src/traffic.ts
18087
18167
  import crypto21 from "crypto";
18168
+ import { Agent as UndiciAgent } from "undici";
18088
18169
  import { and as and19, desc as desc13, eq as eq24, gte as gte3, lte as lte2, sql as sql10 } from "drizzle-orm";
18089
18170
 
18090
18171
  // ../integration-cloud-run/src/auth.ts
@@ -21364,6 +21445,7 @@ async function listWordpressTrafficEvents(options) {
21364
21445
  let skippedEntryCount = 0;
21365
21446
  let hasMore = false;
21366
21447
  const events = [];
21448
+ const dispatcher = options.dispatcher;
21367
21449
  for (let page = 0; page < maxPages; page += 1) {
21368
21450
  const url = new URL(endpoint);
21369
21451
  url.searchParams.set("limit", String(pageSize));
@@ -21377,7 +21459,7 @@ async function listWordpressTrafficEvents(options) {
21377
21459
  url.searchParams.set("until", options.until);
21378
21460
  }
21379
21461
  url.searchParams.set("_cb", randomUUID());
21380
- const response = await fetch(url, {
21462
+ const fetchInit = {
21381
21463
  method: "GET",
21382
21464
  headers: {
21383
21465
  Authorization: authHeader,
@@ -21385,7 +21467,11 @@ async function listWordpressTrafficEvents(options) {
21385
21467
  "Cache-Control": "no-cache"
21386
21468
  },
21387
21469
  signal: AbortSignal.timeout(timeoutMs)
21388
- });
21470
+ };
21471
+ if (dispatcher !== void 0) {
21472
+ fetchInit.dispatcher = dispatcher;
21473
+ }
21474
+ const response = await fetch(url, fetchInit);
21389
21475
  if (!response.ok) {
21390
21476
  const body2 = await readErrorBody2(response);
21391
21477
  throw new WordpressTrafficApiError(
@@ -21599,6 +21685,132 @@ async function listVercelTrafficEvents(options) {
21599
21685
  };
21600
21686
  }
21601
21687
 
21688
+ // ../integration-vercel/src/drain.ts
21689
+ var MIN_SUB_WINDOW_MS = 6e4;
21690
+ var FLOOR_SLICE_MAX_PAGES = 1e3;
21691
+ var RETENTION_BOUNDARY_TOLERANCE_MS = 60 * 6e4;
21692
+ function toMs(value) {
21693
+ return typeof value === "number" ? value : value.getTime();
21694
+ }
21695
+ function isRetentionError(error) {
21696
+ return error instanceof VercelLogsApiError && error.status === 400 && (error.body ?? "").includes("ExceedsBillingLimitError");
21697
+ }
21698
+ async function isServable(options, windowStartMs, windowEndMs) {
21699
+ try {
21700
+ await options.pull({
21701
+ token: options.token,
21702
+ projectId: options.projectId,
21703
+ teamId: options.teamId,
21704
+ environment: options.environment,
21705
+ startDate: windowStartMs,
21706
+ endDate: windowEndMs,
21707
+ maxPages: 1
21708
+ });
21709
+ return true;
21710
+ } catch (error) {
21711
+ if (isRetentionError(error)) return false;
21712
+ throw error;
21713
+ }
21714
+ }
21715
+ async function resolveRetainedStart(options, unservableStartMs, endMs) {
21716
+ const tailStartMs = Math.max(unservableStartMs, endMs - MIN_SUB_WINDOW_MS);
21717
+ if (!await isServable(options, tailStartMs, endMs)) {
21718
+ return endMs;
21719
+ }
21720
+ let lo = unservableStartMs;
21721
+ let hi = tailStartMs;
21722
+ while (hi - lo > RETENTION_BOUNDARY_TOLERANCE_MS) {
21723
+ const mid = lo + Math.floor((hi - lo) / 2);
21724
+ if (await isServable(options, mid, endMs)) {
21725
+ hi = mid;
21726
+ } else {
21727
+ lo = mid;
21728
+ }
21729
+ }
21730
+ return hi;
21731
+ }
21732
+ async function drainVercelTrafficEvents(options) {
21733
+ const startMs = toMs(options.startDate);
21734
+ const endMs = toMs(options.endDate);
21735
+ const events = [];
21736
+ const seenEventIds = /* @__PURE__ */ new Set();
21737
+ if (endMs <= startMs) {
21738
+ return { events, subWindowCount: 0, effectiveStartMs: startMs, retentionClamped: false };
21739
+ }
21740
+ let cursorMs = startMs;
21741
+ let spanMs = endMs - startMs;
21742
+ let subWindowCount = 0;
21743
+ let effectiveStartMs = startMs;
21744
+ let retentionClamped = false;
21745
+ let retentionResolved = false;
21746
+ while (cursorMs < endMs) {
21747
+ if (subWindowCount >= options.maxSubWindows) {
21748
+ throw new Error(
21749
+ `Vercel window not drained within ${options.maxSubWindows} sub-windows \u2014 narrow the time range`
21750
+ );
21751
+ }
21752
+ const subEndMs = Math.min(cursorMs + spanMs, endMs);
21753
+ let page;
21754
+ try {
21755
+ page = await options.pull({
21756
+ token: options.token,
21757
+ projectId: options.projectId,
21758
+ teamId: options.teamId,
21759
+ environment: options.environment,
21760
+ startDate: cursorMs,
21761
+ endDate: subEndMs,
21762
+ maxPages: options.pagesPerSubWindow
21763
+ });
21764
+ } catch (error) {
21765
+ if (isRetentionError(error) && !retentionResolved) {
21766
+ retentionResolved = true;
21767
+ const retainedStartMs = await resolveRetainedStart(options, cursorMs, endMs);
21768
+ retentionClamped = retainedStartMs > cursorMs;
21769
+ cursorMs = retainedStartMs;
21770
+ effectiveStartMs = retainedStartMs;
21771
+ spanMs = Math.max(endMs - cursorMs, MIN_SUB_WINDOW_MS);
21772
+ continue;
21773
+ }
21774
+ throw error;
21775
+ }
21776
+ subWindowCount += 1;
21777
+ if (page.hasMore) {
21778
+ const subSpanMs = subEndMs - cursorMs;
21779
+ if (subSpanMs > MIN_SUB_WINDOW_MS) {
21780
+ spanMs = Math.max(Math.floor(subSpanMs / 2), MIN_SUB_WINDOW_MS);
21781
+ continue;
21782
+ }
21783
+ page = await options.pull({
21784
+ token: options.token,
21785
+ projectId: options.projectId,
21786
+ teamId: options.teamId,
21787
+ environment: options.environment,
21788
+ startDate: cursorMs,
21789
+ endDate: subEndMs,
21790
+ maxPages: FLOOR_SLICE_MAX_PAGES
21791
+ });
21792
+ subWindowCount += 1;
21793
+ if (page.hasMore) {
21794
+ throw new Error(
21795
+ `Vercel ${MIN_SUB_WINDOW_MS / 6e4}-minute slice holds more than ${FLOOR_SLICE_MAX_PAGES} pages and cannot be drained further`
21796
+ );
21797
+ }
21798
+ }
21799
+ for (const event of page.events) {
21800
+ if (!seenEventIds.has(event.eventId)) {
21801
+ seenEventIds.add(event.eventId);
21802
+ events.push(event);
21803
+ }
21804
+ }
21805
+ cursorMs = subEndMs;
21806
+ const remainingMs = endMs - cursorMs;
21807
+ if (remainingMs > 0) {
21808
+ spanMs = Math.min(spanMs * 2, remainingMs);
21809
+ }
21810
+ }
21811
+ return { events, subWindowCount, effectiveStartMs, retentionClamped };
21812
+ }
21813
+
21602
21814
  // ../api-routes/src/traffic.ts
21603
21815
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
21604
21816
  var DEFAULT_PAGE_SIZE3 = 1e3;
@@ -21607,6 +21819,7 @@ var DEFAULT_SAMPLE_LIMIT2 = 100;
21607
21819
  var DEFAULT_WP_PAGE_SIZE = 500;
21608
21820
  var DEFAULT_WP_MAX_PAGES = 20;
21609
21821
  var DEFAULT_VERCEL_MAX_PAGES = 50;
21822
+ var VERCEL_MAX_SUB_WINDOWS = 5e3;
21610
21823
  var MAX_TRACKED_EVENT_IDS = 1e3;
21611
21824
  var DEFAULT_BACKFILL_DAYS = 30;
21612
21825
  var MAX_BACKFILL_DAYS = 90;
@@ -21812,6 +22025,34 @@ async function trafficRoutes(app, opts) {
21812
22025
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
21813
22026
  const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
21814
22027
  const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
22028
+ const allowLoopback = opts.allowLoopbackWebhooks === true;
22029
+ async function assertWordpressTargetAllowed(baseUrl) {
22030
+ const check = await resolveWebhookTarget(baseUrl, { allowLoopback });
22031
+ if (!check.ok) {
22032
+ throw validationError(`WordPress baseUrl rejected: ${check.message}`);
22033
+ }
22034
+ const { address, family } = check.target;
22035
+ return new UndiciAgent({
22036
+ connect: {
22037
+ lookup: (_hostname, options, cb) => {
22038
+ if (options?.all) {
22039
+ cb(null, [{ address, family: family === 6 ? 6 : 4 }]);
22040
+ } else {
22041
+ cb(null, address, family === 6 ? 6 : 4);
22042
+ }
22043
+ }
22044
+ }
22045
+ });
22046
+ }
22047
+ async function withPinnedWordpressDispatcher(baseUrl, fn) {
22048
+ const dispatcher = await assertWordpressTargetAllowed(baseUrl);
22049
+ try {
22050
+ return await fn(dispatcher);
22051
+ } finally {
22052
+ await dispatcher.close().catch(() => {
22053
+ });
22054
+ }
22055
+ }
21815
22056
  const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
21816
22057
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
21817
22058
  const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
@@ -21910,23 +22151,26 @@ async function trafficRoutes(app, opts) {
21910
22151
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
21911
22152
  }
21912
22153
  const { baseUrl, username, applicationPassword, displayName } = parsed.data;
21913
- try {
21914
- await pullWordpressEvents({
21915
- baseUrl,
21916
- username,
21917
- applicationPassword,
21918
- pageSize: 1,
21919
- maxPages: 1
21920
- });
21921
- } catch (e) {
21922
- if (e instanceof WordpressTrafficApiError) {
21923
- throw providerError(
21924
- `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
21925
- );
22154
+ await withPinnedWordpressDispatcher(baseUrl, async (dispatcher) => {
22155
+ try {
22156
+ await pullWordpressEvents({
22157
+ baseUrl,
22158
+ username,
22159
+ applicationPassword,
22160
+ pageSize: 1,
22161
+ maxPages: 1,
22162
+ dispatcher
22163
+ });
22164
+ } catch (e) {
22165
+ if (e instanceof WordpressTrafficApiError) {
22166
+ throw providerError(
22167
+ `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
22168
+ );
22169
+ }
22170
+ const msg = e instanceof Error ? e.message : String(e);
22171
+ throw providerError(`WordPress traffic probe failed: ${msg}`);
21926
22172
  }
21927
- const msg = e instanceof Error ? e.message : String(e);
21928
- throw providerError(`WordPress traffic probe failed: ${msg}`);
21929
- }
22173
+ });
21930
22174
  const now = (/* @__PURE__ */ new Date()).toISOString();
21931
22175
  const existing = credentialStore.getConnection(project.name);
21932
22176
  credentialStore.upsertConnection({
@@ -22176,6 +22420,14 @@ async function trafficRoutes(app, opts) {
22176
22420
  windowStart = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt) : windowEnd;
22177
22421
  const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
22178
22422
  const wpMaxPages = opts.defaultWordpressMaxPages ?? DEFAULT_WP_MAX_PAGES;
22423
+ let pinnedDispatcher;
22424
+ try {
22425
+ pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
22426
+ } catch (e) {
22427
+ const msg = e instanceof Error ? e.message : String(e);
22428
+ markFailed(msg, "PROVIDER_PULL");
22429
+ throw e;
22430
+ }
22179
22431
  const collected = [];
22180
22432
  let cursor = sourceRow.lastCursor ?? void 0;
22181
22433
  try {
@@ -22186,7 +22438,8 @@ async function trafficRoutes(app, opts) {
22186
22438
  applicationPassword: credential.applicationPassword,
22187
22439
  cursor,
22188
22440
  pageSize: wpPageSize,
22189
- maxPages: 1
22441
+ maxPages: 1,
22442
+ dispatcher: pinnedDispatcher
22190
22443
  });
22191
22444
  collected.push(...pageResult.events);
22192
22445
  const previousCursor = cursor;
@@ -22200,6 +22453,9 @@ async function trafficRoutes(app, opts) {
22200
22453
  const msg = e instanceof Error ? e.message : String(e);
22201
22454
  markFailed(msg, "PROVIDER_PULL");
22202
22455
  throw providerError(`WordPress pull failed: ${msg}`);
22456
+ } finally {
22457
+ await pinnedDispatcher.close().catch(() => {
22458
+ });
22203
22459
  }
22204
22460
  } else {
22205
22461
  auditAction = "traffic.vercel.synced";
@@ -22226,28 +22482,34 @@ async function trafficRoutes(app, opts) {
22226
22482
  windowStart = new Date(
22227
22483
  Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
22228
22484
  );
22229
- let page;
22230
22485
  try {
22231
- page = await pullVercelEvents({
22486
+ const drained = await drainVercelTrafficEvents({
22487
+ pull: pullVercelEvents,
22232
22488
  token: credential.token,
22233
22489
  projectId: vercelProjectId,
22234
22490
  teamId: vercelTeamId,
22235
22491
  environment: vercelEnvironment,
22236
22492
  startDate: windowStart.getTime(),
22237
22493
  endDate: windowEnd.getTime(),
22238
- maxPages: vercelMaxPages
22494
+ pagesPerSubWindow: vercelMaxPages,
22495
+ maxSubWindows: VERCEL_MAX_SUB_WINDOWS
22239
22496
  });
22497
+ if (drained.retentionClamped) {
22498
+ app.log.warn(
22499
+ {
22500
+ sourceId: sourceRow.id,
22501
+ requestedStart: windowStart.toISOString(),
22502
+ servedStart: new Date(drained.effectiveStartMs).toISOString()
22503
+ },
22504
+ "Vercel request-logs retention clamp: sync window predated plan retention; ingested only the portion Vercel still serves"
22505
+ );
22506
+ }
22507
+ allEvents = drained.events;
22240
22508
  } catch (e) {
22241
22509
  const msg = e instanceof Error ? e.message : String(e);
22242
22510
  markFailed(msg, "PROVIDER_PULL");
22243
22511
  throw providerError(`Vercel pull failed: ${msg}`);
22244
22512
  }
22245
- if (page.hasMore) {
22246
- const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
22247
- markFailed(msg, "PROVIDER_PULL");
22248
- throw providerError(`Vercel pull failed: ${msg}`);
22249
- }
22250
- allEvents = page.events;
22251
22513
  }
22252
22514
  let crawlerBucketRows = 0;
22253
22515
  let aiUserFetchBucketRows = 0;
@@ -22534,33 +22796,42 @@ async function trafficRoutes(app, opts) {
22534
22796
  `No WordPress credential found for project "${project.name}". Run "canonry traffic connect wordpress" first.`
22535
22797
  );
22536
22798
  }
22799
+ await (await assertWordpressTargetAllowed(credential.baseUrl)).close().catch(() => {
22800
+ });
22537
22801
  const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
22538
22802
  pullErrorPrefix = "WordPress pull failed";
22539
22803
  pullForBackfill = async () => {
22540
- const collected = [];
22541
- const windowStartIso = windowStart.toISOString();
22542
- const windowEndIso = windowEnd.toISOString();
22543
- let cursor = void 0;
22544
- for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
22545
- const pageResult = await pullWordpressEvents({
22546
- baseUrl: credential.baseUrl,
22547
- username: credential.username,
22548
- applicationPassword: credential.applicationPassword,
22549
- cursor,
22550
- pageSize: wpPageSize,
22551
- // Each call fetches a single page; the for-loop drives
22552
- // continuation. Matches the WP sync path's pattern.
22553
- maxPages: 1,
22554
- since: windowStartIso,
22555
- until: windowEndIso
22804
+ const pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
22805
+ try {
22806
+ const collected = [];
22807
+ const windowStartIso = windowStart.toISOString();
22808
+ const windowEndIso = windowEnd.toISOString();
22809
+ let cursor = void 0;
22810
+ for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
22811
+ const pageResult = await pullWordpressEvents({
22812
+ baseUrl: credential.baseUrl,
22813
+ username: credential.username,
22814
+ applicationPassword: credential.applicationPassword,
22815
+ cursor,
22816
+ pageSize: wpPageSize,
22817
+ // Each call fetches a single page; the for-loop drives
22818
+ // continuation. Matches the WP sync path's pattern.
22819
+ maxPages: 1,
22820
+ since: windowStartIso,
22821
+ until: windowEndIso,
22822
+ dispatcher: pinnedDispatcher
22823
+ });
22824
+ collected.push(...pageResult.events);
22825
+ const previousCursor = cursor;
22826
+ cursor = pageResult.nextCursor;
22827
+ if (!pageResult.hasMore) break;
22828
+ if (!cursor || cursor === previousCursor) break;
22829
+ }
22830
+ return collected;
22831
+ } finally {
22832
+ await pinnedDispatcher.close().catch(() => {
22556
22833
  });
22557
- collected.push(...pageResult.events);
22558
- const previousCursor = cursor;
22559
- cursor = pageResult.nextCursor;
22560
- if (!pageResult.hasMore) break;
22561
- if (!cursor || cursor === previousCursor) break;
22562
22834
  }
22563
- return collected;
22564
22835
  };
22565
22836
  } else {
22566
22837
  const credentialStore = opts.vercelTrafficCredentialStore;
@@ -22579,21 +22850,33 @@ async function trafficRoutes(app, opts) {
22579
22850
  const vercelEnvironment = config.environment ?? credential.environment;
22580
22851
  pullErrorPrefix = "Vercel pull failed";
22581
22852
  pullForBackfill = async () => {
22582
- const page = await pullVercelEvents({
22853
+ const drained = await drainVercelTrafficEvents({
22854
+ pull: pullVercelEvents,
22583
22855
  token: credential.token,
22584
22856
  projectId: vercelProjectId,
22585
22857
  teamId: vercelTeamId,
22586
22858
  environment: vercelEnvironment,
22587
22859
  startDate: windowStart.getTime(),
22588
22860
  endDate: windowEnd.getTime(),
22589
- maxPages: BACKFILL_MAX_PAGES
22861
+ pagesPerSubWindow: vercelMaxPages,
22862
+ maxSubWindows: VERCEL_MAX_SUB_WINDOWS
22590
22863
  });
22591
- if (page.hasMore) {
22592
- throw new Error(
22593
- `backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
22864
+ if (drained.retentionClamped) {
22865
+ const servedDays = Math.round(
22866
+ (windowEnd.getTime() - drained.effectiveStartMs) / 864e5
22867
+ );
22868
+ app.log.warn(
22869
+ {
22870
+ sourceId: sourceRow.id,
22871
+ requestedDays,
22872
+ servedDays,
22873
+ requestedStart: windowStart.toISOString(),
22874
+ servedStart: new Date(drained.effectiveStartMs).toISOString()
22875
+ },
22876
+ "Vercel request-logs retention clamp: backfill window predates plan retention; ingested only the portion Vercel still serves"
22594
22877
  );
22595
22878
  }
22596
- return page.events;
22879
+ return drained.events;
22597
22880
  };
22598
22881
  }
22599
22882
  const startedAt = windowEnd.toISOString();
@@ -24701,7 +24984,8 @@ async function apiRoutes(app, opts) {
24701
24984
  pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
24702
24985
  vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
24703
24986
  pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
24704
- onTrafficSynced: opts.onTrafficSynced
24987
+ onTrafficSynced: opts.onTrafficSynced,
24988
+ allowLoopbackWebhooks: opts.allowLoopbackWebhooks
24705
24989
  });
24706
24990
  await api.register(backlinksRoutes, {
24707
24991
  getBacklinksStatus: opts.getBacklinksStatus,
@@ -27251,7 +27535,8 @@ function upsertGoogleConnection(config, connection) {
27251
27535
  sitemapUrl: connection.sitemapUrl ?? null,
27252
27536
  refreshToken: connection.refreshToken ?? null,
27253
27537
  tokenExpiresAt: connection.tokenExpiresAt ?? null,
27254
- scopes: connection.scopes ?? []
27538
+ scopes: connection.scopes ?? [],
27539
+ createdByProjectId: connection.createdByProjectId ?? null
27255
27540
  };
27256
27541
  if (index === -1) {
27257
27542
  connections.push(normalized);
@@ -27512,166 +27797,6 @@ function buildRunCompletedProps(input) {
27512
27797
  return props;
27513
27798
  }
27514
27799
 
27515
- // src/citation-utils.ts
27516
- function domainMatches(domain, canonicalDomain) {
27517
- const normalized = normalizeProjectDomain(canonicalDomain);
27518
- const d = normalizeProjectDomain(domain);
27519
- return d === normalized || d.endsWith(`.${normalized}`);
27520
- }
27521
- function determineCitationState(normalized, domains) {
27522
- for (const canonicalDomain of domains) {
27523
- const bareDomain = normalizeProjectDomain(canonicalDomain);
27524
- if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
27525
- return "cited";
27526
- }
27527
- const lowerDomain = bareDomain.toLowerCase();
27528
- for (const source of normalized.groundingSources) {
27529
- try {
27530
- const uri = source.uri.toLowerCase();
27531
- if (lowerDomain.includes(".") && uri.includes(lowerDomain)) {
27532
- return "cited";
27533
- }
27534
- } catch {
27535
- }
27536
- if (source.title) {
27537
- const titleLower = source.title.toLowerCase().replace(/^www\./, "");
27538
- if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
27539
- return "cited";
27540
- }
27541
- }
27542
- }
27543
- }
27544
- return "not-cited";
27545
- }
27546
- function computeCompetitorOverlap(normalized, competitorDomains) {
27547
- const overlapSet = /* @__PURE__ */ new Set();
27548
- for (const d of normalized.citedDomains) {
27549
- for (const cd of competitorDomains) {
27550
- if (domainMatches(d, cd)) {
27551
- overlapSet.add(cd);
27552
- }
27553
- }
27554
- }
27555
- for (const source of normalized.groundingSources) {
27556
- const uri = source.uri.toLowerCase();
27557
- for (const cd of competitorDomains) {
27558
- if (uri.includes(cd.toLowerCase())) {
27559
- overlapSet.add(cd);
27560
- }
27561
- }
27562
- }
27563
- if (normalized.answerText) {
27564
- const lowerAnswer = normalized.answerText.toLowerCase();
27565
- for (const cd of competitorDomains) {
27566
- if (lowerAnswer.includes(cd.toLowerCase())) {
27567
- overlapSet.add(cd);
27568
- }
27569
- const brand = brandLabelFromDomain(cd);
27570
- if (brand.length >= 4 && new RegExp(`\\b${escapeRegExp5(brand)}\\b`, "i").test(lowerAnswer)) {
27571
- overlapSet.add(cd);
27572
- }
27573
- }
27574
- }
27575
- return [...overlapSet];
27576
- }
27577
- function escapeRegExp5(value) {
27578
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27579
- }
27580
- function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains, ownBrandNames = []) {
27581
- if (!answerText || answerText.length < 20) return [];
27582
- const ownBrandKeys = new Set(
27583
- ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
27584
- );
27585
- for (const name of ownBrandNames) {
27586
- const key = brandKeyFromText(name);
27587
- if (key.length >= 4) ownBrandKeys.add(key);
27588
- }
27589
- const knownCompetitorKeys = new Set(
27590
- [...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
27591
- );
27592
- if (knownCompetitorKeys.size === 0) return [];
27593
- const candidatePatterns = [
27594
- /^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013-]/gm,
27595
- /\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\*\*/g,
27596
- /^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm,
27597
- /\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\]\(https?:\/\/[^\s)]+\)/g
27598
- ];
27599
- const genericKeys = /* @__PURE__ */ new Set([
27600
- "additional",
27601
- "best",
27602
- "benefits",
27603
- "bottomline",
27604
- "comparison",
27605
- "conclusion",
27606
- "directorylisting",
27607
- "example",
27608
- "expertise",
27609
- "features",
27610
- "finalthoughts",
27611
- "howitworks",
27612
- "important",
27613
- "keybenefits",
27614
- "keyfeatures",
27615
- "major",
27616
- "note",
27617
- "notable",
27618
- "option",
27619
- "other",
27620
- "overview",
27621
- "pricing",
27622
- "pros",
27623
- "reviews",
27624
- "step",
27625
- "summary",
27626
- "top",
27627
- "verdict",
27628
- "whattolookfor",
27629
- "whyitmatters",
27630
- "whyitstandsout",
27631
- "whywechoseit"
27632
- ]);
27633
- const seen = /* @__PURE__ */ new Map();
27634
- for (const pattern of candidatePatterns) {
27635
- let match;
27636
- while ((match = pattern.exec(answerText)) !== null) {
27637
- const candidate = cleanCandidateName(match[1] ?? "");
27638
- const candidateKey = brandKeyFromText(candidate);
27639
- if (!candidateKey) continue;
27640
- if (genericKeys.has(candidateKey)) continue;
27641
- if (candidate.split(/\s+/).length > 6) continue;
27642
- if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
27643
- if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
27644
- if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
27645
- }
27646
- }
27647
- return [...seen.values()].slice(0, 10);
27648
- }
27649
- function cleanCandidateName(candidate) {
27650
- return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
27651
- }
27652
- function collectBrandKeysFromDomain(domain) {
27653
- const reg = registrableDomain(domain);
27654
- if (!reg) {
27655
- const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
27656
- const fallback = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
27657
- return fallback.length >= 4 ? [fallback] : [];
27658
- }
27659
- const keys = /* @__PURE__ */ new Set();
27660
- const fullKey = reg.replace(/[^a-z0-9]/gi, "").toLowerCase();
27661
- if (fullKey.length >= 4) keys.add(fullKey);
27662
- const brand = brandLabelFromDomain(reg).replace(/[^a-z0-9]/gi, "").toLowerCase();
27663
- if (brand.length >= 4) keys.add(brand);
27664
- return [...keys];
27665
- }
27666
- function matchesBrandKey(candidateKey, brandKeys) {
27667
- for (const brandKey of brandKeys) {
27668
- if (candidateKey === brandKey) return true;
27669
- if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
27670
- if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
27671
- }
27672
- return false;
27673
- }
27674
-
27675
27800
  // src/job-runner.ts
27676
27801
  var log = createLogger("JobRunner");
27677
27802
  var RunCancelledError = class extends Error {
@@ -29881,7 +30006,7 @@ function readStoredGroundingSources(rawResponse) {
29881
30006
  return result;
29882
30007
  }
29883
30008
  async function backfillInsightsCommand(project, opts) {
29884
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-OCREQUCQ.js");
30009
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-4PT22FED.js");
29885
30010
  const config = loadConfig();
29886
30011
  const db = createClient(config.database);
29887
30012
  migrate(db);
@@ -32895,7 +33020,7 @@ function buildFallbackRecommendedActions(audit) {
32895
33020
  function citesTargetDomain(citedDomains, groundingSources, targetDomain) {
32896
33021
  const normalizedTarget = extractHostname2(targetDomain);
32897
33022
  for (const domain of citedDomains) {
32898
- if (domainMatches2(domain, normalizedTarget)) {
33023
+ if (domainMatches(domain, normalizedTarget)) {
32899
33024
  return true;
32900
33025
  }
32901
33026
  }
@@ -32916,8 +33041,8 @@ function extractCompetitorsFromResponse(ctx) {
32916
33041
  for (const hint of ctx.manualCompetitors) {
32917
33042
  if (isDomainLike(hint)) {
32918
33043
  const normalizedHint = normalizeDomain3(hint);
32919
- if (domainMatches2(normalizedHint, targetDomain)) continue;
32920
- if (ctx.citedDomains.some((domain) => domainMatches2(domain, normalizedHint)) || lowerAnswer.includes(normalizedHint.toLowerCase())) {
33044
+ if (domainMatches(normalizedHint, targetDomain)) continue;
33045
+ if (ctx.citedDomains.some((domain) => domainMatches(domain, normalizedHint)) || lowerAnswer.includes(normalizedHint.toLowerCase())) {
32921
33046
  competitors2.add(normalizedHint);
32922
33047
  }
32923
33048
  continue;
@@ -32987,7 +33112,7 @@ function normalizeDomain3(value) {
32987
33112
  function extractHostname2(value) {
32988
33113
  return normalizeDomain3(value);
32989
33114
  }
32990
- function domainMatches2(candidate, target) {
33115
+ function domainMatches(candidate, target) {
32991
33116
  const normalizedCandidate = normalizeDomain3(candidate);
32992
33117
  const normalizedTarget = normalizeDomain3(target);
32993
33118
  return normalizedCandidate === normalizedTarget || normalizedCandidate.endsWith(`.${normalizedTarget}`);
@@ -33036,6 +33161,48 @@ function summarizeProviderConfig(provider, config) {
33036
33161
  function hashApiKey(key) {
33037
33162
  return crypto35.createHash("sha256").update(key).digest("hex");
33038
33163
  }
33164
+ var DASHBOARD_SCRYPT_KEYLEN = 64;
33165
+ var DASHBOARD_SCRYPT_COST = 1 << 15;
33166
+ var DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024;
33167
+ function hashDashboardPassword(password) {
33168
+ const salt = crypto35.randomBytes(16);
33169
+ const derived = crypto35.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, {
33170
+ N: DASHBOARD_SCRYPT_COST,
33171
+ maxmem: DASHBOARD_SCRYPT_MAXMEM
33172
+ });
33173
+ return `scrypt$1$${salt.toString("base64")}$${derived.toString("base64")}`;
33174
+ }
33175
+ function verifyDashboardPassword(password, storedHash) {
33176
+ if (storedHash.startsWith("scrypt$1$")) {
33177
+ const parts = storedHash.split("$");
33178
+ if (parts.length !== 4) return { ok: false, needsRehash: false };
33179
+ const saltB64 = parts[2];
33180
+ const hashB64 = parts[3];
33181
+ if (!saltB64 || !hashB64) return { ok: false, needsRehash: false };
33182
+ let salt;
33183
+ let expected;
33184
+ try {
33185
+ salt = Buffer.from(saltB64, "base64");
33186
+ expected = Buffer.from(hashB64, "base64");
33187
+ } catch {
33188
+ return { ok: false, needsRehash: false };
33189
+ }
33190
+ const derived = crypto35.scryptSync(password, salt, expected.length, {
33191
+ N: DASHBOARD_SCRYPT_COST,
33192
+ maxmem: DASHBOARD_SCRYPT_MAXMEM
33193
+ });
33194
+ if (derived.length !== expected.length) return { ok: false, needsRehash: false };
33195
+ return { ok: crypto35.timingSafeEqual(derived, expected), needsRehash: false };
33196
+ }
33197
+ if (/^[a-f0-9]{64}$/i.test(storedHash)) {
33198
+ const candidate = Buffer.from(hashApiKey(password), "hex");
33199
+ const expected = Buffer.from(storedHash, "hex");
33200
+ if (candidate.length !== expected.length) return { ok: false, needsRehash: false };
33201
+ const ok = crypto35.timingSafeEqual(candidate, expected);
33202
+ return { ok, needsRehash: ok };
33203
+ }
33204
+ return { ok: false, needsRehash: false };
33205
+ }
33039
33206
  function parseCookies2(header) {
33040
33207
  if (!header) return {};
33041
33208
  return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
@@ -33264,13 +33431,14 @@ async function createServer(opts) {
33264
33431
  if (!opts.config.bing) opts.config.bing = {};
33265
33432
  if (!opts.config.bing.connections) opts.config.bing.connections = [];
33266
33433
  const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
33434
+ const normalized = { ...connection, createdByProjectId: connection.createdByProjectId ?? null };
33267
33435
  if (idx >= 0) {
33268
- opts.config.bing.connections[idx] = connection;
33436
+ opts.config.bing.connections[idx] = normalized;
33269
33437
  } else {
33270
- opts.config.bing.connections.push(connection);
33438
+ opts.config.bing.connections.push(normalized);
33271
33439
  }
33272
33440
  saveConfigPatch(opts.config);
33273
- return connection;
33441
+ return normalized;
33274
33442
  },
33275
33443
  updateConnection: (domain, patch) => {
33276
33444
  const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
@@ -33478,7 +33646,7 @@ async function createServer(opts) {
33478
33646
  const err = validationError("Password must be at least 8 characters");
33479
33647
  return reply.status(err.statusCode).send(err.toJSON());
33480
33648
  }
33481
- opts.config.dashboardPasswordHash = hashApiKey(password);
33649
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
33482
33650
  saveConfigPatch(opts.config);
33483
33651
  if (!createPasswordSession(reply)) {
33484
33652
  const err = authInvalid();
@@ -33494,9 +33662,14 @@ async function createServer(opts) {
33494
33662
  const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
33495
33663
  return reply.status(err2.statusCode).send(err2.toJSON());
33496
33664
  }
33497
- if (hashApiKey(password) !== opts.config.dashboardPasswordHash) {
33665
+ const verification = verifyDashboardPassword(password, opts.config.dashboardPasswordHash);
33666
+ if (!verification.ok) {
33498
33667
  return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
33499
33668
  }
33669
+ if (verification.needsRehash) {
33670
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
33671
+ saveConfigPatch(opts.config);
33672
+ }
33500
33673
  if (!createPasswordSession(reply)) {
33501
33674
  return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
33502
33675
  }