@ainyc/canonry 4.54.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 (25) hide show
  1. package/assets/assets/{BacklinksPage-BXFT4pLI.js → BacklinksPage-DVmaM864.js} +1 -1
  2. package/assets/assets/{ProjectPage-DAtd9Vay.js → ProjectPage-DtL3LFne.js} +4 -4
  3. package/assets/assets/{RunRow-38dDceGl.js → RunRow-BRqiLxj2.js} +1 -1
  4. package/assets/assets/{RunsPage-AJnFLtaE.js → RunsPage-UxZ93-cg.js} +1 -1
  5. package/assets/assets/{SettingsPage-FT9ZAvFH.js → SettingsPage-Cr5_EGbk.js} +1 -1
  6. package/assets/assets/TrafficPage-CUC_lfTe.js +1 -0
  7. package/assets/assets/TrafficSourceDetailPage-DARPL2TU.js +1 -0
  8. package/assets/assets/extract-error-message-DD5MibWI.js +1 -0
  9. package/assets/assets/{index-DLPKqyhx.js → index-nnF1LnyK.js} +62 -62
  10. package/assets/assets/{server-traffic-GqiQYm6x.js → server-traffic-DjRISEZ-.js} +1 -1
  11. package/assets/assets/{trash-2-BwPzJ8NI.js → trash-2-CJ5M--Le.js} +1 -1
  12. package/assets/index.html +1 -1
  13. package/dist/{chunk-CRO6Q25G.js → chunk-2OI7HFAB.js} +315 -85
  14. package/dist/{chunk-VZPDBHBW.js → chunk-OFY3Z2F7.js} +8 -4
  15. package/dist/{chunk-JHAHNKSN.js → chunk-UTM3FPAJ.js} +80 -3
  16. package/dist/{chunk-J7MX3YOH.js → chunk-ZY3EDW3S.js} +1 -1
  17. package/dist/cli.js +6 -6
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +4 -4
  20. package/dist/{intelligence-service-OCREQUCQ.js → intelligence-service-NKAEHHJ5.js} +2 -2
  21. package/dist/mcp.js +2 -2
  22. package/package.json +14 -13
  23. package/assets/assets/TrafficPage-B4A3oO8M.js +0 -1
  24. package/assets/assets/TrafficSourceDetailPage-8NYU1TA6.js +0 -1
  25. 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-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
