@ainyc/canonry 4.54.0 → 4.55.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +9 -4
  2. package/assets/assets/{BacklinksPage-BXFT4pLI.js → BacklinksPage-buvZ4ZOd.js} +1 -1
  3. package/assets/assets/{ProjectPage-DAtd9Vay.js → ProjectPage-D0UqSqe7.js} +4 -4
  4. package/assets/assets/{RunRow-38dDceGl.js → RunRow-D-DTu1PA.js} +1 -1
  5. package/assets/assets/{RunsPage-AJnFLtaE.js → RunsPage-CrBpgwkO.js} +1 -1
  6. package/assets/assets/{SettingsPage-FT9ZAvFH.js → SettingsPage-Bgsi9tZ2.js} +1 -1
  7. package/assets/assets/TrafficPage-DAHXrzqz.js +1 -0
  8. package/assets/assets/TrafficSourceDetailPage-DCcDN3VD.js +1 -0
  9. package/assets/assets/extract-error-message-BGhWiJPr.js +1 -0
  10. package/assets/assets/{index-DLPKqyhx.js → index-CbDkoDBH.js} +62 -62
  11. package/assets/assets/{index-Bm3JQsW0.css → index-dxdJhCQO.css} +1 -1
  12. package/assets/assets/{server-traffic-GqiQYm6x.js → server-traffic-3xxyOEIX.js} +1 -1
  13. package/assets/assets/{trash-2-BwPzJ8NI.js → trash-2-dppRdHYI.js} +1 -1
  14. package/assets/index.html +2 -2
  15. package/dist/{chunk-CRO6Q25G.js → chunk-5EAGNVCJ.js} +423 -250
  16. package/dist/{chunk-J7MX3YOH.js → chunk-UOQ62KDD.js} +8 -3
  17. package/dist/{chunk-JHAHNKSN.js → chunk-XB6Y63NI.js} +260 -5
  18. package/dist/{chunk-VZPDBHBW.js → chunk-XHU35P3S.js} +367 -365
  19. package/dist/cli.js +14 -12
  20. package/dist/index.d.ts +13 -0
  21. package/dist/index.js +4 -4
  22. package/dist/{intelligence-service-OCREQUCQ.js → intelligence-service-4PT22FED.js} +2 -2
  23. package/dist/mcp.js +2 -2
  24. package/package.json +15 -14
  25. package/assets/assets/TrafficPage-B4A3oO8M.js +0 -1
  26. package/assets/assets/TrafficSourceDetailPage-8NYU1TA6.js +0 -1
  27. package/assets/assets/arrow-left-DgI0X1Q1.js +0 -1
@@ -22,7 +22,7 @@ import {
22
22
  trafficConnectVercelRequestSchema,
23
23
  trafficConnectWordpressRequestSchema,
24
24
  trafficEventKindSchema
25
- } from "./chunk-VZPDBHBW.js";
25
+ } from "./chunk-XHU35P3S.js";
26
26
 
27
27
  // src/config.ts
28
28
  import fs from "fs";
@@ -3564,9 +3564,14 @@ var ApiClient = class {
3564
3564
  })
3565
3565
  );
3566
3566
  }
