@ainyc/canonry 4.53.0 → 4.55.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 (26) hide show
  1. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +8 -5
  2. package/assets/assets/{BacklinksPage-DELb5ok3.js → BacklinksPage-DVmaM864.js} +1 -1
  3. package/assets/assets/{ProjectPage-CM_uQa2L.js → ProjectPage-DtL3LFne.js} +4 -4
  4. package/assets/assets/{RunRow-aqJEr7XJ.js → RunRow-BRqiLxj2.js} +1 -1
  5. package/assets/assets/{RunsPage-Dhuj1w72.js → RunsPage-UxZ93-cg.js} +1 -1
  6. package/assets/assets/{SettingsPage-B2_vxr4y.js → SettingsPage-Cr5_EGbk.js} +1 -1
  7. package/assets/assets/TrafficPage-CUC_lfTe.js +1 -0
  8. package/assets/assets/TrafficSourceDetailPage-DARPL2TU.js +1 -0
  9. package/assets/assets/extract-error-message-DD5MibWI.js +1 -0
  10. package/assets/assets/{index-BStwmAg6.js → index-nnF1LnyK.js} +60 -60
  11. package/assets/assets/{server-traffic-D_1gSi-b.js → server-traffic-DjRISEZ-.js} +1 -1
  12. package/assets/assets/{trash-2-8JiADnUJ.js → trash-2-CJ5M--Le.js} +1 -1
  13. package/assets/index.html +1 -1
  14. package/dist/{chunk-KVE7RLBI.js → chunk-2OI7HFAB.js} +396 -87
  15. package/dist/{chunk-VZPDBHBW.js → chunk-OFY3Z2F7.js} +8 -4
  16. package/dist/{chunk-JHAHNKSN.js → chunk-UTM3FPAJ.js} +80 -3
  17. package/dist/{chunk-J7MX3YOH.js → chunk-ZY3EDW3S.js} +1 -1
  18. package/dist/cli.js +6 -6
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.js +4 -4
  21. package/dist/{intelligence-service-OCREQUCQ.js → intelligence-service-NKAEHHJ5.js} +2 -2
  22. package/dist/mcp.js +2 -2
  23. package/package.json +14 -13
  24. package/assets/assets/TrafficPage-BKaiZRIH.js +0 -1
  25. package/assets/assets/TrafficSourceDetailPage-DXIQ4g9S.js +0 -1
  26. package/assets/assets/arrow-left-CYjzP3M3.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-ZY3EDW3S.js";
10
10
  import {
11
11
  DEFAULT_RUN_HISTORY_LIMIT,
12
12
  IntelligenceService,
@@ -84,7 +84,7 @@ import {
84
84
  smoothedRunDelta,
85
85
  trafficSources,
86
86
  usageCounters
87
- } from "./chunk-JHAHNKSN.js";
87
+ } from "./chunk-UTM3FPAJ.js";
88
88
  import {
89
89
  AGENT_MEMORY_VALUE_MAX_BYTES,
90
90
  AGENT_PROVIDER_IDS,
@@ -185,6 +185,7 @@ import {
185
185
  emptyCitationVisibility,
186
186
  extractAnswerMentions,
187
187
  findDuplicateLocationLabels,
188
+ forbidden,
188
189
  formatDate,
189
190
  formatDateRange,
190
191
  formatDeltaCopy,
@@ -285,7 +286,7 @@ import {
285
286
  wordpressSchemaDeployResultDtoSchema,
286
287
  wordpressSchemaStatusResultDtoSchema,
287
288
  wordpressStatusDtoSchema
288
- } from "./chunk-VZPDBHBW.js";
289
+ } from "./chunk-OFY3Z2F7.js";
289
290
 
290
291
  // src/telemetry.ts
291
292
  import crypto from "crypto";
@@ -586,6 +587,12 @@ import fs8 from "fs";
586
587
  // ../api-routes/src/auth.ts
587
588
  import crypto2 from "crypto";
588
589
  import { eq } from "drizzle-orm";
590
+ function requireScope(request, scope) {
591
+ const key = request.apiKey;
592
+ if (!key) return;
593
+ if (key.scopes.includes("*") || key.scopes.includes(scope)) return;
594
+ throw forbidden(`This action requires the "${scope}" scope on your API key.`);
595
+ }
589
596
  function hashKey(key) {
590
597
  return crypto2.createHash("sha256").update(key).digest("hex");
591
598
  }
@@ -645,6 +652,8 @@ async function authPlugin(app, opts = {}) {
645
652
  throw authRequired();
646
653
  }
647
654
  app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
655
+ const scopes = Array.isArray(key.scopes) ? key.scopes : [];
656
+ request.apiKey = { id: key.id, name: key.name, scopes };
648
657
  });
649
658
  }
650
659
 
@@ -1653,9 +1662,9 @@ async function runRoutes(app, opts) {
1653
1662
  });