@@ -21364,6 +21441,7 @@ async function listWordpressTrafficEvents(options) {
21364
21441
  let skippedEntryCount = 0;
21365
21442
  let hasMore = false;
21366
21443
  const events = [];
21444
+ const dispatcher = options.dispatcher;
21367
21445
  for (let page = 0; page < maxPages; page += 1) {
21368
21446
  const url = new URL(endpoint);
21369
21447
  url.searchParams.set("limit", String(pageSize));
@@ -21377,7 +21455,7 @@ async function listWordpressTrafficEvents(options) {
21377
21455
  url.searchParams.set("until", options.until);
21378
21456
  }
21379
21457
  url.searchParams.set("_cb", randomUUID());
21380
- const response = await fetch(url, {
21458
+ const fetchInit = {
21381
21459
  method: "GET",
21382
21460
  headers: {
21383
21461
  Authorization: authHeader,
@@ -21385,7 +21463,11 @@ async function listWordpressTrafficEvents(options) {
21385
21463
  "Cache-Control": "no-cache"
21386
21464
  },
21387
21465
  signal: AbortSignal.timeout(timeoutMs)
21388
- });
21466
+ };
21467
+ if (dispatcher !== void 0) {
21468
+ fetchInit.dispatcher = dispatcher;
21469
+ }
21470
+ const response = await fetch(url, fetchInit);
21389
21471
  if (!response.ok) {
21390
21472
  const body2 = await readErrorBody2(response);
21391
21473
  throw new WordpressTrafficApiError(
@@ -21599,6 +21681,62 @@ async function listVercelTrafficEvents(options) {
21599
21681
  };
21600
21682
  }
21601
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
+
21602
21740
  // ../api-routes/src/traffic.ts
21603
21741
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
21604
21742
  var DEFAULT_PAGE_SIZE3 = 1e3;
@@ -21607,6 +21745,7 @@ var DEFAULT_SAMPLE_LIMIT2 = 100;
21607
21745
  var DEFAULT_WP_PAGE_SIZE = 500;
21608
21746
  var DEFAULT_WP_MAX_PAGES = 20;
21609
21747
  var DEFAULT_VERCEL_MAX_PAGES = 50;
21748
+ var VERCEL_MAX_SUB_WINDOWS = 5e3;
21610
21749
  var MAX_TRACKED_EVENT_IDS = 1e3;
21611
21750
  var DEFAULT_BACKFILL_DAYS = 30;
21612
21751
  var MAX_BACKFILL_DAYS = 90;
@@ -21812,6 +21951,30 @@ async function trafficRoutes(app, opts) {
21812
21951
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
21813
21952
  const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
21814
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
+ }
21815
21978
  const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
21816
21979
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
21817
21980
  const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
@@ -21910,23 +22073,26 @@ async function trafficRoutes(app, opts) {
21910
22073
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
21911
22074
  }
21912
22075
  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
- );
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}`);
21926
22094
  }
21927
- const msg = e instanceof Error ? e.message : String(e);
21928
- throw providerError(`WordPress traffic probe failed: ${msg}`);
21929
- }
22095
+ });
21930
22096
  const now = (/* @__PURE__ */ new Date()).toISOString();
21931
22097
  const existing = credentialStore.getConnection(project.name);
21932
22098
  credentialStore.upsertConnection({
@@ -22176,6 +22342,14 @@ async function trafficRoutes(app, opts) {
22176
22342
  windowStart = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt) : windowEnd;
22177
22343
  const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
22178
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
+ }
22179
22353
  const collected = [];
22180
22354
  let cursor = sourceRow.lastCursor ?? void 0;
22181
22355
  try {
@@ -22186,7 +22360,8 @@ async function trafficRoutes(app, opts) {
22186
22360
  applicationPassword: credential.applicationPassword,
22187
22361
  cursor,
22188
22362
  pageSize: wpPageSize,
22189
- maxPages: 1
22363
+ maxPages: 1,
22364
+ dispatcher: pinnedDispatcher
22190
22365
  });
22191
22366
  collected.push(...pageResult.events);
22192
22367
  const previousCursor = cursor;
@@ -22200,6 +22375,9 @@ async function trafficRoutes(app, opts) {
22200
22375
  const msg = e instanceof Error ? e.message : String(e);
22201
22376
  markFailed(msg, "PROVIDER_PULL");
22202
22377
  throw providerError(`WordPress pull failed: ${msg}`);
22378
+ } finally {
22379
+ await pinnedDispatcher.close().catch(() => {
22380
+ });
22203
22381
  }
22204
22382
  } else {
22205
22383
  auditAction = "traffic.vercel.synced";
@@ -22226,28 +22404,24 @@ async function trafficRoutes(app, opts) {
22226
22404
  windowStart = new Date(
22227
22405
  Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
22228
22406
  );
22229
- let page;
22230
22407
  try {
22231
- page = await pullVercelEvents({
22408
+ const drained = await drainVercelTrafficEvents({
22409
+ pull: pullVercelEvents,
22232
22410
  token: credential.token,
22233
22411
  projectId: vercelProjectId,
22234
22412
  teamId: vercelTeamId,
22235
22413
  environment: vercelEnvironment,
22236
22414
  startDate: windowStart.getTime(),
22237
22415
  endDate: windowEnd.getTime(),
22238
- maxPages: vercelMaxPages
22416
+ pagesPerSubWindow: vercelMaxPages,
22417
+ maxSubWindows: VERCEL_MAX_SUB_WINDOWS
22239
22418
  });
22419
+ allEvents = drained.events;
22240
22420
  } catch (e) {
22241
22421
  const msg = e instanceof Error ? e.message : String(e);
22242
22422
  markFailed(msg, "PROVIDER_PULL");
22243
22423
  throw providerError(`Vercel pull failed: ${msg}`);
22244
22424
  }
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
22425
  }
22252
22426
  let crawlerBucketRows = 0;
22253
22427
  let aiUserFetchBucketRows = 0;
@@ -22534,33 +22708,42 @@ async function trafficRoutes(app, opts) {
22534
22708
  `No WordPress credential found for project "${project.name}". Run "canonry traffic connect wordpress" first.`
22535
22709
  );
22536
22710
  }
22711
+ await (await assertWordpressTargetAllowed(credential.baseUrl)).close().catch(() => {
22712
+ });
22537
22713
  const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
22538
22714
  pullErrorPrefix = "WordPress pull failed";
22539
22715
  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
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(() => {
22556
22745
  });
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
22746
  }
22563
- return collected;
22564
22747
  };
22565
22748
  } else {
22566
22749
  const credentialStore = opts.vercelTrafficCredentialStore;
@@ -22579,21 +22762,18 @@ async function trafficRoutes(app, opts) {
22579
22762
  const vercelEnvironment = config.environment ?? credential.environment;
22580
22763
  pullErrorPrefix = "Vercel pull failed";
22581
22764
  pullForBackfill = async () => {
22582
- const page = await pullVercelEvents({
22765
+ const drained = await drainVercelTrafficEvents({
22766
+ pull: pullVercelEvents,
22583
22767
  token: credential.token,
22584
22768
  projectId: vercelProjectId,
22585
22769
  teamId: vercelTeamId,
22586
22770
  environment: vercelEnvironment,
22587
22771
  startDate: windowStart.getTime(),
22588
22772
  endDate: windowEnd.getTime(),
22589
- maxPages: BACKFILL_MAX_PAGES
22773
+ pagesPerSubWindow: vercelMaxPages,
22774
+ maxSubWindows: VERCEL_MAX_SUB_WINDOWS
22590
22775
  });
22591
- if (page.hasMore) {
22592
- throw new Error(
22593
- `backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
22594
- );
22595
- }
22596
- return page.events;
22776
+ return drained.events;
22597
22777
  };