3567
- async listRuns(project, limit) {
3567
+ async listRuns(project, limit, kind) {
3568
3568
  return this.invoke(
3569
- () => getApiV1ProjectsByNameRuns({ client: this.heyClient, path: { name: project }, query: { limit } })
3569
+ () => getApiV1ProjectsByNameRuns({
3570
+ client: this.heyClient,
3571
+ path: { name: project },
3572
+ // kind arrives as a free CLI string; the server validates it against the enum.
3573
+ query: { limit, kind }
3574
+ })
3570
3575
  );
3571
3576
  }
3572
3577
  async getLatestRun(project) {
@@ -9,8 +9,10 @@ import {
9
9
  categorizeSourceWithCompetitors,
10
10
  categoryLabel,
11
11
  determineAnswerMentioned,
12
- normalizeProjectDomain
13
- } from "./chunk-VZPDBHBW.js";
12
+ effectiveDomains,
13
+ normalizeProjectDomain,
14
+ registrableDomain
15
+ } from "./chunk-XHU35P3S.js";
14
16
 
15
17
  // src/intelligence-service.ts
16
18
  import { eq, desc, asc, and, ne, or, inArray } from "drizzle-orm";
@@ -243,10 +245,19 @@ var googleConnections = sqliteTable("google_connections", {
243
245
  propertyId: text("property_id"),
244
246
  sitemapUrl: text("sitemap_url"),
245
247
  scopes: text("scopes", { mode: "json" }).$type().notNull().default([]),
248
+ // The project that established this connection. Used by the OAuth callback
249
+ // and the DELETE route to refuse cross-project takeover (a malicious caller
250
+ // who points another project at the same `canonicalDomain` cannot overwrite
251
+ // or remove an existing connection owned by the original project). Nullable
252
+ // for legacy rows written before the column existed — those are treated as
253
+ // unowned and the first connect call to claim them succeeds. See root
254
+ // AGENTS.md "Deployment Posture" for the broader multi-tenancy posture.
255
+ createdByProjectId: text("created_by_project_id").references(() => projects.id, { onDelete: "set null" }),
246
256
  createdAt: text("created_at").notNull(),
247
257
  updatedAt: text("updated_at").notNull()
248
258
  }, (table) => [
249
- uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
259
+ uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType),
260
+ index("idx_google_conn_project").on(table.createdByProjectId)
250
261
  ]);
