@canonry/canonry 7.17.1 → 7.18.0

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 (36) hide show
  1. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +8 -6
  2. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +1 -1
  3. package/assets/assets/{AuditHistoryPanel-CzjskfMX.js → AuditHistoryPanel-CgKpjgpq.js} +1 -1
  4. package/assets/assets/BacklinksPage-BBqbRSeG.js +1 -0
  5. package/assets/assets/Hint-DICI5Jq9.js +1 -0
  6. package/assets/assets/{HistoryPage-BZgVoYGm.js → HistoryPage-D8nQ8E8_.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-EBI5sn-D.js → MeasurementPropertyPage-CQ8lLoeA.js} +1 -1
  8. package/assets/assets/{ProjectPage-lGGjM_qj.js → ProjectPage-CP4fvluh.js} +6 -6
  9. package/assets/assets/{RunsPage-CuHi8241.js → RunsPage-D0qkTwas.js} +1 -1
  10. package/assets/assets/{SettingsPage-D0fgAoVu.js → SettingsPage-DaqR1wKC.js} +1 -1
  11. package/assets/assets/SiteHealthSection-dGXxZ8IR.js +4 -0
  12. package/assets/assets/{TrafficPage-DD65RqeR.js → TrafficPage-DodOmnVv.js} +1 -1
  13. package/assets/assets/{TrafficSourceDetailPage-CFVCj13J.js → TrafficSourceDetailPage-C7b-BcKF.js} +1 -1
  14. package/assets/assets/{extract-error-message-D118msoE.js → extract-error-message-BYvzTsML.js} +1 -1
  15. package/assets/assets/index-BVzozur0.js +86 -0
  16. package/assets/assets/{react-sigma_core.esm.min-DdlHLMEz.js → react-sigma_core.esm.min-DXcq1O0r.js} +1 -1
  17. package/assets/assets/{v2-overview-adapter-CoCb2aB0.js → v2-overview-adapter-1MHnrdnr.js} +1 -1
  18. package/assets/assets/{vendor-lucide-N6vOpZmh.js → vendor-lucide-Y6jANGfG.js} +1 -1
  19. package/assets/index.html +2 -2
  20. package/dist/{chunk-ORA5R7QM.js → chunk-5M2RY5WU.js} +132 -104
  21. package/dist/{chunk-ZQ7C6IFA.js → chunk-COQOQYRZ.js} +749 -614
  22. package/dist/{chunk-FK7AWV57.js → chunk-EAAM47H6.js} +24 -9
  23. package/dist/{chunk-PLS4EM2Y.js → chunk-JMC7CEJB.js} +4 -4
  24. package/dist/{chunk-OPJCFDNR.js → chunk-JUOFNJLV.js} +3 -3
  25. package/dist/{chunk-C6GWKYGE.js → chunk-U7D7LNVJ.js} +5 -4
  26. package/dist/{chunk-MHB23E3N.js → chunk-X2S7D3MN.js} +123 -82
  27. package/dist/cli.js +30 -22
  28. package/dist/{demo-server-WQDHQG6P.js → demo-server-XWRIXME5.js} +6 -5
  29. package/dist/index.d.ts +7 -5
  30. package/dist/index.js +6 -6
  31. package/dist/{intelligence-service-JWZRZ6JZ.js → intelligence-service-332S7D7Y.js} +2 -2
  32. package/dist/mcp.js +4 -4
  33. package/package.json +12 -12
  34. package/assets/assets/BacklinksPage-ToENGBdp.js +0 -1
  35. package/assets/assets/SiteHealthSection-DhLxldgs.js +0 -4
  36. package/assets/assets/index-DxFd1t4G.js +0 -86