22598
22778
  }
22599
22779
  const startedAt = windowEnd.toISOString();
@@ -24701,7 +24881,8 @@ async function apiRoutes(app, opts) {
24701
24881
  pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
24702
24882
  vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
24703
24883
  pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
24704
- onTrafficSynced: opts.onTrafficSynced
24884
+ onTrafficSynced: opts.onTrafficSynced,
24885
+ allowLoopbackWebhooks: opts.allowLoopbackWebhooks
24705
24886
  });
24706
24887
  await api.register(backlinksRoutes, {
24707
24888
  getBacklinksStatus: opts.getBacklinksStatus,
@@ -27251,7 +27432,8 @@ function upsertGoogleConnection(config, connection) {
27251
27432
  sitemapUrl: connection.sitemapUrl ?? null,
27252
27433
  refreshToken: connection.refreshToken ?? null,
27253
27434
  tokenExpiresAt: connection.tokenExpiresAt ?? null,
27254
- scopes: connection.scopes ?? []
27435
+ scopes: connection.scopes ?? [],
27436
+ createdByProjectId: connection.createdByProjectId ?? null
27255
27437
  };
27256
27438
  if (index === -1) {
27257
27439
  connections.push(normalized);
@@ -29881,7 +30063,7 @@ function readStoredGroundingSources(rawResponse) {
29881
30063
  return result;
29882
30064
  }
29883
30065
  async function backfillInsightsCommand(project, opts) {
29884
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-OCREQUCQ.js");
30066
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-NKAEHHJ5.js");
29885
30067
  const config = loadConfig();
29886
30068
  const db = createClient(config.database);
29887
30069
  migrate(db);
@@ -33036,6 +33218,48 @@ function summarizeProviderConfig(provider, config) {
33036
33218
  function hashApiKey(key) {
33037
33219
  return crypto35.createHash("sha256").update(key).digest("hex");
33038
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
+ }
33039
33263
  function parseCookies2(header) {
33040
33264
  if (!header) return {};
33041
33265
  return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
@@ -33264,13 +33488,14 @@ async function createServer(opts) {
33264
33488
  if (!opts.config.bing) opts.config.bing = {};
33265
33489
  if (!opts.config.bing.connections) opts.config.bing.connections = [];
33266
33490
  const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
33491
+ const normalized = { ...connection, createdByProjectId: connection.createdByProjectId ?? null };
33267
33492
  if (idx >= 0) {
33268
- opts.config.bing.connections[idx] = connection;
33493
+ opts.config.bing.connections[idx] = normalized;
33269
33494
  } else {
33270
- opts.config.bing.connections.push(connection);
33495
+ opts.config.bing.connections.push(normalized);
33271
33496
  }
33272
33497
  saveConfigPatch(opts.config);
33273
- return connection;
33498
+ return normalized;
33274
33499
  },
33275
33500
  updateConnection: (domain, patch) => {
33276
33501
  const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
@@ -33478,7 +33703,7 @@ async function createServer(opts) {
33478
33703
  const err = validationError("Password must be at least 8 characters");
33479
33704
  return reply.status(err.statusCode).send(err.toJSON());
33480
33705
  }
33481
- opts.config.dashboardPasswordHash = hashApiKey(password);
33706
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
33482
33707
  saveConfigPatch(opts.config);
33483
33708
  if (!createPasswordSession(reply)) {
33484
33709
  const err = authInvalid();
@@ -33494,9 +33719,14 @@ async function createServer(opts) {
33494
33719
  const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
33495
33720
  return reply.status(err2.statusCode).send(err2.toJSON());
33496
33721
  }
33497
- if (hashApiKey(password) !== opts.config.dashboardPasswordHash) {
33722
+ const verification = verifyDashboardPassword(password, opts.config.dashboardPasswordHash);
33723
+ if (!verification.ok) {
33498
33724
  return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
33499
33725
  }
33726
+ if (verification.needsRehash) {
33727
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
33728
+ saveConfigPatch(opts.config);
33729
+ }
33500
33730
  if (!createPasswordSession(reply)) {
33501
33731
  return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
33502
33732
  }