@ainyc/canonry 1.33.0 → 1.35.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.
@@ -1074,6 +1074,13 @@ var ga4TrafficSummaryDtoSchema = z11.object({
1074
1074
  aiReferrals: z11.array(ga4AiReferralDtoSchema),
1075
1075
  lastSyncedAt: z11.string().nullable()
1076
1076
  });
1077
+ var ga4AiReferralHistoryEntrySchema = z11.object({
1078
+ date: z11.string(),
1079
+ source: z11.string(),
1080
+ medium: z11.string(),
1081
+ sessions: z11.number(),
1082
+ users: z11.number()
1083
+ });
1077
1084
 
1078
1085
  // ../contracts/src/answer-visibility.ts
1079
1086
  var GENERIC_TOKENS = /* @__PURE__ */ new Set([
@@ -1123,7 +1130,12 @@ function extractAnswerMentions(answerText, displayName, domains) {
1123
1130
  matchedTerms.push(...matchedTokens);
1124
1131
  }
1125
1132
  const unique = [...new Set(matchedTerms)];
1126
- return { mentioned: unique.length > 0, matchedTerms: unique };
1133
+ const domainMatches3 = unique.filter((t) => t.includes("."));
1134
+ const dedupedFinal = unique.filter((term) => {
1135
+ if (term.includes(".")) return true;
1136
+ return !domainMatches3.some((d) => d.toLowerCase().startsWith(term.toLowerCase() + "."));
1137
+ });
1138
+ return { mentioned: dedupedFinal.length > 0, matchedTerms: dedupedFinal };
1127
1139
  }
1128
1140
  function determineAnswerMentioned(answerText, displayName, domains) {
1129
1141
  return extractAnswerMentions(answerText, displayName, domains).mentioned;
@@ -1131,6 +1143,9 @@ function determineAnswerMentioned(answerText, displayName, domains) {
1131
1143
  function visibilityStateFromAnswerMentioned(answerMentioned) {
1132
1144
  return answerMentioned ? "visible" : "not-visible";
1133
1145
  }
1146
+ function brandKeyFromText(value) {
1147
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
1148
+ }
1134
1149
  function domainMentioned(lowerAnswer, normalizedDomain) {
1135
1150
  const escapedDomain = escapeRegExp(normalizedDomain.toLowerCase());
1136
1151
  const patterns = [
@@ -5746,6 +5761,18 @@ var routeCatalog = [
5746
5761
  404: { description: "Project not found." }
5747
5762
  }
5748
5763
  },
5764
+ {
5765
+ method: "get",
5766
+ path: "/api/v1/projects/{name}/ga/ai-referral-history",
5767
+ summary: "Get AI referral sessions per day grouped by source",
5768
+ tags: ["ga4"],
5769
+ parameters: [nameParameter],
5770
+ responses: {
5771
+ 200: { description: "AI referral history returned." },
5772
+ 400: { description: "GA4 is not connected." },
5773
+ 404: { description: "Project not found." }
5774
+ }
5775
+ },
5749
5776
  {
5750
5777
  method: "get",
5751
5778
  path: "/api/v1/projects/{name}/ga/coverage",
@@ -6241,7 +6268,7 @@ function formatNotification(row) {
6241
6268
  }
6242
6269
 
6243
6270
  // ../api-routes/src/google.ts
6244
- import crypto13 from "crypto";
6271
+ import crypto14 from "crypto";
6245
6272
  import { eq as eq13, and as and2, desc as desc4, sql as sql3 } from "drizzle-orm";
6246
6273
 
6247
6274
  // ../integration-google/src/constants.ts
@@ -6255,6 +6282,7 @@ var GSC_MAX_ROWS_PER_REQUEST = 25e3;
6255
6282
  var GSC_DATA_LAG_DAYS = 3;
6256
6283
  var INDEXING_API_BASE = "https://indexing.googleapis.com/v3";
6257
6284
  var INDEXING_API_DAILY_LIMIT = 200;
6285
+ var GOOGLE_REQUEST_TIMEOUT_MS = 3e4;
6258
6286
 
6259
6287
  // ../integration-google/src/types.ts
6260
6288
  var GoogleAuthError = class extends Error {
@@ -6295,7 +6323,8 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
6295
6323
  code,
6296
6324
  redirect_uri: redirectUri,
6297
6325
  grant_type: "authorization_code"
6298
- })
6326
+ }),
6327
+ signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
6299
6328
  });
6300
6329
  if (!res.ok) {
6301
6330
  const body = await res.text();
@@ -6312,7 +6341,8 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
6312
6341
  client_secret: clientSecret,
6313
6342
  refresh_token: currentRefreshToken,
6314
6343
  grant_type: "refresh_token"
6315
- })
6344
+ }),
6345
+ signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
6316
6346
  });
