@ainyc/canonry 1.10.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -140,6 +140,7 @@ function trackEvent(event, properties) {
140
140
 
141
141
  // src/server.ts
142
142
  import { createRequire as createRequire2 } from "module";
143
+ import crypto16 from "crypto";
143
144
  import fs2 from "fs";
144
145
  import path2 from "path";
145
146
  import { fileURLToPath } from "url";
@@ -161,6 +162,9 @@ __export(schema_exports, {
161
162
  apiKeys: () => apiKeys,
162
163
  auditLog: () => auditLog,
163
164
  competitors: () => competitors,
165
+ googleConnections: () => googleConnections,
166
+ gscSearchData: () => gscSearchData,
167
+ gscUrlInspections: () => gscUrlInspections,
164
168
  keywords: () => keywords,
165
169
  notifications: () => notifications,
166
170
  projects: () => projects,
@@ -286,6 +290,61 @@ var notifications = sqliteTable("notifications", {
286
290
  }, (table) => [
287
291
  index("idx_notifications_project").on(table.projectId)
288
292
  ]);
293
+ var googleConnections = sqliteTable("google_connections", {
294
+ id: text("id").primaryKey(),
295
+ domain: text("domain").notNull(),
296
+ connectionType: text("connection_type").notNull(),
297
+ propertyId: text("property_id"),
298
+ accessToken: text("access_token"),
299
+ refreshToken: text("refresh_token"),
300
+ tokenExpiresAt: text("token_expires_at"),
301
+ scopes: text("scopes").notNull().default("[]"),
302
+ createdAt: text("created_at").notNull(),
303
+ updatedAt: text("updated_at").notNull()
304
+ }, (table) => [
305
+ uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
306
+ ]);
307
+ var gscSearchData = sqliteTable("gsc_search_data", {
308
+ id: text("id").primaryKey(),
309
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
310
+ syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
311
+ date: text("date").notNull(),
312
+ query: text("query").notNull(),
313
+ page: text("page").notNull(),
314
+ country: text("country"),
315
+ device: text("device"),
316
+ clicks: integer("clicks").notNull().default(0),
317
+ impressions: integer("impressions").notNull().default(0),
318
+ ctr: text("ctr").notNull().default("0"),
319
+ position: text("position").notNull().default("0"),
320
+ createdAt: text("created_at").notNull()
321
+ }, (table) => [
322
+ index("idx_gsc_search_project_date").on(table.projectId, table.date),
323
+ index("idx_gsc_search_query").on(table.query),
324
+ index("idx_gsc_search_run").on(table.syncRunId)
325
+ ]);
326
+ var gscUrlInspections = sqliteTable("gsc_url_inspections", {
327
+ id: text("id").primaryKey(),
328
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
329
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
330
+ url: text("url").notNull(),
331
+ indexingState: text("indexing_state"),
332
+ verdict: text("verdict"),
333
+ coverageState: text("coverage_state"),
334
+ pageFetchState: text("page_fetch_state"),
335
+ robotsTxtState: text("robots_txt_state"),
336
+ crawlTime: text("crawl_time"),
337
+ lastCrawlResult: text("last_crawl_result"),
338
+ isMobileFriendly: integer("is_mobile_friendly"),
339
+ richResults: text("rich_results").notNull().default("[]"),
340
+ referringUrls: text("referring_urls").notNull().default("[]"),
341
+ inspectedAt: text("inspected_at").notNull(),
342
+ createdAt: text("created_at").notNull()
343
+ }, (table) => [
344
+ index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
345
+ index("idx_gsc_inspect_run").on(table.syncRunId),
346
+ index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
347
+ ]);
289
348
  var usageCounters = sqliteTable("usage_counters", {
290
349
  id: text("id").primaryKey(),
291
350
  scope: text("scope").notNull(),
@@ -448,7 +507,62 @@ var MIGRATIONS = [
448
507
  // v5: Add model column to query_snapshots for per-model scoring
449
508
  `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
450
509
  // v5b: Backfill model from rawResponse JSON for existing snapshots
451
- `UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`
510
+ `UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`,
511
+ // v6: Google Search Console integration — google_connections table (domain-scoped)
512
+ `CREATE TABLE IF NOT EXISTS google_connections (
513
+ id TEXT PRIMARY KEY,
514
+ domain TEXT NOT NULL,
515
+ connection_type TEXT NOT NULL,
516
+ property_id TEXT,
517
+ access_token TEXT,
518
+ refresh_token TEXT,
519
+ token_expires_at TEXT,
520
+ scopes TEXT NOT NULL DEFAULT '[]',
521
+ created_at TEXT NOT NULL,
522
+ updated_at TEXT NOT NULL
523
+ )`,
524
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
525
+ // v6: Google Search Console integration — gsc_search_data table
526
+ `CREATE TABLE IF NOT EXISTS gsc_search_data (
527
+ id TEXT PRIMARY KEY,
528
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
529
+ sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
530
+ date TEXT NOT NULL,
531
+ query TEXT NOT NULL,
532
+ page TEXT NOT NULL,
533
+ country TEXT,
534
+ device TEXT,
535
+ clicks INTEGER NOT NULL DEFAULT 0,
536
+ impressions INTEGER NOT NULL DEFAULT 0,
537
+ ctr TEXT NOT NULL DEFAULT '0',
538
+ position TEXT NOT NULL DEFAULT '0',
539
+ created_at TEXT NOT NULL
540
+ )`,
541
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
542
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
543
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
544
+ // v6: Google Search Console integration — gsc_url_inspections table
545
+ `CREATE TABLE IF NOT EXISTS gsc_url_inspections (
546
+ id TEXT PRIMARY KEY,
547
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
548
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
549
+ url TEXT NOT NULL,
550
+ indexing_state TEXT,
551
+ verdict TEXT,
552
+ coverage_state TEXT,
553
+ page_fetch_state TEXT,
554
+ robots_txt_state TEXT,
555
+ crawl_time TEXT,
556
+ last_crawl_result TEXT,
557
+ is_mobile_friendly INTEGER,
558
+ rich_results TEXT NOT NULL DEFAULT '[]',
559
+ referring_urls TEXT NOT NULL DEFAULT '[]',
560
+ inspected_at TEXT NOT NULL,
561
+ created_at TEXT NOT NULL
562
+ )`,
563
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
564
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
565
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`
452
566
  ];
453
567
  function migrate(db) {
454
568
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -521,6 +635,15 @@ var configNotificationSchema = z3.object({
521
635
  url: z3.string().url(),
522
636
  events: z3.array(notificationEventSchema).min(1)
523
637
  });
638
+ var configGoogleSchema = z3.object({
639
+ gsc: z3.object({
640
+ propertyUrl: z3.string()
641
+ }).optional(),
642
+ syncSchedule: z3.object({
643
+ preset: z3.string().optional(),
644
+ cron: z3.string().optional()
645
+ }).optional()
646
+ }).optional();
524
647
  var configSpecSchema = z3.object({
525
648
  displayName: z3.string().min(1),
526
649
  canonicalDomain: z3.string().min(1),
@@ -531,7 +654,8 @@ var configSpecSchema = z3.object({
531
654
  competitors: z3.array(z3.string().min(1)).optional().default([]),
532
655
  providers: z3.array(providerNameSchema).optional().default([]),
533
656
  schedule: configScheduleSchema,
534
- notifications: z3.array(configNotificationSchema).optional().default([])
657
+ notifications: z3.array(configNotificationSchema).optional().default([]),
658
+ google: configGoogleSchema
535
659
  });
536
660
  var projectConfigSchema = z3.object({
537
661
  apiVersion: z3.literal("canonry/v1"),
@@ -633,23 +757,62 @@ function unsupportedKind(kind) {
633
757
  return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
634
758
  }
635
759
 
636
- // ../contracts/src/project.ts
760
+ // ../contracts/src/google.ts
637
761
  import { z as z4 } from "zod";
638
- var configSourceSchema = z4.enum(["cli", "api", "config-file"]);
639
- var projectDtoSchema = z4.object({
762
+ var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
763
+ var googleConnectionDtoSchema = z4.object({
764
+ id: z4.string(),
765
+ domain: z4.string(),
766
+ connectionType: googleConnectionTypeSchema,
767
+ propertyId: z4.string().nullable().optional(),
768
+ scopes: z4.array(z4.string()).default([]),
769
+ createdAt: z4.string(),
770
+ updatedAt: z4.string()
771
+ });
772
+ var gscSearchDataDtoSchema = z4.object({
773
+ date: z4.string(),
774
+ query: z4.string(),
775
+ page: z4.string(),
776
+ country: z4.string().nullable().optional(),
777
+ device: z4.string().nullable().optional(),
778
+ clicks: z4.number(),
779
+ impressions: z4.number(),
780
+ ctr: z4.number(),
781
+ position: z4.number()
782
+ });
783
+ var gscUrlInspectionDtoSchema = z4.object({
640
784
  id: z4.string(),
641
- name: z4.string(),
642
- displayName: z4.string().optional(),
643
- canonicalDomain: z4.string(),
644
- ownedDomains: z4.array(z4.string()).default([]),
645
- country: z4.string().length(2),
646
- language: z4.string().min(2),
647
- tags: z4.array(z4.string()).default([]),
648
- labels: z4.record(z4.string(), z4.string()).default({}),
785
+ url: z4.string(),
786
+ indexingState: z4.string().nullable().optional(),
787
+ verdict: z4.string().nullable().optional(),
788
+ coverageState: z4.string().nullable().optional(),
789
+ pageFetchState: z4.string().nullable().optional(),
790
+ robotsTxtState: z4.string().nullable().optional(),
791
+ crawlTime: z4.string().nullable().optional(),
792
+ lastCrawlResult: z4.string().nullable().optional(),
793
+ isMobileFriendly: z4.boolean().nullable().optional(),
794
+ richResults: z4.array(z4.string()).default([]),
795
+ inspectedAt: z4.string()
796
+ });
797
+ var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
798
+
799
+ // ../contracts/src/project.ts
800
+ import { z as z5 } from "zod";
801
+ var configSourceSchema = z5.enum(["cli", "api", "config-file"]);
802
+ var projectDtoSchema = z5.object({
803
+ id: z5.string(),
804
+ name: z5.string(),
805
+ displayName: z5.string().optional(),
806
+ canonicalDomain: z5.string(),
807
+ ownedDomains: z5.array(z5.string()).default([]),
808
+ country: z5.string().length(2),
809
+ language: z5.string().min(2),
810
+ tags: z5.array(z5.string()).default([]),
811
+ labels: z5.record(z5.string(), z5.string()).default({}),
649
812
  configSource: configSourceSchema.default("cli"),
650
- configRevision: z4.number().int().positive().default(1),
651
- createdAt: z4.string().optional(),
652
- updatedAt: z4.string().optional()
813
+ configRevision: z5.number().int().positive().default(1),
814
+ createdAt: z5.string().optional(),
815
+ updatedAt: z5.string().optional()
653
816
  });
654
817
  function normalizeProjectDomain(input) {
655
818
  let domain = input.trim().toLowerCase();
@@ -677,68 +840,68 @@ function effectiveDomains(project) {
677
840
  }
678
841
 
679
842
  // ../contracts/src/run.ts
680
- import { z as z5 } from "zod";
681
- var runStatusSchema = z5.enum(["queued", "running", "completed", "partial", "failed"]);
682
- var runKindSchema = z5.enum(["answer-visibility", "site-audit"]);
683
- var runTriggerSchema = z5.enum(["manual", "scheduled", "config-apply"]);
684
- var citationStateSchema = z5.enum(["cited", "not-cited"]);
685
- var computedTransitionSchema = z5.enum(["new", "cited", "lost", "emerging", "not-cited"]);
686
- var runDtoSchema = z5.object({
687
- id: z5.string(),
688
- projectId: z5.string(),
843
+ import { z as z6 } from "zod";
844
+ var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
845
+ var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync"]);
846
+ var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
847
+ var citationStateSchema = z6.enum(["cited", "not-cited"]);
848
+ var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
849
+ var runDtoSchema = z6.object({
850
+ id: z6.string(),
851
+ projectId: z6.string(),
689
852
  kind: runKindSchema,
690
853
  status: runStatusSchema,
691
854
  trigger: runTriggerSchema.default("manual"),
692
- startedAt: z5.string().nullable().optional(),
693
- finishedAt: z5.string().nullable().optional(),
694
- error: z5.string().nullable().optional(),
695
- createdAt: z5.string()
855
+ startedAt: z6.string().nullable().optional(),
856
+ finishedAt: z6.string().nullable().optional(),
857
+ error: z6.string().nullable().optional(),
858
+ createdAt: z6.string()
696
859
  });
697
- var groundingSourceSchema = z5.object({
698
- uri: z5.string(),
699
- title: z5.string()
860
+ var groundingSourceSchema = z6.object({
861
+ uri: z6.string(),
862
+ title: z6.string()
700
863
  });
701
- var querySnapshotDtoSchema = z5.object({
702
- id: z5.string(),
703
- runId: z5.string(),
704
- keywordId: z5.string(),
705
- keyword: z5.string().optional(),
864
+ var querySnapshotDtoSchema = z6.object({
865
+ id: z6.string(),
866
+ runId: z6.string(),
867
+ keywordId: z6.string(),
868
+ keyword: z6.string().optional(),
706
869
  provider: providerNameSchema,
707
870
  citationState: citationStateSchema,
708
871
  transition: computedTransitionSchema.optional(),
709
- answerText: z5.string().nullable().optional(),
710
- citedDomains: z5.array(z5.string()).default([]),
711
- competitorOverlap: z5.array(z5.string()).default([]),
712
- groundingSources: z5.array(groundingSourceSchema).default([]),
713
- searchQueries: z5.array(z5.string()).default([]),
714
- model: z5.string().nullable().optional(),
715
- createdAt: z5.string()
872
+ answerText: z6.string().nullable().optional(),
873
+ citedDomains: z6.array(z6.string()).default([]),
874
+ competitorOverlap: z6.array(z6.string()).default([]),
875
+ groundingSources: z6.array(groundingSourceSchema).default([]),
876
+ searchQueries: z6.array(z6.string()).default([]),
877
+ model: z6.string().nullable().optional(),
878
+ createdAt: z6.string()
716
879
  });
717
- var auditLogEntrySchema = z5.object({
718
- id: z5.string(),
719
- projectId: z5.string().nullable().optional(),
720
- actor: z5.string(),
721
- action: z5.string(),
722
- entityType: z5.string(),
723
- entityId: z5.string().nullable().optional(),
724
- diff: z5.unknown().optional(),
725
- createdAt: z5.string()
880
+ var auditLogEntrySchema = z6.object({
881
+ id: z6.string(),
882
+ projectId: z6.string().nullable().optional(),
883
+ actor: z6.string(),
884
+ action: z6.string(),
885
+ entityType: z6.string(),
886
+ entityId: z6.string().nullable().optional(),
887
+ diff: z6.unknown().optional(),
888
+ createdAt: z6.string()
726
889
  });
727
890
 
728
891
  // ../contracts/src/schedule.ts
729
- import { z as z6 } from "zod";
730
- var scheduleDtoSchema = z6.object({
731
- id: z6.string(),
732
- projectId: z6.string(),
733
- cronExpr: z6.string(),
734
- preset: z6.string().nullable().optional(),
735
- timezone: z6.string().default("UTC"),
736
- enabled: z6.boolean().default(true),
737
- providers: z6.array(providerNameSchema).default([]),
738
- lastRunAt: z6.string().nullable().optional(),
739
- nextRunAt: z6.string().nullable().optional(),
740
- createdAt: z6.string(),
741
- updatedAt: z6.string()
892
+ import { z as z7 } from "zod";
893
+ var scheduleDtoSchema = z7.object({
894
+ id: z7.string(),
895
+ projectId: z7.string(),
896
+ cronExpr: z7.string(),
897
+ preset: z7.string().nullable().optional(),
898
+ timezone: z7.string().default("UTC"),
899
+ enabled: z7.boolean().default(true),
900
+ providers: z7.array(providerNameSchema).default([]),
901
+ lastRunAt: z7.string().nullable().optional(),
902
+ nextRunAt: z7.string().nullable().optional(),
903
+ createdAt: z7.string(),
904
+ updatedAt: z7.string()
742
905
  });
743
906
 
744
907
  // ../api-routes/src/auth.ts
@@ -749,6 +912,7 @@ var SKIP_PATHS = ["/health"];
749
912
  function shouldSkipAuth(url) {
750
913
  if (SKIP_PATHS.includes(url)) return true;
751
914
  if (url.endsWith("/openapi.json")) return true;
915
+ if (url.includes("/google/callback")) return true;
752
916
  return false;
753
917
  }
754
918
  async function authPlugin(app) {
@@ -1384,7 +1548,7 @@ function resolveProjectSafe3(app, name, reply) {
1384
1548
 
1385
1549
  // ../api-routes/src/apply.ts
1386
1550
  import crypto9 from "crypto";
1387
- import { eq as eq8 } from "drizzle-orm";
1551
+ import { eq as eq8, and as and3 } from "drizzle-orm";
1388
1552
 
1389
1553
  // ../api-routes/src/schedule-utils.ts
1390
1554
  var DAY_MAP = {
@@ -1821,6 +1985,13 @@ async function applyRoutes(app, opts) {
1821
1985
  diff: { notifications: config.spec.notifications }
1822
1986
  });
1823
1987
  }
1988
+ if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
1989
+ const domain = config.spec.canonicalDomain;
1990
+ const conn = app.db.select().from(googleConnections).where(and3(eq8(googleConnections.domain, domain), eq8(googleConnections.connectionType, "gsc"))).get();
1991
+ if (conn) {
1992
+ app.db.update(googleConnections).set({ propertyId: config.spec.google.gsc.propertyUrl, updatedAt: now }).where(eq8(googleConnections.id, conn.id)).run();
1993
+ }
1994
+ }
1824
1995
  const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
1825
1996
  return reply.status(200).send({
1826
1997
  id: project.id,
@@ -3075,6 +3246,501 @@ function resolveProjectSafe6(app, name, reply) {
3075
3246
  }
3076
3247
  }
3077
3248
 
3249
+ // ../api-routes/src/google.ts
3250
+ import crypto12 from "crypto";
3251
+ import { eq as eq12, and as and4, desc as desc2, sql as sql2 } from "drizzle-orm";
3252
+
3253
+ // ../integration-google/src/constants.ts
3254
+ var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
3255
+ var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
3256
+ var GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
3257
+ var GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
3258
+ var URL_INSPECTION_API = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect";
3259
+ var GSC_MAX_ROWS_PER_REQUEST = 25e3;
3260
+ var GSC_DATA_LAG_DAYS = 3;
3261
+
3262
+ // ../integration-google/src/types.ts
3263
+ var GoogleAuthError = class extends Error {
3264
+ constructor(message) {
3265
+ super(message);
3266
+ this.name = "GoogleAuthError";
3267
+ }
3268
+ };
3269
+ var GoogleApiError = class extends Error {
3270
+ status;
3271
+ constructor(message, status) {
3272
+ super(message);
3273
+ this.name = "GoogleApiError";
3274
+ this.status = status;
3275
+ }
3276
+ };
3277
+
3278
+ // ../integration-google/src/oauth.ts
3279
+ function getAuthUrl(clientId, redirectUri, scopes, state) {
3280
+ const params = new URLSearchParams({
3281
+ client_id: clientId,
3282
+ redirect_uri: redirectUri,
3283
+ response_type: "code",
3284
+ scope: scopes.join(" "),
3285
+ access_type: "offline",
3286
+ prompt: "consent"
3287
+ });
3288
+ if (state) params.set("state", state);
3289
+ return `${GOOGLE_AUTH_URL}?${params.toString()}`;
3290
+ }
3291
+ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
3292
+ const res = await fetch(GOOGLE_TOKEN_URL, {
3293
+ method: "POST",
3294
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3295
+ body: new URLSearchParams({
3296
+ client_id: clientId,
3297
+ client_secret: clientSecret,
3298
+ code,
3299
+ redirect_uri: redirectUri,
3300
+ grant_type: "authorization_code"
3301
+ })
3302
+ });
3303
+ if (!res.ok) {
3304
+ const body = await res.text();
3305
+ throw new GoogleAuthError(`Token exchange failed (${res.status}): ${body}`);
3306
+ }
3307
+ return await res.json();
3308
+ }
3309
+ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
3310
+ const res = await fetch(GOOGLE_TOKEN_URL, {
3311
+ method: "POST",
3312
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3313
+ body: new URLSearchParams({
3314
+ client_id: clientId,
3315
+ client_secret: clientSecret,
3316
+ refresh_token: currentRefreshToken,
3317
+ grant_type: "refresh_token"
3318
+ })
3319
+ });
3320
+ if (!res.ok) {
3321
+ const body = await res.text();
3322
+ throw new GoogleAuthError(`Token refresh failed (${res.status}): ${body}`);
3323
+ }
3324
+ return await res.json();
3325
+ }
3326
+
3327
+ // ../integration-google/src/gsc-client.ts
3328
+ async function gscFetch(accessToken, url, opts) {
3329
+ const method = opts?.method ?? "GET";
3330
+ const headers = {
3331
+ Authorization: `Bearer ${accessToken}`,
3332
+ "Content-Type": "application/json"
3333
+ };
3334
+ const res = await fetch(url, {
3335
+ method,
3336
+ headers,
3337
+ body: opts?.body != null ? JSON.stringify(opts.body) : void 0
3338
+ });
3339
+ if (res.status === 401) {
3340
+ throw new GoogleApiError("Access token expired or revoked", 401);
3341
+ }
3342
+ if (res.status === 429) {
3343
+ throw new GoogleApiError("Google API rate limit exceeded", 429);
3344
+ }
3345
+ if (!res.ok) {
3346
+ const body = await res.text();
3347
+ throw new GoogleApiError(`GSC API error (${res.status}): ${body}`, res.status);
3348
+ }
3349
+ return await res.json();
3350
+ }
3351
+ async function listSites(accessToken) {
3352
+ const data = await gscFetch(
3353
+ accessToken,
3354
+ `${GSC_API_BASE}/sites`
3355
+ );
3356
+ return data.siteEntry ?? [];
3357
+ }
3358
+ async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
3359
+ const allRows = [];
3360
+ let startRow = 0;
3361
+ const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
3362
+ for (; ; ) {
3363
+ const requestBody = {
3364
+ startDate: opts.startDate,
3365
+ endDate: opts.endDate,
3366
+ dimensions,
3367
+ rowLimit: GSC_MAX_ROWS_PER_REQUEST,
3368
+ startRow
3369
+ };
3370
+ if (opts.query || opts.page) {
3371
+ const filters = [];
3372
+ const filterList = [];
3373
+ if (opts.query) filterList.push({ dimension: "query", operator: "contains", expression: opts.query });
3374
+ if (opts.page) filterList.push({ dimension: "page", operator: "contains", expression: opts.page });
3375
+ filters.push({ filters: filterList });
3376
+ requestBody.dimensionFilterGroups = filters;
3377
+ }
3378
+ const encodedSiteUrl = encodeURIComponent(siteUrl);
3379
+ const data = await gscFetch(
3380
+ accessToken,
3381
+ `${GSC_API_BASE}/sites/${encodedSiteUrl}/searchAnalytics/query`,
3382
+ { method: "POST", body: requestBody }
3383
+ );
3384
+ const rows = data.rows ?? [];
3385
+ allRows.push(...rows);
3386
+ if (rows.length < GSC_MAX_ROWS_PER_REQUEST) {
3387
+ break;
3388
+ }
3389
+ startRow += rows.length;
3390
+ }
3391
+ return allRows;
3392
+ }
3393
+ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
3394
+ return gscFetch(
3395
+ accessToken,
3396
+ URL_INSPECTION_API,
3397
+ {
3398
+ method: "POST",
3399
+ body: {
3400
+ inspectionUrl,
3401
+ siteUrl
3402
+ }
3403
+ }
3404
+ );
3405
+ }
3406
+
3407
+ // ../api-routes/src/google.ts
3408
+ function signState(payload, secret) {
3409
+ return crypto12.createHmac("sha256", secret).update(payload).digest("hex");
3410
+ }
3411
+ function buildSignedState(data, secret) {
3412
+ const payload = JSON.stringify(data);
3413
+ const sig = signState(payload, secret);
3414
+ return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
3415
+ }
3416
+ function verifySignedState(encoded, secret) {
3417
+ try {
3418
+ const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
3419
+ const expected = signState(payload, secret);
3420
+ if (!crypto12.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
3421
+ return JSON.parse(payload);
3422
+ } catch {
3423
+ return null;
3424
+ }
3425
+ }
3426
+ async function getValidToken(app, domain, connectionType, clientId, clientSecret) {
3427
+ const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, connectionType))).get();
3428
+ if (!conn) {
3429
+ throw notFound("Google connection", connectionType);
3430
+ }
3431
+ if (!conn.accessToken || !conn.refreshToken) {
3432
+ throw validationError("Google connection is incomplete \u2014 please reconnect");
3433
+ }
3434
+ const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
3435
+ const fiveMinutes = 5 * 60 * 1e3;
3436
+ if (Date.now() > expiresAt - fiveMinutes) {
3437
+ const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
3438
+ const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3439
+ app.db.update(googleConnections).set({
3440
+ accessToken: tokens.access_token,
3441
+ tokenExpiresAt: newExpiresAt,
3442
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3443
+ }).where(eq12(googleConnections.id, conn.id)).run();
3444
+ return { accessToken: tokens.access_token, connectionId: conn.id, propertyId: conn.propertyId };
3445
+ }
3446
+ return { accessToken: conn.accessToken, connectionId: conn.id, propertyId: conn.propertyId };
3447
+ }
3448
+ async function googleRoutes(app, opts) {
3449
+ const { googleClientId, googleClientSecret } = opts;
3450
+ const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
3451
+ app.get("/projects/:name/google/connections", async (request) => {
3452
+ const project = resolveProject(app.db, request.params.name);
3453
+ const conns = app.db.select({
3454
+ id: googleConnections.id,
3455
+ domain: googleConnections.domain,
3456
+ connectionType: googleConnections.connectionType,
3457
+ propertyId: googleConnections.propertyId,
3458
+ scopes: googleConnections.scopes,
3459
+ createdAt: googleConnections.createdAt,
3460
+ updatedAt: googleConnections.updatedAt
3461
+ }).from(googleConnections).where(eq12(googleConnections.domain, project.canonicalDomain)).all();
3462
+ return conns.map((c) => ({
3463
+ ...c,
3464
+ scopes: JSON.parse(c.scopes)
3465
+ }));
3466
+ });
3467
+ app.post("/projects/:name/google/connect", async (request, reply) => {
3468
+ if (!googleClientId || !googleClientSecret) {
3469
+ const err = validationError("Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.");
3470
+ return reply.status(err.statusCode).send(err.toJSON());
3471
+ }
3472
+ const { type, propertyId } = request.body ?? {};
3473
+ if (!type || type !== "gsc" && type !== "ga4") {
3474
+ const err = validationError('type must be "gsc" or "ga4"');
3475
+ return reply.status(err.statusCode).send(err.toJSON());
3476
+ }
3477
+ const project = resolveProject(app.db, request.params.name);
3478
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
3479
+ const host = request.headers.host ?? "localhost:4100";
3480
+ const redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
3481
+ const scopes = type === "gsc" ? [GSC_SCOPE] : [];
3482
+ const stateEncoded = buildSignedState(
3483
+ { domain: project.canonicalDomain, type, propertyId, redirectUri },
3484
+ stateSecret
3485
+ );
3486
+ const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
3487
+ return { authUrl };
3488
+ });
3489
+ app.get("/projects/:name/google/callback", async (request, reply) => {
3490
+ if (!googleClientId || !googleClientSecret) {
3491
+ return reply.status(500).send("Google OAuth not configured");
3492
+ }
3493
+ const { code, state, error } = request.query;
3494
+ if (error) {
3495
+ const safeError = String(error).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3496
+ return reply.type("text/html").send(`<html><body><h2>Authorization failed</h2><p>${safeError}</p><p>You can close this tab.</p></body></html>`);
3497
+ }
3498
+ if (!code || !state) {
3499
+ return reply.status(400).send("Missing code or state parameter");
3500
+ }
3501
+ const stateData = verifySignedState(state, stateSecret);
3502
+ if (!stateData) {
3503
+ return reply.status(400).send("Invalid or tampered state parameter");
3504
+ }
3505
+ const { domain, type, propertyId, redirectUri } = stateData;
3506
+ const tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
3507
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3508
+ const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3509
+ const existing = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, type))).get();
3510
+ if (existing) {
3511
+ app.db.update(googleConnections).set({
3512
+ accessToken: tokens.access_token,
3513
+ refreshToken: tokens.refresh_token ?? existing.refreshToken,
3514
+ tokenExpiresAt: expiresAt,
3515
+ propertyId: propertyId ?? existing.propertyId,
3516
+ scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
3517
+ updatedAt: now
3518
+ }).where(eq12(googleConnections.id, existing.id)).run();
3519
+ } else {
3520
+ app.db.insert(googleConnections).values({
3521
+ id: crypto12.randomUUID(),
3522
+ domain,
3523
+ connectionType: type,
3524
+ propertyId: propertyId ?? null,
3525
+ accessToken: tokens.access_token,
3526
+ refreshToken: tokens.refresh_token ?? null,
3527
+ tokenExpiresAt: expiresAt,
3528
+ scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
3529
+ createdAt: now,
3530
+ updatedAt: now
3531
+ }).run();
3532
+ }
3533
+ writeAuditLog(app.db, {
3534
+ projectId: null,
3535
+ actor: "oauth",
3536
+ action: "google.connected",
3537
+ entityType: "google_connection",
3538
+ entityId: type,
3539
+ diff: { domain, type, propertyId }
3540
+ });
3541
+ return reply.type("text/html").send(
3542
+ `<html><body style="font-family:system-ui;text-align:center;padding:60px">
3543
+ <h2>Connected successfully!</h2>
3544
+ <p>Google ${type.toUpperCase()} has been linked to your domain.</p>
3545
+ <p style="color:#888">You can close this tab.</p>
3546
+ </body></html>`
3547
+ );
3548
+ });
3549
+ app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
3550
+ const project = resolveProject(app.db, request.params.name);
3551
+ const deleted = app.db.delete(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).run();
3552
+ if (deleted.changes === 0) {
3553
+ const err = notFound("Google connection", request.params.type);
3554
+ return reply.status(err.statusCode).send(err.toJSON());
3555
+ }
3556
+ writeAuditLog(app.db, {
3557
+ projectId: project.id,
3558
+ actor: "api",
3559
+ action: "google.disconnected",
3560
+ entityType: "google_connection",
3561
+ entityId: request.params.type
3562
+ });
3563
+ return reply.status(204).send();
3564
+ });
3565
+ app.get("/projects/:name/google/properties", async (request, reply) => {
3566
+ if (!googleClientId || !googleClientSecret) {
3567
+ const err = validationError("Google OAuth is not configured");
3568
+ return reply.status(err.statusCode).send(err.toJSON());
3569
+ }
3570
+ const project = resolveProject(app.db, request.params.name);
3571
+ const { accessToken } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3572
+ const sites = await listSites(accessToken);
3573
+ return { sites };
3574
+ });
3575
+ app.post("/projects/:name/google/gsc/sync", async (request, reply) => {
3576
+ const project = resolveProject(app.db, request.params.name);
3577
+ const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, "gsc"))).get();
3578
+ if (!conn) {
3579
+ const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
3580
+ return reply.status(err.statusCode).send(err.toJSON());
3581
+ }
3582
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3583
+ const runId = crypto12.randomUUID();
3584
+ app.db.insert(runs).values({
3585
+ id: runId,
3586
+ projectId: project.id,
3587
+ kind: "gsc-sync",
3588
+ status: "queued",
3589
+ trigger: "manual",
3590
+ createdAt: now
3591
+ }).run();
3592
+ const { days, full } = request.body ?? {};
3593
+ if (opts.onGscSyncRequested) {
3594
+ opts.onGscSyncRequested(runId, project.id, { days, full });
3595
+ }
3596
+ const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
3597
+ return run;
3598
+ });
3599
+ app.get("/projects/:name/google/gsc/performance", async (request) => {
3600
+ const project = resolveProject(app.db, request.params.name);
3601
+ const { startDate, endDate, query, page, limit } = request.query;
3602
+ const conditions = [eq12(gscSearchData.projectId, project.id)];
3603
+ if (startDate) conditions.push(sql2`${gscSearchData.date} >= ${startDate}`);
3604
+ if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
3605
+ if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
3606
+ if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
3607
+ const rows = app.db.select().from(gscSearchData).where(and4(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
3608
+ return rows.map((r) => ({
3609
+ date: r.date,
3610
+ query: r.query,
3611
+ page: r.page,
3612
+ country: r.country,
3613
+ device: r.device,
3614
+ clicks: r.clicks,
3615
+ impressions: r.impressions,
3616
+ ctr: parseFloat(r.ctr),
3617
+ position: parseFloat(r.position)
3618
+ }));
3619
+ });
3620
+ app.post("/projects/:name/google/gsc/inspect", async (request, reply) => {
3621
+ if (!googleClientId || !googleClientSecret) {
3622
+ const err = validationError("Google OAuth is not configured");
3623
+ return reply.status(err.statusCode).send(err.toJSON());
3624
+ }
3625
+ const project = resolveProject(app.db, request.params.name);
3626
+ const { url } = request.body ?? {};
3627
+ if (!url) {
3628
+ const err = validationError("url is required");
3629
+ return reply.status(err.statusCode).send(err.toJSON());
3630
+ }
3631
+ const { accessToken, propertyId } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3632
+ if (!propertyId) {
3633
+ const err = validationError("No GSC property configured for this connection");
3634
+ return reply.status(err.statusCode).send(err.toJSON());
3635
+ }
3636
+ const result = await inspectUrl(accessToken, url, propertyId);
3637
+ const ir = result.inspectionResult;
3638
+ const idx = ir.indexStatusResult;
3639
+ const mob = ir.mobileUsabilityResult;
3640
+ const rich = ir.richResultsResult;
3641
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3642
+ const id = crypto12.randomUUID();
3643
+ app.db.insert(gscUrlInspections).values({
3644
+ id,
3645
+ projectId: project.id,
3646
+ syncRunId: null,
3647
+ url,
3648
+ indexingState: idx?.indexingState ?? null,
3649
+ verdict: idx?.verdict ?? null,
3650
+ coverageState: idx?.coverageState ?? null,
3651
+ pageFetchState: idx?.pageFetchState ?? null,
3652
+ robotsTxtState: idx?.robotsTxtState ?? null,
3653
+ crawlTime: idx?.lastCrawlTime ?? null,
3654
+ lastCrawlResult: idx?.crawlResult ?? null,
3655
+ isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
3656
+ richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
3657
+ referringUrls: JSON.stringify(idx?.referringUrls ?? []),
3658
+ inspectedAt: now,
3659
+ createdAt: now
3660
+ }).run();
3661
+ return {
3662
+ id,
3663
+ url,
3664
+ indexingState: idx?.indexingState,
3665
+ verdict: idx?.verdict,
3666
+ coverageState: idx?.coverageState,
3667
+ pageFetchState: idx?.pageFetchState,
3668
+ robotsTxtState: idx?.robotsTxtState,
3669
+ crawlTime: idx?.lastCrawlTime,
3670
+ lastCrawlResult: idx?.crawlResult,
3671
+ isMobileFriendly: mob?.verdict === "PASS",
3672
+ richResults: rich?.detectedItems?.map((d) => d.richResultType) ?? [],
3673
+ referringUrls: idx?.referringUrls ?? [],
3674
+ inspectedAt: now
3675
+ };
3676
+ });
3677
+ app.get("/projects/:name/google/gsc/inspections", async (request) => {
3678
+ const project = resolveProject(app.db, request.params.name);
3679
+ const { url, limit } = request.query;
3680
+ const conditions = [eq12(gscUrlInspections.projectId, project.id)];
3681
+ if (url) conditions.push(eq12(gscUrlInspections.url, url));
3682
+ const rows = app.db.select().from(gscUrlInspections).where(and4(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
3683
+ return rows.map((r) => ({
3684
+ id: r.id,
3685
+ url: r.url,
3686
+ indexingState: r.indexingState,
3687
+ verdict: r.verdict,
3688
+ coverageState: r.coverageState,
3689
+ pageFetchState: r.pageFetchState,
3690
+ robotsTxtState: r.robotsTxtState,
3691
+ crawlTime: r.crawlTime,
3692
+ lastCrawlResult: r.lastCrawlResult,
3693
+ isMobileFriendly: r.isMobileFriendly === 1 ? true : r.isMobileFriendly === 0 ? false : null,
3694
+ richResults: JSON.parse(r.richResults),
3695
+ referringUrls: JSON.parse(r.referringUrls),
3696
+ inspectedAt: r.inspectedAt
3697
+ }));
3698
+ });
3699
+ app.get("/projects/:name/google/gsc/deindexed", async (request) => {
3700
+ const project = resolveProject(app.db, request.params.name);
3701
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq12(gscUrlInspections.projectId, project.id)).orderBy(desc2(gscUrlInspections.inspectedAt)).all();
3702
+ const byUrl = /* @__PURE__ */ new Map();
3703
+ for (const row of allInspections) {
3704
+ const existing = byUrl.get(row.url);
3705
+ if (existing) {
3706
+ existing.push(row);
3707
+ } else {
3708
+ byUrl.set(row.url, [row]);
3709
+ }
3710
+ }
3711
+ const deindexed = [];
3712
+ for (const [url, inspections] of byUrl) {
3713
+ if (inspections.length < 2) continue;
3714
+ const latest = inspections[0];
3715
+ const previous = inspections[1];
3716
+ if (previous.indexingState?.toUpperCase() === "INDEXED" && latest.indexingState?.toUpperCase() !== "INDEXED") {
3717
+ deindexed.push({
3718
+ url,
3719
+ previousState: previous.indexingState,
3720
+ currentState: latest.indexingState,
3721
+ transitionDate: latest.inspectedAt
3722
+ });
3723
+ }
3724
+ }
3725
+ return deindexed;
3726
+ });
3727
+ app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
3728
+ const project = resolveProject(app.db, request.params.name);
3729
+ const { propertyId } = request.body ?? {};
3730
+ if (!propertyId) {
3731
+ const err = validationError("propertyId is required");
3732
+ return reply.status(err.statusCode).send(err.toJSON());
3733
+ }
3734
+ const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).get();
3735
+ if (!conn) {
3736
+ const err = notFound("Google connection", request.params.type);
3737
+ return reply.status(err.statusCode).send(err.toJSON());
3738
+ }
3739
+ app.db.update(googleConnections).set({ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(googleConnections.id, conn.id)).run();
3740
+ return { propertyId };
3741
+ });
3742
+ }
3743
+
3078
3744
  // ../api-routes/src/index.ts
3079
3745
  async function apiRoutes(app, opts) {
3080
3746
  app.decorate("db", opts.db);
@@ -3107,6 +3773,12 @@ async function apiRoutes(app, opts) {
3107
3773
  getTelemetryStatus: opts.getTelemetryStatus,
3108
3774
  setTelemetryEnabled: opts.setTelemetryEnabled
3109
3775
  });
3776
+ await api.register(googleRoutes, {
3777
+ googleClientId: opts.googleClientId,
3778
+ googleClientSecret: opts.googleClientSecret,
3779
+ googleStateSecret: opts.googleStateSecret,
3780
+ onGscSyncRequested: opts.onGscSyncRequested
3781
+ });
3110
3782
  }, { prefix: "/api/v1" });
3111
3783
  }
3112
3784
 
@@ -4027,8 +4699,8 @@ var localAdapter = {
4027
4699
  };
4028
4700
 
4029
4701
  // src/job-runner.ts
4030
- import crypto12 from "crypto";
4031
- import { eq as eq12, inArray as inArray2 } from "drizzle-orm";
4702
+ import crypto13 from "crypto";
4703
+ import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
4032
4704
  var JobRunner = class {
4033
4705
  db;
4034
4706
  registry;
@@ -4042,7 +4714,7 @@ var JobRunner = class {
4042
4714
  if (stale.length === 0) return;
4043
4715
  const now = (/* @__PURE__ */ new Date()).toISOString();
4044
4716
  for (const run of stale) {
4045
- this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq12(runs.id, run.id)).run();
4717
+ this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq13(runs.id, run.id)).run();
4046
4718
  console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
4047
4719
  }
4048
4720
  }
@@ -4050,8 +4722,8 @@ var JobRunner = class {
4050
4722
  const now = (/* @__PURE__ */ new Date()).toISOString();
4051
4723
  const startTime = Date.now();
4052
4724
  try {
4053
- this.db.update(runs).set({ status: "running", startedAt: now }).where(eq12(runs.id, runId)).run();
4054
- const project = this.db.select().from(projects).where(eq12(projects.id, projectId)).get();
4725
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(eq13(runs.id, runId)).run();
4726
+ const project = this.db.select().from(projects).where(eq13(projects.id, projectId)).get();
4055
4727
  if (!project) {
4056
4728
  throw new Error(`Project ${projectId} not found`);
4057
4729
  }
@@ -4061,14 +4733,14 @@ var JobRunner = class {
4061
4733
  throw new Error("No providers configured. Add at least one provider API key.");
4062
4734
  }
4063
4735
  console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
4064
- const projectKeywords = this.db.select().from(keywords).where(eq12(keywords.projectId, projectId)).all();
4065
- const projectCompetitors = this.db.select().from(competitors).where(eq12(competitors.projectId, projectId)).all();
4736
+ const projectKeywords = this.db.select().from(keywords).where(eq13(keywords.projectId, projectId)).all();
4737
+ const projectCompetitors = this.db.select().from(competitors).where(eq13(competitors.projectId, projectId)).all();
4066
4738
  const competitorDomains = projectCompetitors.map((c) => c.domain);
4067
4739
  const queriesPerProvider = projectKeywords.length;
4068
4740
  const todayPeriod = getCurrentPeriod();
4069
4741
  for (const p of activeProviders) {
4070
4742
  const providerScope = `${projectId}:${p.adapter.name}`;
4071
- const providerUsage = this.db.select().from(usageCounters).where(eq12(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
4743
+ const providerUsage = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
4072
4744
  const limit = p.config.quotaPolicy.maxRequestsPerDay;
4073
4745
  if (providerUsage + queriesPerProvider > limit) {
4074
4746
  throw new Error(
@@ -4108,7 +4780,7 @@ var JobRunner = class {
4108
4780
  const citationState = determineCitationState(normalized, allDomains);
4109
4781
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
4110
4782
  this.db.insert(querySnapshots).values({
4111
- id: crypto12.randomUUID(),
4783
+ id: crypto13.randomUUID(),
4112
4784
  runId,
4113
4785
  keywordId: kw.id,
4114
4786
  provider: providerName,
@@ -4139,12 +4811,12 @@ var JobRunner = class {
4139
4811
  const someFailed = providerErrors.size > 0;
4140
4812
  if (allFailed) {
4141
4813
  const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
4142
- this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq12(runs.id, runId)).run();
4814
+ this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq13(runs.id, runId)).run();
4143
4815
  } else if (someFailed) {
4144
4816
  const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
4145
- this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq12(runs.id, runId)).run();
4817
+ this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq13(runs.id, runId)).run();
4146
4818
  } else {
4147
- this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(runs.id, runId)).run();
4819
+ this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq13(runs.id, runId)).run();
4148
4820
  }
4149
4821
  const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
4150
4822
  trackEvent("run.completed", {
@@ -4169,7 +4841,7 @@ var JobRunner = class {
4169
4841
  status: "failed",
4170
4842
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
4171
4843
  error: errorMessage
4172
- }).where(eq12(runs.id, runId)).run();
4844
+ }).where(eq13(runs.id, runId)).run();
4173
4845
  trackEvent("run.completed", {
4174
4846
  status: "failed",
4175
4847
  providerCount: 0,
@@ -4205,10 +4877,10 @@ var JobRunner = class {
4205
4877
  incrementUsage(scope, metric, count) {
4206
4878
  const now = /* @__PURE__ */ new Date();
4207
4879
  const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
4208
- const id = crypto12.randomUUID();
4209
- const existing = this.db.select().from(usageCounters).where(eq12(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
4880
+ const id = crypto13.randomUUID();
4881
+ const existing = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
4210
4882
  if (existing) {
4211
- this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq12(usageCounters.id, existing.id)).run();
4883
+ this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq13(usageCounters.id, existing.id)).run();
4212
4884
  } else {
4213
4885
  this.db.insert(usageCounters).values({
4214
4886
  id,
@@ -4287,6 +4959,132 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
4287
4959
  return [...overlapSet];
4288
4960
  }
4289
4961
 
4962
+ // src/gsc-sync.ts
4963
+ import crypto14 from "crypto";
4964
+ import { eq as eq14, and as and5, sql as sql3 } from "drizzle-orm";
4965
+ function formatDate(d) {
4966
+ return d.toISOString().split("T")[0];
4967
+ }
4968
+ function daysAgo(n) {
4969
+ const d = /* @__PURE__ */ new Date();
4970
+ d.setDate(d.getDate() - n);
4971
+ return d;
4972
+ }
4973
+ async function executeGscSync(db, runId, projectId, opts) {
4974
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4975
+ db.update(runs).set({ status: "running", startedAt: now }).where(eq14(runs.id, runId)).run();
4976
+ try {
4977
+ const project = db.select().from(projects).where(eq14(projects.id, projectId)).get();
4978
+ if (!project) {
4979
+ throw new Error(`Project not found: ${projectId}`);
4980
+ }
4981
+ const conn = db.select().from(googleConnections).where(and5(eq14(googleConnections.domain, project.canonicalDomain), eq14(googleConnections.connectionType, "gsc"))).get();
4982
+ if (!conn || !conn.refreshToken) {
4983
+ throw new Error("No GSC connection found or connection is incomplete");
4984
+ }
4985
+ if (!conn.propertyId) {
4986
+ throw new Error('No GSC property selected. Use "canonry google properties" to list available sites, then set one with the API.');
4987
+ }
4988
+ let accessToken = conn.accessToken;
4989
+ const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
4990
+ if (Date.now() > expiresAt - 5 * 60 * 1e3) {
4991
+ const tokens = await refreshAccessToken(opts.googleClientId, opts.googleClientSecret, conn.refreshToken);
4992
+ accessToken = tokens.access_token;
4993
+ db.update(googleConnections).set({
4994
+ accessToken: tokens.access_token,
4995
+ tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
4996
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4997
+ }).where(eq14(googleConnections.id, conn.id)).run();
4998
+ }
4999
+ const lagOffset = GSC_DATA_LAG_DAYS;
5000
+ const endDate = formatDate(daysAgo(lagOffset));
5001
+ const days = opts.full ? 480 : opts.days ?? 30;
5002
+ const startDate = formatDate(daysAgo(days + lagOffset));
5003
+ console.log(`[GSC Sync] Fetching search analytics for ${conn.propertyId} from ${startDate} to ${endDate}`);
5004
+ const rows = await fetchSearchAnalytics(accessToken, conn.propertyId, {
5005
+ startDate,
5006
+ endDate
5007
+ });
5008
+ console.log(`[GSC Sync] Received ${rows.length} rows`);
5009
+ db.delete(gscSearchData).where(
5010
+ and5(
5011
+ eq14(gscSearchData.projectId, projectId),
5012
+ sql3`${gscSearchData.date} >= ${startDate}`,
5013
+ sql3`${gscSearchData.date} <= ${endDate}`
5014
+ )
5015
+ ).run();
5016
+ const batchSize = 500;
5017
+ for (let i = 0; i < rows.length; i += batchSize) {
5018
+ const batch = rows.slice(i, i + batchSize);
5019
+ const insertNow = (/* @__PURE__ */ new Date()).toISOString();
5020
+ for (const row of batch) {
5021
+ const [query, page, country, device, date] = row.keys;
5022
+ db.insert(gscSearchData).values({
5023
+ id: crypto14.randomUUID(),
5024
+ projectId,
5025
+ syncRunId: runId,
5026
+ date: date ?? "",
5027
+ query: query ?? "",
5028
+ page: page ?? "",
5029
+ country: country ?? null,
5030
+ device: device ?? null,
5031
+ clicks: row.clicks,
5032
+ impressions: row.impressions,
5033
+ ctr: String(row.ctr),
5034
+ position: String(row.position),
5035
+ createdAt: insertNow
5036
+ }).run();
5037
+ }
5038
+ }
5039
+ const pageClicks = /* @__PURE__ */ new Map();
5040
+ for (const row of rows) {
5041
+ const page = row.keys[1];
5042
+ if (page) {
5043
+ pageClicks.set(page, (pageClicks.get(page) ?? 0) + row.clicks);
5044
+ }
5045
+ }
5046
+ const topPages = [...pageClicks.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([page]) => page);
5047
+ console.log(`[GSC Sync] Inspecting ${topPages.length} URLs`);
5048
+ for (const pageUrl of topPages) {
5049
+ try {
5050
+ const result = await inspectUrl(accessToken, pageUrl, conn.propertyId);
5051
+ const ir = result.inspectionResult;
5052
+ const idx = ir.indexStatusResult;
5053
+ const mob = ir.mobileUsabilityResult;
5054
+ const rich = ir.richResultsResult;
5055
+ const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
5056
+ db.insert(gscUrlInspections).values({
5057
+ id: crypto14.randomUUID(),
5058
+ projectId,
5059
+ syncRunId: runId,
5060
+ url: pageUrl,
5061
+ indexingState: idx?.indexingState ?? null,
5062
+ verdict: idx?.verdict ?? null,
5063
+ coverageState: idx?.coverageState ?? null,
5064
+ pageFetchState: idx?.pageFetchState ?? null,
5065
+ robotsTxtState: idx?.robotsTxtState ?? null,
5066
+ crawlTime: idx?.lastCrawlTime ?? null,
5067
+ lastCrawlResult: idx?.crawlResult ?? null,
5068
+ isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
5069
+ richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
5070
+ referringUrls: JSON.stringify(idx?.referringUrls ?? []),
5071
+ inspectedAt,
5072
+ createdAt: inspectedAt
5073
+ }).run();
5074
+ } catch (err) {
5075
+ console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
5076
+ }
5077
+ }
5078
+ db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
5079
+ console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections.`);
5080
+ } catch (err) {
5081
+ const errorMsg = err instanceof Error ? err.message : String(err);
5082
+ db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
5083
+ console.error(`[GSC Sync] Failed:`, errorMsg);
5084
+ throw err;
5085
+ }
5086
+ }
5087
+
4290
5088
  // src/provider-registry.ts
4291
5089
  var ProviderRegistry = class {
4292
5090
  providers = /* @__PURE__ */ new Map();
@@ -4332,7 +5130,7 @@ var ProviderRegistry = class {
4332
5130
 
4333
5131
  // src/scheduler.ts
4334
5132
  import cron from "node-cron";
4335
- import { eq as eq13 } from "drizzle-orm";
5133
+ import { eq as eq15 } from "drizzle-orm";
4336
5134
  var Scheduler = class {
4337
5135
  db;
4338
5136
  callbacks;
@@ -4343,7 +5141,7 @@ var Scheduler = class {
4343
5141
  }
4344
5142
  /** Load all enabled schedules from DB and register cron jobs. */
4345
5143
  start() {
4346
- const allSchedules = this.db.select().from(schedules).where(eq13(schedules.enabled, 1)).all();
5144
+ const allSchedules = this.db.select().from(schedules).where(eq15(schedules.enabled, 1)).all();
4347
5145
  for (const schedule of allSchedules) {
4348
5146
  const missedRunAt = schedule.nextRunAt;
4349
5147
  this.registerCronTask(schedule);
@@ -4369,7 +5167,7 @@ var Scheduler = class {
4369
5167
  existing.stop();
4370
5168
  this.tasks.delete(projectId);
4371
5169
  }
4372
- const schedule = this.db.select().from(schedules).where(eq13(schedules.projectId, projectId)).get();
5170
+ const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
4373
5171
  if (schedule && schedule.enabled === 1) {
4374
5172
  this.registerCronTask(schedule);
4375
5173
  }
@@ -4398,13 +5196,13 @@ var Scheduler = class {
4398
5196
  this.db.update(schedules).set({
4399
5197
  nextRunAt: task.getNextRun()?.toISOString() ?? null,
4400
5198
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4401
- }).where(eq13(schedules.id, scheduleId)).run();
5199
+ }).where(eq15(schedules.id, scheduleId)).run();
4402
5200
  const label = schedule.preset ?? cronExpr;
4403
5201
  console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
4404
5202
  }
4405
5203
  triggerRun(scheduleId, projectId) {
4406
5204
  const now = (/* @__PURE__ */ new Date()).toISOString();
4407
- const currentSchedule = this.db.select().from(schedules).where(eq13(schedules.id, scheduleId)).get();
5205
+ const currentSchedule = this.db.select().from(schedules).where(eq15(schedules.id, scheduleId)).get();
4408
5206
  if (!currentSchedule || currentSchedule.enabled !== 1) {
4409
5207
  console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
4410
5208
  this.remove(projectId);
@@ -4412,7 +5210,7 @@ var Scheduler = class {
4412
5210
  }
4413
5211
  const task = this.tasks.get(projectId);
4414
5212
  const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
4415
- const project = this.db.select().from(projects).where(eq13(projects.id, projectId)).get();
5213
+ const project = this.db.select().from(projects).where(eq15(projects.id, projectId)).get();
4416
5214
  if (!project) {
4417
5215
  console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
4418
5216
  this.remove(projectId);
@@ -4429,7 +5227,7 @@ var Scheduler = class {
4429
5227
  this.db.update(schedules).set({
4430
5228
  nextRunAt,
4431
5229
  updatedAt: now
4432
- }).where(eq13(schedules.id, currentSchedule.id)).run();
5230
+ }).where(eq15(schedules.id, currentSchedule.id)).run();
4433
5231
  return;
4434
5232
  }
4435
5233
  const runId = queueResult.runId;
@@ -4437,7 +5235,7 @@ var Scheduler = class {
4437
5235
  lastRunAt: now,
4438
5236
  nextRunAt,
4439
5237
  updatedAt: now
4440
- }).where(eq13(schedules.id, currentSchedule.id)).run();
5238
+ }).where(eq15(schedules.id, currentSchedule.id)).run();
4441
5239
  const scheduleProviders = JSON.parse(currentSchedule.providers);
4442
5240
  const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
4443
5241
  console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
@@ -4446,8 +5244,8 @@ var Scheduler = class {
4446
5244
  };
4447
5245
 
4448
5246
  // src/notifier.ts
4449
- import { eq as eq14, desc as desc2, and as and3, or as or2 } from "drizzle-orm";
4450
- import crypto13 from "crypto";
5247
+ import { eq as eq16, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
5248
+ import crypto15 from "crypto";
4451
5249
  var Notifier = class {
4452
5250
  db;
4453
5251
  serverUrl;
@@ -4458,18 +5256,18 @@ var Notifier = class {
4458
5256
  /** Called after a run completes (success, partial, or failed). */
4459
5257
  async onRunCompleted(runId, projectId) {
4460
5258
  console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
4461
- const notifs = this.db.select().from(notifications).where(eq14(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
5259
+ const notifs = this.db.select().from(notifications).where(eq16(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
4462
5260
  if (notifs.length === 0) {
4463
5261
  console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
4464
5262
  return;
4465
5263
  }
4466
5264
  console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
4467
- const run = this.db.select().from(runs).where(eq14(runs.id, runId)).get();
5265
+ const run = this.db.select().from(runs).where(eq16(runs.id, runId)).get();
4468
5266
  if (!run) {
4469
5267
  console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
4470
5268
  return;
4471
5269
  }
4472
- const project = this.db.select().from(projects).where(eq14(projects.id, projectId)).get();
5270
+ const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
4473
5271
  if (!project) {
4474
5272
  console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
4475
5273
  return;
@@ -4509,11 +5307,11 @@ var Notifier = class {
4509
5307
  }
4510
5308
  computeTransitions(runId, projectId) {
4511
5309
  const recentRuns = this.db.select().from(runs).where(
4512
- and3(
4513
- eq14(runs.projectId, projectId),
4514
- or2(eq14(runs.status, "completed"), eq14(runs.status, "partial"))
5310
+ and6(
5311
+ eq16(runs.projectId, projectId),
5312
+ or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
4515
5313
  )
4516
- ).orderBy(desc2(runs.createdAt)).limit(2).all();
5314
+ ).orderBy(desc3(runs.createdAt)).limit(2).all();
4517
5315
  if (recentRuns.length < 2) return [];
4518
5316
  const currentRunId = recentRuns[0].id;
4519
5317
  const previousRunId = recentRuns[1].id;
@@ -4523,12 +5321,12 @@ var Notifier = class {
4523
5321
  keyword: keywords.keyword,
4524
5322
  provider: querySnapshots.provider,
4525
5323
  citationState: querySnapshots.citationState
4526
- }).from(querySnapshots).leftJoin(keywords, eq14(querySnapshots.keywordId, keywords.id)).where(eq14(querySnapshots.runId, currentRunId)).all();
5324
+ }).from(querySnapshots).leftJoin(keywords, eq16(querySnapshots.keywordId, keywords.id)).where(eq16(querySnapshots.runId, currentRunId)).all();
4527
5325
  const previousSnapshots = this.db.select({
4528
5326
  keywordId: querySnapshots.keywordId,
4529
5327
  provider: querySnapshots.provider,
4530
5328
  citationState: querySnapshots.citationState
4531
- }).from(querySnapshots).where(eq14(querySnapshots.runId, previousRunId)).all();
5329
+ }).from(querySnapshots).where(eq16(querySnapshots.runId, previousRunId)).all();
4532
5330
  const prevMap = /* @__PURE__ */ new Map();
4533
5331
  for (const s of previousSnapshots) {
4534
5332
  prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
@@ -4585,7 +5383,7 @@ var Notifier = class {
4585
5383
  }
4586
5384
  logDelivery(projectId, notificationId, event, status, error) {
4587
5385
  this.db.insert(auditLog).values({
4588
- id: crypto13.randomUUID(),
5386
+ id: crypto15.randomUUID(),
4589
5387
  projectId,
4590
5388
  actor: "scheduler",
4591
5389
  action: `notification.${status}`,
@@ -4796,9 +5594,28 @@ async function createServer(opts) {
4796
5594
  quota: registry.get(name)?.config.quotaPolicy
4797
5595
  }));
4798
5596
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
5597
+ const googleClientId = process.env.GOOGLE_CLIENT_ID;
5598
+ const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
5599
+ const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
4799
5600
  await app.register(apiRoutes, {
4800
5601
  db: opts.db,
4801
5602
  skipAuth: false,
5603
+ googleClientId,
5604
+ googleClientSecret,
5605
+ googleStateSecret,
5606
+ onGscSyncRequested: (runId, projectId, syncOpts) => {
5607
+ if (!googleClientId || !googleClientSecret) {
5608
+ app.log.error("GSC sync requested but GOOGLE_CLIENT_ID/SECRET not configured");
5609
+ return;
5610
+ }
5611
+ executeGscSync(opts.db, runId, projectId, {
5612
+ ...syncOpts,
5613
+ googleClientId,
5614
+ googleClientSecret
5615
+ }).catch((err) => {
5616
+ app.log.error({ runId, err }, "GSC sync failed");
5617
+ });
5618
+ },
4802
5619
  openApiInfo: {
4803
5620
  title: "Canonry API",
4804
5621
  version: PKG_VERSION