251
262
  var gscSearchData = sqliteTable("gsc_search_data", {
252
263
  id: text("id").primaryKey(),
@@ -319,10 +330,16 @@ var bingConnections = sqliteTable("bing_connections", {
319
330
  id: text("id").primaryKey(),
320
331
  domain: text("domain").notNull(),
321
332
  siteUrl: text("site_url"),
333
+ // Same takeover-prevention column as `google_connections.createdByProjectId`.
334
+ // The Bing connect / disconnect routes refuse cross-project writes when an
335
+ // existing row's owner doesn't match. Null for legacy rows (treated as
336
+ // unowned).
337
+ createdByProjectId: text("created_by_project_id").references(() => projects.id, { onDelete: "set null" }),
322
338
  createdAt: text("created_at").notNull(),
323
339
  updatedAt: text("updated_at").notNull()
324
340
  }, (table) => [
325
- uniqueIndex("idx_bing_conn_domain").on(table.domain)
341
+ uniqueIndex("idx_bing_conn_domain").on(table.domain),
342
+ index("idx_bing_conn_project").on(table.createdByProjectId)
326
343
  ]);
327
344
  var bingUrlInspections = sqliteTable("bing_url_inspections", {
328
345
  id: text("id").primaryKey(),
@@ -2157,6 +2174,68 @@ var MIGRATION_VERSIONS = [
2157
2174
  `DELETE FROM crawler_events_hourly WHERE bot_id = 'mistral-ai' AND sampled_user_agent LIKE '%MistralAI-User%'`,
2158
2175
  `UPDATE crawler_events_hourly SET bot_id = 'mistral-bot' WHERE bot_id = 'mistral-ai'`
2159
2176
  ]
2177
+ },
2178
+ {
2179
+ version: 66,
2180
+ name: "oauth-connections-track-owning-project",
2181
+ // Cross-project OAuth takeover defense. Before this column, the OAuth
2182
+ // callback for Google and the connect route for Bing keyed everything on
2183
+ // `domain` alone — an attacker who created a project pointed at a victim's
2184
+ // canonical domain could complete OAuth from their own Google/Bing account
2185
+ // and silently overwrite the legitimate refresh token under that domain
2186
+ // key. The new `created_by_project_id` column records the project that
2187
+ // first established each connection; the callback and DELETE routes refuse
2188
+ // cross-project writes when it doesn't match.
2189
+ //
2190
+ // Backfill: for each existing connection row, set the owner to the project
2191
+ // whose `canonical_domain` matches AND whose `created_at` is oldest (the
2192
+ // most likely original owner in a 1:N domain-shared install). Rows with no
2193
+ // matching project stay NULL — treated as "unowned" so a future legitimate
2194
+ // connect from any project can claim them.
2195
+ //
2196
+ // Uses the `run` hook so the schema-edit + backfill only fire when the
2197
+ // target tables exist. The legacy-keyword test scenario seeds a DB at v46
2198
+ // without google_connections / bing_connections (they're created in v6 but
2199
+ // the test bypasses the bootstrap) — without the guard, this version's
2200
+ // ALTER fails with "no such table".
2201
+ //
2202
+ // Idempotent: column-existence guard means re-running this version is a
2203
+ // no-op; the backfill UPDATE only writes rows where the column is NULL.
2204
+ statements: [],
2205
+ run: (db) => {
2206
+ if (tableExists(db, "google_connections") && !columnExists(db, "google_connections", "created_by_project_id")) {
2207
+ db.run(sql.raw(
2208
+ `ALTER TABLE google_connections ADD COLUMN created_by_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL`
2209
+ ));
2210
+ db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_google_conn_project ON google_connections(created_by_project_id)`));
2211
+ db.run(sql.raw(
2212
+ `UPDATE google_connections
2213
+ SET created_by_project_id = (
2214
+ SELECT p.id FROM projects p
2215
+ WHERE LOWER(p.canonical_domain) = LOWER(google_connections.domain)
2216
+ ORDER BY p.created_at ASC
2217
+ LIMIT 1
2218
+ )
2219
+ WHERE created_by_project_id IS NULL`
2220
+ ));
2221
+ }
2222
+ if (tableExists(db, "bing_connections") && !columnExists(db, "bing_connections", "created_by_project_id")) {
2223
+ db.run(sql.raw(
2224
+ `ALTER TABLE bing_connections ADD COLUMN created_by_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL`
2225
+ ));
2226
+ db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_bing_conn_project ON bing_connections(created_by_project_id)`));
2227
+ db.run(sql.raw(
2228
+ `UPDATE bing_connections
2229
+ SET created_by_project_id = (
2230
+ SELECT p.id FROM projects p
2231
+ WHERE LOWER(p.canonical_domain) = LOWER(bing_connections.domain)
2232
+ ORDER BY p.created_at ASC
2233
+ LIMIT 1
2234
+ )
2235
+ WHERE created_by_project_id IS NULL`
2236
+ ));
2237
+ }
2238
+ }
2160
2239
  }
2161
2240
  ];
2162
2241
  function isDuplicateColumnError(err) {
@@ -4079,6 +4158,172 @@ function createLogger(module) {
4079
4158
  };
4080
4159
  }
4081
4160
 