6317
6347
  if (!res.ok) {
6318
6348
  const body = await res.text();
@@ -6336,7 +6366,8 @@ async function gscFetch(accessToken, url, opts) {
6336
6366
  const res = await fetch(url, {
6337
6367
  method,
6338
6368
  headers,
6339
- body: opts?.body != null ? JSON.stringify(opts.body) : void 0
6369
+ body: opts?.body != null ? JSON.stringify(opts.body) : void 0,
6370
+ signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
6340
6371
  });
6341
6372
  if (res.status === 401) {
6342
6373
  const body = await res.text().catch(() => "");
@@ -6429,89 +6460,424 @@ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
6429
6460
  );
6430
6461
  }
6431
6462
 
6432
- // ../api-routes/src/google.ts
6433
- function signState(payload, secret) {
6434
- return crypto13.createHmac("sha256", secret).update(payload).digest("hex");
6463
+ // ../integration-google-analytics/src/ga4-client.ts
6464
+ import crypto13 from "crypto";
6465
+
6466
+ // ../integration-google-analytics/src/constants.ts
6467
+ var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
6468
+ var GA4_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
6469
+ var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
6470
+ var GA4_DEFAULT_SYNC_DAYS = 30;
6471
+ var GA4_MAX_SYNC_DAYS = 90;
6472
+ var GA4_REQUEST_TIMEOUT_MS = 3e4;
6473
+
6474
+ // ../integration-google-analytics/src/types.ts
6475
+ var GA4ApiError = class extends Error {
6476
+ status;
6477
+ constructor(message, status) {
6478
+ super(message);
6479
+ this.name = "GA4ApiError";
6480
+ this.status = status;
6481
+ }
6482
+ };
6483
+
6484
+ // ../integration-google-analytics/src/ga4-client.ts
6485
+ function ga4Log(level, action, ctx) {
6486
+ const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Client", action, ...ctx };
6487
+ const stream = level === "error" ? process.stderr : process.stdout;
6488
+ stream.write(JSON.stringify(entry) + "\n");
6435
6489
  }
6436
- function buildSignedState(data, secret) {
6437
- const payload = JSON.stringify(data);
6438
- const sig = signState(payload, secret);
6439
- return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
6490
+ function createServiceAccountJwt(clientEmail, privateKey, scope) {
6491
+ const now = Math.floor(Date.now() / 1e3);
6492
+ const header = { alg: "RS256", typ: "JWT" };
6493
+ const payload = {
6494
+ iss: clientEmail,
6495
+ scope,
6496
+ aud: GOOGLE_TOKEN_URL2,
6497
+ iat: now,
6498
+ exp: now + 3600
6499
+ // 1 hour
6500
+ };
6501
+ const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
6502
+ const headerB64 = encode(header);
6503
+ const payloadB64 = encode(payload);
6504
+ const signingInput = `${headerB64}.${payloadB64}`;
6505
+ const sign = crypto13.createSign("RSA-SHA256");
6506
+ sign.update(signingInput);
6507
+ const signature = sign.sign(privateKey, "base64url");
6508
+ return `${signingInput}.${signature}`;
6440
6509
  }
6441
- function verifySignedState(encoded, secret) {
6442
- try {
6443
- const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
6444
- const expected = signState(payload, secret);
6445
- if (!crypto13.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
6446
- return JSON.parse(payload);
6447
- } catch {
6448
- return null;
6510
+ async function getAccessToken(clientEmail, privateKey) {
6511
+ const jwt = createServiceAccountJwt(clientEmail, privateKey, GA4_SCOPE);
6512
+ const res = await fetch(GOOGLE_TOKEN_URL2, {
6513
+ method: "POST",
6514
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
6515
+ body: new URLSearchParams({
6516
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
6517
+ assertion: jwt
6518
+ }),
6519
+ signal: AbortSignal.timeout(GA4_REQUEST_TIMEOUT_MS)
6520
+ });
6521
+ if (!res.ok) {
6522
+ const body = await res.text().catch(() => "");
6523
+ ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
6524
+ throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
6449
6525
  }
6526
+ const data = await res.json();
6527
+ return data.access_token;
6450
6528
  }
6451
- async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
6452
- const conn = store.getConnection(domain, connectionType);
6453
- if (!conn) {
6454
- throw notFound("Google connection", connectionType);
6529
+ async function runReport(accessToken, propertyId, request) {
6530
+ const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
6531
+ const res = await fetch(url, {
6532
+ method: "POST",
6533
+ headers: {
6534
+ "Authorization": `Bearer ${accessToken}`,
6535
+ "Content-Type": "application/json"
6536
+ },
6537
+ body: JSON.stringify(request),
6538
+ signal: AbortSignal.timeout(GA4_REQUEST_TIMEOUT_MS)
6539
+ });
6540
+ if (res.status === 401 || res.status === 403) {
6541
+ const body = await res.text().catch(() => "");
6542
+ let detail = "";
6543
+ try {
6544
+ const parsed = JSON.parse(body);
6545
+ if (parsed.error?.status === "SERVICE_DISABLED") {
6546
+ detail = " The Google Analytics Data API is not enabled for this GCP project. Enable it at: https://console.developers.google.com/apis/api/analyticsdata.googleapis.com/overview";
6547
+ } else if (parsed.error?.message) {
6548
+ detail = ` ${parsed.error.message}`;
6549
+ }
6550
+ } catch {
6551
+ if (body.length < 200) detail = ` ${body}`;
6552
+ }
6553
+ ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
6554
+ throw new GA4ApiError(
6555
+ `GA4 API authentication failed \u2014 check service account permissions.${detail}`,
6556
+ res.status
6557
+ );
6455
6558
  }
6456
- if (!conn.accessToken || !conn.refreshToken) {
6457
- throw validationError("Google connection is incomplete \u2014 please reconnect");
6559
+ if (res.status === 429) {
6560
+ ga4Log("error", "report.rate-limited", { propertyId });
6561
+ throw new GA4ApiError("GA4 API rate limit exceeded", 429);
6458
6562
  }
6459
- const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
6460
- const fiveMinutes = 5 * 60 * 1e3;
6461
- if (Date.now() > expiresAt - fiveMinutes) {
6462
- const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
6463
- const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
6464
- const updated = store.updateConnection(domain, connectionType, {
6465
- accessToken: tokens.access_token,
6466
- tokenExpiresAt: newExpiresAt,
6467
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6468
- });
6469
- return {
6470
- accessToken: tokens.access_token,
6471
- connectionId: `${domain}:${connectionType}`,
6472
- propertyId: updated?.propertyId ?? conn.propertyId ?? null
6473
- };
6563
+ if (!res.ok) {
6564
+ const body = await res.text();
6565
+ ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
6566
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6474
6567
  }
6475
- return {
6476
- accessToken: conn.accessToken,
6477
- connectionId: `${domain}:${connectionType}`,
6478
- propertyId: conn.propertyId ?? null
6479
- };
6568
+ return await res.json();
6480
6569
  }
6481
- async function googleRoutes(app, opts) {
6482
- const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
6483
- function getAuthConfig() {
6484
- return opts.getGoogleAuthConfig?.() ?? {};
6570
+ async function batchRunReports(accessToken, propertyId, requests) {
6571
+ const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
6572
+ const res = await fetch(url, {
6573
+ method: "POST",
6574
+ headers: {
6575
+ "Authorization": `Bearer ${accessToken}`,
6576
+ "Content-Type": "application/json"
6577
+ },
6578
+ body: JSON.stringify({ requests }),
6579
+ signal: AbortSignal.timeout(GA4_REQUEST_TIMEOUT_MS)
6580
+ });
6581
+ if (res.status === 401 || res.status === 403) {
6582
+ const body = await res.text().catch(() => "");
6583
+ ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
6584
+ throw new GA4ApiError(
6585
+ `GA4 API authentication failed \u2014 check service account permissions. ${body}`,
6586
+ res.status
6587
+ );
6485
6588
  }
6486
- function requireConnectionStore(reply) {
6487
- if (opts.googleConnectionStore) return opts.googleConnectionStore;
6488
- const err = validationError("Google auth storage is not configured for this deployment");
6489
- reply.status(err.statusCode).send(err.toJSON());
6490
- return null;
6589
+ if (res.status === 429) {
6590
+ ga4Log("error", "batch-report.rate-limited", { propertyId });
6591
+ throw new GA4ApiError("GA4 API rate limit exceeded", 429);
6491
6592
  }
6492
- app.get("/projects/:name/google/connections", async (request) => {
6493
- const project = resolveProject(app.db, request.params.name);
6494
- const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
6495
- return conns.map((connection) => ({
6496
- id: `${connection.domain}:${connection.connectionType}`,
6497
- domain: connection.domain,
6498
- connectionType: connection.connectionType,
6499
- propertyId: connection.propertyId ?? null,
6500
- sitemapUrl: connection.sitemapUrl ?? null,
6501
- scopes: connection.scopes ?? [],
6502
- createdAt: connection.createdAt,
6503
- updatedAt: connection.updatedAt
6593
+ if (!res.ok) {
6594
+ const body = await res.text();
6595
+ ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
6596
+ throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
6597
+ }
6598
+ const data = await res.json();
6599
+ return data.reports;
6600
+ }
6601
+ function formatDate(d) {
6602
+ return d.toISOString().split("T")[0];
6603
+ }
6604
+ var AI_REFERRAL_SOURCE_FILTERS = [
6605
+ { matchType: "CONTAINS", value: "perplexity" },
6606
+ { matchType: "CONTAINS", value: "gemini" },
6607
+ { matchType: "CONTAINS", value: "chatgpt" },
6608
+ { matchType: "CONTAINS", value: "openai" },
6609
+ { matchType: "CONTAINS", value: "claude" },
6610
+ { matchType: "CONTAINS", value: "anthropic" },
6611
+ { matchType: "CONTAINS", value: "copilot" }
6612
+ ];
6613
+ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6614
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6615
+ const endDate = /* @__PURE__ */ new Date();
6616
+ const startDate = /* @__PURE__ */ new Date();
6617
+ startDate.setDate(startDate.getDate() - syncDays);
6618
+ ga4Log("info", "fetch-traffic.start", { propertyId, days: syncDays });
6619
+ const PAGE_SIZE = 1e4;
6620
+ const rows = [];
6621
+ let offset = 0;
6622
+ while (true) {
6623
+ const request = {
6624
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6625
+ dimensions: [
6626
+ { name: "date" },
6627
+ { name: "landingPagePlusQueryString" }
6628
+ ],
6629
+ metrics: [
6630
+ { name: "sessions" },
6631
+ { name: "totalUsers" }
6632
+ ],
6633
+ limit: PAGE_SIZE,
6634
+ offset
6635
+ };
6636
+ const response = await runReport(accessToken, propertyId, request);
6637
+ const pageRows = (response.rows ?? []).map((row) => ({
6638
+ date: row.dimensionValues[0].value,
6639
+ landingPage: row.dimensionValues[1].value,
6640
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
6641
+ organicSessions: 0,
6642
+ // populated by organic-only pass below
6643
+ users: parseInt(row.metricValues[1].value, 10) || 0
6504
6644
  }));
6505
- });
6506
- app.post("/projects/:name/google/connect", async (request, reply) => {
6507
- const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
6508
- if (!googleClientId || !googleClientSecret) {
6509
- const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
6510
- return reply.status(err.statusCode).send(err.toJSON());
6511
- }
6512
- const { type, propertyId, publicUrl } = request.body ?? {};
6513
- if (!type || type !== "gsc" && type !== "ga4") {
6514
- const err = validationError('type must be "gsc" or "ga4"');
6645
+ rows.push(...pageRows);
6646
+ const totalRows = response.rowCount ?? 0;
6647
+ offset += pageRows.length;
6648
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
6649
+ }
6650
+ const organicMap = /* @__PURE__ */ new Map();
6651
+ let organicOffset = 0;
6652
+ while (true) {
6653
+ const organicRequest = {
6654
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6655
+ dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
6656
+ metrics: [{ name: "sessions" }],
6657
+ dimensionFilter: {
6658
+ filter: {
6659
+ fieldName: "sessionDefaultChannelGrouping",
6660
+ stringFilter: { matchType: "EXACT", value: "Organic Search" }
6661
+ }
6662
+ },
6663
+ limit: 1e4,
6664
+ offset: organicOffset
6665
+ };
6666
+ const organicResponse = await runReport(accessToken, propertyId, organicRequest);
6667
+ for (const row of organicResponse.rows ?? []) {
6668
+ const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
6669
+ organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
6670
+ }
6671
+ const total = organicResponse.rowCount ?? 0;
6672
+ organicOffset += (organicResponse.rows ?? []).length;
6673
+ if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
6674
+ }
6675
+ for (const row of rows) {
6676
+ const key = `${row.date}::${row.landingPage}`;
6677
+ row.organicSessions = organicMap.get(key) ?? 0;
6678
+ }
6679
+ for (const row of rows) {
6680
+ if (row.date.length === 8 && !row.date.includes("-")) {
6681
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
6682
+ }
6683
+ }
6684
+ ga4Log("info", "fetch-traffic.done", { propertyId, rowCount: rows.length });
6685
+ return rows;
6686
+ }
6687
+ async function verifyConnection(clientEmail, privateKey, propertyId) {
6688
+ const accessToken = await getAccessToken(clientEmail, privateKey);
6689
+ return verifyConnectionWithToken(accessToken, propertyId);
6690
+ }
6691
+ async function verifyConnectionWithToken(accessToken, propertyId) {
6692
+ const endDate = /* @__PURE__ */ new Date();
6693
+ const startDate = /* @__PURE__ */ new Date();
6694
+ startDate.setDate(startDate.getDate() - 1);
6695
+ await runReport(accessToken, propertyId, {
6696
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6697
+ dimensions: [{ name: "date" }],
6698
+ metrics: [{ name: "sessions" }],
6699
+ limit: 1
6700
+ });
6701
+ return true;
6702
+ }
6703
+ async function fetchAggregateSummary(accessToken, propertyId, days) {
6704
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6705
+ const endDate = /* @__PURE__ */ new Date();
6706
+ const startDate = /* @__PURE__ */ new Date();
6707
+ startDate.setDate(startDate.getDate() - syncDays);
6708
+ ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
6709
+ const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
6710
+ const batchRes = await batchRunReports(accessToken, propertyId, [
6711
+ {
6712
+ dateRanges: [dateRange],
6713
+ dimensions: [],
6714
+ metrics: [{ name: "sessions" }, { name: "totalUsers" }],
6715
+ limit: 1
6716
+ },
6717
+ {
6718
+ dateRanges: [dateRange],
6719
+ dimensions: [],
6720
+ metrics: [{ name: "sessions" }],
6721
+ dimensionFilter: {
6722
+ filter: {
6723
+ fieldName: "sessionDefaultChannelGrouping",
6724
+ stringFilter: { matchType: "EXACT", value: "Organic Search" }
6725
+ }
6726
+ },
6727
+ limit: 1
6728
+ }
6729
+ ]);
6730
+ const totalRow = batchRes[0]?.rows?.[0];
6731
+ const organicRow = batchRes[1]?.rows?.[0];
6732
+ const summary = {
6733
+ periodStart: formatDate(startDate),
6734
+ periodEnd: formatDate(endDate),
6735
+ totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
6736
+ totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
6737
+ totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
6738
+ };
6739
+ ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
6740
+ return summary;
6741
+ }
6742
+ async function fetchAiReferrals(accessToken, propertyId, days) {
6743
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
6744
+ const endDate = /* @__PURE__ */ new Date();
6745
+ const startDate = /* @__PURE__ */ new Date();
6746
+ startDate.setDate(startDate.getDate() - syncDays);
6747
+ ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
6748
+ const PAGE_SIZE = 1e3;
6749
+ const rows = [];
6750
+ let offset = 0;
6751
+ while (true) {
6752
+ const request = {
6753
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6754
+ dimensions: [
6755
+ { name: "date" },
6756
+ { name: "sessionSource" },
6757
+ { name: "sessionMedium" }
6758
+ ],
6759
+ metrics: [
6760
+ { name: "sessions" },
6761
+ { name: "totalUsers" }
6762
+ ],
6763
+ dimensionFilter: {
6764
+ orGroup: {
6765
+ expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
6766
+ filter: {
6767
+ fieldName: "sessionSource",
6768
+ stringFilter: { matchType, value }
6769
+ }
6770
+ }))
6771
+ }
6772
+ },
6773
+ limit: PAGE_SIZE,
6774
+ offset
6775
+ };
6776
+ const response = await runReport(accessToken, propertyId, request);
6777
+ const pageRows = (response.rows ?? []).map((row) => ({
6778
+ date: row.dimensionValues[0].value,
6779
+ source: row.dimensionValues[1].value,
6780
+ medium: row.dimensionValues[2].value,
6781
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
6782
+ users: parseInt(row.metricValues[1].value, 10) || 0
6783
+ }));
6784
+ rows.push(...pageRows);
6785
+ const totalRows = response.rowCount ?? 0;
6786
+ offset += pageRows.length;
6787
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
6788
+ }
6789
+ for (const row of rows) {
6790
+ if (row.date.length === 8 && !row.date.includes("-")) {
6791
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
6792
+ }
6793
+ }
6794
+ ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
6795
+ return rows;
6796
+ }
6797
+
6798
+ // ../api-routes/src/google.ts
6799
+ function signState(payload, secret) {
6800
+ return crypto14.createHmac("sha256", secret).update(payload).digest("hex");
6801
+ }
6802
+ function buildSignedState(data, secret) {
6803
+ const payload = JSON.stringify(data);
6804
+ const sig = signState(payload, secret);
6805
+ return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
6806
+ }
6807
+ function verifySignedState(encoded, secret) {
6808
+ try {
6809
+ const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
6810
+ const expected = signState(payload, secret);
6811
+ if (!crypto14.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
6812
+ return JSON.parse(payload);
6813
+ } catch {
6814
+ return null;
6815
+ }
6816
+ }
6817
+ async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
6818
+ const conn = store.getConnection(domain, connectionType);
6819
+ if (!conn) {
6820
+ throw notFound("Google connection", connectionType);
6821
+ }
6822
+ if (!conn.accessToken || !conn.refreshToken) {
6823
+ throw validationError("Google connection is incomplete \u2014 please reconnect");
6824
+ }
6825
+ const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
6826
+ const fiveMinutes = 5 * 60 * 1e3;
6827
+ if (Date.now() > expiresAt - fiveMinutes) {
6828
+ const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
6829
+ const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
6830
+ const updated = store.updateConnection(domain, connectionType, {
6831
+ accessToken: tokens.access_token,
6832
+ tokenExpiresAt: newExpiresAt,
6833
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6834
+ });
6835
+ return {
6836
+ accessToken: tokens.access_token,
6837
+ connectionId: `${domain}:${connectionType}`,
6838
+ propertyId: updated?.propertyId ?? conn.propertyId ?? null
6839
+ };
6840
+ }
6841
+ return {
6842
+ accessToken: conn.accessToken,
6843
+ connectionId: `${domain}:${connectionType}`,
6844
+ propertyId: conn.propertyId ?? null
6845
+ };
6846
+ }
6847
+ async function googleRoutes(app, opts) {
6848
+ const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
6849
+ function getAuthConfig() {
6850
+ return opts.getGoogleAuthConfig?.() ?? {};
6851
+ }
6852
+ function requireConnectionStore(reply) {
6853
+ if (opts.googleConnectionStore) return opts.googleConnectionStore;
6854
+ const err = validationError("Google auth storage is not configured for this deployment");
6855
+ reply.status(err.statusCode).send(err.toJSON());
6856
+ return null;
6857
+ }
6858
+ app.get("/projects/:name/google/connections", async (request) => {
6859
+ const project = resolveProject(app.db, request.params.name);
6860
+ const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
6861
+ return conns.map((connection) => ({
6862
+ id: `${connection.domain}:${connection.connectionType}`,
6863
+ domain: connection.domain,
6864
+ connectionType: connection.connectionType,
6865
+ propertyId: connection.propertyId ?? null,
6866
+ sitemapUrl: connection.sitemapUrl ?? null,
6867
+ scopes: connection.scopes ?? [],
6868
+ createdAt: connection.createdAt,
6869
+ updatedAt: connection.updatedAt
6870
+ }));
6871
+ });
6872
+ app.post("/projects/:name/google/connect", async (request, reply) => {
6873
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
6874
+ if (!googleClientId || !googleClientSecret) {
6875
+ const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
6876
+ return reply.status(err.statusCode).send(err.toJSON());
6877
+ }
6878
+ const { type, propertyId, publicUrl } = request.body ?? {};
6879
+ if (!type || type !== "gsc" && type !== "ga4") {
6880
+ const err = validationError('type must be "gsc" or "ga4"');
6515
6881
  return reply.status(err.statusCode).send(err.toJSON());
6516
6882
  }
6517
6883
  const project = resolveProject(app.db, request.params.name);
@@ -6525,7 +6891,7 @@ async function googleRoutes(app, opts) {
6525
6891
  const host = request.headers.host ?? "localhost:4100";
6526
6892
  redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
6527
6893
  }
6528
- const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [];
6894
+ const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
6529
6895
  const stateEncoded = buildSignedState(
6530
6896
  { domain: project.canonicalDomain, type, propertyId, redirectUri },
6531
6897
  stateSecret
@@ -6669,7 +7035,7 @@ async function googleRoutes(app, opts) {
6669
7035
  return reply.status(err.statusCode).send(err.toJSON());
6670
7036
  }
6671
7037
  const now = (/* @__PURE__ */ new Date()).toISOString();
6672
- const runId = crypto13.randomUUID();
7038
+ const runId = crypto14.randomUUID();
6673
7039
  app.db.insert(runs).values({
6674
7040
  id: runId,
6675
7041
  projectId: project.id,
@@ -6731,7 +7097,7 @@ async function googleRoutes(app, opts) {
6731
7097
  const mob = ir.mobileUsabilityResult;
6732
7098
  const rich = ir.richResultsResult;
6733
7099
  const now = (/* @__PURE__ */ new Date()).toISOString();
6734
- const id = crypto13.randomUUID();
7100
+ const id = crypto14.randomUUID();
6735
7101
  app.db.insert(gscUrlInspections).values({
6736
7102
  id,
6737
7103
  projectId: project.id,
@@ -6971,7 +7337,7 @@ async function googleRoutes(app, opts) {
6971
7337
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6972
7338
  });
6973
7339
  const now = (/* @__PURE__ */ new Date()).toISOString();
6974
- const runId = crypto13.randomUUID();
7340
+ const runId = crypto14.randomUUID();
6975
7341
  app.db.insert(runs).values({
6976
7342
  id: runId,
6977
7343
  projectId: project.id,
@@ -7000,7 +7366,7 @@ async function googleRoutes(app, opts) {
7000
7366
  return reply.status(err.statusCode).send(err.toJSON());
7001
7367
  }
7002
7368
  const now = (/* @__PURE__ */ new Date()).toISOString();
7003
- const runId = crypto13.randomUUID();
7369
+ const runId = crypto14.randomUUID();
7004
7370
  app.db.insert(runs).values({
7005
7371
  id: runId,
7006
7372
  projectId: project.id,
@@ -7142,13 +7508,14 @@ async function googleRoutes(app, opts) {
7142
7508
  }
7143
7509
 
7144
7510
  // ../api-routes/src/bing.ts
7145
- import crypto14 from "crypto";
7511
+ import crypto15 from "crypto";
7146
7512
  import { eq as eq14, and as and3, desc as desc5 } from "drizzle-orm";
7147
7513
 
7148
7514
  // ../integration-bing/src/constants.ts
7149
7515
  var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
7150
7516
  var BING_SUBMIT_URL_BATCH_LIMIT = 500;
7151
7517
  var BING_SUBMIT_URL_DAILY_LIMIT = 1e4;
7518
+ var BING_REQUEST_TIMEOUT_MS = 3e4;
7152
7519
 
7153
7520
  // ../integration-bing/src/types.ts
7154
7521
  var BingApiError = class extends Error {
@@ -7176,7 +7543,8 @@ async function bingFetch(apiKey, endpoint, opts) {
7176
7543
  const res = await fetch(url, {
7177
7544
  method,
7178
7545
  headers,
7179
- body: opts?.body != null ? JSON.stringify(opts.body) : void 0
7546
+ body: opts?.body != null ? JSON.stringify(opts.body) : void 0,
7547
+ signal: AbortSignal.timeout(BING_REQUEST_TIMEOUT_MS)
7180
7548
  });
7181
7549
  if (res.status === 401 || res.status === 403) {
7182
7550
  const body = await res.text().catch(() => "");
@@ -7489,7 +7857,7 @@ async function bingRoutes(app, opts) {
7489
7857
  throw e;
7490
7858
  }
7491
7859
  const now = (/* @__PURE__ */ new Date()).toISOString();
7492
- const id = crypto14.randomUUID();
7860
+ const id = crypto15.randomUUID();
7493
7861
  const httpCode = result.HttpStatus ?? result.HttpCode ?? null;
7494
7862
  let derivedInIndex = null;
7495
7863
  if (result.InIndex != null) {
@@ -7706,491 +8074,242 @@ async function cdpRoutes(app, opts) {
7706
8074
  const project = resolveProject(app.db, request.params.name);
7707
8075
  const { runId } = request.params;
7708
8076
  const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
7709
- if (!run) {
7710
- const err = notFound("Run", runId);
7711
- return reply.code(err.statusCode).send(err.toJSON());
7712
- }
7713
- const snapshots = app.db.select({
7714
- id: querySnapshots.id,
7715
- keywordId: querySnapshots.keywordId,
7716
- provider: querySnapshots.provider,
7717
- citationState: querySnapshots.citationState,
7718
- citedDomains: querySnapshots.citedDomains,
7719
- screenshotPath: querySnapshots.screenshotPath,
7720
- rawResponse: querySnapshots.rawResponse
7721
- }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
7722
- const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
7723
- const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
7724
- const byKeyword = /* @__PURE__ */ new Map();
7725
- for (const snap of snapshots) {
7726
- const kwName = keywordMap.get(snap.keywordId) ?? snap.keywordId;
7727
- if (!byKeyword.has(snap.keywordId)) {
7728
- byKeyword.set(snap.keywordId, { keyword: kwName, api: null, browser: null });
7729
- }
7730
- const entry = byKeyword.get(snap.keywordId);
7731
- if (snap.provider === "cdp:chatgpt") {
7732
- entry.browser = snap;
7733
- } else if (snap.provider === "openai") {
7734
- entry.api = snap;
7735
- }
7736
- }
7737
- let agreed = 0;
7738
- let apiOnlyCited = 0;
7739
- let browserOnlyCited = 0;
7740
- let disagreed = 0;
7741
- let total = 0;
7742
- const keywordResults = [...byKeyword.values()].map(({ keyword, api, browser }) => {
7743
- total++;
7744
- const apiCited = api?.citationState === "cited";
7745
- const browserCited = browser?.citationState === "cited";
7746
- let agreement;
7747
- if (!api && !browser) {
7748
- agreement = "no-data";
7749
- } else if (!api) {
7750
- agreement = "no-api";
7751
- } else if (!browser) {
7752
- agreement = "no-browser";
7753
- } else if (apiCited && browserCited) {
7754
- agreement = "agree-cited";
7755
- agreed++;
7756
- } else if (!apiCited && !browserCited) {
7757
- agreement = "agree-not-cited";
7758
- agreed++;
7759
- } else if (apiCited && !browserCited) {
7760
- agreement = "api-only-cited";
7761
- apiOnlyCited++;
7762
- disagreed++;
7763
- } else {
7764
- agreement = "browser-only-cited";
7765
- browserOnlyCited++;
7766
- disagreed++;
7767
- }
7768
- const parseGroundingSources2 = (snap) => {
7769
- if (!snap?.rawResponse) return [];
7770
- try {
7771
- const raw = JSON.parse(snap.rawResponse);
7772
- return raw.groundingSources ?? [];
7773
- } catch {
7774
- return [];
7775
- }
7776
- };
7777
- return {
7778
- keyword,
7779
- api: api ? {
7780
- provider: api.provider,
7781
- citationState: api.citationState,
7782
- citedDomains: JSON.parse(api.citedDomains || "[]"),
7783
- groundingSources: parseGroundingSources2(api)
7784
- } : null,
7785
- browser: browser ? {
7786
- provider: browser.provider,
7787
- citationState: browser.citationState,
7788
- citedDomains: JSON.parse(browser.citedDomains || "[]"),
7789
- groundingSources: parseGroundingSources2(browser),
7790
- screenshotUrl: browser.screenshotPath ? `/api/v1/screenshots/${browser.id}` : void 0
7791
- } : null,
7792
- agreement
7793
- };
7794
- });
7795
- return reply.send({
7796
- summary: { total, agreed, apiOnly: apiOnlyCited, browserOnly: browserOnlyCited, disagreed },
7797
- keywords: keywordResults
7798
- });
7799
- }
7800
- );
7801
- }
7802
-
7803
- // ../api-routes/src/ga.ts
7804
- import crypto16 from "crypto";
7805
- import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
7806
-
7807
- // ../integration-google-analytics/src/ga4-client.ts
7808
- import crypto15 from "crypto";
7809
-
7810
- // ../integration-google-analytics/src/constants.ts
7811
- var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
7812
- var GA4_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
7813
- var GOOGLE_TOKEN_URL2 = "https://oauth2.googleapis.com/token";
7814
- var GA4_DEFAULT_SYNC_DAYS = 30;
7815
- var GA4_MAX_SYNC_DAYS = 90;
7816
-
7817
- // ../integration-google-analytics/src/types.ts
7818
- var GA4ApiError = class extends Error {
7819
- status;
7820
- constructor(message, status) {
7821
- super(message);
7822
- this.name = "GA4ApiError";
7823
- this.status = status;
7824
- }
7825
- };
7826
-
7827
- // ../integration-google-analytics/src/ga4-client.ts
7828
- function ga4Log(level, action, ctx) {
7829
- const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Client", action, ...ctx };
7830
- const stream = level === "error" ? process.stderr : process.stdout;
7831
- stream.write(JSON.stringify(entry) + "\n");
7832
- }
7833
- function createServiceAccountJwt(clientEmail, privateKey, scope) {
7834
- const now = Math.floor(Date.now() / 1e3);
7835
- const header = { alg: "RS256", typ: "JWT" };
7836
- const payload = {
7837
- iss: clientEmail,
7838
- scope,
7839
- aud: GOOGLE_TOKEN_URL2,
7840
- iat: now,
7841
- exp: now + 3600
7842
- // 1 hour
7843
- };
7844
- const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
7845
- const headerB64 = encode(header);
7846
- const payloadB64 = encode(payload);
7847
- const signingInput = `${headerB64}.${payloadB64}`;
7848
- const sign = crypto15.createSign("RSA-SHA256");
7849
- sign.update(signingInput);
7850
- const signature = sign.sign(privateKey, "base64url");
7851
- return `${signingInput}.${signature}`;
7852
- }
7853
- async function getAccessToken(clientEmail, privateKey) {
7854
- const jwt = createServiceAccountJwt(clientEmail, privateKey, GA4_SCOPE);
7855
- const res = await fetch(GOOGLE_TOKEN_URL2, {
7856
- method: "POST",
7857
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
7858
- body: new URLSearchParams({
7859
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
7860
- assertion: jwt
7861
- })
7862
- });
7863
- if (!res.ok) {
7864
- const body = await res.text().catch(() => "");
7865
- ga4Log("error", "token.failed", { httpStatus: res.status, responseBody: body });
7866
- throw new GA4ApiError(`Failed to get access token: ${body}`, res.status);
7867
- }
7868
- const data = await res.json();
7869
- return data.access_token;
7870
- }
7871
- async function runReport(accessToken, propertyId, request) {
7872
- const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
7873
- const res = await fetch(url, {
7874
- method: "POST",
7875
- headers: {
7876
- "Authorization": `Bearer ${accessToken}`,
7877
- "Content-Type": "application/json"
7878
- },
7879
- body: JSON.stringify(request)
7880
- });
7881
- if (res.status === 401 || res.status === 403) {
7882
- const body = await res.text().catch(() => "");
7883
- let detail = "";
7884
- try {
7885
- const parsed = JSON.parse(body);
7886
- if (parsed.error?.status === "SERVICE_DISABLED") {
7887
- detail = " The Google Analytics Data API is not enabled for this GCP project. Enable it at: https://console.developers.google.com/apis/api/analyticsdata.googleapis.com/overview";
7888
- } else if (parsed.error?.message) {
7889
- detail = ` ${parsed.error.message}`;
7890
- }
7891
- } catch {
7892
- if (body.length < 200) detail = ` ${body}`;
7893
- }
7894
- ga4Log("error", "report.auth-failed", { propertyId, httpStatus: res.status, responseBody: body });
7895
- throw new GA4ApiError(
7896
- `GA4 API authentication failed \u2014 check service account permissions.${detail}`,
7897
- res.status
7898
- );
7899
- }
7900
- if (res.status === 429) {
7901
- ga4Log("error", "report.rate-limited", { propertyId });
7902
- throw new GA4ApiError("GA4 API rate limit exceeded", 429);
7903
- }
7904
- if (!res.ok) {
7905
- const body = await res.text();
7906
- ga4Log("error", "report.error", { propertyId, httpStatus: res.status, responseBody: body });
7907
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
7908
- }
7909
- return await res.json();
7910
- }
7911
- async function batchRunReports(accessToken, propertyId, requests) {
7912
- const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:batchRunReports`;
7913
- const res = await fetch(url, {
7914
- method: "POST",
7915
- headers: {
7916
- "Authorization": `Bearer ${accessToken}`,
7917
- "Content-Type": "application/json"
7918
- },
7919
- body: JSON.stringify({ requests })
7920
- });
7921
- if (res.status === 401 || res.status === 403) {
7922
- const body = await res.text().catch(() => "");
7923
- ga4Log("error", "batch-report.auth-failed", { propertyId, httpStatus: res.status });
7924
- throw new GA4ApiError(
7925
- `GA4 API authentication failed \u2014 check service account permissions. ${body}`,
7926
- res.status
7927
- );
7928
- }
7929
- if (res.status === 429) {
7930
- ga4Log("error", "batch-report.rate-limited", { propertyId });
7931
- throw new GA4ApiError("GA4 API rate limit exceeded", 429);
7932
- }
7933
- if (!res.ok) {
7934
- const body = await res.text();
7935
- ga4Log("error", "batch-report.error", { propertyId, httpStatus: res.status });
7936
- throw new GA4ApiError(`GA4 API error (${res.status}): ${body}`, res.status);
7937
- }
7938
- const data = await res.json();
7939
- return data.reports;
7940
- }
7941
- function formatDate(d) {
7942
- return d.toISOString().split("T")[0];
7943
- }
7944
- var AI_REFERRAL_SOURCE_FILTERS = [
7945
- { matchType: "CONTAINS", value: "perplexity" },
7946
- { matchType: "CONTAINS", value: "gemini" },
7947
- { matchType: "CONTAINS", value: "chatgpt" },
7948
- { matchType: "CONTAINS", value: "openai" },
7949
- { matchType: "CONTAINS", value: "claude" },
7950
- { matchType: "CONTAINS", value: "anthropic" },
7951
- { matchType: "CONTAINS", value: "copilot" }
7952
- ];
7953
- async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
7954
- const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7955
- const endDate = /* @__PURE__ */ new Date();
7956
- const startDate = /* @__PURE__ */ new Date();
7957
- startDate.setDate(startDate.getDate() - syncDays);
7958
- ga4Log("info", "fetch-traffic.start", { propertyId, days: syncDays });
7959
- const PAGE_SIZE = 1e4;
7960
- const rows = [];
7961
- let offset = 0;
7962
- while (true) {
7963
- const request = {
7964
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
7965
- dimensions: [
7966
- { name: "date" },
7967
- { name: "landingPagePlusQueryString" }
7968
- ],
7969
- metrics: [
7970
- { name: "sessions" },
7971
- { name: "totalUsers" }
7972
- ],
7973
- limit: PAGE_SIZE,
7974
- offset
7975
- };
7976
- const response = await runReport(accessToken, propertyId, request);
7977
- const pageRows = (response.rows ?? []).map((row) => ({
7978
- date: row.dimensionValues[0].value,
7979
- landingPage: row.dimensionValues[1].value,
7980
- sessions: parseInt(row.metricValues[0].value, 10) || 0,
7981
- organicSessions: 0,
7982
- // populated by organic-only pass below
7983
- users: parseInt(row.metricValues[1].value, 10) || 0
7984
- }));
7985
- rows.push(...pageRows);
7986
- const totalRows = response.rowCount ?? 0;
7987
- offset += pageRows.length;
7988
- if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
7989
- }
7990
- const organicMap = /* @__PURE__ */ new Map();
7991
- let organicOffset = 0;
7992
- while (true) {
7993
- const organicRequest = {
7994
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
7995
- dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
7996
- metrics: [{ name: "sessions" }],
7997
- dimensionFilter: {
7998
- filter: {
7999
- fieldName: "sessionDefaultChannelGrouping",
8000
- stringFilter: { matchType: "EXACT", value: "Organic Search" }
8001
- }
8002
- },
8003
- limit: 1e4,
8004
- offset: organicOffset
8005
- };
8006
- const organicResponse = await runReport(accessToken, propertyId, organicRequest);
8007
- for (const row of organicResponse.rows ?? []) {
8008
- const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
8009
- organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
8010
- }
8011
- const total = organicResponse.rowCount ?? 0;
8012
- organicOffset += (organicResponse.rows ?? []).length;
8013
- if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
8014
- }
8015
- for (const row of rows) {
8016
- const key = `${row.date}::${row.landingPage}`;
8017
- row.organicSessions = organicMap.get(key) ?? 0;
8018
- }
8019
- for (const row of rows) {
8020
- if (row.date.length === 8 && !row.date.includes("-")) {
8021
- row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
8022
- }
8023
- }
8024
- ga4Log("info", "fetch-traffic.done", { propertyId, rowCount: rows.length });
8025
- return rows;
8026
- }
8027
- async function verifyConnection(clientEmail, privateKey, propertyId) {
8028
- const accessToken = await getAccessToken(clientEmail, privateKey);
8029
- const endDate = /* @__PURE__ */ new Date();
8030
- const startDate = /* @__PURE__ */ new Date();
8031
- startDate.setDate(startDate.getDate() - 1);
8032
- await runReport(accessToken, propertyId, {
8033
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
8034
- dimensions: [{ name: "date" }],
8035
- metrics: [{ name: "sessions" }],
8036
- limit: 1
8037
- });
8038
- return true;
8039
- }
8040
- async function fetchAggregateSummary(accessToken, propertyId, days) {
8041
- const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
8042
- const endDate = /* @__PURE__ */ new Date();
8043
- const startDate = /* @__PURE__ */ new Date();
8044
- startDate.setDate(startDate.getDate() - syncDays);
8045
- ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
8046
- const dateRange = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
8047
- const batchRes = await batchRunReports(accessToken, propertyId, [
8048
- {
8049
- dateRanges: [dateRange],
8050
- dimensions: [],
8051
- metrics: [{ name: "sessions" }, { name: "totalUsers" }],
8052
- limit: 1
8053
- },
8054
- {
8055
- dateRanges: [dateRange],
8056
- dimensions: [],
8057
- metrics: [{ name: "sessions" }],
8058
- dimensionFilter: {
8059
- filter: {
8060
- fieldName: "sessionDefaultChannelGrouping",
8061
- stringFilter: { matchType: "EXACT", value: "Organic Search" }
8077
+ if (!run) {
8078
+ const err = notFound("Run", runId);
8079
+ return reply.code(err.statusCode).send(err.toJSON());
8080
+ }
8081
+ const snapshots = app.db.select({
8082
+ id: querySnapshots.id,
8083
+ keywordId: querySnapshots.keywordId,
8084
+ provider: querySnapshots.provider,
8085
+ citationState: querySnapshots.citationState,
8086
+ citedDomains: querySnapshots.citedDomains,
8087
+ screenshotPath: querySnapshots.screenshotPath,
8088
+ rawResponse: querySnapshots.rawResponse
8089
+ }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
8090
+ const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
8091
+ const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
8092
+ const byKeyword = /* @__PURE__ */ new Map();
8093
+ for (const snap of snapshots) {
8094
+ const kwName = keywordMap.get(snap.keywordId) ?? snap.keywordId;
8095
+ if (!byKeyword.has(snap.keywordId)) {
8096
+ byKeyword.set(snap.keywordId, { keyword: kwName, api: null, browser: null });
8062
8097
  }
8063
- },
8064
- limit: 1
8065
- }
8066
- ]);
8067
- const totalRow = batchRes[0]?.rows?.[0];
8068
- const organicRow = batchRes[1]?.rows?.[0];
8069
- const summary = {
8070
- periodStart: formatDate(startDate),
8071
- periodEnd: formatDate(endDate),
8072
- totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
8073
- totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
8074
- totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
8075
- };
8076
- ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
8077
- return summary;
8078
- }
8079
- async function fetchAiReferrals(accessToken, propertyId, days) {
8080
- const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
8081
- const endDate = /* @__PURE__ */ new Date();
8082
- const startDate = /* @__PURE__ */ new Date();
8083
- startDate.setDate(startDate.getDate() - syncDays);
8084
- ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
8085
- const PAGE_SIZE = 1e3;
8086
- const rows = [];
8087
- let offset = 0;
8088
- while (true) {
8089
- const request = {
8090
- dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
8091
- dimensions: [
8092
- { name: "date" },
8093
- { name: "sessionSource" },
8094
- { name: "sessionMedium" }
8095
- ],
8096
- metrics: [
8097
- { name: "sessions" },
8098
- { name: "totalUsers" }
8099
- ],
8100
- dimensionFilter: {
8101
- orGroup: {
8102
- expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
8103
- filter: {
8104
- fieldName: "sessionSource",
8105
- stringFilter: { matchType, value }
8106
- }
8107
- }))
8098
+ const entry = byKeyword.get(snap.keywordId);
8099
+ if (snap.provider === "cdp:chatgpt") {
8100
+ entry.browser = snap;
8101
+ } else if (snap.provider === "openai") {
8102
+ entry.api = snap;
8108
8103
  }
8109
- },
8110
- limit: PAGE_SIZE,
8111
- offset
8112
- };
8113
- const response = await runReport(accessToken, propertyId, request);
8114
- const pageRows = (response.rows ?? []).map((row) => ({
8115
- date: row.dimensionValues[0].value,
8116
- source: row.dimensionValues[1].value,
8117
- medium: row.dimensionValues[2].value,
8118
- sessions: parseInt(row.metricValues[0].value, 10) || 0,
8119
- users: parseInt(row.metricValues[1].value, 10) || 0
8120
- }));
8121
- rows.push(...pageRows);
8122
- const totalRows = response.rowCount ?? 0;
8123
- offset += pageRows.length;
8124
- if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
8125
- }
8126
- for (const row of rows) {
8127
- if (row.date.length === 8 && !row.date.includes("-")) {
8128
- row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
8104
+ }
8105
+ let agreed = 0;
8106
+ let apiOnlyCited = 0;
8107
+ let browserOnlyCited = 0;
8108
+ let disagreed = 0;
8109
+ let total = 0;
8110
+ const keywordResults = [...byKeyword.values()].map(({ keyword, api, browser }) => {
8111
+ total++;
8112
+ const apiCited = api?.citationState === "cited";
8113
+ const browserCited = browser?.citationState === "cited";
8114
+ let agreement;
8115
+ if (!api && !browser) {
8116
+ agreement = "no-data";
8117
+ } else if (!api) {
8118
+ agreement = "no-api";
8119
+ } else if (!browser) {
8120
+ agreement = "no-browser";
8121
+ } else if (apiCited && browserCited) {
8122
+ agreement = "agree-cited";
8123
+ agreed++;
8124
+ } else if (!apiCited && !browserCited) {
8125
+ agreement = "agree-not-cited";
8126
+ agreed++;
8127
+ } else if (apiCited && !browserCited) {
8128
+ agreement = "api-only-cited";
8129
+ apiOnlyCited++;
8130
+ disagreed++;
8131
+ } else {
8132
+ agreement = "browser-only-cited";
8133
+ browserOnlyCited++;
8134
+ disagreed++;
8135
+ }
8136
+ const parseGroundingSources2 = (snap) => {
8137
+ if (!snap?.rawResponse) return [];
8138
+ try {
8139
+ const raw = JSON.parse(snap.rawResponse);
8140
+ return raw.groundingSources ?? [];
8141
+ } catch {
8142
+ return [];
8143
+ }
8144
+ };
8145
+ return {
8146
+ keyword,
8147
+ api: api ? {
8148
+ provider: api.provider,
8149
+ citationState: api.citationState,
8150
+ citedDomains: JSON.parse(api.citedDomains || "[]"),
8151
+ groundingSources: parseGroundingSources2(api)
8152
+ } : null,
8153
+ browser: browser ? {
8154
+ provider: browser.provider,
8155
+ citationState: browser.citationState,
8156
+ citedDomains: JSON.parse(browser.citedDomains || "[]"),
8157
+ groundingSources: parseGroundingSources2(browser),
8158
+ screenshotUrl: browser.screenshotPath ? `/api/v1/screenshots/${browser.id}` : void 0
8159
+ } : null,
8160
+ agreement
8161
+ };
8162
+ });
8163
+ return reply.send({
8164
+ summary: { total, agreed, apiOnly: apiOnlyCited, browserOnly: browserOnlyCited, disagreed },
8165
+ keywords: keywordResults
8166
+ });
8129
8167
  }
8130
- }
8131
- ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
8132
- return rows;
8168
+ );
8133
8169
  }
8134
8170
 
8135
8171
  // ../api-routes/src/ga.ts
8172
+ import crypto16 from "crypto";
8173
+ import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
8136
8174
  function gaLog(level, action, ctx) {
8137
8175
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
8138
8176
  const stream = level === "error" ? process.stderr : process.stdout;
8139
8177
  stream.write(JSON.stringify(entry) + "\n");
8140
8178
  }
8141
- async function ga4Routes(app, opts) {
8142
- function requireCredentialStore(reply) {
8143
- if (opts.ga4CredentialStore) return opts.ga4CredentialStore;
8144
- const err = validationError("GA4 credential storage is not configured for this deployment");
8145
- reply.status(err.statusCode).send(err.toJSON());
8146
- return null;
8179
+ async function refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, oauthConn) {
8180
+ const expiresAt = oauthConn.tokenExpiresAt ? new Date(oauthConn.tokenExpiresAt).getTime() : 0;
8181
+ const fiveMinutes = 5 * 60 * 1e3;
8182
+ if (Date.now() > expiresAt - fiveMinutes) {
8183
+ if (!authConfig.clientId || !authConfig.clientSecret) {
8184
+ throw validationError("Google OAuth client credentials are not configured \u2014 cannot refresh GA4 token.");
8185
+ }
8186
+ const tokens = await refreshAccessToken(authConfig.clientId, authConfig.clientSecret, oauthConn.refreshToken);
8187
+ const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
8188
+ googleStore.updateConnection(canonicalDomain, "ga4", {
8189
+ accessToken: tokens.access_token,
8190
+ tokenExpiresAt: newExpiresAt,
8191
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
8192
+ });
8193
+ return tokens.access_token;
8147
8194
  }
8148
- app.post("/projects/:name/ga/connect", async (request, reply) => {
8149
- const store = requireCredentialStore(reply);
8150
- if (!store) return;
8195
+ return oauthConn.accessToken;
8196
+ }
8197
+ async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
8198
+ const saConn = opts.ga4CredentialStore?.getConnection(projectName);
8199
+ if (saConn?.clientEmail && saConn?.privateKey && saConn?.propertyId) {
8200
+ const token = await getAccessToken(saConn.clientEmail, saConn.privateKey);
8201
+ return { accessToken: token, propertyId: saConn.propertyId };
8202
+ }
8203
+ const googleStore = opts.googleConnectionStore;
8204
+ const authConfig = opts.getGoogleAuthConfig?.();
8205
+ if (!googleStore || !authConfig) {
8206
+ throw validationError(
8207
+ 'No GA4 credentials found. Run "canonry ga connect <project> --key-file <path>" or "canonry google connect <project> --type ga4" to authenticate.'
8208
+ );
8209
+ }
8210
+ const oauthConn = googleStore.getConnection(canonicalDomain, "ga4");
8211
+ if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
8212
+ throw validationError(
8213
+ 'No GA4 credentials found. Run "canonry ga connect <project> --key-file <path>" or "canonry google connect <project> --type ga4" to authenticate.'
8214
+ );
8215
+ }
8216
+ if (!oauthConn.propertyId) {
8217
+ throw validationError(
8218
+ 'GA4 property ID not set. Run "canonry ga set-property <project> <propertyId>" to configure it.'
8219
+ );
8220
+ }
8221
+ const accessToken = await refreshOAuthTokenIfNeeded(googleStore, authConfig, canonicalDomain, {
8222
+ accessToken: oauthConn.accessToken,
8223
+ refreshToken: oauthConn.refreshToken,
8224
+ tokenExpiresAt: oauthConn.tokenExpiresAt
8225
+ });
8226
+ return { accessToken, propertyId: oauthConn.propertyId };
8227
+ }
8228
+ function requireGa4Connection(opts, projectName, canonicalDomain) {
8229
+ const saConn = opts.ga4CredentialStore?.getConnection(projectName);
8230
+ const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
8231
+ if (!saConn && !(oauthConn?.accessToken && oauthConn?.propertyId)) {
8232
+ throw validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8233
+ }
8234
+ }
8235
+ async function ga4Routes(app, opts) {
8236
+ app.post("/projects/:name/ga/connect", async (request, _reply) => {
8151
8237
  const project = resolveProject(app.db, request.params.name);
8152
8238
  const { propertyId, keyJson } = request.body ?? {};
8153
8239
  if (!propertyId || typeof propertyId !== "string") {
8154
- const err = validationError("propertyId is required");
8155
- return reply.status(err.statusCode).send(err.toJSON());
8240
+ throw validationError("propertyId is required");
8156
8241
  }
8157
- let clientEmail;
8158
- let privateKey;
8159
8242
  if (keyJson && typeof keyJson === "string") {
8243
+ if (!opts.ga4CredentialStore) {
8244
+ throw validationError("GA4 credential storage is not configured for this deployment");
8245
+ }
8246
+ let parsed;
8160
8247
  try {
8161
- const parsed = JSON.parse(keyJson);
8162
- if (!parsed.client_email || !parsed.private_key) {
8163
- const err = validationError("Service account JSON must contain client_email and private_key");
8164
- return reply.status(err.statusCode).send(err.toJSON());
8165
- }
8166
- clientEmail = parsed.client_email;
8167
- privateKey = parsed.private_key;
8248
+ parsed = JSON.parse(keyJson);
8168
8249
  } catch {
8169
- const err = validationError("Invalid JSON in keyJson");
8170
- return reply.status(err.statusCode).send(err.toJSON());
8250
+ throw validationError("Invalid JSON in keyJson");
8171
8251
  }
8172
- } else {
8173
- const err = validationError("keyJson is required");
8174
- return reply.status(err.statusCode).send(err.toJSON());
8252
+ if (!parsed.client_email || !parsed.private_key) {
8253
+ throw validationError("Service account JSON must contain client_email and private_key");
8254
+ }
8255
+ const clientEmail = parsed.client_email;
8256
+ const privateKey = parsed.private_key;
8257
+ try {
8258
+ await verifyConnection(clientEmail, privateKey, propertyId);
8259
+ gaLog("info", "connect.verified.service-account", { projectId: project.id, propertyId });
8260
+ } catch (e) {
8261
+ const msg = e instanceof Error ? e.message : String(e);
8262
+ gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
8263
+ throw validationError(`Failed to verify GA4 credentials: ${msg}`);
8264
+ }
8265
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8266
+ const existing = opts.ga4CredentialStore.getConnection(project.name);
8267
+ opts.ga4CredentialStore.upsertConnection({
8268
+ projectName: project.name,
8269
+ propertyId,
8270
+ clientEmail,
8271
+ privateKey,
8272
+ createdAt: existing?.createdAt ?? now,
8273
+ updatedAt: now
8274
+ });
8275
+ writeAuditLog(app.db, {
8276
+ projectId: project.id,
8277
+ actor: "api",
8278
+ action: "ga4.connected",
8279
+ entityType: "ga_connection",
8280
+ entityId: propertyId
8281
+ });
8282
+ return { connected: true, propertyId, authMethod: "service-account", clientEmail };
8283
+ }
8284
+ const googleStore = opts.googleConnectionStore;
8285
+ const authConfig = opts.getGoogleAuthConfig?.();
8286
+ if (!googleStore || !authConfig) {
8287
+ throw validationError(
8288
+ 'No service account key provided and OAuth storage is not configured. Pass --key-file or run "canonry google connect <project> --type ga4" first.'
8289
+ );
8175
8290
  }
8291
+ const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
8292
+ if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
8293
+ throw validationError(
8294
+ 'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
8295
+ );
8296
+ }
8297
+ const accessToken = await refreshOAuthTokenIfNeeded(googleStore, authConfig, project.canonicalDomain, {
8298
+ accessToken: oauthConn.accessToken,
8299
+ refreshToken: oauthConn.refreshToken,
8300
+ tokenExpiresAt: oauthConn.tokenExpiresAt
8301
+ });
8176
8302
  try {
8177
- await verifyConnection(clientEmail, privateKey, propertyId);
8178
- gaLog("info", "connect.verified", { projectId: project.id, propertyId });
8303
+ await verifyConnectionWithToken(accessToken, propertyId);
8304
+ gaLog("info", "connect.verified.oauth", { projectId: project.id, propertyId });
8179
8305
  } catch (e) {
8180
8306
  const msg = e instanceof Error ? e.message : String(e);
8181
- gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
8182
- const err = validationError(`Failed to verify GA4 credentials: ${msg}`);
8183
- return reply.status(err.statusCode).send(err.toJSON());
8307
+ gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
8308
+ throw validationError(`Failed to verify GA4 access: ${msg}`);
8184
8309
  }
8185
- const now = (/* @__PURE__ */ new Date()).toISOString();
8186
- const existing = store.getConnection(project.name);
8187
- store.upsertConnection({
8188
- projectName: project.name,
8310
+ googleStore.updateConnection(project.canonicalDomain, "ga4", {
8189
8311
  propertyId,
8190
- clientEmail,
8191
- privateKey,
8192
- createdAt: existing?.createdAt ?? now,
8193
- updatedAt: now
8312
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
8194
8313
  });
8195
8314
  writeAuditLog(app.db, {
8196
8315
  projectId: project.id,
@@ -8199,80 +8318,62 @@ async function ga4Routes(app, opts) {
8199
8318
  entityType: "ga_connection",
8200
8319
  entityId: propertyId
8201
8320
  });
8202
- return {
8203
- connected: true,
8204
- propertyId,
8205
- clientEmail
8206
- };
8321
+ return { connected: true, propertyId, authMethod: "oauth" };
8207
8322
  });
8208
8323
  app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
8209
- const store = requireCredentialStore(reply);
8210
- if (!store) return;
8211
8324
  const project = resolveProject(app.db, request.params.name);
8212
- const conn = store.getConnection(project.name);
8213
- if (!conn) {
8214
- const err = notFound("GA4 connection", project.name);
8215
- return reply.status(err.statusCode).send(err.toJSON());
8325
+ const saConn = opts.ga4CredentialStore?.getConnection(project.name);
8326
+ const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
8327
+ if (!saConn && !oauthConn) {
8328
+ throw notFound("GA4 connection", project.name);
8216
8329
  }
8217
8330
  app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
8218
8331
  app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8219
8332
  app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
8220
- store.deleteConnection(project.name);
8333
+ const propertyId = saConn?.propertyId ?? oauthConn?.propertyId ?? null;
8334
+ opts.ga4CredentialStore?.deleteConnection(project.name);
8335
+ opts.googleConnectionStore?.deleteConnection(project.canonicalDomain, "ga4");
8221
8336
  writeAuditLog(app.db, {
8222
8337
  projectId: project.id,
8223
8338
  actor: "api",
8224
8339
  action: "ga4.disconnected",
8225
8340
  entityType: "ga_connection",
8226
- entityId: conn.propertyId
8341
+ entityId: propertyId
8227
8342
  });
8228
8343
  return reply.status(204).send();
8229
8344
  });
8230
- app.get("/projects/:name/ga/status", async (request, reply) => {
8231
- const store = requireCredentialStore(reply);
8232
- if (!store) return;
8345
+ app.get("/projects/:name/ga/status", async (request, _reply) => {
8233
8346
  const project = resolveProject(app.db, request.params.name);
8234
- const conn = store.getConnection(project.name);
8235
- if (!conn) {
8236
- return { connected: false, propertyId: null, clientEmail: null, lastSyncedAt: null };
8347
+ const saConn = opts.ga4CredentialStore?.getConnection(project.name);
8348
+ const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
8349
+ const connected = !!(saConn || oauthConn?.accessToken && oauthConn?.propertyId);
8350
+ if (!connected) {
8351
+ return { connected: false, propertyId: null, clientEmail: null, authMethod: null, lastSyncedAt: null };
8237
8352
  }
8238
8353
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
8239
8354
  return {
8240
8355
  connected: true,
8241
- propertyId: conn.propertyId,
8242
- clientEmail: conn.clientEmail,
8356
+ propertyId: saConn?.propertyId ?? oauthConn?.propertyId ?? null,
8357
+ clientEmail: saConn?.clientEmail ?? null,
8358
+ authMethod: saConn ? "service-account" : "oauth",
8243
8359
  lastSyncedAt: latestSync?.syncedAt ?? null,
8244
- createdAt: conn.createdAt,
8245
- updatedAt: conn.updatedAt
8360
+ createdAt: saConn?.createdAt ?? oauthConn?.createdAt ?? null,
8361
+ updatedAt: saConn?.updatedAt ?? oauthConn?.updatedAt ?? null
8246
8362
  };
8247
8363
  });
8248
- app.post("/projects/:name/ga/sync", async (request, reply) => {
8249
- const store = requireCredentialStore(reply);
8250
- if (!store) return;
8364
+ app.post("/projects/:name/ga/sync", async (request, _reply) => {
8251
8365
  const project = resolveProject(app.db, request.params.name);
8252
- const conn = store.getConnection(project.name);
8253
- if (!conn) {
8254
- const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8255
- return reply.status(err.statusCode).send(err.toJSON());
8256
- }
8257
8366
  const days = request.body?.days ?? 30;
8258
- let accessToken;
8259
- try {
8260
- accessToken = await getAccessToken(conn.clientEmail, conn.privateKey);
8261
- } catch (e) {
8262
- const msg = e instanceof Error ? e.message : String(e);
8263
- gaLog("error", "sync.auth-failed", { projectId: project.id, error: msg });
8264
- const err = validationError(`GA4 authentication failed: ${msg}`);
8265
- return reply.status(err.statusCode).send(err.toJSON());
8266
- }
8367
+ const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
8267
8368
  let rows;
8268
8369
  let summary;
8269
8370
  let aiReferrals;
8270
8371
  try {
8271
8372
  ;
8272
8373
  [rows, summary, aiReferrals] = await Promise.all([
8273
- fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
8274
- fetchAggregateSummary(accessToken, conn.propertyId, days),
8275
- fetchAiReferrals(accessToken, conn.propertyId, days)
8374
+ fetchTrafficByLandingPage(accessToken, propertyId, days),
8375
+ fetchAggregateSummary(accessToken, propertyId, days),
8376
+ fetchAiReferrals(accessToken, propertyId, days)
8276
8377
  ]);
8277
8378
  } catch (e) {
8278
8379
  const msg = e instanceof Error ? e.message : String(e);
@@ -8350,15 +8451,9 @@ async function ga4Routes(app, opts) {
8350
8451
  syncedAt: now
8351
8452
  };
8352
8453
  });
8353
- app.get("/projects/:name/ga/traffic", async (request, reply) => {
8354
- const store = requireCredentialStore(reply);
8355
- if (!store) return;
8454
+ app.get("/projects/:name/ga/traffic", async (request, _reply) => {
8356
8455
  const project = resolveProject(app.db, request.params.name);
8357
- const conn = store.getConnection(project.name);
8358
- if (!conn) {
8359
- const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8360
- return reply.status(err.statusCode).send(err.toJSON());
8361
- }
8456
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8362
8457
  const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
8363
8458
  const summary = app.db.select({
8364
8459
  totalSessions: gaTrafficSummaries.totalSessions,
@@ -8397,15 +8492,21 @@ async function ga4Routes(app, opts) {
8397
8492
  lastSyncedAt: latestSync?.syncedAt ?? null
8398
8493
  };
8399
8494
  });
8400
- app.get("/projects/:name/ga/coverage", async (request, reply) => {
8401
- const store = requireCredentialStore(reply);
8402
- if (!store) return;
8495
+ app.get("/projects/:name/ga/ai-referral-history", async (request, _reply) => {
8403
8496
  const project = resolveProject(app.db, request.params.name);
8404
- const conn = store.getConnection(project.name);
8405
- if (!conn) {
8406
- const err = validationError('No GA4 connection found. Run "canonry ga connect <project>" first.');
8407
- return reply.status(err.statusCode).send(err.toJSON());
8408
- }
8497
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8498
+ const rows = app.db.select({
8499
+ date: gaAiReferrals.date,
8500
+ source: gaAiReferrals.source,
8501
+ medium: gaAiReferrals.medium,
8502
+ sessions: gaAiReferrals.sessions,
8503
+ users: gaAiReferrals.users
8504
+ }).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).orderBy(gaAiReferrals.date).all();
8505
+ return rows;
8506
+ });
8507
+ app.get("/projects/:name/ga/coverage", async (request, _reply) => {
8508
+ const project = resolveProject(app.db, request.params.name);
8509
+ requireGa4Connection(opts, project.name, project.canonicalDomain);
8409
8510
  const trafficPages = app.db.select({
8410
8511
  landingPage: gaTrafficSnapshots.landingPage,
8411
8512
  sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
@@ -8546,6 +8647,8 @@ function parseSchemaPageEntry(entry) {
8546
8647
 
8547
8648
  // ../integration-wordpress/src/wordpress-client.ts
8548
8649
  import crypto17 from "crypto";
8650
+ var WP_REQUEST_TIMEOUT_MS = 3e4;
8651
+ var WP_FETCH_TEXT_TIMEOUT_MS = 15e3;
8549
8652
  var PAGE_FIELDS = "id,slug,status,link,modified,modified_gmt,title,content,meta";
8550
8653
  var PAGE_LIST_FIELDS = "id,slug,status,link,modified,modified_gmt,title";
8551
8654
  var VERIFY_PAGE_FIELDS = "id,status";
@@ -8602,7 +8705,8 @@ async function fetchJson(connection, siteUrl, path7, init) {
8602
8705
  "Authorization": `Basic ${encodeBasicAuth(connection.username, connection.appPassword)}`,
8603
8706
  ...init?.body != null ? { "Content-Type": "application/json" } : {},
8604
8707
  ...init?.headers ?? {}
8605
- }
8708
+ },
8709
+ signal: AbortSignal.timeout(WP_REQUEST_TIMEOUT_MS)
8606
8710
  });
8607
8711
  if (res.status === 401 || res.status === 403) {
8608
8712
  const text2 = await res.text().catch(() => "");
@@ -8645,7 +8749,7 @@ async function fetchPageCollectionSummary(connection, siteUrl, options) {
8645
8749
  }
8646
8750
  async function fetchText(url) {
8647
8751
  try {
8648
- const res = await fetch(url);
8752
+ const res = await fetch(url, { signal: AbortSignal.timeout(WP_FETCH_TEXT_TIMEOUT_MS) });
8649
8753
  if (!res.ok) return null;
8650
8754
  return await res.text();
8651
8755
  } catch {
@@ -10162,7 +10266,9 @@ async function apiRoutes(app, opts) {
10162
10266
  onCdpConfigure: opts.onCdpConfigure
10163
10267
  });
10164
10268
  await api.register(ga4Routes, {
10165
- ga4CredentialStore: opts.ga4CredentialStore
10269
+ ga4CredentialStore: opts.ga4CredentialStore,
10270
+ googleConnectionStore: opts.googleConnectionStore,
10271
+ getGoogleAuthConfig: opts.getGoogleAuthConfig
10166
10272
  });
10167
10273
  }, { prefix: opts.routePrefix ?? "/api/v1" });
10168
10274
  }
@@ -12706,9 +12812,6 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
12706
12812
  function cleanCandidateName(candidate) {
12707
12813
  return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
12708
12814
  }
12709
- function brandKeyFromText(value) {
12710
- return value.toLowerCase().replace(/[^a-z0-9]/g, "");
12711
- }
12712
12815
  function collectBrandKeysFromDomain(domain) {
12713
12816
  const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
12714
12817
  const labels = hostname.split(".").filter(Boolean);