@@ -20,23 +20,25 @@ import {
20
20
  saveConfig,
21
21
  saveConfigPatch,
22
22
  sentimentInstallReadiness
23
- } from "./chunk-PLS4EM2Y.js";
23
+ } from "./chunk-JMC7CEJB.js";
24
24
  import {
25
25
  computeFactorAverages,
26
26
  deleteSiteCrawlGraphLayout,
27
27
  persistSiteCrawlGraphLayout,
28
28
  prepareSiteCrawlGraphLayout,
29
29
  recordedSharePct
30
- } from "./chunk-C6GWKYGE.js";
30
+ } from "./chunk-U7D7LNVJ.js";
31
31
  import {
32
32
  CanonryMcpToolNames,
33
33
  CliError,
34
34
  PACKAGE_VERSION,
35
35
  canonryMcpTools
36
- } from "./chunk-FK7AWV57.js";
36
+ } from "./chunk-EAAM47H6.js";
37
37
  import {
38
38
  CC_CACHE_DIR,
39
39
  DUCKDB_SPEC,
40
+ EgressFailedError,
41
+ EgressRefusedError,
40
42
  GBP_DAILY_METRICS,
41
43
  GSC_DATA_LAG_DAYS,
42
44
  GSC_REPORTING_TIME_ZONE,
@@ -65,6 +67,7 @@ import {
65
67
  apiRoutes,
66
68
  applyCompetitorAutoAliases,
67
69
  assessConversionTrackingIntegrity,
70
+ auditFromRequest,
68
71
  auditLog,
69
72
  backfillProjectAnswerMentions,
70
73
  backfillProjectAnswerMentionsInChunks,
@@ -86,6 +89,7 @@ import {
86
89
  countPopulatedGroups,
87
90
  createCredentialChecker,
88
91
  createFastifyLogger,
92
+ createGuardedFetch,
89
93
  createLogger,
90
94
  createProjectPassQueue,
91
95
  createRunCompetitorResolver,
@@ -133,6 +137,7 @@ import {
133
137
  gtmConnections,
134
138
  gtmRawSnapshots,
135
139
  hasOutstandingProviderBatch,
140
+ hasProxyHeaders,
136
141
  hashApiKey,
137
142
  hashAttributes,
138
143
  hashLodging,
@@ -216,8 +221,9 @@ import {
216
221
  siteCrawlSnapshots,
217
222
  siteLivenessState,
218
223
  toAlertView,
219
- usageCounters
220
- } from "./chunk-ZQ7C6IFA.js";
224
+ usageCounters,
225
+ writeAuditLog
226
+ } from "./chunk-COQOQYRZ.js";
221
227
  import {
222
228
  AGENT_MEMORY_VALUE_MAX_BYTES,
223
229
  AGENT_NONE,
@@ -337,6 +343,7 @@ import {
337
343
  effectiveDomains,
338
344
  embedClientConfigForRequest,
339
345
  embedProjectTabsError,
346
+ escapeRegExp,
340
347
  extractProviderHttpStatus,
341
348
  extractRecommendedCompetitors,
342
349
  factorStatusFromScore,
@@ -432,7 +439,7 @@ import {
432
439
  winnabilityClassLabel,
433
440
  withRetry,
434
441
  withSkippedProviders
435
- } from "./chunk-MHB23E3N.js";
442
+ } from "./chunk-X2S7D3MN.js";
436
443
 
437
444
  // src/runtime-context.ts
438
445
  function cliRuntimeContext(env = process.env, stdio = process) {
@@ -1240,9 +1247,6 @@ function buildDailyConversionMetricsQuery(options) {
1240
1247
  }
1241
1248
 
1242
1249
  // ../integration-google-ads/src/google-ads-client.ts
1243
- function escapeRegExp(value) {
1244
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1245
- }
1246
1250
  function sanitizeText(value, secrets) {
1247
1251
  let sanitized = value.replace(/(authorization\s*:\s*bearer\s+)[^\s,"'}]+/gi, "$1***").replace(/((?:developer|access)[_-]?token\s*[:=]\s*["']?)[^\s,"'}]+/gi, "$1***");
1248
1252
  for (const secret of secrets) {
@@ -8045,7 +8049,7 @@ function nonBlank(value) {
8045
8049
  return trimmed ? trimmed : void 0;
8046
8050
  }
8047
8051
  function resolveBuildCommit(env = process.env) {
8048
- const embedded = true ? "69eee64dc770c3172d9de2c52a912e5f14bdb532" : void 0;
8052
+ const embedded = true ? "32caa11b2aaf9890ca60a6eb4abfcffe902b69f6" : void 0;
8049
8053
  return nonBlank(embedded) ?? nonBlank(env.CANONRY_COMMIT);
8050
8054
  }
8051
8055
  function resolveInstanceIdentity(env = process.env) {
@@ -12143,10 +12147,14 @@ import { eq as eq9 } from "drizzle-orm";
12143
12147
  var log7 = createLogger("SitemapParser");
12144
12148
  var LOC_REGEX = /<loc>([^<]+)<\/loc>/gi;
12145
12149
  var SITEMAP_TAG_REGEX = /<sitemap>[\s\S]*?<\/sitemap>/gi;
12146
- async function validateSitemapUrl(url) {
12147
- const check = await resolveWebhookTarget(url, { allowLoopback: true });
12148
- if (!check.ok) {
12149
- throw new Error(`Sitemap URL rejected: ${check.message.replace(/^"url" /, "")} (${url})`);
12150
+ var sitemapFetch = createGuardedFetch({ allowLoopback: true });
12151
+ async function fetchSitemap(url) {
12152
+ try {
12153
+ return await sitemapFetch(url);
12154
+ } catch (err) {
12155
+ if (err instanceof EgressRefusedError) throw new Error(`Sitemap ${url} rejected: ${err.message}`);
12156
+ if (err instanceof EgressFailedError) throw new Error(`Failed to fetch sitemap at ${url}: ${err.message}`);
12157
+ throw err;
12150
12158
  }
12151
12159
  }
12152
12160
  async function readSitemapBody(res) {
@@ -12178,8 +12186,7 @@ async function parseSitemapRecursive(url, urls, visited, depth, isChild) {
12178
12186
  visited.add(url);
12179
12187
  let res;
12180
12188
  try {
12181
- await validateSitemapUrl(url);
12182
- res = await fetch(url);
12189
+ res = await fetchSitemap(url);
12183
12190
  } catch (err) {
12184
12191
  if (!isChild) throw err;
12185
12192
  log7.warn("child-sitemap.fetch-failed", {
@@ -12864,7 +12871,7 @@ async function executeReleaseSync(db, syncId, opts) {
12864
12871
  const rowsByProject = groupByProject(rows, projectsByDomain);
12865
12872
  for (const p of allProjects) {
12866
12873
  const projectRows = rowsByProject.get(p.id) ?? [];
12867
- const summary = computeSummary(projectRows);
12874
+ const summary = computeBacklinkSummaryMetrics(projectRows);
12868
12875
  tx.insert(backlinkSummaries).values({
12869
12876
  id: crypto17.randomUUID(),
12870
12877
  projectId: p.id,
@@ -12947,20 +12954,6 @@ function groupByProject(rows, projectsByDomain) {
12947
12954
  }
12948
12955
  return out;
12949
12956
  }
12950
- function computeSummary(rows) {
12951
- if (rows.length === 0) {
12952
- return { totalLinkingDomains: 0, totalHosts: 0, top10HostsShare: "0" };
12953
- }
12954
- const sorted = [...rows].sort((a, b) => b.numHosts - a.numHosts);
12955
- const totalHosts = sorted.reduce((acc, r) => acc + r.numHosts, 0);
12956
- const top10Hosts = sorted.slice(0, 10).reduce((acc, r) => acc + r.numHosts, 0);
12957
- const share = totalHosts > 0 ? top10Hosts / totalHosts : 0;
12958
- return {
12959
- totalLinkingDomains: rows.length,
12960
- totalHosts,
12961
- top10HostsShare: share.toFixed(6)
12962
- };
12963
- }
12964
12957
 
12965
12958
  // src/backlink-extract.ts
12966
12959
  import crypto18 from "crypto";
@@ -13031,7 +13024,7 @@ async function executeBacklinkExtract(db, runId, projectId, opts = {}) {
13031
13024
  }));
13032
13025
  tx.insert(backlinkDomains).values(values).run();
13033
13026
  }
13034
- const summary = computeSummary2(rows);
13027
+ const summary = computeSummary(rows);
13035
13028
  tx.insert(backlinkSummaries).values({
13036
13029
  id: crypto18.randomUUID(),
13037
13030
  projectId,
@@ -13071,7 +13064,7 @@ async function executeBacklinkExtract(db, runId, projectId, opts = {}) {
13071
13064
  throw err;
13072
13065
  }
13073
13066
  }
13074
- function computeSummary2(rows) {
13067
+ function computeSummary(rows) {
13075
13068
  return computeBacklinkSummaryMetrics(rows);
13076
13069
  }
13077
13070
 
@@ -15526,11 +15519,17 @@ var SITE_LIVENESS_MAX_PASS_GAP_MS = 45 * 6e4;
15526
15519
  var Notifier = class {
15527
15520
  db;
15528
15521
  serverUrl;
15522
+ /**
15523
+ * The server's `allowLoopbackWebhooks`, so delivery admits exactly the
15524
+ * destinations that creating and testing a webhook admitted.
15525
+ */
15526
+ allowLoopbackWebhooks;
15529
15527
  /** Projects with a review dispatch in progress; see {@link dispatchReviewAlerts}. */
15530
15528
  reviewDispatchInFlight = /* @__PURE__ */ new Set();
15531
- constructor(db, serverUrl) {
15529
+ constructor(db, serverUrl, opts = {}) {
15532
15530
  this.db = db;
15533
15531
  this.serverUrl = serverUrl;
15532
+ this.allowLoopbackWebhooks = opts.allowLoopbackWebhooks === true;
15534
15533
  }
15535
15534
  /** Called after a run completes (success, partial, or failed). */
15536
15535
  async onRunCompleted(runId, projectId, opts) {
@@ -16096,7 +16095,7 @@ var Notifier = class {
16096
16095
  const body = destination.render ? destination.render(toAlertView(payload)) : payload;
16097
16096
  const signingSecret = destination.signed ? webhookSecret : null;
16098
16097
  const targetLabel = redactNotificationUrl(url).urlDisplay;
16099
- const targetCheck = await resolveWebhookTarget(url);
16098
+ const targetCheck = await resolveWebhookTarget(url, { allowLoopback: this.allowLoopbackWebhooks });
16100
16099
  if (!targetCheck.ok) {
16101
16100
  log20.error("webhook.ssrf-blocked", { url: targetLabel, reason: targetCheck.message });
16102
16101
  this.logDelivery(projectId, notificationId, payload.event, "failed", `SSRF: ${targetCheck.message}`);
@@ -18781,7 +18780,8 @@ var ADVANCED_TOOLS = [
18781
18780
  var LEGACY_TOOLS = ["canonry_measurement_overview", "canonry_measurement_plan_get", "canonry_run_completeness"];
18782
18781
  var SIMPLE_TOOLS = ["canonry_visibility_report"];
18783
18782
  var ADVANCED_ROUTES = [
18784
- "Weakest or strongest Properties: canonry_measurement_portfolio_summary (mentionRanking for both ends). Compact results carry detailsOmitted; use Property evidence reads for omitted names and cited domains. The default cursor enumerates weakest Properties only; set list=strongest-mentions or list=weakest-mentions to enumerate the chosen ranking. Follow nextCursor with unchanged list and filters; pageList names that selection. Use groupKey or page canonry_measurement_overview for all Property rows.",
18783
+ 'Weakest or strongest Properties: canonry_measurement_portfolio_summary (mentionRanking for both ends). Compact results carry detailsOmitted; use Property evidence reads for omitted names and cited domains. The default cursor enumerates weakest Properties only; set list=strongest-mentions or list=weakest-mentions to enumerate the chosen ranking. Follow nextCursor with unchanged list and filters; pageList names that selection. Use groupKey, or page canonry_measurement_overview with queryClass "non-brand", for all Property rows.',
18784
+ `Rates for one Property, group or market: canonry_measurement_overview with queryClass "non-brand". Branded is a separate call (queryClass "branded"), reported as its own figure; never quote queryClass "all", which pools both. For a market, use scope "market" with its marketKey (a kind market id in canonry_visibility_report scopeOptions): it counts only that market's queries, as the dashboard does. Scope "group" counts every query of its Properties, so never quote a group figure under a market's name; quote the returned scope kind and label.`,
18785
18785
  "Which metros have the biggest gaps: the compact summary's weakestMarkets, whose rates cover full top-level metros. Set list=markets and follow nextCursor with unchanged list and filters for every market; tiedAtWeakest describes only the zero-signal Property cohort, never full metro rates.",
18786
18786
  "One Property: canonry_measurement_property_evidence and canonry_measurement_property_competitors (names written instead of it, and citedDomains, its own cited sources). Portfolio lists such as weakestAnswerSources pool many Properties.",
18787
18787
  'Which names answers give instead across the portfolio: canonry_competitor_landscape with queryClass and runId "latest", answers=not-mentioned. This selects answers naming none of their targeted Properties; preserve populationSize, answerCount and unknownMentionAnswers. Per-Property named-instead lists are samples of weak Properties.',
@@ -21526,6 +21526,10 @@ function waitForServerRuntimeStartup(app) {
21526
21526
  const startup = runtimeStartupByServer.get(app);
21527
21527
  return startup ?? Promise.reject(new Error("Server runtime startup was not registered"));
21528
21528
  }
21529
+ var keylessFirstRunSetupByServer = /* @__PURE__ */ new WeakMap();
21530
+ function allowsKeylessFirstRunSetup(app) {
21531
+ return keylessFirstRunSetupByServer.get(app) ?? false;
21532
+ }
21529
21533
  var DEFAULT_QUOTA = {
21530
21534
  maxConcurrency: 2,
21531
21535
  maxRequestsPerMinute: 10,
@@ -21618,9 +21622,6 @@ function summarizeProviderConfig(config) {
21618
21622
  quota: { ...config?.quota ?? DEFAULT_QUOTA }
21619
21623
  };
21620
21624
  }
21621
- function hashApiKey2(key) {
21622
- return crypto29.createHash("sha256").update(key).digest("hex");
21623
- }
21624
21625
  var DASHBOARD_SCRYPT_KEYLEN = 64;
21625
21626
  var DASHBOARD_SCRYPT_COST = 1 << 15;
21626
21627
  var DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024;
@@ -21659,7 +21660,7 @@ function verifyDashboardPassword(password, storedHash) {
21659
21660
  };
21660
21661
  }
21661
21662
  if (/^[a-f0-9]{64}$/i.test(storedHash)) {
21662
- const candidate = Buffer.from(hashApiKey2(password), "hex");
21663
+ const candidate = Buffer.from(hashApiKey(password), "hex");
21663
21664
  const expected = Buffer.from(storedHash, "hex");
21664
21665
  if (candidate.length !== expected.length)
21665
21666
  return { ok: false, needsRehash: false };
@@ -21668,22 +21669,6 @@ function verifyDashboardPassword(password, storedHash) {
21668
21669
  }
21669
21670
  return { ok: false, needsRehash: false };
21670
21671
  }
21671
- function parseCookies(header) {
21672
- if (!header) return {};
21673
- return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
21674
- const eqIdx = part.indexOf("=");
21675
- if (eqIdx <= 0) return cookies;
21676
- const name = part.slice(0, eqIdx).trim();
21677
- const value = part.slice(eqIdx + 1).trim();
21678
- if (!name) return cookies;
21679
- try {
21680
- cookies[name] = decodeURIComponent(value);
21681
- } catch {
21682
- cookies[name] = value;
21683
- }
21684
- return cookies;
21685
- }, {});
21686
- }
21687
21672
  function serializeSessionCookie(opts) {
21688
21673
  const parts = [
21689
21674
  `${opts.name}=${opts.value ? encodeURIComponent(opts.value) : ""}`,
@@ -21754,6 +21739,38 @@ function isLoopbackBindHost(host) {
21754
21739
  if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)) return true;
21755
21740
  return false;
21756
21741
  }
21742
+ function isLoopbackPeerAddress(address) {
21743
+ if (!address) return false;
21744
+ const normalized = address.toLowerCase().replace(/^::ffff:(?=\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$)/, "");
21745
+ return isIP(normalized) !== 0 && isLoopbackBindHost(normalized);
21746
+ }
21747
+ function isDirectLocalRequest(request) {
21748
+ if (request.raw.httpVersion !== "1.1") return false;
21749
+ if (!isLoopbackPeerAddress(request.raw.socket.remoteAddress)) return false;
21750
+ if (hasProxyHeaders(request)) return false;
21751
+ const host = request.headers.host;
21752
+ if (!host) return false;
21753
+ let hostname;
21754
+ try {
21755
+ hostname = new URL(`http://${host}`).hostname;
21756
+ } catch {
21757
+ return false;
21758
+ }
21759
+ return hostname !== "" && isLoopbackBindHost(hostname);
21760
+ }
21761
+ function configuresExternalAccess(trustProxy, basePath, ...configuredUrls) {
21762
+ if (trustProxy !== false || basePath !== void 0) return true;
21763
+ return configuredUrls.some((configuredUrl) => {
21764
+ const value = configuredUrl?.trim();
21765
+ if (!value) return false;
21766
+ try {
21767
+ const { hostname } = new URL(value);
21768
+ return hostname === "" || !isLoopbackBindHost(hostname);
21769
+ } catch {
21770
+ return true;
21771
+ }
21772
+ });
21773
+ }
21757
21774
  function isAllowedHost(host, allowedHosts) {
21758
21775
  const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
21759
21776
  return allowedHosts.has(normalized) || isIP(normalized) !== 0;
@@ -22028,7 +22045,8 @@ async function createServer(opts) {
22028
22045
  onFirstActivation: () => maybeShowActivationNotice()
22029
22046
  });
22030
22047
  jobRunner.recoverStaleRuns();
22031
- const notifier = new Notifier(opts.db, serverUrl);
22048
+ const allowLoopbackWebhooks = process.env.CANONRY_ALLOW_LOOPBACK_WEBHOOKS !== "0";
22049
+ const notifier = new Notifier(opts.db, serverUrl, { allowLoopbackWebhooks });
22032
22050
  const intelligenceService = new IntelligenceService(opts.db);
22033
22051
  const aeroClient = new ApiClient(opts.config.apiUrl, opts.config.apiKey, {
22034
22052
  skipProbe: true,
@@ -22819,7 +22837,7 @@ async function createServer(opts) {
22819
22837
  }
22820
22838
  })();
22821
22839
  if (opts.config.apiKey) {
22822
- const keyHash = hashApiKey2(opts.config.apiKey);
22840
+ const keyHash = hashApiKey(opts.config.apiKey);
22823
22841
  const existing = opts.db.select().from(apiKeys).where(eq29(apiKeys.keyHash, keyHash)).get();
22824
22842
  if (!existing) {
22825
22843
  const prefix = opts.config.apiKey.slice(0, 12);
@@ -22845,11 +22863,11 @@ async function createServer(opts) {
22845
22863
  ne3(dashboardSessions.passwordFingerprint, currentPasswordFingerprint())
22846
22864
  )
22847
22865
  )).run();
22848
- const createSession = (key, passwordBased = false) => {
22866
+ const createSession = (key, passwordBased = false, db = opts.db) => {
22849
22867
  const now = /* @__PURE__ */ new Date();
22850
- opts.db.delete(dashboardSessions).where(lte2(dashboardSessions.expiresAt, now.toISOString())).run();
22868
+ db.delete(dashboardSessions).where(lte2(dashboardSessions.expiresAt, now.toISOString())).run();
22851
22869
  const sessionId = crypto29.randomBytes(32).toString("hex");
22852
- opts.db.insert(dashboardSessions).values({
22870
+ db.insert(dashboardSessions).values({
22853
22871
  tokenHash: hashSessionToken(sessionId),
22854
22872
  apiKeyId: key.id,
22855
22873
  apiKeyHash: key.keyHash,
@@ -22883,12 +22901,9 @@ async function createServer(opts) {
22883
22901
  };
22884
22902
  const getDefaultApiKey = () => {
22885
22903
  if (!opts.config.apiKey) return void 0;
22886
- return opts.db.select().from(apiKeys).where(eq29(apiKeys.keyHash, hashApiKey2(opts.config.apiKey))).get();
22904
+ return opts.db.select().from(apiKeys).where(eq29(apiKeys.keyHash, hashApiKey(opts.config.apiKey))).get();
22887
22905
  };
22888
- const createPasswordSession = (reply) => {
22889
- const key = getDefaultApiKey();
22890
- if (!key || key.revokedAt) return false;
22891
- const sessionId = createSession(key, true);
22906
+ const setSessionCookie = (reply, sessionId) => {
22892
22907
  reply.header(
22893
22908
  "set-cookie",
22894
22909
  serializeSessionCookie({
@@ -22899,19 +22914,27 @@ async function createServer(opts) {
22899
22914
  ttlMs: SESSION_TTL_MS
22900
22915
  })
22901
22916
  );
22917
+ };
22918
+ const createPasswordSession = (reply) => {
22919
+ const key = getDefaultApiKey();
22920
+ if (!key || key.revokedAt) return false;
22921
+ setSessionCookie(reply, createSession(key, true));
22902
22922
  return true;
22903
22923
  };
22904
- const boundToLoopback = isLoopbackBindHost(opts.host);
22905
- const requestHasValidApiKey = (request) => {
22924
+ const keylessLocalSetup = isLoopbackBindHost(opts.host) && !configuresExternalAccess(trustProxy, basePath, opts.config.apiUrl, opts.config.publicUrl);
22925
+ keylessFirstRunSetupByServer.set(app, keylessLocalSetup);
22926
+ const requestPresentsKey = (request, key) => {
22906
22927
  const header = request.headers.authorization;
22907
22928
  if (!header) return false;
22908
22929
  const parts = header.split(" ");
22909
- if (parts.length !== 2 || parts[0] !== "Bearer") return false;
22910
- const key = opts.db.select().from(apiKeys).where(eq29(apiKeys.keyHash, hashApiKey2(parts[1]))).get();
22911
- return Boolean(key && !key.revokedAt);
22930
+ if (parts.length !== 2 || parts[0] !== "Bearer" || !parts[1]) return false;
22931
+ const presented = Buffer.from(hashApiKey(parts[1]), "hex");
22932
+ const expected = Buffer.from(key.keyHash, "hex");
22933
+ return presented.length === expected.length && crypto29.timingSafeEqual(presented, expected);
22912
22934
  };
22913
22935
  const namedAccountsInUse = () => anyUsersExist(opts.db);
22914
22936
  const NAMED_ACCOUNTS_MESSAGE = "This install uses named accounts. Sign in with your name and password.";
22937
+ const serverApiKeyMissing = () => new AppError("AUTH_INVALID", "Server API key not found \u2014 run canonry bootstrap", 401);
22915
22938
  app.get(apiPrefix + "/session", async (request, reply) => {
22916
22939
  if (namedAccountsInUse()) {
22917
22940
  return reply.send({ authenticated: false, setupRequired: false });
@@ -22919,7 +22942,7 @@ async function createServer(opts) {
22919
22942
  if (!dashboardRequirePassword) {
22920
22943
  return reply.send({ authenticated: true, setupRequired: false });
22921
22944
  }
22922
- const sessionId = parseCookies(request.headers.cookie)[SESSION_COOKIE_NAME];
22945
+ const sessionId = parseCookieHeader(request.headers.cookie)[SESSION_COOKIE_NAME];
22923
22946
  return reply.send({
22924
22947
  authenticated: Boolean(sessionId && resolveSessionApiKeyId(sessionId)),
22925
22948
  setupRequired: !opts.config.dashboardPasswordHash
@@ -22933,9 +22956,13 @@ async function createServer(opts) {
22933
22956
  if (!dashboardRequirePassword) {
22934
22957
  return reply.send({ authenticated: true, setupRequired: false });
22935
22958
  }
22936
- if (!boundToLoopback && !requestHasValidApiKey(request)) {
22937
- const err = authRequired(
22938
- "This server is network-reachable; setting the dashboard password requires a valid API key."
22959
+ const found = getDefaultApiKey();
22960
+ const defaultKey = found && !found.revokedAt ? found : void 0;
22961
+ const presentsConfiguredKey = opts.config.apiKey ? requestPresentsKey(request, { keyHash: hashApiKey(opts.config.apiKey) }) : false;
22962
+ const presentedRootKey = defaultKey !== void 0 && presentsConfiguredKey;
22963
+ if (!presentedRootKey && !(keylessLocalSetup && isDirectLocalRequest(request))) {
22964
+ const err = presentsConfiguredKey ? serverApiKeyMissing() : authRequired(
22965
+ "Setting the dashboard password requires the root API key (apiKey in config.yaml), unless the request comes directly from this machine to a loopback-bound server that has no external URL, base path, or trusted proxy configured."
22939
22966
  );
22940
22967
  return reply.status(err.statusCode).send(err.toJSON());
22941
22968
  }
@@ -22948,12 +22975,31 @@ async function createServer(opts) {
22948
22975
  const err = validationError("Password must be at least 8 characters");
22949
22976
  return reply.status(err.statusCode).send(err.toJSON());
22950
22977
  }
22951
- opts.config.dashboardPasswordHash = hashDashboardPassword(password);
22952
- saveConfigPatch(opts.config);
22953
- if (!createPasswordSession(reply)) {
22954
- const err = authInvalid();
22978
+ if (!defaultKey) {
22979
+ const err = serverApiKeyMissing();
22955
22980
  return reply.status(err.statusCode).send(err.toJSON());
22956
22981
  }
22982
+ opts.config.dashboardPasswordHash = hashDashboardPassword(password);
22983
+ let sessionId;
22984
+ try {
22985
+ sessionId = opts.db.transaction((tx) => {
22986
+ writeAuditLog(tx, auditFromRequest(request, {
22987
+ actor: `api-key:${defaultKey.id}`,
22988
+ credentialId: presentedRootKey ? defaultKey.id : null,
22989
+ requestId: request.id,
22990
+ action: "dashboard-password.created",
22991
+ entityType: "dashboard-password",
22992
+ diff: { authorizedBy: presentedRootKey ? "root-api-key" : "local-request" }
22993
+ }));
22994
+ const created = createSession(defaultKey, true, tx);
22995
+ saveConfigPatch(opts.config);
22996
+ return created;
22997
+ });
22998
+ } catch (err) {
22999
+ opts.config.dashboardPasswordHash = void 0;
23000
+ throw err;
23001
+ }
23002
+ setSessionCookie(reply, sessionId);
22957
23003
  return reply.send({ authenticated: true });
22958
23004
  });
22959
23005
  app.post(apiPrefix + "/session", async (request, reply) => {
@@ -22992,40 +23038,26 @@ async function createServer(opts) {
22992
23038
  opts.config.dashboardPasswordHash = migratedHash;
22993
23039
  }
22994
23040
  if (!createPasswordSession(reply)) {
22995
- return reply.status(401).send({
22996
- error: {
22997
- code: "AUTH_INVALID",
22998
- message: "Server API key not found \u2014 run canonry bootstrap"
22999
- }
23000
- });
23041
+ const err2 = serverApiKeyMissing();
23042
+ return reply.status(err2.statusCode).send(err2.toJSON());
23001
23043
  }
23002
23044
  return reply.send({ authenticated: true });
23003
23045
  }
23004
23046
  if (apiKey) {
23005
- const key = opts.db.select().from(apiKeys).where(eq29(apiKeys.keyHash, hashApiKey2(apiKey))).get();
23047
+ const key = opts.db.select().from(apiKeys).where(eq29(apiKeys.keyHash, hashApiKey(apiKey))).get();
23006
23048
  if (!key || key.revokedAt) {
23007
23049
  const err2 = authInvalid();
23008
23050
  return reply.status(err2.statusCode).send(err2.toJSON());
23009
23051
  }
23010
23052
  opts.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq29(apiKeys.id, key.id)).run();
23011
- const sessionId = createSession(key);
23012
- reply.header(
23013
- "set-cookie",
23014
- serializeSessionCookie({
23015
- name: SESSION_COOKIE_NAME,
23016
- value: sessionId,
23017
- path: sessionCookiePath,
23018
- secure: sessionCookieSecure,
23019
- ttlMs: SESSION_TTL_MS
23020
- })
23021
- );
23053
+ setSessionCookie(reply, createSession(key));
23022
23054
  return reply.send({ authenticated: true });
23023
23055
  }
23024
23056
  const err = validationError("Either password or apiKey is required");
23025
23057
  return reply.status(err.statusCode).send(err.toJSON());
23026
23058
  });
23027
23059
  app.delete(apiPrefix + "/session", async (request, reply) => {
23028
- const sessionId = parseCookies(request.headers.cookie)[SESSION_COOKIE_NAME];
23060
+ const sessionId = parseCookieHeader(request.headers.cookie)[SESSION_COOKIE_NAME];
23029
23061
  clearSession(sessionId);
23030
23062
  reply.header(
23031
23063
  "set-cookie",
@@ -23277,12 +23309,7 @@ async function createServer(opts) {
23277
23309
  getAgentPluginState: opts.getAgentPluginState,
23278
23310
  // Powers the `canonry.version.current` doctor check. Non-blocking.
23279
23311
  getUpdateStatus: () => getServerUpdateStatus(),
23280
- // Local canonry serve runs on the operator's machine, where pointing a
23281
- // webhook at localhost (Discord test container, Pipedream-mock dev server,
23282
- // etc.) is a legitimate workflow. Default to allowing it for the local
23283
- // installer; cloud deployments inherit the secure default of `false` by
23284
- // not passing this option. Override with CANONRY_ALLOW_LOOPBACK_WEBHOOKS=0.
23285
- allowLoopbackWebhooks: process.env.CANONRY_ALLOW_LOOPBACK_WEBHOOKS !== "0",
23312
+ allowLoopbackWebhooks,
23286
23313
  // Wall-clock budget for one incremental Vercel drain. This is the only lever
23287
23314
  // that decides whether a source catches up or falls further behind, and it
23288
23315
  // was previously reachable only from tests, so a source losing ground could
@@ -24164,6 +24191,7 @@ export {
24164
24191
  createGoogleMarketingCredentialStore,
24165
24192
  createGoogleMarketingRuntime,
24166
24193
  waitForServerRuntimeStartup,
24194
+ allowsKeylessFirstRunSetup,
24167
24195
  isLoopbackBindHost,
24168
24196
  createServer
24169
24197
  };