4161
+ // src/citation-utils.ts
4162
+ function domainMatches(domain, canonicalDomain) {
4163
+ const normalized = normalizeProjectDomain(canonicalDomain);
4164
+ const d = normalizeProjectDomain(domain);
4165
+ return d === normalized || d.endsWith(`.${normalized}`);
4166
+ }
4167
+ function pickProjectCitedDomain(citedDomains, projectDomains) {
4168
+ for (const cited of citedDomains) {
4169
+ if (projectDomains.some((pd) => domainMatches(cited, pd))) return cited;
4170
+ }
4171
+ return void 0;
4172
+ }
4173
+ function determineCitationState(normalized, domains) {
4174
+ for (const canonicalDomain of domains) {
4175
+ const bareDomain = normalizeProjectDomain(canonicalDomain);
4176
+ if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
4177
+ return "cited";
4178
+ }
4179
+ const lowerDomain = bareDomain.toLowerCase();
4180
+ for (const source of normalized.groundingSources) {
4181
+ try {
4182
+ const uri = source.uri.toLowerCase();
4183
+ if (lowerDomain.includes(".") && uri.includes(lowerDomain)) {
4184
+ return "cited";
4185
+ }
4186
+ } catch {
4187
+ }
4188
+ if (source.title) {
4189
+ const titleLower = source.title.toLowerCase().replace(/^www\./, "");
4190
+ if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
4191
+ return "cited";
4192
+ }
4193
+ }
4194
+ }
4195
+ }
4196
+ return "not-cited";
4197
+ }
4198
+ function computeCompetitorOverlap(normalized, competitorDomains) {
4199
+ const overlapSet = /* @__PURE__ */ new Set();
4200
+ for (const d of normalized.citedDomains) {
4201
+ for (const cd of competitorDomains) {
4202
+ if (domainMatches(d, cd)) {
4203
+ overlapSet.add(cd);
4204
+ }
4205
+ }
4206
+ }
4207
+ for (const source of normalized.groundingSources) {
4208
+ const uri = source.uri.toLowerCase();
4209
+ for (const cd of competitorDomains) {
4210
+ if (uri.includes(cd.toLowerCase())) {
4211
+ overlapSet.add(cd);
4212
+ }
4213
+ }
4214
+ }
4215
+ if (normalized.answerText) {
4216
+ const lowerAnswer = normalized.answerText.toLowerCase();
4217
+ for (const cd of competitorDomains) {
4218
+ if (lowerAnswer.includes(cd.toLowerCase())) {
4219
+ overlapSet.add(cd);
4220
+ }
4221
+ const brand = brandLabelFromDomain(cd);
4222
+ if (brand.length >= 4 && new RegExp(`\\b${escapeRegExp(brand)}\\b`, "i").test(lowerAnswer)) {
4223
+ overlapSet.add(cd);
4224
+ }
4225
+ }
4226
+ }
4227
+ return [...overlapSet];
4228
+ }
4229
+ function escapeRegExp(value) {
4230
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4231
+ }
4232
+ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains, ownBrandNames = []) {
4233
+ if (!answerText || answerText.length < 20) return [];
4234
+ const ownBrandKeys = new Set(
4235
+ ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
4236
+ );
4237
+ for (const name of ownBrandNames) {
4238
+ const key = brandKeyFromText(name);
4239
+ if (key.length >= 4) ownBrandKeys.add(key);
4240
+ }
4241
+ const knownCompetitorKeys = new Set(
4242
+ [...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
4243
+ );
4244
+ if (knownCompetitorKeys.size === 0) return [];
4245
+ const candidatePatterns = [
4246
+ /^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013-]/gm,
4247
+ /\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\*\*/g,
4248
+ /^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm,
4249
+ /\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\]\(https?:\/\/[^\s)]+\)/g
4250
+ ];
4251
+ const genericKeys = /* @__PURE__ */ new Set([
4252
+ "additional",
4253
+ "best",
4254
+ "benefits",
4255
+ "bottomline",
4256
+ "comparison",
4257
+ "conclusion",
4258
+ "directorylisting",
4259
+ "example",
4260
+ "expertise",
4261
+ "features",
4262
+ "finalthoughts",
4263
+ "howitworks",
4264
+ "important",
4265
+ "keybenefits",
4266
+ "keyfeatures",
4267
+ "major",
4268
+ "note",
4269
+ "notable",
4270
+ "option",
4271
+ "other",
4272
+ "overview",
4273
+ "pricing",
4274
+ "pros",
4275
+ "reviews",
4276
+ "step",
4277
+ "summary",
4278
+ "top",
4279
+ "verdict",
4280
+ "whattolookfor",
4281
+ "whyitmatters",
4282
+ "whyitstandsout",
4283
+ "whywechoseit"
4284
+ ]);
4285
+ const seen = /* @__PURE__ */ new Map();
4286
+ for (const pattern of candidatePatterns) {
4287
+ let match;
4288
+ while ((match = pattern.exec(answerText)) !== null) {
4289
+ const candidate = cleanCandidateName(match[1] ?? "");
4290
+ const candidateKey = brandKeyFromText(candidate);
4291
+ if (!candidateKey) continue;
4292
+ if (genericKeys.has(candidateKey)) continue;
4293
+ if (candidate.split(/\s+/).length > 6) continue;
4294
+ if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
4295
+ if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
4296
+ if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
4297
+ }
4298
+ }
4299
+ return [...seen.values()].slice(0, 10);
4300
+ }
4301
+ function cleanCandidateName(candidate) {
4302
+ return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
4303
+ }
4304
+ function collectBrandKeysFromDomain(domain) {
4305
+ const reg = registrableDomain(domain);
4306
+ if (!reg) {
4307
+ const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
4308
+ const fallback = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
4309
+ return fallback.length >= 4 ? [fallback] : [];
4310
+ }
4311
+ const keys = /* @__PURE__ */ new Set();
4312
+ const fullKey = reg.replace(/[^a-z0-9]/gi, "").toLowerCase();
4313
+ if (fullKey.length >= 4) keys.add(fullKey);
4314
+ const brand = brandLabelFromDomain(reg).replace(/[^a-z0-9]/gi, "").toLowerCase();
4315
+ if (brand.length >= 4) keys.add(brand);
4316
+ return [...keys];
4317
+ }
4318
+ function matchesBrandKey(candidateKey, brandKeys) {
4319
+ for (const brandKey of brandKeys) {
4320
+ if (candidateKey === brandKey) return true;
4321
+ if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
4322
+ if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
4323
+ }
4324
+ return false;
4325
+ }
4326
+
4082
4327
  // src/intelligence-service.ts