1654
1663
  app.get("/projects/:name/runs/latest", async (request, reply) => {
1655
1664
  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();
1665
+ const countRow = app.db.select({ count: sql4`count(*)` }).from(runs).where(and2(eq7(runs.projectId, project.id), notProbeRun())).get();
1657
1666
  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();
1667
+ 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
1668
  if (!latestRun) {
1660
1669
  return reply.send({ totalRuns: 0, run: null });
1661
1670
  }
@@ -3816,6 +3825,14 @@ var COLORS = {
3816
3825
  function escapeHtml(value) {
3817
3826
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3818
3827
  }
3828
+ function safeHref(value) {
3829
+ const trimmed = (value ?? "").trim();
3830
+ if (!trimmed) return "#";
3831
+ if (trimmed.startsWith("/")) return escapeHtml(trimmed);
3832
+ if (/^https?:\/\//i.test(trimmed)) return escapeHtml(trimmed);
3833
+ if (/^mailto:/i.test(trimmed)) return escapeHtml(trimmed);
3834
+ return "#";
3835
+ }
3819
3836
  function summarizeQueryParams(params) {
3820
3837
  const keys = Array.from(params.keys());
3821
3838
  const total = keys.length;
@@ -5075,7 +5092,7 @@ function renderCompetitorLandscape(report) {
5075
5092
  const mentionCount = mention?.mentionCount ?? 0;
5076
5093
  const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
5077
5094
  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>
5095
+ <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
5096
  </details>` : "";
5080
5097
  return `<tr>
5081
5098
  <td>${escapeHtml(c.domain)}</td>
@@ -5490,7 +5507,7 @@ function renderServerActivity(report, audience) {
5490
5507
  <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
5508
  <tbody>${clientOperatorRows}</tbody>
5492
5509
  </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>
5510
+ <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
5511
  </div>` : ""}`
5495
5512
  );
5496
5513
  }
@@ -5556,7 +5573,7 @@ function renderServerActivity(report, audience) {
5556
5573
  </div>
5557
5574
  ${trendChart}
5558
5575
  ${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>
5576
+ <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
5577
  <table class="report-table">
5561
5578
  <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
5579
  <tbody>${operatorRows}</tbody>
@@ -5707,8 +5724,8 @@ function renderOpportunities(report) {
5707
5724
  </article>`).join("")}
5708
5725
  </div>`;
5709
5726
  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>';
5727
+ 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>';
5728
+ 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
5729
  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
5730
  return `<tr>
5714
5731
  <td>${escapeHtml(o.query)}</td>
@@ -6026,10 +6043,12 @@ function renderReportHtml(report, opts = {}) {
6026
6043
  renderRecommendedNextSteps(report)
6027
6044
  ].join("\n");
6028
6045
  const json = escapeJsonForScript(JSON.stringify(report));
6046
+ 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
6047
  return `<!DOCTYPE html>
6030
6048
  <html lang="en">
6031
6049
  <head>
6032
6050
  <meta charset="utf-8" />
6051
+ <meta http-equiv="Content-Security-Policy" content="${csp}" />
6033
6052
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6034
6053
  <title>${escapeHtml(title)}</title>
6035
6054
  <style>${STYLE}</style>
@@ -11392,7 +11411,7 @@ var routeCatalog = [
11392
11411
  method: "post",
11393
11412
  path: "/api/v1/projects/{name}/traffic/connect/vercel",
11394
11413
  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.",
11414
+ 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
11415
  tags: ["traffic"],
11397
11416
  parameters: [nameParameter],
11398
11417
  requestBody: {
@@ -11404,8 +11423,8 @@ var routeCatalog = [
11404
11423
  required: ["projectId", "teamId", "token"],
11405
11424
  properties: {
11406
11425
  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." },
11426
+ teamId: { ...stringSchema, description: "Vercel team or account id: the org that owns the project (`orgId` in `.vercel/project.json`)." },
11427
+ token: { ...stringSchema, description: "Vercel personal access token. Stored in `~/.canonry/config.yaml`, never the DB or response." },
11409
11428
  environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
11410
11429
  displayName: stringSchema
11411
11430
  }
@@ -11454,7 +11473,7 @@ var routeCatalog = [
11454
11473
  method: "post",
11455
11474
  path: "/api/v1/projects/{name}/traffic/sources/{id}/backfill",
11456
11475
  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`.',
11476
+ 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
11477
  tags: ["traffic"],
11459
11478
  parameters: [
11460
11479
  nameParameter,
@@ -11885,6 +11904,7 @@ function buildOperationId(method, path16) {
11885
11904
  }
11886
11905
 
11887
11906
  // ../api-routes/src/settings.ts
11907
+ var SETTINGS_WRITE_SCOPE = "settings.write";
11888
11908
  async function settingsRoutes(app, opts) {
11889
11909
  app.get("/settings", async () => ({
11890
11910
  providers: opts.providerSummary ?? [],
@@ -11892,6 +11912,7 @@ async function settingsRoutes(app, opts) {
11892
11912
  bing: opts.bing ?? { configured: false }
11893
11913
  }));
11894
11914
  app.put("/settings/providers/:name", async (request) => {
11915
+ requireScope(request, SETTINGS_WRITE_SCOPE);
11895
11916
  const { apiKey, baseUrl, model, quota } = request.body ?? {};
11896
11917
  const name = request.params.name;
11897
11918
  const adapters = opts.providerAdapters ?? [];
@@ -11950,6 +11971,7 @@ async function settingsRoutes(app, opts) {
11950
11971
  return result;
11951
11972
  });
11952
11973
  app.put("/settings/google", async (request) => {
11974
+ requireScope(request, SETTINGS_WRITE_SCOPE);
11953
11975
  const { clientId, clientSecret } = request.body ?? {};
11954
11976
  if (!clientId || typeof clientId !== "string" || !clientSecret || typeof clientSecret !== "string") {
11955
11977
  throw validationError("clientId and clientSecret are required");
@@ -11964,6 +11986,7 @@ async function settingsRoutes(app, opts) {
11964
11986
  return result;
11965
11987
  });
11966
11988
  app.put("/settings/bing", async (request) => {
11989
+ requireScope(request, SETTINGS_WRITE_SCOPE);
11967
11990
  const { apiKey } = request.body ?? {};
11968
11991
  if (!apiKey || typeof apiKey !== "string") {
11969
11992
  throw validationError("apiKey is required");
@@ -13482,7 +13505,14 @@ async function googleRoutes(app, opts) {
13482
13505
  }
13483
13506
  const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
13484
13507
  const stateEncoded = buildSignedState(
13485
- { domain: project.canonicalDomain, type, propertyId, redirectUri },
13508
+ {
13509
+ projectId: project.id,
13510
+ projectName: project.name,
13511
+ domain: project.canonicalDomain,
13512
+ type,
13513
+ propertyId,
13514
+ redirectUri
13515
+ },
13486
13516
  stateSecret
13487
13517
  );
13488
13518
  const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
@@ -13529,7 +13559,19 @@ async function googleRoutes(app, opts) {
13529
13559
  if (!stateData) {
13530
13560
  return reply.status(400).send("Invalid or tampered state parameter");
13531
13561
  }
13532
- const { domain, type, propertyId, redirectUri } = stateData;
13562
+ const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
13563
+ if (!projectId) {
13564
+ return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
13565
+ }
13566
+ const project = app.db.select().from(projects).where(eq19(projects.id, projectId)).get();
13567
+ if (!project) {
13568
+ return reply.status(400).send("Project no longer exists. Restart the connect flow.");
13569
+ }
13570
+ if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
13571
+ return reply.status(400).send(
13572
+ `Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
13573
+ );
13574
+ }
13533
13575
  let tokens;
13534
13576
  try {
13535
13577
  tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
@@ -13550,6 +13592,11 @@ async function googleRoutes(app, opts) {
13550
13592
  const now = (/* @__PURE__ */ new Date()).toISOString();
13551
13593
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
13552
13594
  const existing = store.getConnection(domain, type);
13595
+ if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
13596
+ return reply.status(403).send(
13597
+ `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 ?? "")}".`
13598
+ );
13599
+ }
13553
13600
  store.upsertConnection({
13554
13601
  domain,
13555
13602
  connectionType: type,
@@ -13558,6 +13605,9 @@ async function googleRoutes(app, opts) {
13558
13605
  refreshToken: tokens.refresh_token ?? existing?.refreshToken ?? null,
13559
13606
  tokenExpiresAt: expiresAt,
13560
13607
  scopes: tokens.scope?.split(" ") ?? [],
13608
+ // Stamp ownership on first write; subsequent same-project re-connects
13609
+ // preserve it.
13610
+ createdByProjectId: existing?.createdByProjectId ?? projectId,
13561
13611
  createdAt: existing?.createdAt ?? now,
13562
13612
  updatedAt: now
13563
13613
  });
@@ -13586,16 +13636,26 @@ async function googleRoutes(app, opts) {
13586
13636
  app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
13587
13637
  const store = requireConnectionStore();
13588
13638
  const project = resolveProject(app.db, request.params.name);
13589
- const deleted = store.deleteConnection(project.canonicalDomain, request.params.type);
13639
+ const type = request.params.type;
13640
+ const existing = store.getConnection(project.canonicalDomain, type);
13641
+ if (!existing) {
13642
+ throw notFound("Google connection", type);
13643
+ }
13644
+ if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
13645
+ throw validationError(
13646
+ `This Google ${type.toUpperCase()} connection is owned by a different project. Disconnect from the owning project instead.`
13647
+ );
13648
+ }
13649
+ const deleted = store.deleteConnection(project.canonicalDomain, type);
13590
13650
  if (!deleted) {
13591
- throw notFound("Google connection", request.params.type);
13651
+ throw notFound("Google connection", type);
13592
13652
  }
13593
13653
  writeAuditLog(app.db, {
13594
13654
  projectId: project.id,
13595
13655
  actor: "api",
13596
13656
  action: "google.disconnected",
13597
13657
  entityType: "google_connection",
13598
- entityId: request.params.type
13658
+ entityId: type
13599
13659
  });
13600
13660
  return reply.status(204).send();
13601
13661
  });
@@ -14342,10 +14402,16 @@ async function bingRoutes(app, opts) {
14342
14402
  }
14343
14403
  const now = (/* @__PURE__ */ new Date()).toISOString();
14344
14404
  const existing = store.getConnection(project.canonicalDomain);
14405
+ if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
14406
+ throw validationError(
14407
+ `This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
14408
+ );
14409
+ }
14345
14410
  store.upsertConnection({
14346
14411
  domain: project.canonicalDomain,
14347
14412
  apiKey,
14348
14413
  siteUrl: existing?.siteUrl ?? null,
14414
+ createdByProjectId: existing?.createdByProjectId ?? project.id,
14349
14415
  createdAt: existing?.createdAt ?? now,
14350
14416
  updatedAt: now
14351
14417
  });
@@ -14366,6 +14432,15 @@ async function bingRoutes(app, opts) {
14366
14432
  app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
14367
14433
  const store = requireConnectionStore();
14368
14434
  const project = resolveProject(app.db, request.params.name);
14435
+ const existing = store.getConnection(project.canonicalDomain);
14436
+ if (!existing) {
14437
+ throw notFound("Bing connection", project.canonicalDomain);
14438
+ }
14439
+ if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
14440
+ throw validationError(
14441
+ `This Bing connection is owned by a different project. Disconnect from the owning project instead.`
14442
+ );
14443
+ }
14369
14444
  const deleted = store.deleteConnection(project.canonicalDomain);
14370
14445
  if (!deleted) {
14371
14446
  throw notFound("Bing connection", project.canonicalDomain);
@@ -14775,6 +14850,7 @@ async function cdpRoutes(app, opts) {
14775
14850
  return reply.type("image/png").send(stream);
14776
14851
  });
14777
14852
  app.put("/settings/cdp", async (request, reply) => {
14853
+ requireScope(request, SETTINGS_WRITE_SCOPE);
14778
14854
  if (!opts.onCdpConfigure) {
14779
14855
  const err = notImplemented("CDP configuration not supported in this deployment");
14780
14856
  return reply.code(err.statusCode).send(err.toJSON());
@@ -18085,6 +18161,7 @@ async function backlinksRoutes(app, opts) {
18085
18161
 
18086
18162
  // ../api-routes/src/traffic.ts
18087
18163
  import crypto21 from "crypto";
18164
+ import { Agent as UndiciAgent } from "undici";
18088
18165
  import { and as and19, desc as desc13, eq as eq24, gte as gte3, lte as lte2, sql as sql10 } from "drizzle-orm";
18089
18166
 
18090
18167
  // ../integration-cloud-run/src/auth.ts
@@ -21260,6 +21337,9 @@ function incrementBucket(map, key, fields) {
21260
21337
  else map.set(key, { fields, hits: 1 });
21261
21338
  }
21262
21339
 
21340
+ // ../integration-wordpress-traffic/src/client.ts
21341
+ import { randomUUID } from "crypto";
21342
+
21263
21343
  // ../integration-wordpress-traffic/src/normalize.ts
21264
21344
  function trimOrNull(value) {
21265
21345
  if (value === null || value === void 0) return null;
@@ -21290,7 +21370,7 @@ function normalizeWordpressTrafficEvent(event) {
21290
21370
  queryString,
21291
21371
  status: typeof event.status === "number" && Number.isFinite(event.status) ? event.status : null,
21292
21372
  userAgent: trimOrNull(event.user_agent),
21293
- remoteIp: trimOrNull(event.remote_ip_hash),
21373
+ remoteIp: trimOrNull(event.remote_ip),
21294
21374
  referer: trimOrNull(event.referer),
21295
21375
  latencyMs: null,
21296
21376
  requestSizeBytes: null,
@@ -21361,6 +21441,7 @@ async function listWordpressTrafficEvents(options) {
21361
21441
  let skippedEntryCount = 0;
21362
21442
  let hasMore = false;
21363
21443
  const events = [];
21444
+ const dispatcher = options.dispatcher;
21364
21445
  for (let page = 0; page < maxPages; page += 1) {
21365
21446
  const url = new URL(endpoint);
21366
21447
  url.searchParams.set("limit", String(pageSize));
@@ -21373,14 +21454,20 @@ async function listWordpressTrafficEvents(options) {
21373
21454
  if (options.until !== void 0 && options.until !== "") {
21374
21455
  url.searchParams.set("until", options.until);
21375
21456
  }
21376
- const response = await fetch(url, {
21457
+ url.searchParams.set("_cb", randomUUID());
21458
+ const fetchInit = {
21377
21459
  method: "GET",
21378
21460
  headers: {
21379
21461
  Authorization: authHeader,
21380
- Accept: "application/json"
21462
+ Accept: "application/json",
21463
+ "Cache-Control": "no-cache"
21381
21464
  },
21382
21465
  signal: AbortSignal.timeout(timeoutMs)
21383
- });
21466
+ };
21467
+ if (dispatcher !== void 0) {
21468
+ fetchInit.dispatcher = dispatcher;
21469
+ }
21470
+ const response = await fetch(url, fetchInit);
21384
21471
  if (!response.ok) {
21385
21472
  const body2 = await readErrorBody2(response);
21386
21473
  throw new WordpressTrafficApiError(
@@ -21594,6 +21681,62 @@ async function listVercelTrafficEvents(options) {
21594
21681
  };
21595
21682
  }
21596
21683
 
21684
+ // ../integration-vercel/src/drain.ts
21685
+ var MIN_SUB_WINDOW_MS = 6e4;
21686
+ function toMs(value) {
21687
+ return typeof value === "number" ? value : value.getTime();
21688
+ }
21689
+ async function drainVercelTrafficEvents(options) {
21690
+ const startMs = toMs(options.startDate);
21691
+ const endMs = toMs(options.endDate);
21692
+ const events = [];
21693
+ const seenEventIds = /* @__PURE__ */ new Set();
21694
+ if (endMs <= startMs) return { events, subWindowCount: 0 };
21695
+ let cursorMs = startMs;
21696
+ let spanMs = endMs - startMs;
21697
+ let subWindowCount = 0;
21698
+ while (cursorMs < endMs) {
21699
+ if (subWindowCount >= options.maxSubWindows) {
21700
+ throw new Error(
21701
+ `Vercel window not drained within ${options.maxSubWindows} sub-windows \u2014 narrow the time range`
21702
+ );
21703
+ }
21704
+ const subEndMs = Math.min(cursorMs + spanMs, endMs);
21705
+ const page = await options.pull({
21706
+ token: options.token,
21707
+ projectId: options.projectId,
21708
+ teamId: options.teamId,
21709
+ environment: options.environment,
21710
+ startDate: cursorMs,
21711
+ endDate: subEndMs,
21712
+ maxPages: options.pagesPerSubWindow
21713
+ });
21714
+ subWindowCount += 1;
21715
+ if (page.hasMore) {
21716
+ const subSpanMs = subEndMs - cursorMs;
21717
+ if (subSpanMs <= MIN_SUB_WINDOW_MS) {
21718
+ throw new Error(
21719
+ `Vercel window holds more than ${options.pagesPerSubWindow} pages within a ${MIN_SUB_WINDOW_MS / 6e4}-minute slice \u2014 cannot subdivide further`
21720
+ );
21721
+ }
21722
+ spanMs = Math.max(Math.floor(subSpanMs / 2), MIN_SUB_WINDOW_MS);
21723
+ continue;
21724
+ }
21725
+ for (const event of page.events) {
21726
+ if (!seenEventIds.has(event.eventId)) {
21727
+ seenEventIds.add(event.eventId);
21728
+ events.push(event);
21729
+ }
21730
+ }
21731
+ cursorMs = subEndMs;
21732
+ const remainingMs = endMs - cursorMs;
21733
+ if (remainingMs > 0) {
21734
+ spanMs = Math.min(spanMs * 2, remainingMs);
21735
+ }
21736
+ }
21737
+ return { events, subWindowCount };
21738
+ }
21739
+
21597
21740
  // ../api-routes/src/traffic.ts
21598
21741
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
21599
21742
  var DEFAULT_PAGE_SIZE3 = 1e3;
@@ -21602,6 +21745,7 @@ var DEFAULT_SAMPLE_LIMIT2 = 100;
21602
21745
  var DEFAULT_WP_PAGE_SIZE = 500;
21603
21746
  var DEFAULT_WP_MAX_PAGES = 20;
21604
21747
  var DEFAULT_VERCEL_MAX_PAGES = 50;
21748
+ var VERCEL_MAX_SUB_WINDOWS = 5e3;
21605
21749
  var MAX_TRACKED_EVENT_IDS = 1e3;
21606
21750
  var DEFAULT_BACKFILL_DAYS = 30;
21607
21751
  var MAX_BACKFILL_DAYS = 90;
@@ -21807,6 +21951,30 @@ async function trafficRoutes(app, opts) {
21807
21951
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
21808
21952
  const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
21809
21953
  const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
21954
+ const allowLoopback = opts.allowLoopbackWebhooks === true;
21955
+ async function assertWordpressTargetAllowed(baseUrl) {
21956
+ const check = await resolveWebhookTarget(baseUrl, { allowLoopback });
21957
+ if (!check.ok) {
21958
+ throw validationError(`WordPress baseUrl rejected: ${check.message}`);
21959
+ }
21960
+ const { address, family } = check.target;
21961
+ return new UndiciAgent({
21962
+ connect: {
21963
+ lookup: (_hostname, _options, cb) => {
21964
+ cb(null, address, family === 6 ? 6 : 4);
21965
+ }
21966
+ }
21967
+ });
21968
+ }
21969
+ async function withPinnedWordpressDispatcher(baseUrl, fn) {
21970
+ const dispatcher = await assertWordpressTargetAllowed(baseUrl);
21971
+ try {
21972
+ return await fn(dispatcher);
21973
+ } finally {
21974
+ await dispatcher.close().catch(() => {
21975
+ });
21976
+ }
21977
+ }
21810
21978
  const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
21811
21979
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
21812
21980
  const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
@@ -21905,23 +22073,26 @@ async function trafficRoutes(app, opts) {
21905
22073
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
21906
22074
  }
21907
22075
  const { baseUrl, username, applicationPassword, displayName } = parsed.data;
21908
- try {
21909
- await pullWordpressEvents({
21910
- baseUrl,
21911
- username,
21912
- applicationPassword,
21913
- pageSize: 1,
21914
- maxPages: 1
21915
- });
21916
- } catch (e) {
21917
- if (e instanceof WordpressTrafficApiError) {
21918
- throw providerError(
21919
- `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
21920
- );
22076
+ await withPinnedWordpressDispatcher(baseUrl, async (dispatcher) => {
22077
+ try {
22078
+ await pullWordpressEvents({
22079
+ baseUrl,
22080
+ username,
22081
+ applicationPassword,
22082
+ pageSize: 1,
22083
+ maxPages: 1,
22084
+ dispatcher
22085
+ });
22086
+ } catch (e) {
22087
+ if (e instanceof WordpressTrafficApiError) {
22088
+ throw providerError(
22089
+ `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
22090
+ );
22091
+ }
22092
+ const msg = e instanceof Error ? e.message : String(e);
22093
+ throw providerError(`WordPress traffic probe failed: ${msg}`);
21921
22094
  }
21922
- const msg = e instanceof Error ? e.message : String(e);
21923
- throw providerError(`WordPress traffic probe failed: ${msg}`);
21924
- }
22095
+ });
21925
22096
  const now = (/* @__PURE__ */ new Date()).toISOString();
21926
22097
  const existing = credentialStore.getConnection(project.name);
21927
22098
  credentialStore.upsertConnection({
@@ -22171,6 +22342,14 @@ async function trafficRoutes(app, opts) {
22171
22342
  windowStart = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt) : windowEnd;
22172
22343
  const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
22173
22344
  const wpMaxPages = opts.defaultWordpressMaxPages ?? DEFAULT_WP_MAX_PAGES;
22345
+ let pinnedDispatcher;
22346
+ try {
22347
+ pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
22348
+ } catch (e) {
22349
+ const msg = e instanceof Error ? e.message : String(e);
22350
+ markFailed(msg, "PROVIDER_PULL");
22351
+ throw e;
22352
+ }
22174
22353
  const collected = [];
22175
22354
  let cursor = sourceRow.lastCursor ?? void 0;
22176
22355
  try {
@@ -22181,7 +22360,8 @@ async function trafficRoutes(app, opts) {
22181
22360
  applicationPassword: credential.applicationPassword,
22182
22361
  cursor,
22183
22362
  pageSize: wpPageSize,
22184
- maxPages: 1
22363
+ maxPages: 1,
22364
+ dispatcher: pinnedDispatcher
22185
22365
  });
22186
22366
  collected.push(...pageResult.events);
22187
22367
  const previousCursor = cursor;
@@ -22195,6 +22375,9 @@ async function trafficRoutes(app, opts) {
22195
22375
  const msg = e instanceof Error ? e.message : String(e);
22196
22376
  markFailed(msg, "PROVIDER_PULL");
22197
22377
  throw providerError(`WordPress pull failed: ${msg}`);
22378
+ } finally {
22379
+ await pinnedDispatcher.close().catch(() => {
22380
+ });
22198
22381
  }
22199
22382
  } else {
22200
22383
  auditAction = "traffic.vercel.synced";
@@ -22221,28 +22404,24 @@ async function trafficRoutes(app, opts) {
22221
22404
  windowStart = new Date(
22222
22405
  Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
22223
22406
  );
22224
- let page;
22225
22407
  try {
22226
- page = await pullVercelEvents({
22408
+ const drained = await drainVercelTrafficEvents({
22409
+ pull: pullVercelEvents,
22227
22410
  token: credential.token,
22228
22411
  projectId: vercelProjectId,
22229
22412
  teamId: vercelTeamId,
22230
22413
  environment: vercelEnvironment,
22231
22414
  startDate: windowStart.getTime(),
22232
22415
  endDate: windowEnd.getTime(),
22233
- maxPages: vercelMaxPages
22416
+ pagesPerSubWindow: vercelMaxPages,
22417
+ maxSubWindows: VERCEL_MAX_SUB_WINDOWS
22234
22418
  });
22419
+ allEvents = drained.events;
22235
22420
  } catch (e) {
22236
22421
  const msg = e instanceof Error ? e.message : String(e);
22237
22422
  markFailed(msg, "PROVIDER_PULL");
22238
22423
  throw providerError(`Vercel pull failed: ${msg}`);
22239
22424
  }
22240
- if (page.hasMore) {
22241
- const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
22242
- markFailed(msg, "PROVIDER_PULL");
22243
- throw providerError(`Vercel pull failed: ${msg}`);
22244
- }
22245
- allEvents = page.events;
22246
22425
  }
22247
22426
  let crawlerBucketRows = 0;
22248
22427
  let aiUserFetchBucketRows = 0;
@@ -22529,33 +22708,42 @@ async function trafficRoutes(app, opts) {
22529
22708
  `No WordPress credential found for project "${project.name}". Run "canonry traffic connect wordpress" first.`
22530
22709
  );
22531
22710
  }
22711
+ await (await assertWordpressTargetAllowed(credential.baseUrl)).close().catch(() => {
22712
+ });
22532
22713
  const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
22533
22714
  pullErrorPrefix = "WordPress pull failed";
22534
22715
  pullForBackfill = async () => {
22535
- const collected = [];
22536
- const windowStartIso = windowStart.toISOString();
22537
- const windowEndIso = windowEnd.toISOString();
22538
- let cursor = void 0;
22539
- for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
22540
- const pageResult = await pullWordpressEvents({
22541
- baseUrl: credential.baseUrl,
22542
- username: credential.username,
22543
- applicationPassword: credential.applicationPassword,
22544
- cursor,
22545
- pageSize: wpPageSize,
22546
- // Each call fetches a single page; the for-loop drives
22547
- // continuation. Matches the WP sync path's pattern.
22548
- maxPages: 1,
22549
- since: windowStartIso,
22550
- until: windowEndIso
22716
+ const pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
22717
+ try {
22718
+ const collected = [];
22719
+ const windowStartIso = windowStart.toISOString();
22720
+ const windowEndIso = windowEnd.toISOString();
22721
+ let cursor = void 0;
22722
+ for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
22723
+ const pageResult = await pullWordpressEvents({
22724
+ baseUrl: credential.baseUrl,
22725
+ username: credential.username,
22726
+ applicationPassword: credential.applicationPassword,
22727
+ cursor,
22728
+ pageSize: wpPageSize,
22729
+ // Each call fetches a single page; the for-loop drives
22730
+ // continuation. Matches the WP sync path's pattern.
22731
+ maxPages: 1,
22732
+ since: windowStartIso,
22733
+ until: windowEndIso,
22734
+ dispatcher: pinnedDispatcher
22735
+ });
22736
+ collected.push(...pageResult.events);
22737
+ const previousCursor = cursor;
22738
+ cursor = pageResult.nextCursor;
22739
+ if (!pageResult.hasMore) break;
22740
+ if (!cursor || cursor === previousCursor) break;
22741
+ }
22742
+ return collected;
22743
+ } finally {
22744
+ await pinnedDispatcher.close().catch(() => {
22551
22745
  });
22552
- collected.push(...pageResult.events);
22553
- const previousCursor = cursor;
22554
- cursor = pageResult.nextCursor;
22555
- if (!pageResult.hasMore) break;
22556
- if (!cursor || cursor === previousCursor) break;
22557
22746
  }
22558
- return collected;
22559
22747
  };
22560
22748
  } else {
22561
22749
  const credentialStore = opts.vercelTrafficCredentialStore;
@@ -22574,21 +22762,18 @@ async function trafficRoutes(app, opts) {
22574
22762
  const vercelEnvironment = config.environment ?? credential.environment;
22575
22763
  pullErrorPrefix = "Vercel pull failed";
22576
22764
  pullForBackfill = async () => {
22577
- const page = await pullVercelEvents({
22765
+ const drained = await drainVercelTrafficEvents({
22766
+ pull: pullVercelEvents,
22578
22767
  token: credential.token,
22579
22768
  projectId: vercelProjectId,
22580
22769
  teamId: vercelTeamId,
22581
22770
  environment: vercelEnvironment,
22582
22771
  startDate: windowStart.getTime(),
22583
22772
  endDate: windowEnd.getTime(),
22584
- maxPages: BACKFILL_MAX_PAGES
22773
+ pagesPerSubWindow: vercelMaxPages,
22774
+ maxSubWindows: VERCEL_MAX_SUB_WINDOWS
22585
22775
  });
22586
- if (page.hasMore) {
22587
- throw new Error(
22588
- `backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
22589
- );
22590
- }
22591
- return page.events;
22776
+ return drained.events;
22592
22777
  };
22593
22778
  }
22594
22779
  const startedAt = windowEnd.toISOString();
@@ -23899,6 +24084,76 @@ var TRAFFIC_SOURCE_CHECKS = [
23899
24084
  scopesCheck2
23900
24085
  ];
23901
24086
 
24087
+ // ../api-routes/src/doctor/checks/wordpress-publish.ts
24088
+ var WORDPRESS_PUBLISH_CHECKS = [
24089
+ {
24090
+ id: "wordpress.publish.connection",
24091
+ category: CheckCategories.auth,
24092
+ scope: CheckScopes.project,
24093
+ title: "WordPress publishing connection",
24094
+ run: async (ctx) => {
24095
+ if (!ctx.project) {
24096
+ return {
24097
+ status: CheckStatuses.skipped,
24098
+ code: "wordpress.publish.no-project",
24099
+ summary: "Project context required.",
24100
+ remediation: null
24101
+ };
24102
+ }
24103
+ const store = ctx.wordpressConnectionStore;
24104
+ if (!store) {
24105
+ return {
24106
+ status: CheckStatuses.skipped,
24107
+ code: "wordpress.publish.store-unavailable",
24108
+ summary: "WordPress connection store is not configured for this deployment.",
24109
+ remediation: null
24110
+ };
24111
+ }
24112
+ const connection = store.getConnection(ctx.project.name);
24113
+ if (!connection) {
24114
+ return {
24115
+ status: CheckStatuses.skipped,
24116
+ code: "wordpress.publish.not-configured",
24117
+ summary: `No WordPress publishing connection configured for ${ctx.project.name}.`,
24118
+ remediation: `If this project publishes to WordPress, run \`canonry wordpress connect ${ctx.project.name} --url <url> --user <user>\`.`
24119
+ };
24120
+ }
24121
+ try {
24122
+ const status = await verifyWordpressConnection(connection);
24123
+ return {
24124
+ status: CheckStatuses.ok,
24125
+ code: "wordpress.publish.connected",
24126
+ summary: `WordPress publishing connection verified; wp/v2 REST API reachable at ${status.url}.`,
24127
+ remediation: null,
24128
+ details: {
24129
+ url: status.url,
24130
+ wordpressVersion: status.version,
24131
+ pageCount: status.pageCount
24132
+ }
24133
+ };
24134
+ } catch (err) {
24135
+ if (err instanceof WordpressApiError && err.code === "AUTH_INVALID") {
24136
+ return {
24137
+ status: CheckStatuses.fail,
24138
+ code: "wordpress.publish.unauthorized",
24139
+ summary: "WordPress rejected the stored application password.",
24140
+ remediation: `Regenerate the Application Password in wp-admin (Users \u2192 Profile \u2192 Application Passwords), then reconnect with \`canonry wordpress connect ${ctx.project.name} --url <url> --user <user>\`.`,
24141
+ details: { error: err.message }
24142
+ };
24143
+ }
24144
+ const message = err instanceof Error ? err.message : String(err);
24145
+ return {
24146
+ status: CheckStatuses.fail,
24147
+ code: "wordpress.publish.verification-failed",
24148
+ summary: "WordPress publishing connection could not be verified.",
24149
+ remediation: "Confirm the site URL is correct and the WordPress REST API is reachable.",
24150
+ details: { error: message }
24151
+ };
24152
+ }
24153
+ }
24154
+ }
24155
+ ];
24156
+
23902
24157
  // ../api-routes/src/doctor/registry.ts
23903
24158
  var ALL_CHECKS = [
23904
24159
  // Runtime-state checks run first so file-system gone errors surface
@@ -23906,6 +24161,7 @@ var ALL_CHECKS = [
23906
24161
  ...RUNTIME_STATE_CHECKS,
23907
24162
  ...GOOGLE_AUTH_CHECKS,
23908
24163
  ...BING_AUTH_CHECKS,
24164
+ ...WORDPRESS_PUBLISH_CHECKS,
23909
24165
  ...GA_AUTH_CHECKS,
23910
24166
  ...PROVIDERS_CHECKS,
23911
24167
  ...TRAFFIC_SOURCE_CHECKS,
@@ -23990,6 +24246,7 @@ async function doctorRoutes(app, opts) {
23990
24246
  project: null,
23991
24247
  googleConnectionStore: opts.googleConnectionStore,
23992
24248
  bingConnectionStore: opts.bingConnectionStore,
24249
+ wordpressConnectionStore: opts.wordpressConnectionStore,
23993
24250
  ga4CredentialStore: opts.ga4CredentialStore,
23994
24251
  getGoogleAuthConfig: opts.getGoogleAuthConfig,
23995
24252
  redirectUri,
@@ -24012,6 +24269,7 @@ async function doctorRoutes(app, opts) {
24012
24269
  },
24013
24270
  googleConnectionStore: opts.googleConnectionStore,
24014
24271
  bingConnectionStore: opts.bingConnectionStore,
24272
+ wordpressConnectionStore: opts.wordpressConnectionStore,
24015
24273
  ga4CredentialStore: opts.ga4CredentialStore,
24016
24274
  getGoogleAuthConfig: opts.getGoogleAuthConfig,
24017
24275
  redirectUri,
@@ -24623,7 +24881,8 @@ async function apiRoutes(app, opts) {
24623
24881
  pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
24624
24882
  vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
24625
24883
  pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
24626
- onTrafficSynced: opts.onTrafficSynced
24884
+ onTrafficSynced: opts.onTrafficSynced,
24885
+ allowLoopbackWebhooks: opts.allowLoopbackWebhooks
24627
24886
  });
24628
24887
  await api.register(backlinksRoutes, {
24629
24888
  getBacklinksStatus: opts.getBacklinksStatus,
@@ -24640,6 +24899,7 @@ async function apiRoutes(app, opts) {
24640
24899
  await api.register(doctorRoutes, {
24641
24900
  googleConnectionStore: opts.googleConnectionStore,
24642
24901
  bingConnectionStore: opts.bingConnectionStore,
24902
+ wordpressConnectionStore: opts.wordpressConnectionStore,
24643
24903
  ga4CredentialStore: opts.ga4CredentialStore,
24644
24904
  getGoogleAuthConfig: opts.getGoogleAuthConfig,
24645
24905
  publicUrl: opts.publicUrl,
@@ -27172,7 +27432,8 @@ function upsertGoogleConnection(config, connection) {
27172
27432
  sitemapUrl: connection.sitemapUrl ?? null,
27173
27433
  refreshToken: connection.refreshToken ?? null,
27174
27434
  tokenExpiresAt: connection.tokenExpiresAt ?? null,
27175
- scopes: connection.scopes ?? []
27435
+ scopes: connection.scopes ?? [],
27436
+ createdByProjectId: connection.createdByProjectId ?? null
27176
27437
  };
27177
27438
  if (index === -1) {
27178
27439
  connections.push(normalized);
@@ -29802,7 +30063,7 @@ function readStoredGroundingSources(rawResponse) {
29802
30063
  return result;
29803
30064
  }
29804
30065
  async function backfillInsightsCommand(project, opts) {
29805
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-OCREQUCQ.js");
30066
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-NKAEHHJ5.js");
29806
30067
  const config = loadConfig();
29807
30068
  const db = createClient(config.database);
29808
30069
  migrate(db);
@@ -32957,6 +33218,48 @@ function summarizeProviderConfig(provider, config) {
32957
33218
  function hashApiKey(key) {
32958
33219
  return crypto35.createHash("sha256").update(key).digest("hex");
32959
33220
  }
33221
+ var DASHBOARD_SCRYPT_KEYLEN = 64;
33222
+ var DASHBOARD_SCRYPT_COST = 1 << 15;
33223
+ var DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024;
33224
+ function hashDashboardPassword(password) {
33225
+ const salt = crypto35.randomBytes(16);
33226
+ const derived = crypto35.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, {
33227
+ N: DASHBOARD_SCRYPT_COST,
33228
+ maxmem: DASHBOARD_SCRYPT_MAXMEM
33229
+ });
33230
+ return `scrypt$1$${salt.toString("base64")}$${derived.toString("base64")}`;
33231
+ }
33232
+ function verifyDashboardPassword(password, storedHash) {
33233
+ if (storedHash.startsWith("scrypt$1$")) {
33234
+ const parts = storedHash.split("$");
33235
+ if (parts.length !== 4) return { ok: false, needsRehash: false };
33236
+ const saltB64 = parts[2];
33237
+ const hashB64 = parts[3];
33238
+ if (!saltB64 || !hashB64) return { ok: false, needsRehash: false };
33239
+ let salt;
33240
+ let expected;
33241
+ try {
33242
+ salt = Buffer.from(saltB64, "base64");
33243
+ expected = Buffer.from(hashB64, "base64");
33244
+ } catch {
33245
+ return { ok: false, needsRehash: false };
33246
+ }
33247
+ const derived = crypto35.scryptSync(password, salt, expected.length, {
33248
+ N: DASHBOARD_SCRYPT_COST,
33249
+ maxmem: DASHBOARD_SCRYPT_MAXMEM
33250
+ });
33251
+ if (derived.length !== expected.length) return { ok: false, needsRehash: false };
33252
+ return { ok: crypto35.timingSafeEqual(derived, expected), needsRehash: false };
33253
+ }
33254
+ if (/^[a-f0-9]{64}$/i.test(storedHash)) {
33255
+ const candidate = Buffer.from(hashApiKey(password), "hex");
33256
+ const expected = Buffer.from(storedHash, "hex");
33257
+ if (candidate.length !== expected.length) return { ok: false, needsRehash: false };
33258
+ const ok = crypto35.timingSafeEqual(candidate, expected);
33259
+ return { ok, needsRehash: ok };
33260
+ }
33261
+ return { ok: false, needsRehash: false };
33262
+ }
32960
33263
  function parseCookies2(header) {
32961
33264
  if (!header) return {};
32962
33265
  return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
@@ -33185,13 +33488,14 @@ async function createServer(opts) {
33185
33488
  if (!opts.config.bing) opts.config.bing = {};
33186
33489
  if (!opts.config.bing.connections) opts.config.bing.connections = [];
33187
33490
  const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
33491
+ const normalized = { ...connection, createdByProjectId: connection.createdByProjectId ?? null };
33188
33492
  if (idx >= 0) {
33189
- opts.config.bing.connections[idx] = connection;
33493
+ opts.config.bing.connections[idx] = normalized;
33190
33494
  } else {
33191
- opts.config.bing.connections.push(connection);
33495
+ opts.config.bing.connections.push(normalized);
33192
33496
  }
33193
33497
  saveConfigPatch(opts.config);
33194
- return connection;
33498
+ return normalized;
33195
33499
  },
33196
33500
  updateConnection: (domain, patch) => {
33197
33501
  const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
@@ -33399,7 +33703,7 @@ async function createServer(opts) {
33399
33703
  const err = validationError("Password must be at least 8 characters");
33400
33704
  return reply.status(err.statusCode).send(err.toJSON());
33401
33705
  }
33402
- opts.config.dashboardPasswordHash = hashApiKey(password);
33706
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
33403
33707
  saveConfigPatch(opts.config);
33404
33708
  if (!createPasswordSession(reply)) {
33405
33709
  const err = authInvalid();
@@ -33415,9 +33719,14 @@ async function createServer(opts) {
33415
33719
  const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
33416
33720
  return reply.status(err2.statusCode).send(err2.toJSON());
33417
33721
  }
33418
- if (hashApiKey(password) !== opts.config.dashboardPasswordHash) {
33722
+ const verification = verifyDashboardPassword(password, opts.config.dashboardPasswordHash);
33723
+ if (!verification.ok) {
33419
33724
  return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
33420
33725
  }
33726
+ if (verification.needsRehash) {
33727
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
33728
+ saveConfigPatch(opts.config);
33729
+ }
33421
33730
  if (!createPasswordSession(reply)) {
33422
33731
  return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
33423
33732
  }