@ainyc/canonry 1.8.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,
@@ -223,6 +227,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
223
227
  runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
224
228
  keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
225
229
  provider: text("provider").notNull().default("gemini"),
230
+ model: text("model"),
226
231
  citationState: text("citation_state").notNull(),
227
232
  answerText: text("answer_text"),
228
233
  citedDomains: text("cited_domains").notNull().default("[]"),
@@ -285,6 +290,61 @@ var notifications = sqliteTable("notifications", {
285
290
  }, (table) => [
286
291
  index("idx_notifications_project").on(table.projectId)
287
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
+ ]);
288
348
  var usageCounters = sqliteTable("usage_counters", {
289
349
  id: text("id").primaryKey(),
290
350
  scope: text("scope").notNull(),
@@ -443,7 +503,66 @@ var MIGRATIONS = [
443
503
  // v3: Add webhook_secret column to notifications for HMAC signing
444
504
  `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
445
505
  // v4: Add owned_domains column to projects for multi-domain citation matching
446
- `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`
506
+ `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
507
+ // v5: Add model column to query_snapshots for per-model scoring
508
+ `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
509
+ // v5b: Backfill model from rawResponse JSON for existing snapshots
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)`
447
566
  ];
448
567
  function migrate(db) {
449
568
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -516,6 +635,15 @@ var configNotificationSchema = z3.object({
516
635
  url: z3.string().url(),
517
636
  events: z3.array(notificationEventSchema).min(1)
518
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();
519
647
  var configSpecSchema = z3.object({
520
648
  displayName: z3.string().min(1),
521
649
  canonicalDomain: z3.string().min(1),
@@ -526,7 +654,8 @@ var configSpecSchema = z3.object({
526
654
  competitors: z3.array(z3.string().min(1)).optional().default([]),
527
655
  providers: z3.array(providerNameSchema).optional().default([]),
528
656
  schedule: configScheduleSchema,
529
- notifications: z3.array(configNotificationSchema).optional().default([])
657
+ notifications: z3.array(configNotificationSchema).optional().default([]),
658
+ google: configGoogleSchema
530
659
  });
531
660
  var projectConfigSchema = z3.object({
532
661
  apiVersion: z3.literal("canonry/v1"),
@@ -535,6 +664,58 @@ var projectConfigSchema = z3.object({
535
664
  spec: configSpecSchema
536
665
  });
537
666
 
667
+ // ../contracts/src/models.ts
668
+ var MODEL_REGISTRY = {
669
+ gemini: {
670
+ defaultModel: "gemini-3-flash",
671
+ validationPattern: /^gemini-/,
672
+ validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
673
+ knownModels: [
674
+ { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
675
+ { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
676
+ { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
677
+ { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
678
+ ]
679
+ },
680
+ openai: {
681
+ defaultModel: "gpt-5.4",
682
+ validationPattern: /^(gpt-|o\d)/,
683
+ validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
684
+ knownModels: [
685
+ { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
686
+ { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
687
+ { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
688
+ { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
689
+ { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
690
+ { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
691
+ ]
692
+ },
693
+ claude: {
694
+ defaultModel: "claude-sonnet-4-6",
695
+ validationPattern: /^claude-/,
696
+ validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
697
+ knownModels: [
698
+ { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
699
+ { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
700
+ { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
701
+ ]
702
+ },
703
+ local: {
704
+ defaultModel: "llama3",
705
+ validationPattern: /./,
706
+ validationHint: "any model name accepted",
707
+ knownModels: [
708
+ { id: "llama3", displayName: "Llama 3", tier: "standard" }
709
+ ]
710
+ }
711
+ };
712
+ function getDefaultModel(provider) {
713
+ return MODEL_REGISTRY[provider].defaultModel;
714
+ }
715
+ function isValidModelName(provider, model) {
716
+ return MODEL_REGISTRY[provider].validationPattern.test(model);
717
+ }
718
+
538
719
  // ../contracts/src/errors.ts
539
720
  var AppError = class extends Error {
540
721
  code;
@@ -576,23 +757,62 @@ function unsupportedKind(kind) {
576
757
  return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
577
758
  }
578
759
 
579
- // ../contracts/src/project.ts
760
+ // ../contracts/src/google.ts
580
761
  import { z as z4 } from "zod";
581
- var configSourceSchema = z4.enum(["cli", "api", "config-file"]);
582
- var projectDtoSchema = z4.object({
762
+ var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
763
+ var googleConnectionDtoSchema = z4.object({
583
764
  id: z4.string(),
584
- name: z4.string(),
585
- displayName: z4.string().optional(),
586
- canonicalDomain: z4.string(),
587
- ownedDomains: z4.array(z4.string()).default([]),
588
- country: z4.string().length(2),
589
- language: z4.string().min(2),
590
- tags: z4.array(z4.string()).default([]),
591
- labels: z4.record(z4.string(), z4.string()).default({}),
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({
784
+ id: z4.string(),
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({}),
592
812
  configSource: configSourceSchema.default("cli"),
593
- configRevision: z4.number().int().positive().default(1),
594
- createdAt: z4.string().optional(),
595
- updatedAt: z4.string().optional()
813
+ configRevision: z5.number().int().positive().default(1),
814
+ createdAt: z5.string().optional(),
815
+ updatedAt: z5.string().optional()
596
816
  });
597
817
  function normalizeProjectDomain(input) {
598
818
  let domain = input.trim().toLowerCase();
@@ -620,68 +840,68 @@ function effectiveDomains(project) {
620
840
  }
621
841
 
622
842
  // ../contracts/src/run.ts
623
- import { z as z5 } from "zod";
624
- var runStatusSchema = z5.enum(["queued", "running", "completed", "partial", "failed"]);
625
- var runKindSchema = z5.enum(["answer-visibility", "site-audit"]);
626
- var runTriggerSchema = z5.enum(["manual", "scheduled", "config-apply"]);
627
- var citationStateSchema = z5.enum(["cited", "not-cited"]);
628
- var computedTransitionSchema = z5.enum(["new", "cited", "lost", "emerging", "not-cited"]);
629
- var runDtoSchema = z5.object({
630
- id: z5.string(),
631
- 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(),
632
852
  kind: runKindSchema,
633
853
  status: runStatusSchema,
634
854
  trigger: runTriggerSchema.default("manual"),
635
- startedAt: z5.string().nullable().optional(),
636
- finishedAt: z5.string().nullable().optional(),
637
- error: z5.string().nullable().optional(),
638
- 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()
639
859
  });
640
- var groundingSourceSchema = z5.object({
641
- uri: z5.string(),
642
- title: z5.string()
860
+ var groundingSourceSchema = z6.object({
861
+ uri: z6.string(),
862
+ title: z6.string()
643
863
  });
644
- var querySnapshotDtoSchema = z5.object({
645
- id: z5.string(),
646
- runId: z5.string(),
647
- keywordId: z5.string(),
648
- 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(),
649
869
  provider: providerNameSchema,
650
870
  citationState: citationStateSchema,
651
871
  transition: computedTransitionSchema.optional(),
652
- answerText: z5.string().nullable().optional(),
653
- citedDomains: z5.array(z5.string()).default([]),
654
- competitorOverlap: z5.array(z5.string()).default([]),
655
- groundingSources: z5.array(groundingSourceSchema).default([]),
656
- searchQueries: z5.array(z5.string()).default([]),
657
- model: z5.string().nullable().optional(),
658
- 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()
659
879
  });
660
- var auditLogEntrySchema = z5.object({
661
- id: z5.string(),
662
- projectId: z5.string().nullable().optional(),
663
- actor: z5.string(),
664
- action: z5.string(),
665
- entityType: z5.string(),
666
- entityId: z5.string().nullable().optional(),
667
- diff: z5.unknown().optional(),
668
- 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()
669
889
  });
670
890
 
671
891
  // ../contracts/src/schedule.ts
672
- import { z as z6 } from "zod";
673
- var scheduleDtoSchema = z6.object({
674
- id: z6.string(),
675
- projectId: z6.string(),
676
- cronExpr: z6.string(),
677
- preset: z6.string().nullable().optional(),
678
- timezone: z6.string().default("UTC"),
679
- enabled: z6.boolean().default(true),
680
- providers: z6.array(providerNameSchema).default([]),
681
- lastRunAt: z6.string().nullable().optional(),
682
- nextRunAt: z6.string().nullable().optional(),
683
- createdAt: z6.string(),
684
- 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()
685
905
  });
686
906
 
687
907
  // ../api-routes/src/auth.ts
@@ -692,6 +912,7 @@ var SKIP_PATHS = ["/health"];
692
912
  function shouldSkipAuth(url) {
693
913
  if (SKIP_PATHS.includes(url)) return true;
694
914
  if (url.endsWith("/openapi.json")) return true;
915
+ if (url.includes("/google/callback")) return true;
695
916
  return false;
696
917
  }
697
918
  async function authPlugin(app) {
@@ -1253,6 +1474,7 @@ async function runRoutes(app, opts) {
1253
1474
  keywordId: querySnapshots.keywordId,
1254
1475
  keyword: keywords.keyword,
1255
1476
  provider: querySnapshots.provider,
1477
+ model: querySnapshots.model,
1256
1478
  citationState: querySnapshots.citationState,
1257
1479
  answerText: querySnapshots.answerText,
1258
1480
  citedDomains: querySnapshots.citedDomains,
@@ -1262,19 +1484,24 @@ async function runRoutes(app, opts) {
1262
1484
  }).from(querySnapshots).leftJoin(keywords, eq7(querySnapshots.keywordId, keywords.id)).where(eq7(querySnapshots.runId, run.id)).all();
1263
1485
  return reply.send({
1264
1486
  ...formatRun(run),
1265
- snapshots: snapshots.map((s) => ({
1266
- id: s.id,
1267
- runId: s.runId,
1268
- keywordId: s.keywordId,
1269
- keyword: s.keyword,
1270
- provider: s.provider,
1271
- citationState: s.citationState,
1272
- answerText: s.answerText,
1273
- citedDomains: tryParseJson(s.citedDomains, []),
1274
- competitorOverlap: tryParseJson(s.competitorOverlap, []),
1275
- ...parseSnapshotRawResponse(s.rawResponse),
1276
- createdAt: s.createdAt
1277
- }))
1487
+ snapshots: snapshots.map((s) => {
1488
+ const rawParsed = parseSnapshotRawResponse(s.rawResponse);
1489
+ return {
1490
+ id: s.id,
1491
+ runId: s.runId,
1492
+ keywordId: s.keywordId,
1493
+ keyword: s.keyword,
1494
+ provider: s.provider,
1495
+ citationState: s.citationState,
1496
+ answerText: s.answerText,
1497
+ citedDomains: tryParseJson(s.citedDomains, []),
1498
+ competitorOverlap: tryParseJson(s.competitorOverlap, []),
1499
+ model: s.model ?? rawParsed.model,
1500
+ groundingSources: rawParsed.groundingSources,
1501
+ searchQueries: rawParsed.searchQueries,
1502
+ createdAt: s.createdAt
1503
+ };
1504
+ })
1278
1505
  });
1279
1506
  });
1280
1507
  }
@@ -1321,7 +1548,7 @@ function resolveProjectSafe3(app, name, reply) {
1321
1548
 
1322
1549
  // ../api-routes/src/apply.ts
1323
1550
  import crypto9 from "crypto";
1324
- import { eq as eq8 } from "drizzle-orm";
1551
+ import { eq as eq8, and as and3 } from "drizzle-orm";
1325
1552
 
1326
1553
  // ../api-routes/src/schedule-utils.ts
1327
1554
  var DAY_MAP = {
@@ -1758,6 +1985,13 @@ async function applyRoutes(app, opts) {
1758
1985
  diff: { notifications: config.spec.notifications }
1759
1986
  });
1760
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
+ }
1761
1995
  const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
1762
1996
  return reply.status(200).send({
1763
1997
  id: project.id,
@@ -1806,6 +2040,7 @@ async function historyRoutes(app) {
1806
2040
  keywordId: querySnapshots.keywordId,
1807
2041
  keyword: keywords.keyword,
1808
2042
  provider: querySnapshots.provider,
2043
+ model: querySnapshots.model,
1809
2044
  citationState: querySnapshots.citationState,
1810
2045
  answerText: querySnapshots.answerText,
1811
2046
  citedDomains: querySnapshots.citedDomains,
@@ -1821,6 +2056,7 @@ async function historyRoutes(app) {
1821
2056
  keywordId: s.keywordId,
1822
2057
  keyword: s.keyword,
1823
2058
  provider: s.provider,
2059
+ model: s.model,
1824
2060
  citationState: s.citationState,
1825
2061
  answerText: s.answerText,
1826
2062
  citedDomains: tryParseJson2(s.citedDomains, []),
@@ -1856,6 +2092,13 @@ async function historyRoutes(app) {
1856
2092
  if (arr) arr.push(snap);
1857
2093
  else rawByKwProvider.set(key, [snap]);
1858
2094
  }
2095
+ const rawByKwModel = /* @__PURE__ */ new Map();
2096
+ for (const snap of allSnapshots) {
2097
+ const key = `${snap.keywordId}::${snap.provider}:${snap.model ?? "unknown"}`;
2098
+ const arr = rawByKwModel.get(key);
2099
+ if (arr) arr.push(snap);
2100
+ else rawByKwModel.set(key, [snap]);
2101
+ }
1859
2102
  function computeTransitions(snaps) {
1860
2103
  return snaps.map((snap, idx) => {
1861
2104
  const run = projectRuns.find((r) => r.id === snap.runId);
@@ -1888,10 +2131,18 @@ async function historyRoutes(app) {
1888
2131
  const provSnaps = rawByKwProvider.get(pk).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1889
2132
  providerRuns[provider] = computeTransitions(provSnaps);
1890
2133
  }
2134
+ const modelRuns = {};
2135
+ const modelKeys = [...rawByKwModel.keys()].filter((k) => k.startsWith(`${kw.id}::`));
2136
+ for (const mk of modelKeys) {
2137
+ const modelKey = mk.split("::")[1];
2138
+ const modelSnaps = rawByKwModel.get(mk).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2139
+ modelRuns[modelKey] = computeTransitions(modelSnaps);
2140
+ }
1891
2141
  return {
1892
2142
  keyword: kw.keyword,
1893
2143
  runs: runEntries,
1894
- providerRuns
2144
+ providerRuns,
2145
+ modelRuns
1895
2146
  };
1896
2147
  });
1897
2148
  return reply.send(timeline);
@@ -2655,19 +2906,10 @@ async function settingsRoutes(app, opts) {
2655
2906
  }
2656
2907
  }
2657
2908
  if (model !== void 0) {
2658
- if (name === "gemini" && !model.startsWith("gemini-")) {
2909
+ const registry = MODEL_REGISTRY[name];
2910
+ if (!registry.validationPattern.test(model)) {
2659
2911
  return reply.status(400).send({
2660
- error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "gemini" \u2014 model name must start with "gemini-" (e.g. gemini-2.5-flash)` }
2661
- });
2662
- }
2663
- if (name === "openai" && !/^(gpt-|o\d)/.test(model)) {
2664
- return reply.status(400).send({
2665
- error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "openai" \u2014 expected a GPT or o-series model name (e.g. gpt-4o, o3)` }
2666
- });
2667
- }
2668
- if (name === "claude" && !model.startsWith("claude-")) {
2669
- return reply.status(400).send({
2670
- error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "claude" \u2014 model name must start with "claude-" (e.g. claude-sonnet-4-6)` }
2912
+ error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "${name}" \u2014 ${registry.validationHint}` }
2671
2913
  });
2672
2914
  }
2673
2915
  }
@@ -3004,6 +3246,501 @@ function resolveProjectSafe6(app, name, reply) {
3004
3246
  }
3005
3247
  }
3006
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
+
3007
3744
  // ../api-routes/src/index.ts
3008
3745
  async function apiRoutes(app, opts) {
3009
3746
  app.decorate("db", opts.db);
@@ -3036,16 +3773,22 @@ async function apiRoutes(app, opts) {
3036
3773
  getTelemetryStatus: opts.getTelemetryStatus,
3037
3774
  setTelemetryEnabled: opts.setTelemetryEnabled
3038
3775
  });
3776
+ await api.register(googleRoutes, {
3777
+ googleClientId: opts.googleClientId,
3778
+ googleClientSecret: opts.googleClientSecret,
3779
+ googleStateSecret: opts.googleStateSecret,
3780
+ onGscSyncRequested: opts.onGscSyncRequested
3781
+ });
3039
3782
  }, { prefix: "/api/v1" });
3040
3783
  }
3041
3784
 
3042
3785
  // ../provider-gemini/src/normalize.ts
3043
3786
  import { GoogleGenerativeAI } from "@google/generative-ai";
3044
- var DEFAULT_MODEL = "gemini-2.5-flash";
3787
+ var DEFAULT_MODEL = getDefaultModel("gemini");
3045
3788
  function resolveModel(config) {
3046
3789
  const m = config.model;
3047
3790
  if (!m) return DEFAULT_MODEL;
3048
- if (m.startsWith("gemini-")) return m;
3791
+ if (isValidModelName("gemini", m)) return m;
3049
3792
  console.warn(
3050
3793
  `[provider-gemini] Invalid model name "${m}" \u2014 this provider uses the Gemini AI Studio API (generativelanguage.googleapis.com) which only accepts "gemini-*" model names. Falling back to ${DEFAULT_MODEL}.`
3051
3794
  );
@@ -3056,7 +3799,7 @@ function validateConfig(config) {
3056
3799
  return { ok: false, provider: "gemini", message: "missing api key" };
3057
3800
  }
3058
3801
  const model = resolveModel(config);
3059
- const warning = config.model && !config.model.startsWith("gemini-") ? ` (invalid model "${config.model}" replaced with default)` : "";
3802
+ const warning = config.model && !isValidModelName("gemini", config.model) ? ` (invalid model "${config.model}" replaced with default)` : "";
3060
3803
  return {
3061
3804
  ok: true,
3062
3805
  provider: "gemini",
@@ -3294,7 +4037,7 @@ var geminiAdapter = {
3294
4037
 
3295
4038
  // ../provider-openai/src/normalize.ts
3296
4039
  import OpenAI from "openai";
3297
- var DEFAULT_MODEL2 = "gpt-4o";
4040
+ var DEFAULT_MODEL2 = getDefaultModel("openai");
3298
4041
  function validateConfig2(config) {
3299
4042
  if (!config.apiKey || config.apiKey.length === 0) {
3300
4043
  return { ok: false, provider: "openai", message: "missing api key" };
@@ -3535,7 +4278,7 @@ var openaiAdapter = {
3535
4278
 
3536
4279
  // ../provider-claude/src/normalize.ts
3537
4280
  import Anthropic from "@anthropic-ai/sdk";
3538
- var DEFAULT_MODEL3 = "claude-sonnet-4-6";
4281
+ var DEFAULT_MODEL3 = getDefaultModel("claude");
3539
4282
  function validateConfig3(config) {
3540
4283
  if (!config.apiKey || config.apiKey.length === 0) {
3541
4284
  return { ok: false, provider: "claude", message: "missing api key" };
@@ -3772,7 +4515,7 @@ var claudeAdapter = {
3772
4515
 
3773
4516
  // ../provider-local/src/normalize.ts
3774
4517
  import OpenAI2 from "openai";
3775
- var DEFAULT_MODEL4 = "llama3";
4518
+ var DEFAULT_MODEL4 = getDefaultModel("local");
3776
4519
  function validateConfig4(config) {
3777
4520
  if (!config.baseUrl || config.baseUrl.length === 0) {
3778
4521
  return { ok: false, provider: "local", message: "missing base URL" };
@@ -3956,8 +4699,8 @@ var localAdapter = {
3956
4699
  };
3957
4700
 
3958
4701
  // src/job-runner.ts
3959
- import crypto12 from "crypto";
3960
- 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";
3961
4704
  var JobRunner = class {
3962
4705
  db;
3963
4706
  registry;
@@ -3971,7 +4714,7 @@ var JobRunner = class {
3971
4714
  if (stale.length === 0) return;
3972
4715
  const now = (/* @__PURE__ */ new Date()).toISOString();
3973
4716
  for (const run of stale) {
3974
- 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();
3975
4718
  console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
3976
4719
  }
3977
4720
  }
@@ -3979,8 +4722,8 @@ var JobRunner = class {
3979
4722
  const now = (/* @__PURE__ */ new Date()).toISOString();
3980
4723
  const startTime = Date.now();
3981
4724
  try {
3982
- this.db.update(runs).set({ status: "running", startedAt: now }).where(eq12(runs.id, runId)).run();
3983
- 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();
3984
4727
  if (!project) {
3985
4728
  throw new Error(`Project ${projectId} not found`);
3986
4729
  }
@@ -3990,14 +4733,14 @@ var JobRunner = class {
3990
4733
  throw new Error("No providers configured. Add at least one provider API key.");
3991
4734
  }
3992
4735
  console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
3993
- const projectKeywords = this.db.select().from(keywords).where(eq12(keywords.projectId, projectId)).all();
3994
- 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();
3995
4738
  const competitorDomains = projectCompetitors.map((c) => c.domain);
3996
4739
  const queriesPerProvider = projectKeywords.length;
3997
4740
  const todayPeriod = getCurrentPeriod();
3998
4741
  for (const p of activeProviders) {
3999
4742
  const providerScope = `${projectId}:${p.adapter.name}`;
4000
- 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);
4001
4744
  const limit = p.config.quotaPolicy.maxRequestsPerDay;
4002
4745
  if (providerUsage + queriesPerProvider > limit) {
4003
4746
  throw new Error(
@@ -4037,10 +4780,11 @@ var JobRunner = class {
4037
4780
  const citationState = determineCitationState(normalized, allDomains);
4038
4781
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
4039
4782
  this.db.insert(querySnapshots).values({
4040
- id: crypto12.randomUUID(),
4783
+ id: crypto13.randomUUID(),
4041
4784
  runId,
4042
4785
  keywordId: kw.id,
4043
4786
  provider: providerName,
4787
+ model: raw.model,
4044
4788
  citationState,
4045
4789
  answerText: normalized.answerText,
4046
4790
  citedDomains: JSON.stringify(normalized.citedDomains),
@@ -4067,12 +4811,12 @@ var JobRunner = class {
4067
4811
  const someFailed = providerErrors.size > 0;
4068
4812
  if (allFailed) {
4069
4813
  const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
4070
- 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();
4071
4815
  } else if (someFailed) {
4072
4816
  const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
4073
- 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();
4074
4818
  } else {
4075
- 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();
4076
4820
  }
4077
4821
  const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
4078
4822
  trackEvent("run.completed", {
@@ -4097,7 +4841,7 @@ var JobRunner = class {
4097
4841
  status: "failed",
4098
4842
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
4099
4843
  error: errorMessage
4100
- }).where(eq12(runs.id, runId)).run();
4844
+ }).where(eq13(runs.id, runId)).run();
4101
4845
  trackEvent("run.completed", {
4102
4846
  status: "failed",
4103
4847
  providerCount: 0,
@@ -4133,10 +4877,10 @@ var JobRunner = class {
4133
4877
  incrementUsage(scope, metric, count) {
4134
4878
  const now = /* @__PURE__ */ new Date();
4135
4879
  const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
4136
- const id = crypto12.randomUUID();
4137
- 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);
4138
4882
  if (existing) {
4139
- 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();
4140
4884
  } else {
4141
4885
  this.db.insert(usageCounters).values({
4142
4886
  id,
@@ -4215,6 +4959,132 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
4215
4959
  return [...overlapSet];
4216
4960
  }
4217
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
+
4218
5088
  // src/provider-registry.ts
4219
5089
  var ProviderRegistry = class {
4220
5090
  providers = /* @__PURE__ */ new Map();
@@ -4260,7 +5130,7 @@ var ProviderRegistry = class {
4260
5130
 
4261
5131
  // src/scheduler.ts
4262
5132
  import cron from "node-cron";
4263
- import { eq as eq13 } from "drizzle-orm";
5133
+ import { eq as eq15 } from "drizzle-orm";
4264
5134
  var Scheduler = class {
4265
5135
  db;
4266
5136
  callbacks;
@@ -4271,7 +5141,7 @@ var Scheduler = class {
4271
5141
  }
4272
5142
  /** Load all enabled schedules from DB and register cron jobs. */
4273
5143
  start() {
4274
- 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();
4275
5145
  for (const schedule of allSchedules) {
4276
5146
  const missedRunAt = schedule.nextRunAt;
4277
5147
  this.registerCronTask(schedule);
@@ -4297,7 +5167,7 @@ var Scheduler = class {
4297
5167
  existing.stop();
4298
5168
  this.tasks.delete(projectId);
4299
5169
  }
4300
- 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();
4301
5171
  if (schedule && schedule.enabled === 1) {
4302
5172
  this.registerCronTask(schedule);
4303
5173
  }
@@ -4326,13 +5196,13 @@ var Scheduler = class {
4326
5196
  this.db.update(schedules).set({
4327
5197
  nextRunAt: task.getNextRun()?.toISOString() ?? null,
4328
5198
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4329
- }).where(eq13(schedules.id, scheduleId)).run();
5199
+ }).where(eq15(schedules.id, scheduleId)).run();
4330
5200
  const label = schedule.preset ?? cronExpr;
4331
5201
  console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
4332
5202
  }
4333
5203
  triggerRun(scheduleId, projectId) {
4334
5204
  const now = (/* @__PURE__ */ new Date()).toISOString();
4335
- 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();
4336
5206
  if (!currentSchedule || currentSchedule.enabled !== 1) {
4337
5207
  console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
4338
5208
  this.remove(projectId);
@@ -4340,7 +5210,7 @@ var Scheduler = class {
4340
5210
  }
4341
5211
  const task = this.tasks.get(projectId);
4342
5212
  const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
4343
- 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();
4344
5214
  if (!project) {
4345
5215
  console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
4346
5216
  this.remove(projectId);
@@ -4357,7 +5227,7 @@ var Scheduler = class {
4357
5227
  this.db.update(schedules).set({
4358
5228
  nextRunAt,
4359
5229
  updatedAt: now
4360
- }).where(eq13(schedules.id, currentSchedule.id)).run();
5230
+ }).where(eq15(schedules.id, currentSchedule.id)).run();
4361
5231
  return;
4362
5232
  }
4363
5233
  const runId = queueResult.runId;
@@ -4365,7 +5235,7 @@ var Scheduler = class {
4365
5235
  lastRunAt: now,
4366
5236
  nextRunAt,
4367
5237
  updatedAt: now
4368
- }).where(eq13(schedules.id, currentSchedule.id)).run();
5238
+ }).where(eq15(schedules.id, currentSchedule.id)).run();
4369
5239
  const scheduleProviders = JSON.parse(currentSchedule.providers);
4370
5240
  const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
4371
5241
  console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
@@ -4374,8 +5244,8 @@ var Scheduler = class {
4374
5244
  };
4375
5245
 
4376
5246
  // src/notifier.ts
4377
- import { eq as eq14, desc as desc2, and as and3, or as or2 } from "drizzle-orm";
4378
- 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";
4379
5249
  var Notifier = class {
4380
5250
  db;
4381
5251
  serverUrl;
@@ -4386,18 +5256,18 @@ var Notifier = class {
4386
5256
  /** Called after a run completes (success, partial, or failed). */
4387
5257
  async onRunCompleted(runId, projectId) {
4388
5258
  console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
4389
- 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);
4390
5260
  if (notifs.length === 0) {
4391
5261
  console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
4392
5262
  return;
4393
5263
  }
4394
5264
  console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
4395
- 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();
4396
5266
  if (!run) {
4397
5267
  console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
4398
5268
  return;
4399
5269
  }
4400
- 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();
4401
5271
  if (!project) {
4402
5272
  console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
4403
5273
  return;
@@ -4437,11 +5307,11 @@ var Notifier = class {
4437
5307
  }
4438
5308
  computeTransitions(runId, projectId) {
4439
5309
  const recentRuns = this.db.select().from(runs).where(
4440
- and3(
4441
- eq14(runs.projectId, projectId),
4442
- 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"))
4443
5313
  )
4444
- ).orderBy(desc2(runs.createdAt)).limit(2).all();
5314
+ ).orderBy(desc3(runs.createdAt)).limit(2).all();
4445
5315
  if (recentRuns.length < 2) return [];
4446
5316
  const currentRunId = recentRuns[0].id;
4447
5317
  const previousRunId = recentRuns[1].id;
@@ -4451,12 +5321,12 @@ var Notifier = class {
4451
5321
  keyword: keywords.keyword,
4452
5322
  provider: querySnapshots.provider,
4453
5323
  citationState: querySnapshots.citationState
4454
- }).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();
4455
5325
  const previousSnapshots = this.db.select({
4456
5326
  keywordId: querySnapshots.keywordId,
4457
5327
  provider: querySnapshots.provider,
4458
5328
  citationState: querySnapshots.citationState
4459
- }).from(querySnapshots).where(eq14(querySnapshots.runId, previousRunId)).all();
5329
+ }).from(querySnapshots).where(eq16(querySnapshots.runId, previousRunId)).all();
4460
5330
  const prevMap = /* @__PURE__ */ new Map();
4461
5331
  for (const s of previousSnapshots) {
4462
5332
  prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
@@ -4513,7 +5383,7 @@ var Notifier = class {
4513
5383
  }
4514
5384
  logDelivery(projectId, notificationId, event, status, error) {
4515
5385
  this.db.insert(auditLog).values({
4516
- id: crypto13.randomUUID(),
5386
+ id: crypto15.randomUUID(),
4517
5387
  projectId,
4518
5388
  actor: "scheduler",
4519
5389
  action: `notification.${status}`,
@@ -4724,9 +5594,28 @@ async function createServer(opts) {
4724
5594
  quota: registry.get(name)?.config.quotaPolicy
4725
5595
  }));
4726
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");
4727
5600
  await app.register(apiRoutes, {
4728
5601
  db: opts.db,
4729
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
+ },
4730
5619
  openApiInfo: {
4731
5620
  title: "Canonry API",
4732
5621
  version: PKG_VERSION
@@ -4910,6 +5799,7 @@ function parseKeywordResponse(raw, count) {
4910
5799
  export {
4911
5800
  providerQuotaPolicySchema,
4912
5801
  notificationEventSchema,
5802
+ getDefaultModel,
4913
5803
  effectiveDomains,
4914
5804
  apiKeys,
4915
5805
  createClient,