4083
4328
  var RECURRENCE_LOOKBACK_RUNS = 5;
4084
4329
  var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
@@ -4460,6 +4705,11 @@ var IntelligenceService = class {
4460
4705
  });
4461
4706
  }
4462
4707
  buildRunData(runId, projectId, completedAt, location = null) {
4708
+ const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq(projects.id, projectId)).get();
4709
+ const projectDomains = projectDomainRow ? effectiveDomains({
4710
+ canonicalDomain: projectDomainRow.canonicalDomain,
4711
+ ownedDomains: projectDomainRow.ownedDomains
4712
+ }) : [];
4463
4713
  const rows = this.db.select({
4464
4714
  query: queries.query,
4465
4715
  // Denormalized query text persisted by v58 — the fallback when the
@@ -4486,7 +4736,9 @@ var IntelligenceService = class {
4486
4736
  query: resolvedQuery,
4487
4737
  provider: r.provider,
4488
4738
  cited: r.citationState === CitationStates.cited,
4489
- citationUrl: domains[0] ?? void 0,
4739
+ // The project's OWN cited domain — never a co-cited competitor that
4740
+ // happens to sort first in the full citedDomains set.
4741
+ citationUrl: pickProjectCitedDomain(domains, projectDomains),
4490
4742
  // Snapshots carry their own location for downstream detectors. In
4491
4743
  // practice every snapshot in a single runId shares the run's
4492
4744
  // location; the per-row column is the same value duplicated, but
@@ -4552,6 +4804,9 @@ export {
4552
4804
  groupRunsByCreatedAt,
4553
4805
  pickGroupRepresentative,
4554
4806
  filterTrackedSnapshots,
4807
+ determineCitationState,
4808
+ computeCompetitorOverlap,
4809
+ extractRecommendedCompetitors,
4555
4810
  isBlogShapedQuery,
4556
4811
  buildInventory,
4557
4812
  buildContentTargetRows,