@ainyc/canonry 1.19.3 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -160,987 +160,1070 @@ function trackEvent(event, properties) {
160
160
 
161
161
  // src/server.ts
162
162
  import { createRequire as createRequire2 } from "module";
163
- import crypto18 from "crypto";
163
+ import crypto19 from "crypto";
164
164
  import fs5 from "fs";
165
165
  import path6 from "path";
166
166
  import { fileURLToPath } from "url";
167
167
  import Fastify from "fastify";
168
168
 
169
- // ../api-routes/src/auth.ts
170
- import crypto2 from "crypto";
171
- import { eq } from "drizzle-orm";
172
-
173
- // ../db/src/client.ts
174
- import { mkdirSync } from "fs";
175
- import { dirname } from "path";
176
- import Database from "better-sqlite3";
177
- import { drizzle } from "drizzle-orm/better-sqlite3";
169
+ // ../contracts/src/config-schema.ts
170
+ import { z as z3 } from "zod";
178
171
 
179
- // ../db/src/schema.ts
180
- var schema_exports = {};
181
- __export(schema_exports, {
182
- apiKeys: () => apiKeys,
183
- auditLog: () => auditLog,
184
- competitors: () => competitors,
185
- googleConnections: () => googleConnections,
186
- gscCoverageSnapshots: () => gscCoverageSnapshots,
187
- gscSearchData: () => gscSearchData,
188
- gscUrlInspections: () => gscUrlInspections,
189
- keywords: () => keywords,
190
- notifications: () => notifications,
191
- projects: () => projects,
192
- querySnapshots: () => querySnapshots,
193
- runs: () => runs,
194
- schedules: () => schedules,
195
- usageCounters: () => usageCounters
172
+ // ../contracts/src/provider.ts
173
+ import { z } from "zod";
174
+ var providerQuotaPolicySchema = z.object({
175
+ maxConcurrency: z.number().int().positive(),
176
+ maxRequestsPerMinute: z.number().int().positive(),
177
+ maxRequestsPerDay: z.number().int().positive()
196
178
  });
197
- import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
198
- var projects = sqliteTable("projects", {
199
- id: text("id").primaryKey(),
200
- name: text("name").notNull().unique(),
201
- displayName: text("display_name").notNull(),
202
- canonicalDomain: text("canonical_domain").notNull(),
203
- ownedDomains: text("owned_domains").notNull().default("[]"),
204
- country: text("country").notNull(),
205
- language: text("language").notNull(),
206
- tags: text("tags").notNull().default("[]"),
207
- labels: text("labels").notNull().default("{}"),
208
- providers: text("providers").notNull().default("[]"),
209
- locations: text("locations").notNull().default("[]"),
210
- defaultLocation: text("default_location"),
211
- configSource: text("config_source").notNull().default("cli"),
212
- configRevision: integer("config_revision").notNull().default(1),
213
- createdAt: text("created_at").notNull(),
214
- updatedAt: text("updated_at").notNull()
179
+ var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
180
+ var providerNameSchema = z.enum(PROVIDER_NAMES);
181
+ var PROVIDER_MODE = {
182
+ gemini: "api",
183
+ openai: "api",
184
+ claude: "api",
185
+ local: "api",
186
+ "cdp:chatgpt": "browser"
187
+ };
188
+ function isBrowserProvider(name) {
189
+ return PROVIDER_MODE[name] === "browser";
190
+ }
191
+ var CDP_TARGETS = ["cdp:chatgpt"];
192
+ function parseProviderName(input) {
193
+ const lower = input.trim().toLowerCase();
194
+ return PROVIDER_NAMES.includes(lower) ? lower : void 0;
195
+ }
196
+ function resolveProviderInput(input) {
197
+ const lower = input.trim().toLowerCase();
198
+ if (lower === "cdp") {
199
+ return [...CDP_TARGETS];
200
+ }
201
+ const parsed = parseProviderName(lower);
202
+ return parsed ? [parsed] : [];
203
+ }
204
+ var locationContextSchema = z.object({
205
+ label: z.string().min(1),
206
+ city: z.string().min(1),
207
+ region: z.string().min(1),
208
+ country: z.string().length(2),
209
+ timezone: z.string().optional()
215
210
  });
216
- var keywords = sqliteTable("keywords", {
217
- id: text("id").primaryKey(),
218
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
219
- keyword: text("keyword").notNull(),
220
- createdAt: text("created_at").notNull()
221
- }, (table) => [
222
- index("idx_keywords_project").on(table.projectId),
223
- uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
224
- ]);
225
- var competitors = sqliteTable("competitors", {
226
- id: text("id").primaryKey(),
227
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
228
- domain: text("domain").notNull(),
229
- createdAt: text("created_at").notNull()
230
- }, (table) => [
231
- index("idx_competitors_project").on(table.projectId),
232
- uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
233
- ]);
234
- var runs = sqliteTable("runs", {
235
- id: text("id").primaryKey(),
236
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
237
- kind: text("kind").notNull().default("answer-visibility"),
238
- status: text("status").notNull().default("queued"),
239
- trigger: text("trigger").notNull().default("manual"),
240
- location: text("location"),
241
- startedAt: text("started_at"),
242
- finishedAt: text("finished_at"),
243
- error: text("error"),
244
- createdAt: text("created_at").notNull()
245
- }, (table) => [
246
- index("idx_runs_project").on(table.projectId),
247
- index("idx_runs_status").on(table.status)
248
- ]);
249
- var querySnapshots = sqliteTable("query_snapshots", {
250
- id: text("id").primaryKey(),
251
- runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
252
- keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
253
- provider: text("provider").notNull().default("gemini"),
254
- model: text("model"),
255
- citationState: text("citation_state").notNull(),
256
- answerText: text("answer_text"),
257
- citedDomains: text("cited_domains").notNull().default("[]"),
258
- competitorOverlap: text("competitor_overlap").notNull().default("[]"),
259
- location: text("location"),
260
- screenshotPath: text("screenshot_path"),
261
- rawResponse: text("raw_response"),
262
- createdAt: text("created_at").notNull()
263
- }, (table) => [
264
- index("idx_snapshots_run").on(table.runId),
265
- index("idx_snapshots_keyword").on(table.keywordId)
266
- ]);
267
- var auditLog = sqliteTable("audit_log", {
268
- id: text("id").primaryKey(),
269
- projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
270
- actor: text("actor").notNull(),
271
- action: text("action").notNull(),
272
- entityType: text("entity_type").notNull(),
273
- entityId: text("entity_id"),
274
- diff: text("diff"),
275
- createdAt: text("created_at").notNull()
276
- }, (table) => [
277
- index("idx_audit_log_project").on(table.projectId),
278
- index("idx_audit_log_created").on(table.createdAt)
279
- ]);
280
- var apiKeys = sqliteTable("api_keys", {
281
- id: text("id").primaryKey(),
282
- name: text("name").notNull(),
283
- keyHash: text("key_hash").notNull().unique(),
284
- keyPrefix: text("key_prefix").notNull(),
285
- scopes: text("scopes").notNull().default('["*"]'),
286
- createdAt: text("created_at").notNull(),
287
- lastUsedAt: text("last_used_at"),
288
- revokedAt: text("revoked_at")
289
- }, (table) => [
290
- index("idx_api_keys_prefix").on(table.keyPrefix)
291
- ]);
292
- var schedules = sqliteTable("schedules", {
293
- id: text("id").primaryKey(),
294
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
295
- cronExpr: text("cron_expr").notNull(),
296
- preset: text("preset"),
297
- timezone: text("timezone").notNull().default("UTC"),
298
- enabled: integer("enabled").notNull().default(1),
299
- providers: text("providers").notNull().default("[]"),
300
- lastRunAt: text("last_run_at"),
301
- nextRunAt: text("next_run_at"),
302
- createdAt: text("created_at").notNull(),
303
- updatedAt: text("updated_at").notNull()
304
- }, (table) => [
305
- uniqueIndex("idx_schedules_project").on(table.projectId)
306
- ]);
307
- var notifications = sqliteTable("notifications", {
308
- id: text("id").primaryKey(),
309
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
310
- channel: text("channel").notNull(),
311
- config: text("config").notNull(),
312
- webhookSecret: text("webhook_secret"),
313
- enabled: integer("enabled").notNull().default(1),
314
- createdAt: text("created_at").notNull(),
315
- updatedAt: text("updated_at").notNull()
316
- }, (table) => [
317
- index("idx_notifications_project").on(table.projectId)
318
- ]);
319
- var googleConnections = sqliteTable("google_connections", {
320
- id: text("id").primaryKey(),
321
- domain: text("domain").notNull(),
322
- connectionType: text("connection_type").notNull(),
323
- propertyId: text("property_id"),
324
- sitemapUrl: text("sitemap_url"),
325
- accessToken: text("access_token"),
326
- refreshToken: text("refresh_token"),
327
- tokenExpiresAt: text("token_expires_at"),
328
- scopes: text("scopes").notNull().default("[]"),
329
- createdAt: text("created_at").notNull(),
330
- updatedAt: text("updated_at").notNull()
331
- }, (table) => [
332
- uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
333
- ]);
334
- var gscSearchData = sqliteTable("gsc_search_data", {
335
- id: text("id").primaryKey(),
336
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
337
- syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
338
- date: text("date").notNull(),
339
- query: text("query").notNull(),
340
- page: text("page").notNull(),
341
- country: text("country"),
342
- device: text("device"),
343
- clicks: integer("clicks").notNull().default(0),
344
- impressions: integer("impressions").notNull().default(0),
345
- ctr: text("ctr").notNull().default("0"),
346
- position: text("position").notNull().default("0"),
347
- createdAt: text("created_at").notNull()
348
- }, (table) => [
349
- index("idx_gsc_search_project_date").on(table.projectId, table.date),
350
- index("idx_gsc_search_query").on(table.query),
351
- index("idx_gsc_search_run").on(table.syncRunId)
352
- ]);
353
- var gscUrlInspections = sqliteTable("gsc_url_inspections", {
354
- id: text("id").primaryKey(),
355
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
356
- syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
357
- url: text("url").notNull(),
358
- indexingState: text("indexing_state"),
359
- verdict: text("verdict"),
360
- coverageState: text("coverage_state"),
361
- pageFetchState: text("page_fetch_state"),
362
- robotsTxtState: text("robots_txt_state"),
363
- crawlTime: text("crawl_time"),
364
- lastCrawlResult: text("last_crawl_result"),
365
- isMobileFriendly: integer("is_mobile_friendly"),
366
- richResults: text("rich_results").notNull().default("[]"),
367
- referringUrls: text("referring_urls").notNull().default("[]"),
368
- inspectedAt: text("inspected_at").notNull(),
369
- createdAt: text("created_at").notNull()
370
- }, (table) => [
371
- index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
372
- index("idx_gsc_inspect_run").on(table.syncRunId),
373
- index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
211
+
212
+ // ../contracts/src/notification.ts
213
+ import { z as z2 } from "zod";
214
+ var notificationEventSchema = z2.enum([
215
+ "citation.lost",
216
+ "citation.gained",
217
+ "run.completed",
218
+ "run.failed"
374
219
  ]);
375
- var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
376
- id: text("id").primaryKey(),
377
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
378
- syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
379
- date: text("date").notNull(),
380
- indexed: integer("indexed").notNull().default(0),
381
- notIndexed: integer("not_indexed").notNull().default(0),
382
- reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
383
- createdAt: text("created_at").notNull()
384
- }, (table) => [
385
- index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
386
- index("idx_gsc_coverage_snap_run").on(table.syncRunId)
387
- ]);
388
- var usageCounters = sqliteTable("usage_counters", {
389
- id: text("id").primaryKey(),
390
- scope: text("scope").notNull(),
391
- period: text("period").notNull(),
392
- metric: text("metric").notNull(),
393
- count: integer("count").notNull().default(0),
394
- updatedAt: text("updated_at").notNull()
395
- }, (table) => [
396
- uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
397
- index("idx_usage_scope_period").on(table.scope, table.period)
398
- ]);
399
-
400
- // ../db/src/client.ts
401
- function createClient(databasePath) {
402
- mkdirSync(dirname(databasePath), { recursive: true });
403
- const sqlite = new Database(databasePath);
404
- sqlite.pragma("journal_mode = WAL");
405
- sqlite.pragma("foreign_keys = ON");
406
- return drizzle(sqlite, { schema: schema_exports });
407
- }
408
-
409
- // ../db/src/migrate.ts
410
- import { sql } from "drizzle-orm";
411
- var MIGRATION_SQL = `
412
- CREATE TABLE IF NOT EXISTS projects (
413
- id TEXT PRIMARY KEY,
414
- name TEXT NOT NULL UNIQUE,
415
- display_name TEXT NOT NULL,
416
- canonical_domain TEXT NOT NULL,
417
- owned_domains TEXT NOT NULL DEFAULT '[]',
418
- country TEXT NOT NULL,
419
- language TEXT NOT NULL,
420
- tags TEXT NOT NULL DEFAULT '[]',
421
- labels TEXT NOT NULL DEFAULT '{}',
422
- providers TEXT NOT NULL DEFAULT '[]',
423
- config_source TEXT NOT NULL DEFAULT 'cli',
424
- config_revision INTEGER NOT NULL DEFAULT 1,
425
- created_at TEXT NOT NULL,
426
- updated_at TEXT NOT NULL
427
- );
428
-
429
- CREATE TABLE IF NOT EXISTS keywords (
430
- id TEXT PRIMARY KEY,
431
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
432
- keyword TEXT NOT NULL,
433
- created_at TEXT NOT NULL,
434
- UNIQUE(project_id, keyword)
435
- );
220
+ var notificationDtoSchema = z2.object({
221
+ id: z2.string(),
222
+ projectId: z2.string(),
223
+ channel: z2.literal("webhook"),
224
+ url: z2.string().url(),
225
+ events: z2.array(notificationEventSchema),
226
+ enabled: z2.boolean().default(true),
227
+ webhookSecret: z2.string().optional(),
228
+ createdAt: z2.string(),
229
+ updatedAt: z2.string()
230
+ });
436
231
 
437
- CREATE TABLE IF NOT EXISTS competitors (
438
- id TEXT PRIMARY KEY,
439
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
440
- domain TEXT NOT NULL,
441
- created_at TEXT NOT NULL,
442
- UNIQUE(project_id, domain)
443
- );
232
+ // ../contracts/src/config-schema.ts
233
+ var configMetadataSchema = z3.object({
234
+ name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
235
+ message: "Name must be a lowercase slug (letters, numbers, hyphens)"
236
+ }),
237
+ labels: z3.record(z3.string(), z3.string()).optional().default({})
238
+ });
239
+ var configScheduleSchema = z3.object({
240
+ preset: z3.string().optional(),
241
+ cron: z3.string().optional(),
242
+ timezone: z3.string().optional().default("UTC"),
243
+ providers: z3.array(providerNameSchema).optional().default([])
244
+ }).refine(
245
+ (data) => data.preset && !data.cron || !data.preset && data.cron,
246
+ { message: 'Exactly one of "preset" or "cron" must be provided' }
247
+ ).optional();
248
+ var configNotificationSchema = z3.object({
249
+ channel: z3.literal("webhook"),
250
+ url: z3.string().url(),
251
+ events: z3.array(notificationEventSchema).min(1)
252
+ });
253
+ var configGoogleSchema = z3.object({
254
+ gsc: z3.object({
255
+ propertyUrl: z3.string()
256
+ }).optional(),
257
+ syncSchedule: z3.object({
258
+ preset: z3.string().optional(),
259
+ cron: z3.string().optional()
260
+ }).optional()
261
+ }).optional();
262
+ var configSpecSchema = z3.object({
263
+ displayName: z3.string().min(1),
264
+ canonicalDomain: z3.string().min(1),
265
+ ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
266
+ country: z3.string().length(2),
267
+ language: z3.string().min(2),
268
+ keywords: z3.array(z3.string().min(1)).optional().default([]),
269
+ competitors: z3.array(z3.string().min(1)).optional().default([]),
270
+ providers: z3.array(providerNameSchema).optional().default([]),
271
+ locations: z3.array(locationContextSchema).optional().default([]),
272
+ defaultLocation: z3.string().optional(),
273
+ schedule: configScheduleSchema,
274
+ notifications: z3.array(configNotificationSchema).optional().default([]),
275
+ google: configGoogleSchema
276
+ });
277
+ var projectConfigSchema = z3.object({
278
+ apiVersion: z3.literal("canonry/v1"),
279
+ kind: z3.literal("Project"),
280
+ metadata: configMetadataSchema,
281
+ spec: configSpecSchema
282
+ });
444
283
 
445
- CREATE TABLE IF NOT EXISTS runs (
446
- id TEXT PRIMARY KEY,
447
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
448
- kind TEXT NOT NULL DEFAULT 'answer-visibility',
449
- status TEXT NOT NULL DEFAULT 'queued',
450
- trigger TEXT NOT NULL DEFAULT 'manual',
451
- started_at TEXT,
452
- finished_at TEXT,
453
- error TEXT,
454
- created_at TEXT NOT NULL
455
- );
284
+ // ../contracts/src/models.ts
285
+ var MODEL_REGISTRY = {
286
+ gemini: {
287
+ defaultModel: "gemini-3-flash",
288
+ validationPattern: /^gemini-/,
289
+ validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
290
+ knownModels: [
291
+ { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
292
+ { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
293
+ { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
294
+ { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
295
+ ]
296
+ },
297
+ openai: {
298
+ defaultModel: "gpt-5.4",
299
+ validationPattern: /^(gpt-|o\d)/,
300
+ validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
301
+ knownModels: [
302
+ { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
303
+ { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
304
+ { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
305
+ { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
306
+ { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
307
+ { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
308
+ ]
309
+ },
310
+ claude: {
311
+ defaultModel: "claude-sonnet-4-6",
312
+ validationPattern: /^claude-/,
313
+ validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
314
+ knownModels: [
315
+ { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
316
+ { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
317
+ { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
318
+ ]
319
+ },
320
+ local: {
321
+ defaultModel: "llama3",
322
+ validationPattern: /./,
323
+ validationHint: "any model name accepted",
324
+ knownModels: [
325
+ { id: "llama3", displayName: "Llama 3", tier: "standard" }
326
+ ]
327
+ },
328
+ "cdp:chatgpt": {
329
+ defaultModel: "chatgpt-web",
330
+ validationPattern: /./,
331
+ validationHint: "model is detected from the ChatGPT web UI",
332
+ knownModels: [
333
+ { id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
334
+ ]
335
+ }
336
+ };
337
+ function getDefaultModel(provider) {
338
+ return MODEL_REGISTRY[provider].defaultModel;
339
+ }
340
+ function isValidModelName(provider, model) {
341
+ return MODEL_REGISTRY[provider].validationPattern.test(model);
342
+ }
456
343
 
457
- CREATE TABLE IF NOT EXISTS query_snapshots (
458
- id TEXT PRIMARY KEY,
459
- run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
460
- keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
461
- provider TEXT NOT NULL DEFAULT 'gemini',
462
- citation_state TEXT NOT NULL,
463
- answer_text TEXT,
464
- cited_domains TEXT NOT NULL DEFAULT '[]',
465
- competitor_overlap TEXT NOT NULL DEFAULT '[]',
466
- raw_response TEXT,
467
- created_at TEXT NOT NULL
468
- );
344
+ // ../contracts/src/errors.ts
345
+ var AppError = class extends Error {
346
+ code;
347
+ statusCode;
348
+ details;
349
+ constructor(code, message, statusCode, details) {
350
+ super(message);
351
+ this.name = "AppError";
352
+ this.code = code;
353
+ this.statusCode = statusCode;
354
+ this.details = details;
355
+ }
356
+ toJSON() {
357
+ return {
358
+ error: {
359
+ code: this.code,
360
+ message: this.message,
361
+ ...this.details ? { details: this.details } : {}
362
+ }
363
+ };
364
+ }
365
+ };
366
+ function notFound(entity, id) {
367
+ return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
368
+ }
369
+ function validationError(message, details) {
370
+ return new AppError("VALIDATION_ERROR", message, 400, details);
371
+ }
372
+ function authRequired() {
373
+ return new AppError("AUTH_REQUIRED", "Authentication required", 401);
374
+ }
375
+ function authInvalid() {
376
+ return new AppError("AUTH_INVALID", "Invalid API key", 401);
377
+ }
378
+ function runInProgress(projectName) {
379
+ return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
380
+ }
381
+ function unsupportedKind(kind) {
382
+ return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
383
+ }
469
384
 
470
- CREATE TABLE IF NOT EXISTS audit_log (
471
- id TEXT PRIMARY KEY,
472
- project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
473
- actor TEXT NOT NULL,
474
- action TEXT NOT NULL,
475
- entity_type TEXT NOT NULL,
476
- entity_id TEXT,
477
- diff TEXT,
478
- created_at TEXT NOT NULL
479
- );
385
+ // ../contracts/src/google.ts
386
+ import { z as z4 } from "zod";
387
+ var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
388
+ var googleConnectionDtoSchema = z4.object({
389
+ id: z4.string(),
390
+ domain: z4.string(),
391
+ connectionType: googleConnectionTypeSchema,
392
+ propertyId: z4.string().nullable().optional(),
393
+ sitemapUrl: z4.string().nullable().optional(),
394
+ scopes: z4.array(z4.string()).default([]),
395
+ createdAt: z4.string(),
396
+ updatedAt: z4.string()
397
+ });
398
+ var gscSearchDataDtoSchema = z4.object({
399
+ date: z4.string(),
400
+ query: z4.string(),
401
+ page: z4.string(),
402
+ country: z4.string().nullable().optional(),
403
+ device: z4.string().nullable().optional(),
404
+ clicks: z4.number(),
405
+ impressions: z4.number(),
406
+ ctr: z4.number(),
407
+ position: z4.number()
408
+ });
409
+ var gscUrlInspectionDtoSchema = z4.object({
410
+ id: z4.string(),
411
+ url: z4.string(),
412
+ indexingState: z4.string().nullable().optional(),
413
+ verdict: z4.string().nullable().optional(),
414
+ coverageState: z4.string().nullable().optional(),
415
+ pageFetchState: z4.string().nullable().optional(),
416
+ robotsTxtState: z4.string().nullable().optional(),
417
+ crawlTime: z4.string().nullable().optional(),
418
+ lastCrawlResult: z4.string().nullable().optional(),
419
+ isMobileFriendly: z4.boolean().nullable().optional(),
420
+ richResults: z4.array(z4.string()).default([]),
421
+ inspectedAt: z4.string()
422
+ });
423
+ var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
424
+ var gscDeindexedRowSchema = z4.object({
425
+ url: z4.string(),
426
+ previousState: z4.string().nullable(),
427
+ currentState: z4.string().nullable(),
428
+ transitionDate: z4.string()
429
+ });
430
+ var gscReasonGroupSchema = z4.object({
431
+ reason: z4.string(),
432
+ count: z4.number(),
433
+ urls: z4.array(gscUrlInspectionDtoSchema).default([])
434
+ });
435
+ var gscCoverageSummaryDtoSchema = z4.object({
436
+ summary: z4.object({
437
+ total: z4.number(),
438
+ indexed: z4.number(),
439
+ notIndexed: z4.number(),
440
+ deindexed: z4.number(),
441
+ percentage: z4.number()
442
+ }),
443
+ lastInspectedAt: z4.string().nullable(),
444
+ indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
445
+ notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
446
+ deindexed: z4.array(gscDeindexedRowSchema).default([]),
447
+ reasonGroups: z4.array(gscReasonGroupSchema).default([])
448
+ });
449
+ var indexingNotificationDtoSchema = z4.object({
450
+ url: z4.string(),
451
+ type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
452
+ notifiedAt: z4.string()
453
+ });
454
+ var indexingRequestResultDtoSchema = z4.object({
455
+ url: z4.string(),
456
+ type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
457
+ notifiedAt: z4.string(),
458
+ status: z4.enum(["success", "error"]),
459
+ error: z4.string().optional()
460
+ });
461
+ var gscCoverageSnapshotDtoSchema = z4.object({
462
+ date: z4.string(),
463
+ indexed: z4.number(),
464
+ notIndexed: z4.number(),
465
+ reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
466
+ });
480
467
 
481
- CREATE TABLE IF NOT EXISTS api_keys (
482
- id TEXT PRIMARY KEY,
483
- name TEXT NOT NULL,
484
- key_hash TEXT NOT NULL UNIQUE,
485
- key_prefix TEXT NOT NULL,
486
- scopes TEXT NOT NULL DEFAULT '["*"]',
487
- created_at TEXT NOT NULL,
488
- last_used_at TEXT,
489
- revoked_at TEXT
490
- );
468
+ // ../contracts/src/bing.ts
469
+ import { z as z5 } from "zod";
470
+ var bingConnectionDtoSchema = z5.object({
471
+ id: z5.string(),
472
+ domain: z5.string(),
473
+ siteUrl: z5.string().nullable().optional(),
474
+ createdAt: z5.string(),
475
+ updatedAt: z5.string()
476
+ });
477
+ var bingUrlInspectionDtoSchema = z5.object({
478
+ id: z5.string(),
479
+ url: z5.string(),
480
+ httpCode: z5.number().nullable().optional(),
481
+ inIndex: z5.boolean().nullable().optional(),
482
+ lastCrawledDate: z5.string().nullable().optional(),
483
+ inIndexDate: z5.string().nullable().optional(),
484
+ inspectedAt: z5.string()
485
+ });
486
+ var bingCoverageSummaryDtoSchema = z5.object({
487
+ summary: z5.object({
488
+ total: z5.number(),
489
+ indexed: z5.number(),
490
+ notIndexed: z5.number(),
491
+ percentage: z5.number()
492
+ }),
493
+ lastInspectedAt: z5.string().nullable(),
494
+ indexed: z5.array(bingUrlInspectionDtoSchema).default([]),
495
+ notIndexed: z5.array(bingUrlInspectionDtoSchema).default([])
496
+ });
497
+ var bingKeywordStatsDtoSchema = z5.object({
498
+ query: z5.string(),
499
+ impressions: z5.number(),
500
+ clicks: z5.number(),
501
+ ctr: z5.number(),
502
+ averagePosition: z5.number()
503
+ });
504
+ var bingSubmitResultDtoSchema = z5.object({
505
+ url: z5.string(),
506
+ status: z5.enum(["success", "error"]),
507
+ submittedAt: z5.string(),
508
+ error: z5.string().optional()
509
+ });
491
510
 
492
- CREATE TABLE IF NOT EXISTS usage_counters (
493
- id TEXT PRIMARY KEY,
494
- scope TEXT NOT NULL,
495
- period TEXT NOT NULL,
496
- metric TEXT NOT NULL,
497
- count INTEGER NOT NULL DEFAULT 0,
498
- updated_at TEXT NOT NULL,
499
- UNIQUE(scope, period, metric)
500
- );
511
+ // ../contracts/src/project.ts
512
+ import { z as z6 } from "zod";
513
+ var configSourceSchema = z6.enum(["cli", "api", "config-file"]);
514
+ var projectDtoSchema = z6.object({
515
+ id: z6.string(),
516
+ name: z6.string(),
517
+ displayName: z6.string().optional(),
518
+ canonicalDomain: z6.string(),
519
+ ownedDomains: z6.array(z6.string()).default([]),
520
+ country: z6.string().length(2),
521
+ language: z6.string().min(2),
522
+ tags: z6.array(z6.string()).default([]),
523
+ labels: z6.record(z6.string(), z6.string()).default({}),
524
+ locations: z6.array(locationContextSchema).default([]),
525
+ defaultLocation: z6.string().nullable().optional(),
526
+ configSource: configSourceSchema.default("cli"),
527
+ configRevision: z6.number().int().positive().default(1),
528
+ createdAt: z6.string().optional(),
529
+ updatedAt: z6.string().optional()
530
+ });
531
+ function normalizeProjectDomain(input) {
532
+ let domain = input.trim().toLowerCase();
533
+ try {
534
+ if (domain.includes("://")) {
535
+ domain = new URL(domain).hostname.toLowerCase();
536
+ }
537
+ } catch {
538
+ }
539
+ return domain.replace(/^www\./, "");
540
+ }
541
+ function effectiveDomains(project) {
542
+ const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
543
+ const seen = /* @__PURE__ */ new Set();
544
+ const result = [];
545
+ for (const d of all) {
546
+ const trimmed = d.trim();
547
+ if (!trimmed) continue;
548
+ const norm = normalizeProjectDomain(trimmed);
549
+ if (seen.has(norm)) continue;
550
+ seen.add(norm);
551
+ result.push(trimmed);
552
+ }
553
+ return result;
554
+ }
501
555
 
502
- CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
503
- CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
504
- CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
505
- CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
506
- CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
507
- CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
508
- CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
509
- CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
510
- CREATE TABLE IF NOT EXISTS schedules (
511
- id TEXT PRIMARY KEY,
512
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
513
- cron_expr TEXT NOT NULL,
514
- preset TEXT,
515
- timezone TEXT NOT NULL DEFAULT 'UTC',
516
- enabled INTEGER NOT NULL DEFAULT 1,
517
- providers TEXT NOT NULL DEFAULT '[]',
518
- last_run_at TEXT,
519
- next_run_at TEXT,
520
- created_at TEXT NOT NULL,
521
- updated_at TEXT NOT NULL,
522
- UNIQUE(project_id)
523
- );
556
+ // ../contracts/src/run.ts
557
+ import { z as z7 } from "zod";
558
+ var runStatusSchema = z7.enum(["queued", "running", "completed", "partial", "failed"]);
559
+ var runKindSchema = z7.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
560
+ var runTriggerSchema = z7.enum(["manual", "scheduled", "config-apply"]);
561
+ var citationStateSchema = z7.enum(["cited", "not-cited"]);
562
+ var computedTransitionSchema = z7.enum(["new", "cited", "lost", "emerging", "not-cited"]);
563
+ var runDtoSchema = z7.object({
564
+ id: z7.string(),
565
+ projectId: z7.string(),
566
+ kind: runKindSchema,
567
+ status: runStatusSchema,
568
+ trigger: runTriggerSchema.default("manual"),
569
+ location: z7.string().nullable().optional(),
570
+ startedAt: z7.string().nullable().optional(),
571
+ finishedAt: z7.string().nullable().optional(),
572
+ error: z7.string().nullable().optional(),
573
+ createdAt: z7.string()
574
+ });
575
+ var groundingSourceSchema = z7.object({
576
+ uri: z7.string(),
577
+ title: z7.string()
578
+ });
579
+ var querySnapshotDtoSchema = z7.object({
580
+ id: z7.string(),
581
+ runId: z7.string(),
582
+ keywordId: z7.string(),
583
+ keyword: z7.string().optional(),
584
+ provider: providerNameSchema,
585
+ citationState: citationStateSchema,
586
+ transition: computedTransitionSchema.optional(),
587
+ answerText: z7.string().nullable().optional(),
588
+ citedDomains: z7.array(z7.string()).default([]),
589
+ competitorOverlap: z7.array(z7.string()).default([]),
590
+ groundingSources: z7.array(groundingSourceSchema).default([]),
591
+ searchQueries: z7.array(z7.string()).default([]),
592
+ model: z7.string().nullable().optional(),
593
+ location: z7.string().nullable().optional(),
594
+ createdAt: z7.string()
595
+ });
596
+ var auditLogEntrySchema = z7.object({
597
+ id: z7.string(),
598
+ projectId: z7.string().nullable().optional(),
599
+ actor: z7.string(),
600
+ action: z7.string(),
601
+ entityType: z7.string(),
602
+ entityId: z7.string().nullable().optional(),
603
+ diff: z7.unknown().optional(),
604
+ createdAt: z7.string()
605
+ });
524
606
 
525
- CREATE TABLE IF NOT EXISTS notifications (
526
- id TEXT PRIMARY KEY,
527
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
528
- channel TEXT NOT NULL,
529
- config TEXT NOT NULL,
530
- enabled INTEGER NOT NULL DEFAULT 1,
531
- created_at TEXT NOT NULL,
532
- updated_at TEXT NOT NULL
533
- );
607
+ // ../contracts/src/schedule.ts
608
+ import { z as z8 } from "zod";
609
+ var scheduleDtoSchema = z8.object({
610
+ id: z8.string(),
611
+ projectId: z8.string(),
612
+ cronExpr: z8.string(),
613
+ preset: z8.string().nullable().optional(),
614
+ timezone: z8.string().default("UTC"),
615
+ enabled: z8.boolean().default(true),
616
+ providers: z8.array(providerNameSchema).default([]),
617
+ lastRunAt: z8.string().nullable().optional(),
618
+ nextRunAt: z8.string().nullable().optional(),
619
+ createdAt: z8.string(),
620
+ updatedAt: z8.string()
621
+ });
534
622
 
535
- CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
536
- CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
537
- CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
538
- CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
539
- `;
540
- var MIGRATIONS = [
541
- // v2: Add providers column to projects for multi-provider support
542
- `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
543
- // v3: Add webhook_secret column to notifications for HMAC signing
544
- `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
545
- // v4: Add owned_domains column to projects for multi-domain citation matching
546
- `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
547
- // v5: Add model column to query_snapshots for per-model scoring
548
- `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
549
- // v5b: Backfill model from rawResponse JSON for existing snapshots
550
- `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`,
551
- // v6: Google Search Console integration google_connections table (domain-scoped)
552
- `CREATE TABLE IF NOT EXISTS google_connections (
553
- id TEXT PRIMARY KEY,
554
- domain TEXT NOT NULL,
555
- connection_type TEXT NOT NULL,
556
- property_id TEXT,
557
- access_token TEXT,
558
- refresh_token TEXT,
559
- token_expires_at TEXT,
560
- scopes TEXT NOT NULL DEFAULT '[]',
561
- created_at TEXT NOT NULL,
562
- updated_at TEXT NOT NULL
563
- )`,
564
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
565
- // v6: Google Search Console integration gsc_search_data table
566
- `CREATE TABLE IF NOT EXISTS gsc_search_data (
567
- id TEXT PRIMARY KEY,
568
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
569
- sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
570
- date TEXT NOT NULL,
571
- query TEXT NOT NULL,
572
- page TEXT NOT NULL,
573
- country TEXT,
574
- device TEXT,
575
- clicks INTEGER NOT NULL DEFAULT 0,
576
- impressions INTEGER NOT NULL DEFAULT 0,
577
- ctr TEXT NOT NULL DEFAULT '0',
578
- position TEXT NOT NULL DEFAULT '0',
579
- created_at TEXT NOT NULL
580
- )`,
581
- `CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
582
- `CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
583
- `CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
584
- // v6: Google Search Console integration — gsc_url_inspections table
585
- `CREATE TABLE IF NOT EXISTS gsc_url_inspections (
586
- id TEXT PRIMARY KEY,
587
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
588
- sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
589
- url TEXT NOT NULL,
590
- indexing_state TEXT,
591
- verdict TEXT,
592
- coverage_state TEXT,
593
- page_fetch_state TEXT,
594
- robots_txt_state TEXT,
595
- crawl_time TEXT,
596
- last_crawl_result TEXT,
597
- is_mobile_friendly INTEGER,
598
- rich_results TEXT NOT NULL DEFAULT '[]',
599
- referring_urls TEXT NOT NULL DEFAULT '[]',
600
- inspected_at TEXT NOT NULL,
601
- created_at TEXT NOT NULL
602
- )`,
603
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
604
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
605
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
606
- // v7: GSC coverage snapshots for historical tracking
607
- `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
608
- id TEXT PRIMARY KEY,
609
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
610
- sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
611
- date TEXT NOT NULL,
612
- indexed INTEGER NOT NULL DEFAULT 0,
613
- not_indexed INTEGER NOT NULL DEFAULT 0,
614
- reason_breakdown TEXT NOT NULL DEFAULT '{}',
615
- created_at TEXT NOT NULL
616
- )`,
617
- `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
618
- `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
619
- // v8: Location-aware sweeps — project locations + snapshot location tag
620
- `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
621
- `ALTER TABLE projects ADD COLUMN default_location TEXT`,
622
- `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
623
- // v9: Add location column to runs for per-location run tracking
624
- `ALTER TABLE runs ADD COLUMN location TEXT`,
625
- // v10: Add sitemapUrl to google_connections for persistent sitemap storage
626
- `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
627
- // v11: CDP browser provider — screenshot path for captured evidence
628
- `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
623
+ // ../contracts/src/source-categories.ts
624
+ var SOURCE_CATEGORY_RULES = [
625
+ // Forums
626
+ { pattern: "reddit.com", category: "forum", label: "Reddit" },
627
+ { pattern: "quora.com", category: "forum", label: "Quora" },
628
+ { pattern: "stackexchange.com", category: "forum", label: "Stack Exchange" },
629
+ { pattern: "stackoverflow.com", category: "forum", label: "Stack Overflow" },
630
+ { pattern: "discourse.org", category: "forum", label: "Discourse" },
631
+ // Social
632
+ { pattern: "linkedin.com", category: "social", label: "LinkedIn" },
633
+ { pattern: "twitter.com", category: "social", label: "X (Twitter)" },
634
+ { pattern: "x.com", category: "social", label: "X (Twitter)" },
635
+ { pattern: "facebook.com", category: "social", label: "Facebook" },
636
+ { pattern: "instagram.com", category: "social", label: "Instagram" },
637
+ { pattern: "threads.net", category: "social", label: "Threads" },
638
+ { pattern: "pinterest.com", category: "social", label: "Pinterest" },
639
+ { pattern: "tiktok.com", category: "social", label: "TikTok" },
640
+ // Video
641
+ { pattern: "youtube.com", category: "video", label: "YouTube" },
642
+ { pattern: "youtu.be", category: "video", label: "YouTube" },
643
+ { pattern: "vimeo.com", category: "video", label: "Vimeo" },
644
+ // News
645
+ { pattern: "nytimes.com", category: "news", label: "NY Times" },
646
+ { pattern: "bbc.com", category: "news", label: "BBC" },
647
+ { pattern: "bbc.co.uk", category: "news", label: "BBC" },
648
+ { pattern: "cnn.com", category: "news", label: "CNN" },
649
+ { pattern: "reuters.com", category: "news", label: "Reuters" },
650
+ { pattern: "apnews.com", category: "news", label: "AP News" },
651
+ { pattern: "theguardian.com", category: "news", label: "The Guardian" },
652
+ { pattern: "washingtonpost.com", category: "news", label: "Washington Post" },
653
+ { pattern: "wsj.com", category: "news", label: "WSJ" },
654
+ { pattern: "forbes.com", category: "news", label: "Forbes" },
655
+ { pattern: "techcrunch.com", category: "news", label: "TechCrunch" },
656
+ { pattern: "theverge.com", category: "news", label: "The Verge" },
657
+ { pattern: "wired.com", category: "news", label: "Wired" },
658
+ { pattern: "arstechnica.com", category: "news", label: "Ars Technica" },
659
+ // Reference
660
+ { pattern: "wikipedia.org", category: "reference", label: "Wikipedia" },
661
+ { pattern: "wikimedia.org", category: "reference", label: "Wikimedia" },
662
+ { pattern: "britannica.com", category: "reference", label: "Britannica" },
663
+ { pattern: "merriam-webster.com", category: "reference", label: "Merriam-Webster" },
664
+ // Blog / Content platforms
665
+ { pattern: "medium.com", category: "blog", label: "Medium" },
666
+ { pattern: "substack.com", category: "blog", label: "Substack" },
667
+ { pattern: "dev.to", category: "blog", label: "DEV Community" },
668
+ { pattern: "hashnode.dev", category: "blog", label: "Hashnode" },
669
+ { pattern: "wordpress.com", category: "blog", label: "WordPress" },
670
+ { pattern: "blogger.com", category: "blog", label: "Blogger" },
671
+ { pattern: "hubspot.com", category: "blog", label: "HubSpot" },
672
+ // E-commerce
673
+ { pattern: "amazon.com", category: "ecommerce", label: "Amazon" },
674
+ { pattern: "amazon.co.uk", category: "ecommerce", label: "Amazon UK" },
675
+ { pattern: "shopify.com", category: "ecommerce", label: "Shopify" },
676
+ { pattern: "ebay.com", category: "ecommerce", label: "eBay" },
677
+ // Academic
678
+ { pattern: "scholar.google.com", category: "academic", label: "Google Scholar" },
679
+ { pattern: "arxiv.org", category: "academic", label: "arXiv" },
680
+ { pattern: "pubmed.ncbi.nlm.nih.gov", category: "academic", label: "PubMed" },
681
+ { pattern: "researchgate.net", category: "academic", label: "ResearchGate" },
682
+ { pattern: ".edu", category: "academic", label: "Academic (.edu)" }
629
683
  ];
630
- function migrate(db) {
631
- const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
632
- for (const statement of statements) {
633
- db.run(sql.raw(statement));
684
+ var CATEGORY_LABELS = {
685
+ social: "Social Media",
686
+ forum: "Forums & Q&A",
687
+ news: "News & Media",
688
+ reference: "Reference",
689
+ blog: "Blogs & Content",
690
+ ecommerce: "E-commerce",
691
+ video: "Video",
692
+ academic: "Academic",
693
+ other: "Other"
694
+ };
695
+ function categorizeSource(uri) {
696
+ let domain;
697
+ try {
698
+ const url = new URL(uri.startsWith("http") ? uri : `https://${uri}`);
699
+ domain = url.hostname.replace(/^www\./, "");
700
+ } catch {
701
+ domain = uri.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0] ?? uri;
634
702
  }
635
- for (const migration of MIGRATIONS) {
636
- try {
637
- db.run(sql.raw(migration));
638
- } catch {
703
+ const domainLower = domain.toLowerCase();
704
+ for (const rule of SOURCE_CATEGORY_RULES) {
705
+ if (domainLower === rule.pattern || domainLower.endsWith(`.${rule.pattern}`) || rule.pattern.startsWith(".") && domainLower.endsWith(rule.pattern)) {
706
+ return { category: rule.category, label: rule.label, domain };
639
707
  }
640
708
  }
709
+ return { category: "other", label: CATEGORY_LABELS.other, domain };
641
710
  }
642
-
643
- // ../contracts/src/config-schema.ts
644
- import { z as z3 } from "zod";
645
-
646
- // ../contracts/src/provider.ts
647
- import { z } from "zod";
648
- var providerQuotaPolicySchema = z.object({
649
- maxConcurrency: z.number().int().positive(),
650
- maxRequestsPerMinute: z.number().int().positive(),
651
- maxRequestsPerDay: z.number().int().positive()
652
- });
653
- var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
654
- var providerNameSchema = z.enum(PROVIDER_NAMES);
655
- var PROVIDER_MODE = {
656
- gemini: "api",
657
- openai: "api",
658
- claude: "api",
659
- local: "api",
660
- "cdp:chatgpt": "browser"
661
- };
662
- function isBrowserProvider(name) {
663
- return PROVIDER_MODE[name] === "browser";
664
- }
665
- var CDP_TARGETS = ["cdp:chatgpt"];
666
- function parseProviderName(input) {
667
- const lower = input.trim().toLowerCase();
668
- return PROVIDER_NAMES.includes(lower) ? lower : void 0;
669
- }
670
- function resolveProviderInput(input) {
671
- const lower = input.trim().toLowerCase();
672
- if (lower === "cdp") {
673
- return [...CDP_TARGETS];
674
- }
675
- const parsed = parseProviderName(lower);
676
- return parsed ? [parsed] : [];
711
+ function categoryLabel(category) {
712
+ return CATEGORY_LABELS[category];
677
713
  }
678
- var locationContextSchema = z.object({
679
- label: z.string().min(1),
680
- city: z.string().min(1),
681
- region: z.string().min(1),
682
- country: z.string().length(2),
683
- timezone: z.string().optional()
684
- });
685
714
 
686
- // ../contracts/src/notification.ts
687
- import { z as z2 } from "zod";
688
- var notificationEventSchema = z2.enum([
689
- "citation.lost",
690
- "citation.gained",
691
- "run.completed",
692
- "run.failed"
693
- ]);
694
- var notificationDtoSchema = z2.object({
695
- id: z2.string(),
696
- projectId: z2.string(),
697
- channel: z2.literal("webhook"),
698
- url: z2.string().url(),
699
- events: z2.array(notificationEventSchema),
700
- enabled: z2.boolean().default(true),
701
- webhookSecret: z2.string().optional(),
702
- createdAt: z2.string(),
703
- updatedAt: z2.string()
704
- });
715
+ // ../api-routes/src/auth.ts
716
+ import crypto2 from "crypto";
717
+ import { eq } from "drizzle-orm";
705
718
 
706
- // ../contracts/src/config-schema.ts
707
- var configMetadataSchema = z3.object({
708
- name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
709
- message: "Name must be a lowercase slug (letters, numbers, hyphens)"
710
- }),
711
- labels: z3.record(z3.string(), z3.string()).optional().default({})
719
+ // ../db/src/client.ts
720
+ import { mkdirSync } from "fs";
721
+ import { dirname } from "path";
722
+ import Database from "better-sqlite3";
723
+ import { drizzle } from "drizzle-orm/better-sqlite3";
724
+
725
+ // ../db/src/schema.ts
726
+ var schema_exports = {};
727
+ __export(schema_exports, {
728
+ apiKeys: () => apiKeys,
729
+ auditLog: () => auditLog,
730
+ bingConnections: () => bingConnections,
731
+ bingKeywordStats: () => bingKeywordStats,
732
+ bingUrlInspections: () => bingUrlInspections,
733
+ competitors: () => competitors,
734
+ googleConnections: () => googleConnections,
735
+ gscCoverageSnapshots: () => gscCoverageSnapshots,
736
+ gscSearchData: () => gscSearchData,
737
+ gscUrlInspections: () => gscUrlInspections,
738
+ keywords: () => keywords,
739
+ notifications: () => notifications,
740
+ projects: () => projects,
741
+ querySnapshots: () => querySnapshots,
742
+ runs: () => runs,
743
+ schedules: () => schedules,
744
+ usageCounters: () => usageCounters
712
745
  });
713
- var configScheduleSchema = z3.object({
714
- preset: z3.string().optional(),
715
- cron: z3.string().optional(),
716
- timezone: z3.string().optional().default("UTC"),
717
- providers: z3.array(providerNameSchema).optional().default([])
718
- }).refine(
719
- (data) => data.preset && !data.cron || !data.preset && data.cron,
720
- { message: 'Exactly one of "preset" or "cron" must be provided' }
721
- ).optional();
722
- var configNotificationSchema = z3.object({
723
- channel: z3.literal("webhook"),
724
- url: z3.string().url(),
725
- events: z3.array(notificationEventSchema).min(1)
726
- });
727
- var configGoogleSchema = z3.object({
728
- gsc: z3.object({
729
- propertyUrl: z3.string()
730
- }).optional(),
731
- syncSchedule: z3.object({
732
- preset: z3.string().optional(),
733
- cron: z3.string().optional()
734
- }).optional()
735
- }).optional();
736
- var configSpecSchema = z3.object({
737
- displayName: z3.string().min(1),
738
- canonicalDomain: z3.string().min(1),
739
- ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
740
- country: z3.string().length(2),
741
- language: z3.string().min(2),
742
- keywords: z3.array(z3.string().min(1)).optional().default([]),
743
- competitors: z3.array(z3.string().min(1)).optional().default([]),
744
- providers: z3.array(providerNameSchema).optional().default([]),
745
- locations: z3.array(locationContextSchema).optional().default([]),
746
- defaultLocation: z3.string().optional(),
747
- schedule: configScheduleSchema,
748
- notifications: z3.array(configNotificationSchema).optional().default([]),
749
- google: configGoogleSchema
750
- });
751
- var projectConfigSchema = z3.object({
752
- apiVersion: z3.literal("canonry/v1"),
753
- kind: z3.literal("Project"),
754
- metadata: configMetadataSchema,
755
- spec: configSpecSchema
746
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
747
+ var projects = sqliteTable("projects", {
748
+ id: text("id").primaryKey(),
749
+ name: text("name").notNull().unique(),
750
+ displayName: text("display_name").notNull(),
751
+ canonicalDomain: text("canonical_domain").notNull(),
752
+ ownedDomains: text("owned_domains").notNull().default("[]"),
753
+ country: text("country").notNull(),
754
+ language: text("language").notNull(),
755
+ tags: text("tags").notNull().default("[]"),
756
+ labels: text("labels").notNull().default("{}"),
757
+ providers: text("providers").notNull().default("[]"),
758
+ locations: text("locations").notNull().default("[]"),
759
+ defaultLocation: text("default_location"),
760
+ configSource: text("config_source").notNull().default("cli"),
761
+ configRevision: integer("config_revision").notNull().default(1),
762
+ createdAt: text("created_at").notNull(),
763
+ updatedAt: text("updated_at").notNull()
756
764
  });
765
+ var keywords = sqliteTable("keywords", {
766
+ id: text("id").primaryKey(),
767
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
768
+ keyword: text("keyword").notNull(),
769
+ createdAt: text("created_at").notNull()
770
+ }, (table) => [
771
+ index("idx_keywords_project").on(table.projectId),
772
+ uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
773
+ ]);
774
+ var competitors = sqliteTable("competitors", {
775
+ id: text("id").primaryKey(),
776
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
777
+ domain: text("domain").notNull(),
778
+ createdAt: text("created_at").notNull()
779
+ }, (table) => [
780
+ index("idx_competitors_project").on(table.projectId),
781
+ uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
782
+ ]);
783
+ var runs = sqliteTable("runs", {
784
+ id: text("id").primaryKey(),
785
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
786
+ kind: text("kind").notNull().default("answer-visibility"),
787
+ status: text("status").notNull().default("queued"),
788
+ trigger: text("trigger").notNull().default("manual"),
789
+ location: text("location"),
790
+ startedAt: text("started_at"),
791
+ finishedAt: text("finished_at"),
792
+ error: text("error"),
793
+ createdAt: text("created_at").notNull()
794
+ }, (table) => [
795
+ index("idx_runs_project").on(table.projectId),
796
+ index("idx_runs_status").on(table.status)
797
+ ]);
798
+ var querySnapshots = sqliteTable("query_snapshots", {
799
+ id: text("id").primaryKey(),
800
+ runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
801
+ keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
802
+ provider: text("provider").notNull().default("gemini"),
803
+ model: text("model"),
804
+ citationState: text("citation_state").notNull(),
805
+ answerText: text("answer_text"),
806
+ citedDomains: text("cited_domains").notNull().default("[]"),
807
+ competitorOverlap: text("competitor_overlap").notNull().default("[]"),
808
+ location: text("location"),
809
+ screenshotPath: text("screenshot_path"),
810
+ rawResponse: text("raw_response"),
811
+ createdAt: text("created_at").notNull()
812
+ }, (table) => [
813
+ index("idx_snapshots_run").on(table.runId),
814
+ index("idx_snapshots_keyword").on(table.keywordId)
815
+ ]);
816
+ var auditLog = sqliteTable("audit_log", {
817
+ id: text("id").primaryKey(),
818
+ projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
819
+ actor: text("actor").notNull(),
820
+ action: text("action").notNull(),
821
+ entityType: text("entity_type").notNull(),
822
+ entityId: text("entity_id"),
823
+ diff: text("diff"),
824
+ createdAt: text("created_at").notNull()
825
+ }, (table) => [
826
+ index("idx_audit_log_project").on(table.projectId),
827
+ index("idx_audit_log_created").on(table.createdAt)
828
+ ]);
829
+ var apiKeys = sqliteTable("api_keys", {
830
+ id: text("id").primaryKey(),
831
+ name: text("name").notNull(),
832
+ keyHash: text("key_hash").notNull().unique(),
833
+ keyPrefix: text("key_prefix").notNull(),
834
+ scopes: text("scopes").notNull().default('["*"]'),
835
+ createdAt: text("created_at").notNull(),
836
+ lastUsedAt: text("last_used_at"),
837
+ revokedAt: text("revoked_at")
838
+ }, (table) => [
839
+ index("idx_api_keys_prefix").on(table.keyPrefix)
840
+ ]);
841
+ var schedules = sqliteTable("schedules", {
842
+ id: text("id").primaryKey(),
843
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
844
+ cronExpr: text("cron_expr").notNull(),
845
+ preset: text("preset"),
846
+ timezone: text("timezone").notNull().default("UTC"),
847
+ enabled: integer("enabled").notNull().default(1),
848
+ providers: text("providers").notNull().default("[]"),
849
+ lastRunAt: text("last_run_at"),
850
+ nextRunAt: text("next_run_at"),
851
+ createdAt: text("created_at").notNull(),
852
+ updatedAt: text("updated_at").notNull()
853
+ }, (table) => [
854
+ uniqueIndex("idx_schedules_project").on(table.projectId)
855
+ ]);
856
+ var notifications = sqliteTable("notifications", {
857
+ id: text("id").primaryKey(),
858
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
859
+ channel: text("channel").notNull(),
860
+ config: text("config").notNull(),
861
+ webhookSecret: text("webhook_secret"),
862
+ enabled: integer("enabled").notNull().default(1),
863
+ createdAt: text("created_at").notNull(),
864
+ updatedAt: text("updated_at").notNull()
865
+ }, (table) => [
866
+ index("idx_notifications_project").on(table.projectId)
867
+ ]);
868
+ var googleConnections = sqliteTable("google_connections", {
869
+ id: text("id").primaryKey(),
870
+ domain: text("domain").notNull(),
871
+ connectionType: text("connection_type").notNull(),
872
+ propertyId: text("property_id"),
873
+ sitemapUrl: text("sitemap_url"),
874
+ accessToken: text("access_token"),
875
+ refreshToken: text("refresh_token"),
876
+ tokenExpiresAt: text("token_expires_at"),
877
+ scopes: text("scopes").notNull().default("[]"),
878
+ createdAt: text("created_at").notNull(),
879
+ updatedAt: text("updated_at").notNull()
880
+ }, (table) => [
881
+ uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
882
+ ]);
883
+ var gscSearchData = sqliteTable("gsc_search_data", {
884
+ id: text("id").primaryKey(),
885
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
886
+ syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
887
+ date: text("date").notNull(),
888
+ query: text("query").notNull(),
889
+ page: text("page").notNull(),
890
+ country: text("country"),
891
+ device: text("device"),
892
+ clicks: integer("clicks").notNull().default(0),
893
+ impressions: integer("impressions").notNull().default(0),
894
+ ctr: text("ctr").notNull().default("0"),
895
+ position: text("position").notNull().default("0"),
896
+ createdAt: text("created_at").notNull()
897
+ }, (table) => [
898
+ index("idx_gsc_search_project_date").on(table.projectId, table.date),
899
+ index("idx_gsc_search_query").on(table.query),
900
+ index("idx_gsc_search_run").on(table.syncRunId)
901
+ ]);
902
+ var gscUrlInspections = sqliteTable("gsc_url_inspections", {
903
+ id: text("id").primaryKey(),
904
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
905
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
906
+ url: text("url").notNull(),
907
+ indexingState: text("indexing_state"),
908
+ verdict: text("verdict"),
909
+ coverageState: text("coverage_state"),
910
+ pageFetchState: text("page_fetch_state"),
911
+ robotsTxtState: text("robots_txt_state"),
912
+ crawlTime: text("crawl_time"),
913
+ lastCrawlResult: text("last_crawl_result"),
914
+ isMobileFriendly: integer("is_mobile_friendly"),
915
+ richResults: text("rich_results").notNull().default("[]"),
916
+ referringUrls: text("referring_urls").notNull().default("[]"),
917
+ inspectedAt: text("inspected_at").notNull(),
918
+ createdAt: text("created_at").notNull()
919
+ }, (table) => [
920
+ index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
921
+ index("idx_gsc_inspect_run").on(table.syncRunId),
922
+ index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
923
+ ]);
924
+ var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
925
+ id: text("id").primaryKey(),
926
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
927
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
928
+ date: text("date").notNull(),
929
+ indexed: integer("indexed").notNull().default(0),
930
+ notIndexed: integer("not_indexed").notNull().default(0),
931
+ reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
932
+ createdAt: text("created_at").notNull()
933
+ }, (table) => [
934
+ index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
935
+ index("idx_gsc_coverage_snap_run").on(table.syncRunId)
936
+ ]);
937
+ var bingConnections = sqliteTable("bing_connections", {
938
+ id: text("id").primaryKey(),
939
+ domain: text("domain").notNull(),
940
+ siteUrl: text("site_url"),
941
+ createdAt: text("created_at").notNull(),
942
+ updatedAt: text("updated_at").notNull()
943
+ }, (table) => [
944
+ uniqueIndex("idx_bing_conn_domain").on(table.domain)
945
+ ]);
946
+ var bingUrlInspections = sqliteTable("bing_url_inspections", {
947
+ id: text("id").primaryKey(),
948
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
949
+ url: text("url").notNull(),
950
+ httpCode: integer("http_code"),
951
+ inIndex: integer("in_index"),
952
+ lastCrawledDate: text("last_crawled_date"),
953
+ inIndexDate: text("in_index_date"),
954
+ inspectedAt: text("inspected_at").notNull(),
955
+ createdAt: text("created_at").notNull()
956
+ }, (table) => [
957
+ index("idx_bing_inspect_project_url").on(table.projectId, table.url),
958
+ index("idx_bing_inspect_url_time").on(table.url, table.inspectedAt)
959
+ ]);
960
+ var bingKeywordStats = sqliteTable("bing_keyword_stats", {
961
+ id: text("id").primaryKey(),
962
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
963
+ query: text("query").notNull(),
964
+ impressions: integer("impressions").notNull().default(0),
965
+ clicks: integer("clicks").notNull().default(0),
966
+ ctr: text("ctr").notNull().default("0"),
967
+ averagePosition: text("average_position").notNull().default("0"),
968
+ syncedAt: text("synced_at").notNull(),
969
+ createdAt: text("created_at").notNull()
970
+ }, (table) => [
971
+ index("idx_bing_keyword_project").on(table.projectId),
972
+ index("idx_bing_keyword_query").on(table.query)
973
+ ]);
974
+ var usageCounters = sqliteTable("usage_counters", {
975
+ id: text("id").primaryKey(),
976
+ scope: text("scope").notNull(),
977
+ period: text("period").notNull(),
978
+ metric: text("metric").notNull(),
979
+ count: integer("count").notNull().default(0),
980
+ updatedAt: text("updated_at").notNull()
981
+ }, (table) => [
982
+ uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
983
+ index("idx_usage_scope_period").on(table.scope, table.period)
984
+ ]);
757
985
 
758
- // ../contracts/src/models.ts
759
- var MODEL_REGISTRY = {
760
- gemini: {
761
- defaultModel: "gemini-3-flash",
762
- validationPattern: /^gemini-/,
763
- validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
764
- knownModels: [
765
- { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
766
- { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
767
- { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
768
- { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
769
- ]
770
- },
771
- openai: {
772
- defaultModel: "gpt-5.4",
773
- validationPattern: /^(gpt-|o\d)/,
774
- validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
775
- knownModels: [
776
- { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
777
- { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
778
- { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
779
- { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
780
- { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
781
- { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
782
- ]
783
- },
784
- claude: {
785
- defaultModel: "claude-sonnet-4-6",
786
- validationPattern: /^claude-/,
787
- validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
788
- knownModels: [
789
- { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
790
- { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
791
- { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
792
- ]
793
- },
794
- local: {
795
- defaultModel: "llama3",
796
- validationPattern: /./,
797
- validationHint: "any model name accepted",
798
- knownModels: [
799
- { id: "llama3", displayName: "Llama 3", tier: "standard" }
800
- ]
801
- },
802
- "cdp:chatgpt": {
803
- defaultModel: "chatgpt-web",
804
- validationPattern: /./,
805
- validationHint: "model is detected from the ChatGPT web UI",
806
- knownModels: [
807
- { id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
808
- ]
809
- }
810
- };
811
- function getDefaultModel(provider) {
812
- return MODEL_REGISTRY[provider].defaultModel;
813
- }
814
- function isValidModelName(provider, model) {
815
- return MODEL_REGISTRY[provider].validationPattern.test(model);
986
+ // ../db/src/client.ts
987
+ function createClient(databasePath) {
988
+ mkdirSync(dirname(databasePath), { recursive: true });
989
+ const sqlite = new Database(databasePath);
990
+ sqlite.pragma("journal_mode = WAL");
991
+ sqlite.pragma("foreign_keys = ON");
992
+ return drizzle(sqlite, { schema: schema_exports });
816
993
  }
817
994
 
818
- // ../contracts/src/errors.ts
819
- var AppError = class extends Error {
820
- code;
821
- statusCode;
822
- details;
823
- constructor(code, message, statusCode, details) {
824
- super(message);
825
- this.name = "AppError";
826
- this.code = code;
827
- this.statusCode = statusCode;
828
- this.details = details;
829
- }
830
- toJSON() {
831
- return {
832
- error: {
833
- code: this.code,
834
- message: this.message,
835
- ...this.details ? { details: this.details } : {}
836
- }
837
- };
838
- }
839
- };
840
- function notFound(entity, id) {
841
- return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
842
- }
843
- function validationError(message, details) {
844
- return new AppError("VALIDATION_ERROR", message, 400, details);
845
- }
846
- function authRequired() {
847
- return new AppError("AUTH_REQUIRED", "Authentication required", 401);
848
- }
849
- function authInvalid() {
850
- return new AppError("AUTH_INVALID", "Invalid API key", 401);
851
- }
852
- function runInProgress(projectName) {
853
- return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
854
- }
855
- function unsupportedKind(kind) {
856
- return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
857
- }
995
+ // ../db/src/migrate.ts
996
+ import { sql } from "drizzle-orm";
997
+ var MIGRATION_SQL = `
998
+ CREATE TABLE IF NOT EXISTS projects (
999
+ id TEXT PRIMARY KEY,
1000
+ name TEXT NOT NULL UNIQUE,
1001
+ display_name TEXT NOT NULL,
1002
+ canonical_domain TEXT NOT NULL,
1003
+ owned_domains TEXT NOT NULL DEFAULT '[]',
1004
+ country TEXT NOT NULL,
1005
+ language TEXT NOT NULL,
1006
+ tags TEXT NOT NULL DEFAULT '[]',
1007
+ labels TEXT NOT NULL DEFAULT '{}',
1008
+ providers TEXT NOT NULL DEFAULT '[]',
1009
+ config_source TEXT NOT NULL DEFAULT 'cli',
1010
+ config_revision INTEGER NOT NULL DEFAULT 1,
1011
+ created_at TEXT NOT NULL,
1012
+ updated_at TEXT NOT NULL
1013
+ );
858
1014
 
859
- // ../contracts/src/google.ts
860
- import { z as z4 } from "zod";
861
- var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
862
- var googleConnectionDtoSchema = z4.object({
863
- id: z4.string(),
864
- domain: z4.string(),
865
- connectionType: googleConnectionTypeSchema,
866
- propertyId: z4.string().nullable().optional(),
867
- sitemapUrl: z4.string().nullable().optional(),
868
- scopes: z4.array(z4.string()).default([]),
869
- createdAt: z4.string(),
870
- updatedAt: z4.string()
871
- });
872
- var gscSearchDataDtoSchema = z4.object({
873
- date: z4.string(),
874
- query: z4.string(),
875
- page: z4.string(),
876
- country: z4.string().nullable().optional(),
877
- device: z4.string().nullable().optional(),
878
- clicks: z4.number(),
879
- impressions: z4.number(),
880
- ctr: z4.number(),
881
- position: z4.number()
882
- });
883
- var gscUrlInspectionDtoSchema = z4.object({
884
- id: z4.string(),
885
- url: z4.string(),
886
- indexingState: z4.string().nullable().optional(),
887
- verdict: z4.string().nullable().optional(),
888
- coverageState: z4.string().nullable().optional(),
889
- pageFetchState: z4.string().nullable().optional(),
890
- robotsTxtState: z4.string().nullable().optional(),
891
- crawlTime: z4.string().nullable().optional(),
892
- lastCrawlResult: z4.string().nullable().optional(),
893
- isMobileFriendly: z4.boolean().nullable().optional(),
894
- richResults: z4.array(z4.string()).default([]),
895
- inspectedAt: z4.string()
896
- });
897
- var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
898
- var gscDeindexedRowSchema = z4.object({
899
- url: z4.string(),
900
- previousState: z4.string().nullable(),
901
- currentState: z4.string().nullable(),
902
- transitionDate: z4.string()
903
- });
904
- var gscReasonGroupSchema = z4.object({
905
- reason: z4.string(),
906
- count: z4.number(),
907
- urls: z4.array(gscUrlInspectionDtoSchema).default([])
908
- });
909
- var gscCoverageSummaryDtoSchema = z4.object({
910
- summary: z4.object({
911
- total: z4.number(),
912
- indexed: z4.number(),
913
- notIndexed: z4.number(),
914
- deindexed: z4.number(),
915
- percentage: z4.number()
916
- }),
917
- lastInspectedAt: z4.string().nullable(),
918
- indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
919
- notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
920
- deindexed: z4.array(gscDeindexedRowSchema).default([]),
921
- reasonGroups: z4.array(gscReasonGroupSchema).default([])
922
- });
923
- var indexingNotificationDtoSchema = z4.object({
924
- url: z4.string(),
925
- type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
926
- notifiedAt: z4.string()
927
- });
928
- var indexingRequestResultDtoSchema = z4.object({
929
- url: z4.string(),
930
- type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
931
- notifiedAt: z4.string(),
932
- status: z4.enum(["success", "error"]),
933
- error: z4.string().optional()
934
- });
935
- var gscCoverageSnapshotDtoSchema = z4.object({
936
- date: z4.string(),
937
- indexed: z4.number(),
938
- notIndexed: z4.number(),
939
- reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
940
- });
1015
+ CREATE TABLE IF NOT EXISTS keywords (
1016
+ id TEXT PRIMARY KEY,
1017
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1018
+ keyword TEXT NOT NULL,
1019
+ created_at TEXT NOT NULL,
1020
+ UNIQUE(project_id, keyword)
1021
+ );
1022
+
1023
+ CREATE TABLE IF NOT EXISTS competitors (
1024
+ id TEXT PRIMARY KEY,
1025
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1026
+ domain TEXT NOT NULL,
1027
+ created_at TEXT NOT NULL,
1028
+ UNIQUE(project_id, domain)
1029
+ );
1030
+
1031
+ CREATE TABLE IF NOT EXISTS runs (
1032
+ id TEXT PRIMARY KEY,
1033
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1034
+ kind TEXT NOT NULL DEFAULT 'answer-visibility',
1035
+ status TEXT NOT NULL DEFAULT 'queued',
1036
+ trigger TEXT NOT NULL DEFAULT 'manual',
1037
+ started_at TEXT,
1038
+ finished_at TEXT,
1039
+ error TEXT,
1040
+ created_at TEXT NOT NULL
1041
+ );
1042
+
1043
+ CREATE TABLE IF NOT EXISTS query_snapshots (
1044
+ id TEXT PRIMARY KEY,
1045
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1046
+ keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
1047
+ provider TEXT NOT NULL DEFAULT 'gemini',
1048
+ citation_state TEXT NOT NULL,
1049
+ answer_text TEXT,
1050
+ cited_domains TEXT NOT NULL DEFAULT '[]',
1051
+ competitor_overlap TEXT NOT NULL DEFAULT '[]',
1052
+ raw_response TEXT,
1053
+ created_at TEXT NOT NULL
1054
+ );
1055
+
1056
+ CREATE TABLE IF NOT EXISTS audit_log (
1057
+ id TEXT PRIMARY KEY,
1058
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
1059
+ actor TEXT NOT NULL,
1060
+ action TEXT NOT NULL,
1061
+ entity_type TEXT NOT NULL,
1062
+ entity_id TEXT,
1063
+ diff TEXT,
1064
+ created_at TEXT NOT NULL
1065
+ );
1066
+
1067
+ CREATE TABLE IF NOT EXISTS api_keys (
1068
+ id TEXT PRIMARY KEY,
1069
+ name TEXT NOT NULL,
1070
+ key_hash TEXT NOT NULL UNIQUE,
1071
+ key_prefix TEXT NOT NULL,
1072
+ scopes TEXT NOT NULL DEFAULT '["*"]',
1073
+ created_at TEXT NOT NULL,
1074
+ last_used_at TEXT,
1075
+ revoked_at TEXT
1076
+ );
941
1077
 
942
- // ../contracts/src/project.ts
943
- import { z as z5 } from "zod";
944
- var configSourceSchema = z5.enum(["cli", "api", "config-file"]);
945
- var projectDtoSchema = z5.object({
946
- id: z5.string(),
947
- name: z5.string(),
948
- displayName: z5.string().optional(),
949
- canonicalDomain: z5.string(),
950
- ownedDomains: z5.array(z5.string()).default([]),
951
- country: z5.string().length(2),
952
- language: z5.string().min(2),
953
- tags: z5.array(z5.string()).default([]),
954
- labels: z5.record(z5.string(), z5.string()).default({}),
955
- locations: z5.array(locationContextSchema).default([]),
956
- defaultLocation: z5.string().nullable().optional(),
957
- configSource: configSourceSchema.default("cli"),
958
- configRevision: z5.number().int().positive().default(1),
959
- createdAt: z5.string().optional(),
960
- updatedAt: z5.string().optional()
961
- });
962
- function normalizeProjectDomain(input) {
963
- let domain = input.trim().toLowerCase();
964
- try {
965
- if (domain.includes("://")) {
966
- domain = new URL(domain).hostname.toLowerCase();
967
- }
968
- } catch {
969
- }
970
- return domain.replace(/^www\./, "");
971
- }
972
- function effectiveDomains(project) {
973
- const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
974
- const seen = /* @__PURE__ */ new Set();
975
- const result = [];
976
- for (const d of all) {
977
- const trimmed = d.trim();
978
- if (!trimmed) continue;
979
- const norm = normalizeProjectDomain(trimmed);
980
- if (seen.has(norm)) continue;
981
- seen.add(norm);
982
- result.push(trimmed);
983
- }
984
- return result;
985
- }
1078
+ CREATE TABLE IF NOT EXISTS usage_counters (
1079
+ id TEXT PRIMARY KEY,
1080
+ scope TEXT NOT NULL,
1081
+ period TEXT NOT NULL,
1082
+ metric TEXT NOT NULL,
1083
+ count INTEGER NOT NULL DEFAULT 0,
1084
+ updated_at TEXT NOT NULL,
1085
+ UNIQUE(scope, period, metric)
1086
+ );
986
1087
 
987
- // ../contracts/src/run.ts
988
- import { z as z6 } from "zod";
989
- var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
990
- var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
991
- var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
992
- var citationStateSchema = z6.enum(["cited", "not-cited"]);
993
- var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
994
- var runDtoSchema = z6.object({
995
- id: z6.string(),
996
- projectId: z6.string(),
997
- kind: runKindSchema,
998
- status: runStatusSchema,
999
- trigger: runTriggerSchema.default("manual"),
1000
- location: z6.string().nullable().optional(),
1001
- startedAt: z6.string().nullable().optional(),
1002
- finishedAt: z6.string().nullable().optional(),
1003
- error: z6.string().nullable().optional(),
1004
- createdAt: z6.string()
1005
- });
1006
- var groundingSourceSchema = z6.object({
1007
- uri: z6.string(),
1008
- title: z6.string()
1009
- });
1010
- var querySnapshotDtoSchema = z6.object({
1011
- id: z6.string(),
1012
- runId: z6.string(),
1013
- keywordId: z6.string(),
1014
- keyword: z6.string().optional(),
1015
- provider: providerNameSchema,
1016
- citationState: citationStateSchema,
1017
- transition: computedTransitionSchema.optional(),
1018
- answerText: z6.string().nullable().optional(),
1019
- citedDomains: z6.array(z6.string()).default([]),
1020
- competitorOverlap: z6.array(z6.string()).default([]),
1021
- groundingSources: z6.array(groundingSourceSchema).default([]),
1022
- searchQueries: z6.array(z6.string()).default([]),
1023
- model: z6.string().nullable().optional(),
1024
- location: z6.string().nullable().optional(),
1025
- createdAt: z6.string()
1026
- });
1027
- var auditLogEntrySchema = z6.object({
1028
- id: z6.string(),
1029
- projectId: z6.string().nullable().optional(),
1030
- actor: z6.string(),
1031
- action: z6.string(),
1032
- entityType: z6.string(),
1033
- entityId: z6.string().nullable().optional(),
1034
- diff: z6.unknown().optional(),
1035
- createdAt: z6.string()
1036
- });
1088
+ CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
1089
+ CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
1090
+ CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
1091
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
1092
+ CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
1093
+ CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
1094
+ CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
1095
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
1096
+ CREATE TABLE IF NOT EXISTS schedules (
1097
+ id TEXT PRIMARY KEY,
1098
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1099
+ cron_expr TEXT NOT NULL,
1100
+ preset TEXT,
1101
+ timezone TEXT NOT NULL DEFAULT 'UTC',
1102
+ enabled INTEGER NOT NULL DEFAULT 1,
1103
+ providers TEXT NOT NULL DEFAULT '[]',
1104
+ last_run_at TEXT,
1105
+ next_run_at TEXT,
1106
+ created_at TEXT NOT NULL,
1107
+ updated_at TEXT NOT NULL,
1108
+ UNIQUE(project_id)
1109
+ );
1037
1110
 
1038
- // ../contracts/src/schedule.ts
1039
- import { z as z7 } from "zod";
1040
- var scheduleDtoSchema = z7.object({
1041
- id: z7.string(),
1042
- projectId: z7.string(),
1043
- cronExpr: z7.string(),
1044
- preset: z7.string().nullable().optional(),
1045
- timezone: z7.string().default("UTC"),
1046
- enabled: z7.boolean().default(true),
1047
- providers: z7.array(providerNameSchema).default([]),
1048
- lastRunAt: z7.string().nullable().optional(),
1049
- nextRunAt: z7.string().nullable().optional(),
1050
- createdAt: z7.string(),
1051
- updatedAt: z7.string()
1052
- });
1111
+ CREATE TABLE IF NOT EXISTS notifications (
1112
+ id TEXT PRIMARY KEY,
1113
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1114
+ channel TEXT NOT NULL,
1115
+ config TEXT NOT NULL,
1116
+ enabled INTEGER NOT NULL DEFAULT 1,
1117
+ created_at TEXT NOT NULL,
1118
+ updated_at TEXT NOT NULL
1119
+ );
1053
1120
 
1054
- // ../contracts/src/source-categories.ts
1055
- var SOURCE_CATEGORY_RULES = [
1056
- // Forums
1057
- { pattern: "reddit.com", category: "forum", label: "Reddit" },
1058
- { pattern: "quora.com", category: "forum", label: "Quora" },
1059
- { pattern: "stackexchange.com", category: "forum", label: "Stack Exchange" },
1060
- { pattern: "stackoverflow.com", category: "forum", label: "Stack Overflow" },
1061
- { pattern: "discourse.org", category: "forum", label: "Discourse" },
1062
- // Social
1063
- { pattern: "linkedin.com", category: "social", label: "LinkedIn" },
1064
- { pattern: "twitter.com", category: "social", label: "X (Twitter)" },
1065
- { pattern: "x.com", category: "social", label: "X (Twitter)" },
1066
- { pattern: "facebook.com", category: "social", label: "Facebook" },
1067
- { pattern: "instagram.com", category: "social", label: "Instagram" },
1068
- { pattern: "threads.net", category: "social", label: "Threads" },
1069
- { pattern: "pinterest.com", category: "social", label: "Pinterest" },
1070
- { pattern: "tiktok.com", category: "social", label: "TikTok" },
1071
- // Video
1072
- { pattern: "youtube.com", category: "video", label: "YouTube" },
1073
- { pattern: "youtu.be", category: "video", label: "YouTube" },
1074
- { pattern: "vimeo.com", category: "video", label: "Vimeo" },
1075
- // News
1076
- { pattern: "nytimes.com", category: "news", label: "NY Times" },
1077
- { pattern: "bbc.com", category: "news", label: "BBC" },
1078
- { pattern: "bbc.co.uk", category: "news", label: "BBC" },
1079
- { pattern: "cnn.com", category: "news", label: "CNN" },
1080
- { pattern: "reuters.com", category: "news", label: "Reuters" },
1081
- { pattern: "apnews.com", category: "news", label: "AP News" },
1082
- { pattern: "theguardian.com", category: "news", label: "The Guardian" },
1083
- { pattern: "washingtonpost.com", category: "news", label: "Washington Post" },
1084
- { pattern: "wsj.com", category: "news", label: "WSJ" },
1085
- { pattern: "forbes.com", category: "news", label: "Forbes" },
1086
- { pattern: "techcrunch.com", category: "news", label: "TechCrunch" },
1087
- { pattern: "theverge.com", category: "news", label: "The Verge" },
1088
- { pattern: "wired.com", category: "news", label: "Wired" },
1089
- { pattern: "arstechnica.com", category: "news", label: "Ars Technica" },
1090
- // Reference
1091
- { pattern: "wikipedia.org", category: "reference", label: "Wikipedia" },
1092
- { pattern: "wikimedia.org", category: "reference", label: "Wikimedia" },
1093
- { pattern: "britannica.com", category: "reference", label: "Britannica" },
1094
- { pattern: "merriam-webster.com", category: "reference", label: "Merriam-Webster" },
1095
- // Blog / Content platforms
1096
- { pattern: "medium.com", category: "blog", label: "Medium" },
1097
- { pattern: "substack.com", category: "blog", label: "Substack" },
1098
- { pattern: "dev.to", category: "blog", label: "DEV Community" },
1099
- { pattern: "hashnode.dev", category: "blog", label: "Hashnode" },
1100
- { pattern: "wordpress.com", category: "blog", label: "WordPress" },
1101
- { pattern: "blogger.com", category: "blog", label: "Blogger" },
1102
- { pattern: "hubspot.com", category: "blog", label: "HubSpot" },
1103
- // E-commerce
1104
- { pattern: "amazon.com", category: "ecommerce", label: "Amazon" },
1105
- { pattern: "amazon.co.uk", category: "ecommerce", label: "Amazon UK" },
1106
- { pattern: "shopify.com", category: "ecommerce", label: "Shopify" },
1107
- { pattern: "ebay.com", category: "ecommerce", label: "eBay" },
1108
- // Academic
1109
- { pattern: "scholar.google.com", category: "academic", label: "Google Scholar" },
1110
- { pattern: "arxiv.org", category: "academic", label: "arXiv" },
1111
- { pattern: "pubmed.ncbi.nlm.nih.gov", category: "academic", label: "PubMed" },
1112
- { pattern: "researchgate.net", category: "academic", label: "ResearchGate" },
1113
- { pattern: ".edu", category: "academic", label: "Academic (.edu)" }
1114
- ];
1115
- var CATEGORY_LABELS = {
1116
- social: "Social Media",
1117
- forum: "Forums & Q&A",
1118
- news: "News & Media",
1119
- reference: "Reference",
1120
- blog: "Blogs & Content",
1121
- ecommerce: "E-commerce",
1122
- video: "Video",
1123
- academic: "Academic",
1124
- other: "Other"
1125
- };
1126
- function categorizeSource(uri) {
1127
- let domain;
1128
- try {
1129
- const url = new URL(uri.startsWith("http") ? uri : `https://${uri}`);
1130
- domain = url.hostname.replace(/^www\./, "");
1131
- } catch {
1132
- domain = uri.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0] ?? uri;
1121
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
1122
+ CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
1123
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
1124
+ CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
1125
+ `;
1126
+ var MIGRATIONS = [
1127
+ // v2: Add providers column to projects for multi-provider support
1128
+ `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
1129
+ // v3: Add webhook_secret column to notifications for HMAC signing
1130
+ `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
1131
+ // v4: Add owned_domains column to projects for multi-domain citation matching
1132
+ `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
1133
+ // v5: Add model column to query_snapshots for per-model scoring
1134
+ `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
1135
+ // v5b: Backfill model from rawResponse JSON for existing snapshots
1136
+ `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`,
1137
+ // v6: Google Search Console integration google_connections table (domain-scoped)
1138
+ `CREATE TABLE IF NOT EXISTS google_connections (
1139
+ id TEXT PRIMARY KEY,
1140
+ domain TEXT NOT NULL,
1141
+ connection_type TEXT NOT NULL,
1142
+ property_id TEXT,
1143
+ access_token TEXT,
1144
+ refresh_token TEXT,
1145
+ token_expires_at TEXT,
1146
+ scopes TEXT NOT NULL DEFAULT '[]',
1147
+ created_at TEXT NOT NULL,
1148
+ updated_at TEXT NOT NULL
1149
+ )`,
1150
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
1151
+ // v6: Google Search Console integration gsc_search_data table
1152
+ `CREATE TABLE IF NOT EXISTS gsc_search_data (
1153
+ id TEXT PRIMARY KEY,
1154
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1155
+ sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1156
+ date TEXT NOT NULL,
1157
+ query TEXT NOT NULL,
1158
+ page TEXT NOT NULL,
1159
+ country TEXT,
1160
+ device TEXT,
1161
+ clicks INTEGER NOT NULL DEFAULT 0,
1162
+ impressions INTEGER NOT NULL DEFAULT 0,
1163
+ ctr TEXT NOT NULL DEFAULT '0',
1164
+ position TEXT NOT NULL DEFAULT '0',
1165
+ created_at TEXT NOT NULL
1166
+ )`,
1167
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
1168
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
1169
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
1170
+ // v6: Google Search Console integration — gsc_url_inspections table
1171
+ `CREATE TABLE IF NOT EXISTS gsc_url_inspections (
1172
+ id TEXT PRIMARY KEY,
1173
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1174
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
1175
+ url TEXT NOT NULL,
1176
+ indexing_state TEXT,
1177
+ verdict TEXT,
1178
+ coverage_state TEXT,
1179
+ page_fetch_state TEXT,
1180
+ robots_txt_state TEXT,
1181
+ crawl_time TEXT,
1182
+ last_crawl_result TEXT,
1183
+ is_mobile_friendly INTEGER,
1184
+ rich_results TEXT NOT NULL DEFAULT '[]',
1185
+ referring_urls TEXT NOT NULL DEFAULT '[]',
1186
+ inspected_at TEXT NOT NULL,
1187
+ created_at TEXT NOT NULL
1188
+ )`,
1189
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
1190
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
1191
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
1192
+ // v7: GSC coverage snapshots for historical tracking
1193
+ `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
1194
+ id TEXT PRIMARY KEY,
1195
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1196
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
1197
+ date TEXT NOT NULL,
1198
+ indexed INTEGER NOT NULL DEFAULT 0,
1199
+ not_indexed INTEGER NOT NULL DEFAULT 0,
1200
+ reason_breakdown TEXT NOT NULL DEFAULT '{}',
1201
+ created_at TEXT NOT NULL
1202
+ )`,
1203
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
1204
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
1205
+ // v8: Location-aware sweeps — project locations + snapshot location tag
1206
+ `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
1207
+ `ALTER TABLE projects ADD COLUMN default_location TEXT`,
1208
+ `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
1209
+ // v9: Add location column to runs for per-location run tracking
1210
+ `ALTER TABLE runs ADD COLUMN location TEXT`,
1211
+ // v10: Add sitemapUrl to google_connections for persistent sitemap storage
1212
+ `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
1213
+ // v11: CDP browser provider — screenshot path for captured evidence
1214
+ `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
1215
+ ];
1216
+ function migrate(db) {
1217
+ const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
1218
+ for (const statement of statements) {
1219
+ db.run(sql.raw(statement));
1133
1220
  }
1134
- const domainLower = domain.toLowerCase();
1135
- for (const rule of SOURCE_CATEGORY_RULES) {
1136
- if (domainLower === rule.pattern || domainLower.endsWith(`.${rule.pattern}`) || rule.pattern.startsWith(".") && domainLower.endsWith(rule.pattern)) {
1137
- return { category: rule.category, label: rule.label, domain };
1221
+ for (const migration of MIGRATIONS) {
1222
+ try {
1223
+ db.run(sql.raw(migration));
1224
+ } catch {
1138
1225
  }
1139
1226
  }
1140
- return { category: "other", label: CATEGORY_LABELS.other, domain };
1141
- }
1142
- function categoryLabel(category) {
1143
- return CATEGORY_LABELS[category];
1144
1227
  }
1145
1228
 
1146
1229
  // ../api-routes/src/auth.ts
@@ -3644,7 +3727,8 @@ function buildOperationId(method, path7) {
3644
3727
  async function settingsRoutes(app, opts) {
3645
3728
  app.get("/settings", async () => ({
3646
3729
  providers: opts.providerSummary ?? [],
3647
- google: opts.google ?? { configured: false }
3730
+ google: opts.google ?? { configured: false },
3731
+ bing: opts.bing ?? { configured: false }
3648
3732
  }));
3649
3733
  app.put("/settings/providers/:name", async (request, reply) => {
3650
3734
  const providerName = parseProviderName(request.params.name);
@@ -3708,6 +3792,22 @@ async function settingsRoutes(app, opts) {
3708
3792
  }
3709
3793
  return result;
3710
3794
  });
3795
+ app.put("/settings/bing", async (request, reply) => {
3796
+ const { apiKey } = request.body ?? {};
3797
+ if (!apiKey || typeof apiKey !== "string") {
3798
+ return reply.status(400).send({
3799
+ error: { code: "VALIDATION_ERROR", message: "apiKey is required" }
3800
+ });
3801
+ }
3802
+ if (!opts.onBingUpdate) {
3803
+ return reply.status(501).send({ error: "Bing configuration updates are not supported in this deployment" });
3804
+ }
3805
+ const result = opts.onBingUpdate(apiKey);
3806
+ if (!result) {
3807
+ return reply.status(500).send({ error: "Failed to update Bing configuration" });
3808
+ }
3809
+ return result;
3810
+ });
3711
3811
  }
3712
3812
 
3713
3813
  // ../api-routes/src/telemetry.ts
@@ -4750,89 +4850,480 @@ async function googleRoutes(app, opts) {
4750
4850
  const run = app.db.select().from(runs).where(eq13(runs.id, runId)).get();
4751
4851
  return { sitemaps, primarySitemapUrl: sitemapUrl, run };
4752
4852
  });
4753
- app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
4853
+ app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
4854
+ const store = requireConnectionStore(reply);
4855
+ if (!store) return;
4856
+ const project = resolveProject(app.db, request.params.name);
4857
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
4858
+ if (!conn) {
4859
+ const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
4860
+ return reply.status(err.statusCode).send(err.toJSON());
4861
+ }
4862
+ if (!conn.propertyId) {
4863
+ const err = validationError("No GSC property configured for this connection");
4864
+ return reply.status(err.statusCode).send(err.toJSON());
4865
+ }
4866
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4867
+ const runId = crypto13.randomUUID();
4868
+ app.db.insert(runs).values({
4869
+ id: runId,
4870
+ projectId: project.id,
4871
+ kind: "inspect-sitemap",
4872
+ status: "queued",
4873
+ trigger: "manual",
4874
+ createdAt: now
4875
+ }).run();
4876
+ const { sitemapUrl } = request.body ?? {};
4877
+ if (opts.onInspectSitemapRequested) {
4878
+ opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl: sitemapUrl ?? void 0 });
4879
+ }
4880
+ const run = app.db.select().from(runs).where(eq13(runs.id, runId)).get();
4881
+ return run;
4882
+ });
4883
+ app.put("/projects/:name/google/connections/:type/sitemap", async (request, reply) => {
4884
+ const store = requireConnectionStore(reply);
4885
+ if (!store) return;
4886
+ const project = resolveProject(app.db, request.params.name);
4887
+ const { sitemapUrl } = request.body ?? {};
4888
+ if (!sitemapUrl || !sitemapUrl.trim()) {
4889
+ const err = validationError("sitemapUrl is required");
4890
+ return reply.status(err.statusCode).send(err.toJSON());
4891
+ }
4892
+ const conn = store.updateConnection(
4893
+ project.canonicalDomain,
4894
+ request.params.type,
4895
+ { sitemapUrl: sitemapUrl.trim(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
4896
+ );
4897
+ if (!conn) {
4898
+ const err = notFound("Google connection", request.params.type);
4899
+ return reply.status(err.statusCode).send(err.toJSON());
4900
+ }
4901
+ return { sitemapUrl: sitemapUrl.trim() };
4902
+ });
4903
+ app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
4904
+ const store = requireConnectionStore(reply);
4905
+ if (!store) return;
4906
+ const project = resolveProject(app.db, request.params.name);
4907
+ const { propertyId } = request.body ?? {};
4908
+ if (!propertyId) {
4909
+ const err = validationError("propertyId is required");
4910
+ return reply.status(err.statusCode).send(err.toJSON());
4911
+ }
4912
+ const conn = store.updateConnection(
4913
+ project.canonicalDomain,
4914
+ request.params.type,
4915
+ { propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
4916
+ );
4917
+ if (!conn) {
4918
+ const err = notFound("Google connection", request.params.type);
4919
+ return reply.status(err.statusCode).send(err.toJSON());
4920
+ }
4921
+ return { propertyId };
4922
+ });
4923
+ app.post("/projects/:name/google/indexing/request", async (request, reply) => {
4924
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
4925
+ if (!googleClientId || !googleClientSecret) {
4926
+ const err = validationError("Google OAuth is not configured");
4927
+ return reply.status(err.statusCode).send(err.toJSON());
4928
+ }
4929
+ const store = requireConnectionStore(reply);
4930
+ if (!store) return;
4931
+ const project = resolveProject(app.db, request.params.name);
4932
+ const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
4933
+ let urlsToNotify = request.body?.urls ?? [];
4934
+ if (request.body?.allUnindexed) {
4935
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
4936
+ const latestByUrl = /* @__PURE__ */ new Map();
4937
+ for (const row of allInspections) {
4938
+ if (!latestByUrl.has(row.url)) {
4939
+ latestByUrl.set(row.url, row);
4940
+ }
4941
+ }
4942
+ const unindexedUrls = [];
4943
+ for (const [url, row] of latestByUrl) {
4944
+ if (row.indexingState !== "INDEXING_ALLOWED") {
4945
+ unindexedUrls.push(url);
4946
+ }
4947
+ }
4948
+ if (unindexedUrls.length === 0) {
4949
+ const err = validationError('No unindexed URLs found. Run "canonry google inspect-sitemap" first.');
4950
+ return reply.status(err.statusCode).send(err.toJSON());
4951
+ }
4952
+ urlsToNotify = unindexedUrls;
4953
+ }
4954
+ if (urlsToNotify.length === 0) {
4955
+ const err = validationError("At least one URL is required (or use allUnindexed: true)");
4956
+ return reply.status(err.statusCode).send(err.toJSON());
4957
+ }
4958
+ if (urlsToNotify.length > INDEXING_API_DAILY_LIMIT) {
4959
+ const err = validationError(`Cannot request indexing for more than ${INDEXING_API_DAILY_LIMIT} URLs per request (got ${urlsToNotify.length})`);
4960
+ return reply.status(err.statusCode).send(err.toJSON());
4961
+ }
4962
+ const projectDomain = normalizeProjectDomain(project.canonicalDomain);
4963
+ const invalidUrls = urlsToNotify.filter((url) => {
4964
+ try {
4965
+ const hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
4966
+ return hostname !== projectDomain;
4967
+ } catch {
4968
+ return true;
4969
+ }
4970
+ });
4971
+ if (invalidUrls.length > 0) {
4972
+ const err = validationError(
4973
+ `URLs must belong to project domain "${project.canonicalDomain}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`
4974
+ );
4975
+ return reply.status(err.statusCode).send(err.toJSON());
4976
+ }
4977
+ const results = [];
4978
+ for (const url of urlsToNotify) {
4979
+ try {
4980
+ const response = await publishUrlNotification(accessToken, url, "URL_UPDATED");
4981
+ const notifyTime = response.urlNotificationMetadata?.latestUpdate?.notifyTime ?? (/* @__PURE__ */ new Date()).toISOString();
4982
+ results.push({
4983
+ url,
4984
+ type: "URL_UPDATED",
4985
+ notifiedAt: notifyTime,
4986
+ status: "success"
4987
+ });
4988
+ } catch (err) {
4989
+ const msg = err instanceof Error ? err.message : String(err);
4990
+ results.push({
4991
+ url,
4992
+ type: "URL_UPDATED",
4993
+ notifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
4994
+ status: "error",
4995
+ error: msg
4996
+ });
4997
+ }
4998
+ }
4999
+ const succeeded = results.filter((r) => r.status === "success").length;
5000
+ const failed = results.filter((r) => r.status === "error").length;
5001
+ return {
5002
+ summary: { total: results.length, succeeded, failed },
5003
+ results
5004
+ };
5005
+ });
5006
+ }
5007
+
5008
+ // ../api-routes/src/bing.ts
5009
+ import crypto14 from "crypto";
5010
+ import { eq as eq14, and as and4, desc as desc4 } from "drizzle-orm";
5011
+
5012
+ // ../integration-bing/src/constants.ts
5013
+ var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
5014
+ var BING_SUBMIT_URL_BATCH_LIMIT = 500;
5015
+ var BING_SUBMIT_URL_DAILY_LIMIT = 1e4;
5016
+
5017
+ // ../integration-bing/src/types.ts
5018
+ var BingApiError = class extends Error {
5019
+ status;
5020
+ constructor(message, status) {
5021
+ super(message);
5022
+ this.name = "BingApiError";
5023
+ this.status = status;
5024
+ }
5025
+ };
5026
+
5027
+ // ../integration-bing/src/bing-client.ts
5028
+ async function bingFetch(apiKey, endpoint, opts) {
5029
+ const method = opts?.method ?? "GET";
5030
+ const separator = endpoint.includes("?") ? "&" : "?";
5031
+ const url = `${BING_WMT_API_BASE}/${endpoint}${separator}apikey=${encodeURIComponent(apiKey)}`;
5032
+ const headers = {
5033
+ "Content-Type": "application/json; charset=utf-8"
5034
+ };
5035
+ const res = await fetch(url, {
5036
+ method,
5037
+ headers,
5038
+ body: opts?.body != null ? JSON.stringify(opts.body) : void 0
5039
+ });
5040
+ if (res.status === 401 || res.status === 403) {
5041
+ throw new BingApiError("Bing API key is invalid or unauthorized", res.status);
5042
+ }
5043
+ if (res.status === 429) {
5044
+ throw new BingApiError("Bing API rate limit exceeded", 429);
5045
+ }
5046
+ if (!res.ok) {
5047
+ const body = await res.text();
5048
+ throw new BingApiError(`Bing API error (${res.status}): ${body}`, res.status);
5049
+ }
5050
+ const text2 = await res.text();
5051
+ if (!text2 || text2.trim() === "") {
5052
+ return void 0;
5053
+ }
5054
+ try {
5055
+ const parsed = JSON.parse(text2);
5056
+ if (parsed && typeof parsed === "object" && "d" in parsed) {
5057
+ return parsed.d;
5058
+ }
5059
+ return parsed;
5060
+ } catch {
5061
+ throw new BingApiError("Bing API returned invalid JSON", 502);
5062
+ }
5063
+ }
5064
+ async function getSites(apiKey) {
5065
+ const data = await bingFetch(apiKey, "GetUserSites");
5066
+ return data ?? [];
5067
+ }
5068
+ async function getUrlInfo(apiKey, siteUrl, url) {
5069
+ const encodedSite = encodeURIComponent(siteUrl);
5070
+ const encodedUrl = encodeURIComponent(url);
5071
+ return bingFetch(apiKey, `GetUrlInfo?siteUrl=${encodedSite}&url=${encodedUrl}`);
5072
+ }
5073
+ async function submitUrl(apiKey, siteUrl, url) {
5074
+ await bingFetch(apiKey, "SubmitUrl", {
5075
+ method: "POST",
5076
+ body: { siteUrl, url }
5077
+ });
5078
+ }
5079
+ async function submitUrlBatch(apiKey, siteUrl, urls) {
5080
+ for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
5081
+ const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
5082
+ await bingFetch(apiKey, "SubmitUrlbatch", {
5083
+ method: "POST",
5084
+ body: { siteUrl, urlList: batch }
5085
+ });
5086
+ }
5087
+ }
5088
+ async function getKeywordStats(apiKey, siteUrl) {
5089
+ const encodedSite = encodeURIComponent(siteUrl);
5090
+ const data = await bingFetch(apiKey, `GetQueryStats?siteUrl=${encodedSite}`);
5091
+ return data ?? [];
5092
+ }
5093
+
5094
+ // ../api-routes/src/bing.ts
5095
+ async function bingRoutes(app, opts) {
5096
+ function requireConnectionStore(reply) {
5097
+ if (opts.bingConnectionStore) return opts.bingConnectionStore;
5098
+ const err = validationError("Bing connection storage is not configured for this deployment");
5099
+ reply.status(err.statusCode).send(err.toJSON());
5100
+ return null;
5101
+ }
5102
+ function requireConnection(store, domain, reply) {
5103
+ const conn = store.getConnection(domain);
5104
+ if (!conn) {
5105
+ const err = validationError('No Bing connection found for this domain. Run "canonry bing connect <project>" first.');
5106
+ reply.status(err.statusCode).send(err.toJSON());
5107
+ return null;
5108
+ }
5109
+ return conn;
5110
+ }
5111
+ app.post("/projects/:name/bing/connect", async (request, reply) => {
5112
+ const store = requireConnectionStore(reply);
5113
+ if (!store) return;
5114
+ const { apiKey } = request.body ?? {};
5115
+ if (!apiKey || typeof apiKey !== "string") {
5116
+ const err = validationError("apiKey is required");
5117
+ return reply.status(err.statusCode).send(err.toJSON());
5118
+ }
5119
+ const project = resolveProject(app.db, request.params.name);
5120
+ let sites;
5121
+ try {
5122
+ sites = await getSites(apiKey);
5123
+ } catch (e) {
5124
+ const msg = e instanceof Error ? e.message : String(e);
5125
+ const err = validationError(`Failed to verify Bing API key: ${msg}`);
5126
+ return reply.status(err.statusCode).send(err.toJSON());
5127
+ }
5128
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5129
+ const existing = store.getConnection(project.canonicalDomain);
5130
+ store.upsertConnection({
5131
+ domain: project.canonicalDomain,
5132
+ apiKey,
5133
+ siteUrl: existing?.siteUrl ?? null,
5134
+ createdAt: existing?.createdAt ?? now,
5135
+ updatedAt: now
5136
+ });
5137
+ writeAuditLog(app.db, {
5138
+ projectId: project.id,
5139
+ actor: "api",
5140
+ action: "bing.connected",
5141
+ entityType: "bing_connection",
5142
+ entityId: project.canonicalDomain
5143
+ });
5144
+ return {
5145
+ connected: true,
5146
+ domain: project.canonicalDomain,
5147
+ siteUrl: existing?.siteUrl ?? null,
5148
+ availableSites: sites.map((s) => ({ url: s.Url, verified: s.Verified ?? false }))
5149
+ };
5150
+ });
5151
+ app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
4754
5152
  const store = requireConnectionStore(reply);
4755
5153
  if (!store) return;
4756
5154
  const project = resolveProject(app.db, request.params.name);
4757
- const conn = store.getConnection(project.canonicalDomain, "gsc");
4758
- if (!conn) {
4759
- const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
4760
- return reply.status(err.statusCode).send(err.toJSON());
4761
- }
4762
- if (!conn.propertyId) {
4763
- const err = validationError("No GSC property configured for this connection");
5155
+ const deleted = store.deleteConnection(project.canonicalDomain);
5156
+ if (!deleted) {
5157
+ const err = notFound("Bing connection", project.canonicalDomain);
4764
5158
  return reply.status(err.statusCode).send(err.toJSON());
4765
5159
  }
4766
- const now = (/* @__PURE__ */ new Date()).toISOString();
4767
- const runId = crypto13.randomUUID();
4768
- app.db.insert(runs).values({
4769
- id: runId,
5160
+ writeAuditLog(app.db, {
4770
5161
  projectId: project.id,
4771
- kind: "inspect-sitemap",
4772
- status: "queued",
4773
- trigger: "manual",
4774
- createdAt: now
4775
- }).run();
4776
- const { sitemapUrl } = request.body ?? {};
4777
- if (opts.onInspectSitemapRequested) {
4778
- opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl: sitemapUrl ?? void 0 });
4779
- }
4780
- const run = app.db.select().from(runs).where(eq13(runs.id, runId)).get();
4781
- return run;
5162
+ actor: "api",
5163
+ action: "bing.disconnected",
5164
+ entityType: "bing_connection",
5165
+ entityId: project.canonicalDomain
5166
+ });
5167
+ return reply.status(204).send();
4782
5168
  });
4783
- app.put("/projects/:name/google/connections/:type/sitemap", async (request, reply) => {
5169
+ app.get("/projects/:name/bing/status", async (request, reply) => {
4784
5170
  const store = requireConnectionStore(reply);
4785
5171
  if (!store) return;
4786
5172
  const project = resolveProject(app.db, request.params.name);
4787
- const { sitemapUrl } = request.body ?? {};
4788
- if (!sitemapUrl || !sitemapUrl.trim()) {
4789
- const err = validationError("sitemapUrl is required");
5173
+ const conn = store.getConnection(project.canonicalDomain);
5174
+ return {
5175
+ connected: !!conn,
5176
+ domain: project.canonicalDomain,
5177
+ siteUrl: conn?.siteUrl ?? null,
5178
+ createdAt: conn?.createdAt ?? null,
5179
+ updatedAt: conn?.updatedAt ?? null
5180
+ };
5181
+ });
5182
+ app.get("/projects/:name/bing/sites", async (request, reply) => {
5183
+ const store = requireConnectionStore(reply);
5184
+ if (!store) return;
5185
+ const project = resolveProject(app.db, request.params.name);
5186
+ const conn = requireConnection(store, project.canonicalDomain, reply);
5187
+ if (!conn) return;
5188
+ const sites = await getSites(conn.apiKey);
5189
+ return { sites: sites.map((s) => ({ url: s.Url, verified: s.Verified ?? false })) };
5190
+ });
5191
+ app.post("/projects/:name/bing/set-site", async (request, reply) => {
5192
+ const store = requireConnectionStore(reply);
5193
+ if (!store) return;
5194
+ const project = resolveProject(app.db, request.params.name);
5195
+ const conn = requireConnection(store, project.canonicalDomain, reply);
5196
+ if (!conn) return;
5197
+ const { siteUrl } = request.body ?? {};
5198
+ if (!siteUrl || typeof siteUrl !== "string") {
5199
+ const err = validationError("siteUrl is required");
4790
5200
  return reply.status(err.statusCode).send(err.toJSON());
4791
5201
  }
4792
- const conn = store.updateConnection(
4793
- project.canonicalDomain,
4794
- request.params.type,
4795
- { sitemapUrl: sitemapUrl.trim(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
4796
- );
4797
- if (!conn) {
4798
- const err = notFound("Google connection", request.params.type);
4799
- return reply.status(err.statusCode).send(err.toJSON());
5202
+ store.updateConnection(project.canonicalDomain, {
5203
+ siteUrl,
5204
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5205
+ });
5206
+ return { siteUrl };
5207
+ });
5208
+ app.get("/projects/:name/bing/coverage", async (request, reply) => {
5209
+ const store = requireConnectionStore(reply);
5210
+ if (!store) return;
5211
+ const project = resolveProject(app.db, request.params.name);
5212
+ const conn = requireConnection(store, project.canonicalDomain, reply);
5213
+ if (!conn) return;
5214
+ const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
5215
+ const latestByUrl = /* @__PURE__ */ new Map();
5216
+ for (const row of allInspections) {
5217
+ if (!latestByUrl.has(row.url)) {
5218
+ latestByUrl.set(row.url, row);
5219
+ }
4800
5220
  }
4801
- return { sitemapUrl: sitemapUrl.trim() };
5221
+ const indexedUrls = [];
5222
+ const notIndexedUrls = [];
5223
+ let lastInspectedAt = null;
5224
+ for (const [, row] of latestByUrl) {
5225
+ if (row.inIndex === 1) {
5226
+ indexedUrls.push(row);
5227
+ } else {
5228
+ notIndexedUrls.push(row);
5229
+ }
5230
+ if (!lastInspectedAt || row.inspectedAt > lastInspectedAt) {
5231
+ lastInspectedAt = row.inspectedAt;
5232
+ }
5233
+ }
5234
+ const total = latestByUrl.size;
5235
+ const indexed = indexedUrls.length;
5236
+ const notIndexed = notIndexedUrls.length;
5237
+ const formatRow = (r) => ({
5238
+ id: r.id,
5239
+ url: r.url,
5240
+ httpCode: r.httpCode,
5241
+ inIndex: r.inIndex === 1 ? true : r.inIndex === 0 ? false : null,
5242
+ lastCrawledDate: r.lastCrawledDate,
5243
+ inIndexDate: r.inIndexDate,
5244
+ inspectedAt: r.inspectedAt
5245
+ });
5246
+ return {
5247
+ summary: {
5248
+ total,
5249
+ indexed,
5250
+ notIndexed,
5251
+ percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
5252
+ },
5253
+ lastInspectedAt,
5254
+ indexed: indexedUrls.map(formatRow),
5255
+ notIndexed: notIndexedUrls.map(formatRow)
5256
+ };
4802
5257
  });
4803
- app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
5258
+ app.get("/projects/:name/bing/inspections", async (request, reply) => {
4804
5259
  const store = requireConnectionStore(reply);
4805
5260
  if (!store) return;
4806
5261
  const project = resolveProject(app.db, request.params.name);
4807
- const { propertyId } = request.body ?? {};
4808
- if (!propertyId) {
4809
- const err = validationError("propertyId is required");
5262
+ const { url, limit } = request.query;
5263
+ const whereClause = url ? and4(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
5264
+ const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc4(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
5265
+ return filtered.map((r) => ({
5266
+ id: r.id,
5267
+ url: r.url,
5268
+ httpCode: r.httpCode,
5269
+ inIndex: r.inIndex === 1 ? true : r.inIndex === 0 ? false : null,
5270
+ lastCrawledDate: r.lastCrawledDate,
5271
+ inIndexDate: r.inIndexDate,
5272
+ inspectedAt: r.inspectedAt
5273
+ }));
5274
+ });
5275
+ app.post("/projects/:name/bing/inspect-url", async (request, reply) => {
5276
+ const store = requireConnectionStore(reply);
5277
+ if (!store) return;
5278
+ const project = resolveProject(app.db, request.params.name);
5279
+ const conn = requireConnection(store, project.canonicalDomain, reply);
5280
+ if (!conn) return;
5281
+ if (!conn.siteUrl) {
5282
+ const err = validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
4810
5283
  return reply.status(err.statusCode).send(err.toJSON());
4811
5284
  }
4812
- const conn = store.updateConnection(
4813
- project.canonicalDomain,
4814
- request.params.type,
4815
- { propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
4816
- );
4817
- if (!conn) {
4818
- const err = notFound("Google connection", request.params.type);
5285
+ const { url } = request.body ?? {};
5286
+ if (!url) {
5287
+ const err = validationError("url is required");
4819
5288
  return reply.status(err.statusCode).send(err.toJSON());
4820
5289
  }
4821
- return { propertyId };
5290
+ const result = await getUrlInfo(conn.apiKey, conn.siteUrl, url);
5291
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5292
+ const id = crypto14.randomUUID();
5293
+ app.db.insert(bingUrlInspections).values({
5294
+ id,
5295
+ projectId: project.id,
5296
+ url,
5297
+ httpCode: result.HttpCode ?? null,
5298
+ inIndex: result.InIndex === true ? 1 : result.InIndex === false ? 0 : null,
5299
+ lastCrawledDate: result.LastCrawledDate ?? null,
5300
+ inIndexDate: result.InIndexDate ?? null,
5301
+ inspectedAt: now,
5302
+ createdAt: now
5303
+ }).run();
5304
+ return {
5305
+ id,
5306
+ url,
5307
+ httpCode: result.HttpCode ?? null,
5308
+ inIndex: result.InIndex ?? null,
5309
+ lastCrawledDate: result.LastCrawledDate ?? null,
5310
+ inIndexDate: result.InIndexDate ?? null,
5311
+ inspectedAt: now
5312
+ };
4822
5313
  });
4823
- app.post("/projects/:name/google/indexing/request", async (request, reply) => {
4824
- const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
4825
- if (!googleClientId || !googleClientSecret) {
4826
- const err = validationError("Google OAuth is not configured");
4827
- return reply.status(err.statusCode).send(err.toJSON());
4828
- }
5314
+ app.post("/projects/:name/bing/request-indexing", async (request, reply) => {
4829
5315
  const store = requireConnectionStore(reply);
4830
5316
  if (!store) return;
4831
5317
  const project = resolveProject(app.db, request.params.name);
4832
- const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
4833
- let urlsToNotify = request.body?.urls ?? [];
5318
+ const conn = requireConnection(store, project.canonicalDomain, reply);
5319
+ if (!conn) return;
5320
+ if (!conn.siteUrl) {
5321
+ const err = validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
5322
+ return reply.status(err.statusCode).send(err.toJSON());
5323
+ }
5324
+ let urlsToSubmit = request.body?.urls ?? [];
4834
5325
  if (request.body?.allUnindexed) {
4835
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5326
+ const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
4836
5327
  const latestByUrl = /* @__PURE__ */ new Map();
4837
5328
  for (const row of allInspections) {
4838
5329
  if (!latestByUrl.has(row.url)) {
@@ -4841,59 +5332,50 @@ async function googleRoutes(app, opts) {
4841
5332
  }
4842
5333
  const unindexedUrls = [];
4843
5334
  for (const [url, row] of latestByUrl) {
4844
- if (row.indexingState !== "INDEXING_ALLOWED") {
5335
+ if (row.inIndex !== 1) {
4845
5336
  unindexedUrls.push(url);
4846
5337
  }
4847
5338
  }
4848
5339
  if (unindexedUrls.length === 0) {
4849
- const err = validationError('No unindexed URLs found. Run "canonry google inspect-sitemap" first.');
5340
+ const err = validationError('No unindexed URLs found. Run "canonry bing inspect <project> <url>" first.');
4850
5341
  return reply.status(err.statusCode).send(err.toJSON());
4851
5342
  }
4852
- urlsToNotify = unindexedUrls;
5343
+ urlsToSubmit = unindexedUrls;
4853
5344
  }
4854
- if (urlsToNotify.length === 0) {
5345
+ if (urlsToSubmit.length === 0) {
4855
5346
  const err = validationError("At least one URL is required (or use allUnindexed: true)");
4856
5347
  return reply.status(err.statusCode).send(err.toJSON());
4857
5348
  }
4858
- if (urlsToNotify.length > INDEXING_API_DAILY_LIMIT) {
4859
- const err = validationError(`Cannot request indexing for more than ${INDEXING_API_DAILY_LIMIT} URLs per request (got ${urlsToNotify.length})`);
4860
- return reply.status(err.statusCode).send(err.toJSON());
4861
- }
4862
- const projectDomain = normalizeProjectDomain(project.canonicalDomain);
4863
- const invalidUrls = urlsToNotify.filter((url) => {
4864
- try {
4865
- const hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
4866
- return hostname !== projectDomain;
4867
- } catch {
4868
- return true;
4869
- }
4870
- });
4871
- if (invalidUrls.length > 0) {
4872
- const err = validationError(
4873
- `URLs must belong to project domain "${project.canonicalDomain}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`
4874
- );
5349
+ if (urlsToSubmit.length > BING_SUBMIT_URL_DAILY_LIMIT) {
5350
+ const err = validationError(`Cannot submit more than ${BING_SUBMIT_URL_DAILY_LIMIT} URLs per day (got ${urlsToSubmit.length})`);
4875
5351
  return reply.status(err.statusCode).send(err.toJSON());
4876
5352
  }
4877
5353
  const results = [];
4878
- for (const url of urlsToNotify) {
5354
+ if (urlsToSubmit.length > 1) {
5355
+ for (let i = 0; i < urlsToSubmit.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
5356
+ const batch = urlsToSubmit.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
5357
+ try {
5358
+ await submitUrlBatch(conn.apiKey, conn.siteUrl, batch);
5359
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5360
+ for (const url of batch) {
5361
+ results.push({ url, status: "success", submittedAt: now });
5362
+ }
5363
+ } catch (e) {
5364
+ const msg = e instanceof Error ? e.message : String(e);
5365
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5366
+ for (const url of batch) {
5367
+ results.push({ url, status: "error", submittedAt: now, error: msg });
5368
+ }
5369
+ }
5370
+ }
5371
+ } else {
5372
+ const url = urlsToSubmit[0];
4879
5373
  try {
4880
- const response = await publishUrlNotification(accessToken, url, "URL_UPDATED");
4881
- const notifyTime = response.urlNotificationMetadata?.latestUpdate?.notifyTime ?? (/* @__PURE__ */ new Date()).toISOString();
4882
- results.push({
4883
- url,
4884
- type: "URL_UPDATED",
4885
- notifiedAt: notifyTime,
4886
- status: "success"
4887
- });
4888
- } catch (err) {
4889
- const msg = err instanceof Error ? err.message : String(err);
4890
- results.push({
4891
- url,
4892
- type: "URL_UPDATED",
4893
- notifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
4894
- status: "error",
4895
- error: msg
4896
- });
5374
+ await submitUrl(conn.apiKey, conn.siteUrl, url);
5375
+ results.push({ url, status: "success", submittedAt: (/* @__PURE__ */ new Date()).toISOString() });
5376
+ } catch (e) {
5377
+ const msg = e instanceof Error ? e.message : String(e);
5378
+ results.push({ url, status: "error", submittedAt: (/* @__PURE__ */ new Date()).toISOString(), error: msg });
4897
5379
  }
4898
5380
  }
4899
5381
  const succeeded = results.filter((r) => r.status === "success").length;
@@ -4903,20 +5385,39 @@ async function googleRoutes(app, opts) {
4903
5385
  results
4904
5386
  };
4905
5387
  });
5388
+ app.get("/projects/:name/bing/performance", async (request, reply) => {
5389
+ const store = requireConnectionStore(reply);
5390
+ if (!store) return;
5391
+ const project = resolveProject(app.db, request.params.name);
5392
+ const conn = requireConnection(store, project.canonicalDomain, reply);
5393
+ if (!conn) return;
5394
+ if (!conn.siteUrl) {
5395
+ const err = validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
5396
+ return reply.status(err.statusCode).send(err.toJSON());
5397
+ }
5398
+ const stats = await getKeywordStats(conn.apiKey, conn.siteUrl);
5399
+ return stats.map((s) => ({
5400
+ query: s.Query,
5401
+ impressions: s.Impressions,
5402
+ clicks: s.Clicks,
5403
+ ctr: s.Ctr,
5404
+ averagePosition: s.AverageClickPosition ?? s.AverageImpressionPosition ?? 0
5405
+ }));
5406
+ });
4906
5407
  }
4907
5408
 
4908
5409
  // ../api-routes/src/cdp.ts
4909
5410
  import fs2 from "fs";
4910
5411
  import path2 from "path";
4911
5412
  import os2 from "os";
4912
- import { eq as eq14, and as and4 } from "drizzle-orm";
5413
+ import { eq as eq15, and as and5 } from "drizzle-orm";
4913
5414
  function getScreenshotDir() {
4914
5415
  return path2.join(os2.homedir(), ".canonry", "screenshots");
4915
5416
  }
4916
5417
  async function cdpRoutes(app, opts) {
4917
5418
  app.get("/screenshots/:snapshotId", async (request, reply) => {
4918
5419
  const { snapshotId } = request.params;
4919
- const snapshot = app.db.select({ screenshotPath: querySnapshots.screenshotPath }).from(querySnapshots).where(eq14(querySnapshots.id, snapshotId)).get();
5420
+ const snapshot = app.db.select({ screenshotPath: querySnapshots.screenshotPath }).from(querySnapshots).where(eq15(querySnapshots.id, snapshotId)).get();
4920
5421
  if (!snapshot?.screenshotPath) {
4921
5422
  return reply.code(404).send({ error: "Screenshot not found" });
4922
5423
  }
@@ -4972,7 +5473,7 @@ async function cdpRoutes(app, opts) {
4972
5473
  async (request, reply) => {
4973
5474
  const project = resolveProject(app.db, request.params.name);
4974
5475
  const { runId } = request.params;
4975
- const run = app.db.select().from(runs).where(and4(eq14(runs.id, runId), eq14(runs.projectId, project.id))).get();
5476
+ const run = app.db.select().from(runs).where(and5(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
4976
5477
  if (!run) return reply.code(404).send({ error: "Run not found" });
4977
5478
  const snapshots = app.db.select({
4978
5479
  id: querySnapshots.id,
@@ -4982,8 +5483,8 @@ async function cdpRoutes(app, opts) {
4982
5483
  citedDomains: querySnapshots.citedDomains,
4983
5484
  screenshotPath: querySnapshots.screenshotPath,
4984
5485
  rawResponse: querySnapshots.rawResponse
4985
- }).from(querySnapshots).where(eq14(querySnapshots.runId, runId)).all();
4986
- const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq14(keywords.projectId, project.id)).all();
5486
+ }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
5487
+ const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
4987
5488
  const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
4988
5489
  const byKeyword = /* @__PURE__ */ new Map();
4989
5490
  for (const snap of snapshots) {
@@ -5067,6 +5568,27 @@ async function cdpRoutes(app, opts) {
5067
5568
  // ../api-routes/src/index.ts
5068
5569
  async function apiRoutes(app, opts) {
5069
5570
  app.decorate("db", opts.db);
5571
+ app.setErrorHandler((error, _request, reply) => {
5572
+ if (error instanceof AppError) {
5573
+ return reply.status(error.statusCode).send(error.toJSON());
5574
+ }
5575
+ const httpStatus = error.statusCode ?? error.status ?? 500;
5576
+ if (httpStatus >= 400 && httpStatus < 500) {
5577
+ return reply.status(httpStatus).send({
5578
+ error: {
5579
+ code: httpStatus === 401 ? "AUTH_INVALID" : httpStatus === 403 ? "FORBIDDEN" : httpStatus === 404 ? "NOT_FOUND" : httpStatus === 429 ? "QUOTA_EXCEEDED" : "VALIDATION_ERROR",
5580
+ message: error.message
5581
+ }
5582
+ });
5583
+ }
5584
+ app.log.error(error);
5585
+ return reply.status(500).send({
5586
+ error: {
5587
+ code: "INTERNAL_ERROR",
5588
+ message: "An unexpected error occurred"
5589
+ }
5590
+ });
5591
+ });
5070
5592
  if (!opts.skipAuth) {
5071
5593
  await app.register(authPlugin);
5072
5594
  }
@@ -5095,7 +5617,9 @@ async function apiRoutes(app, opts) {
5095
5617
  providerSummary: opts.providerSummary,
5096
5618
  onProviderUpdate: opts.onProviderUpdate,
5097
5619
  google: opts.googleSettingsSummary,
5098
- onGoogleUpdate: opts.onGoogleSettingsUpdate
5620
+ onGoogleUpdate: opts.onGoogleSettingsUpdate,
5621
+ bing: opts.bingSettingsSummary,
5622
+ onBingUpdate: opts.onBingSettingsUpdate
5099
5623
  });
5100
5624
  await api.register(scheduleRoutes, {
5101
5625
  onScheduleUpdated: opts.onScheduleUpdated
@@ -5105,6 +5629,9 @@ async function apiRoutes(app, opts) {
5105
5629
  getTelemetryStatus: opts.getTelemetryStatus,
5106
5630
  setTelemetryEnabled: opts.setTelemetryEnabled
5107
5631
  });
5632
+ await api.register(bingRoutes, {
5633
+ bingConnectionStore: opts.bingConnectionStore
5634
+ });
5108
5635
  await api.register(googleRoutes, {
5109
5636
  getGoogleAuthConfig: opts.getGoogleAuthConfig,
5110
5637
  googleConnectionStore: opts.googleConnectionStore,
@@ -5118,7 +5645,7 @@ async function apiRoutes(app, opts) {
5118
5645
  onCdpScreenshot: opts.onCdpScreenshot,
5119
5646
  onCdpConfigure: opts.onCdpConfigure
5120
5647
  });
5121
- }, { prefix: "/api/v1" });
5648
+ }, { prefix: opts.routePrefix ?? "/api/v1" });
5122
5649
  }
5123
5650
 
5124
5651
  // ../provider-gemini/src/normalize.ts
@@ -6677,11 +7204,11 @@ function removeGoogleConnection(config, domain, connectionType) {
6677
7204
  }
6678
7205
 
6679
7206
  // src/job-runner.ts
6680
- import crypto14 from "crypto";
7207
+ import crypto15 from "crypto";
6681
7208
  import fs4 from "fs";
6682
7209
  import path5 from "path";
6683
7210
  import os4 from "os";
6684
- import { eq as eq15, inArray as inArray3 } from "drizzle-orm";
7211
+ import { eq as eq16, inArray as inArray3 } from "drizzle-orm";
6685
7212
  var JobRunner = class {
6686
7213
  db;
6687
7214
  registry;
@@ -6695,7 +7222,7 @@ var JobRunner = class {
6695
7222
  if (stale.length === 0) return;
6696
7223
  const now = (/* @__PURE__ */ new Date()).toISOString();
6697
7224
  for (const run of stale) {
6698
- this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq15(runs.id, run.id)).run();
7225
+ this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq16(runs.id, run.id)).run();
6699
7226
  console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
6700
7227
  }
6701
7228
  }
@@ -6703,8 +7230,8 @@ var JobRunner = class {
6703
7230
  const now = (/* @__PURE__ */ new Date()).toISOString();
6704
7231
  const startTime = Date.now();
6705
7232
  try {
6706
- this.db.update(runs).set({ status: "running", startedAt: now }).where(eq15(runs.id, runId)).run();
6707
- const project = this.db.select().from(projects).where(eq15(projects.id, projectId)).get();
7233
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(eq16(runs.id, runId)).run();
7234
+ const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
6708
7235
  if (!project) {
6709
7236
  throw new Error(`Project ${projectId} not found`);
6710
7237
  }
@@ -6725,14 +7252,14 @@ var JobRunner = class {
6725
7252
  throw new Error("No providers configured. Add at least one provider API key.");
6726
7253
  }
6727
7254
  console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
6728
- const projectKeywords = this.db.select().from(keywords).where(eq15(keywords.projectId, projectId)).all();
6729
- const projectCompetitors = this.db.select().from(competitors).where(eq15(competitors.projectId, projectId)).all();
7255
+ const projectKeywords = this.db.select().from(keywords).where(eq16(keywords.projectId, projectId)).all();
7256
+ const projectCompetitors = this.db.select().from(competitors).where(eq16(competitors.projectId, projectId)).all();
6730
7257
  const competitorDomains = projectCompetitors.map((c) => c.domain);
6731
7258
  const queriesPerProvider = projectKeywords.length;
6732
7259
  const todayPeriod = getCurrentPeriod();
6733
7260
  for (const p of activeProviders) {
6734
7261
  const providerScope = `${projectId}:${p.adapter.name}`;
6735
- const providerUsage = this.db.select().from(usageCounters).where(eq15(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
7262
+ const providerUsage = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
6736
7263
  const limit = p.config.quotaPolicy.maxRequestsPerDay;
6737
7264
  if (providerUsage + queriesPerProvider > limit) {
6738
7265
  throw new Error(
@@ -6776,7 +7303,7 @@ var JobRunner = class {
6776
7303
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
6777
7304
  let screenshotRelPath = null;
6778
7305
  if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
6779
- const snapshotId = crypto14.randomUUID();
7306
+ const snapshotId = crypto15.randomUUID();
6780
7307
  const screenshotDir = path5.join(os4.homedir(), ".canonry", "screenshots", runId);
6781
7308
  if (!fs4.existsSync(screenshotDir)) fs4.mkdirSync(screenshotDir, { recursive: true });
6782
7309
  const destPath = path5.join(screenshotDir, `${snapshotId}.png`);
@@ -6804,7 +7331,7 @@ var JobRunner = class {
6804
7331
  }).run();
6805
7332
  } else {
6806
7333
  this.db.insert(querySnapshots).values({
6807
- id: crypto14.randomUUID(),
7334
+ id: crypto15.randomUUID(),
6808
7335
  runId,
6809
7336
  keywordId: kw.id,
6810
7337
  provider: providerName,
@@ -6859,7 +7386,7 @@ var JobRunner = class {
6859
7386
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
6860
7387
  let screenshotRelPath = null;
6861
7388
  if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
6862
- const snapshotId = crypto14.randomUUID();
7389
+ const snapshotId = crypto15.randomUUID();
6863
7390
  const screenshotDir = path5.join(os4.homedir(), ".canonry", "screenshots", runId);
6864
7391
  if (!fs4.existsSync(screenshotDir)) fs4.mkdirSync(screenshotDir, { recursive: true });
6865
7392
  const destPath = path5.join(screenshotDir, `${snapshotId}.png`);
@@ -6887,7 +7414,7 @@ var JobRunner = class {
6887
7414
  }).run();
6888
7415
  } else {
6889
7416
  this.db.insert(querySnapshots).values({
6890
- id: crypto14.randomUUID(),
7417
+ id: crypto15.randomUUID(),
6891
7418
  runId,
6892
7419
  keywordId: kw.id,
6893
7420
  provider: providerName,
@@ -6919,12 +7446,12 @@ var JobRunner = class {
6919
7446
  const someFailed = providerErrors.size > 0;
6920
7447
  if (allFailed) {
6921
7448
  const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
6922
- this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq15(runs.id, runId)).run();
7449
+ this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq16(runs.id, runId)).run();
6923
7450
  } else if (someFailed) {
6924
7451
  const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
6925
- this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq15(runs.id, runId)).run();
7452
+ this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq16(runs.id, runId)).run();
6926
7453
  } else {
6927
- this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
7454
+ this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq16(runs.id, runId)).run();
6928
7455
  }
6929
7456
  const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
6930
7457
  trackEvent("run.completed", {
@@ -6950,7 +7477,7 @@ var JobRunner = class {
6950
7477
  status: "failed",
6951
7478
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
6952
7479
  error: errorMessage
6953
- }).where(eq15(runs.id, runId)).run();
7480
+ }).where(eq16(runs.id, runId)).run();
6954
7481
  trackEvent("run.completed", {
6955
7482
  status: "failed",
6956
7483
  providerCount: 0,
@@ -6986,10 +7513,10 @@ var JobRunner = class {
6986
7513
  incrementUsage(scope, metric, count) {
6987
7514
  const now = /* @__PURE__ */ new Date();
6988
7515
  const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
6989
- const id = crypto14.randomUUID();
6990
- const existing = this.db.select().from(usageCounters).where(eq15(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
7516
+ const id = crypto15.randomUUID();
7517
+ const existing = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
6991
7518
  if (existing) {
6992
- this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq15(usageCounters.id, existing.id)).run();
7519
+ this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq16(usageCounters.id, existing.id)).run();
6993
7520
  } else {
6994
7521
  this.db.insert(usageCounters).values({
6995
7522
  id,
@@ -7069,8 +7596,8 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
7069
7596
  }
7070
7597
 
7071
7598
  // src/gsc-sync.ts
7072
- import crypto15 from "crypto";
7073
- import { eq as eq16, and as and5, sql as sql3 } from "drizzle-orm";
7599
+ import crypto16 from "crypto";
7600
+ import { eq as eq17, and as and6, sql as sql3 } from "drizzle-orm";
7074
7601
  function formatDate(d) {
7075
7602
  return d.toISOString().split("T")[0];
7076
7603
  }
@@ -7081,13 +7608,13 @@ function daysAgo(n) {
7081
7608
  }
7082
7609
  async function executeGscSync(db, runId, projectId, opts) {
7083
7610
  const now = (/* @__PURE__ */ new Date()).toISOString();
7084
- db.update(runs).set({ status: "running", startedAt: now }).where(eq16(runs.id, runId)).run();
7611
+ db.update(runs).set({ status: "running", startedAt: now }).where(eq17(runs.id, runId)).run();
7085
7612
  try {
7086
7613
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
7087
7614
  if (!googleClientId || !googleClientSecret) {
7088
7615
  throw new Error("Google OAuth is not configured in the local Canonry config");
7089
7616
  }
7090
- const project = db.select().from(projects).where(eq16(projects.id, projectId)).get();
7617
+ const project = db.select().from(projects).where(eq17(projects.id, projectId)).get();
7091
7618
  if (!project) {
7092
7619
  throw new Error(`Project not found: ${projectId}`);
7093
7620
  }
@@ -7121,8 +7648,8 @@ async function executeGscSync(db, runId, projectId, opts) {
7121
7648
  });
7122
7649
  console.log(`[GSC Sync] Received ${rows.length} rows`);
7123
7650
  db.delete(gscSearchData).where(
7124
- and5(
7125
- eq16(gscSearchData.projectId, projectId),
7651
+ and6(
7652
+ eq17(gscSearchData.projectId, projectId),
7126
7653
  sql3`${gscSearchData.date} >= ${startDate}`,
7127
7654
  sql3`${gscSearchData.date} <= ${endDate}`
7128
7655
  )
@@ -7134,7 +7661,7 @@ async function executeGscSync(db, runId, projectId, opts) {
7134
7661
  for (const row of batch) {
7135
7662
  const [query, page, country, device, date] = row.keys;
7136
7663
  db.insert(gscSearchData).values({
7137
- id: crypto15.randomUUID(),
7664
+ id: crypto16.randomUUID(),
7138
7665
  projectId,
7139
7666
  syncRunId: runId,
7140
7667
  date: date ?? "",
@@ -7168,7 +7695,7 @@ async function executeGscSync(db, runId, projectId, opts) {
7168
7695
  const rich = ir.richResultsResult;
7169
7696
  const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
7170
7697
  db.insert(gscUrlInspections).values({
7171
- id: crypto15.randomUUID(),
7698
+ id: crypto16.randomUUID(),
7172
7699
  projectId,
7173
7700
  syncRunId: runId,
7174
7701
  url: pageUrl,
@@ -7189,7 +7716,7 @@ async function executeGscSync(db, runId, projectId, opts) {
7189
7716
  console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
7190
7717
  }
7191
7718
  }
7192
- const allInspections = db.select().from(gscUrlInspections).where(eq16(gscUrlInspections.projectId, projectId)).all();
7719
+ const allInspections = db.select().from(gscUrlInspections).where(eq17(gscUrlInspections.projectId, projectId)).all();
7193
7720
  const latestByUrl = /* @__PURE__ */ new Map();
7194
7721
  for (const row of allInspections) {
7195
7722
  const existing = latestByUrl.get(row.url);
@@ -7210,9 +7737,9 @@ async function executeGscSync(db, runId, projectId, opts) {
7210
7737
  }
7211
7738
  }
7212
7739
  const snapshotDate = formatDate(/* @__PURE__ */ new Date());
7213
- db.delete(gscCoverageSnapshots).where(and5(eq16(gscCoverageSnapshots.projectId, projectId), eq16(gscCoverageSnapshots.date, snapshotDate))).run();
7740
+ db.delete(gscCoverageSnapshots).where(and6(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
7214
7741
  db.insert(gscCoverageSnapshots).values({
7215
- id: crypto15.randomUUID(),
7742
+ id: crypto16.randomUUID(),
7216
7743
  projectId,
7217
7744
  syncRunId: runId,
7218
7745
  date: snapshotDate,
@@ -7221,19 +7748,19 @@ async function executeGscSync(db, runId, projectId, opts) {
7221
7748
  reasonBreakdown: JSON.stringify(reasonCounts),
7222
7749
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
7223
7750
  }).run();
7224
- db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq16(runs.id, runId)).run();
7751
+ db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq17(runs.id, runId)).run();
7225
7752
  console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections, coverage snapshot: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
7226
7753
  } catch (err) {
7227
7754
  const errorMsg = err instanceof Error ? err.message : String(err);
7228
- db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq16(runs.id, runId)).run();
7755
+ db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq17(runs.id, runId)).run();
7229
7756
  console.error(`[GSC Sync] Failed:`, errorMsg);
7230
7757
  throw err;
7231
7758
  }
7232
7759
  }
7233
7760
 
7234
7761
  // src/gsc-inspect-sitemap.ts
7235
- import crypto16 from "crypto";
7236
- import { eq as eq17, and as and6 } from "drizzle-orm";
7762
+ import crypto17 from "crypto";
7763
+ import { eq as eq18, and as and7 } from "drizzle-orm";
7237
7764
 
7238
7765
  // src/sitemap-parser.ts
7239
7766
  var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
@@ -7301,13 +7828,13 @@ async function parseSitemapRecursive(url, urls, depth) {
7301
7828
  // src/gsc-inspect-sitemap.ts
7302
7829
  async function executeInspectSitemap(db, runId, projectId, opts) {
7303
7830
  const now = (/* @__PURE__ */ new Date()).toISOString();
7304
- db.update(runs).set({ status: "running", startedAt: now }).where(eq17(runs.id, runId)).run();
7831
+ db.update(runs).set({ status: "running", startedAt: now }).where(eq18(runs.id, runId)).run();
7305
7832
  try {
7306
7833
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
7307
7834
  if (!googleClientId || !googleClientSecret) {
7308
7835
  throw new Error("Google OAuth is not configured in the local Canonry config");
7309
7836
  }
7310
- const project = db.select().from(projects).where(eq17(projects.id, projectId)).get();
7837
+ const project = db.select().from(projects).where(eq18(projects.id, projectId)).get();
7311
7838
  if (!project) {
7312
7839
  throw new Error(`Project not found: ${projectId}`);
7313
7840
  }
@@ -7330,7 +7857,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7330
7857
  });
7331
7858
  saveConfig(opts.config);
7332
7859
  }
7333
- const sitemapUrl = opts.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
7860
+ const sitemapUrl = opts.sitemapUrl || conn.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
7334
7861
  console.log(`[Inspect Sitemap] Fetching sitemap from ${sitemapUrl}`);
7335
7862
  const urls = await fetchAndParseSitemap(sitemapUrl);
7336
7863
  console.log(`[Inspect Sitemap] Found ${urls.length} URLs in sitemap`);
@@ -7348,7 +7875,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7348
7875
  const rich = ir.richResultsResult;
7349
7876
  const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
7350
7877
  db.insert(gscUrlInspections).values({
7351
- id: crypto16.randomUUID(),
7878
+ id: crypto17.randomUUID(),
7352
7879
  projectId,
7353
7880
  syncRunId: runId,
7354
7881
  url: pageUrl,
@@ -7375,7 +7902,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7375
7902
  await new Promise((r) => setTimeout(r, 1e3));
7376
7903
  }
7377
7904
  }
7378
- const allInspections = db.select().from(gscUrlInspections).where(eq17(gscUrlInspections.projectId, projectId)).all();
7905
+ const allInspections = db.select().from(gscUrlInspections).where(eq18(gscUrlInspections.projectId, projectId)).all();
7379
7906
  const latestByUrl = /* @__PURE__ */ new Map();
7380
7907
  for (const row of allInspections) {
7381
7908
  const existing = latestByUrl.get(row.url);
@@ -7396,9 +7923,9 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7396
7923
  }
7397
7924
  }
7398
7925
  const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
7399
- db.delete(gscCoverageSnapshots).where(and6(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
7926
+ db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
7400
7927
  db.insert(gscCoverageSnapshots).values({
7401
- id: crypto16.randomUUID(),
7928
+ id: crypto17.randomUUID(),
7402
7929
  projectId,
7403
7930
  syncRunId: runId,
7404
7931
  date: snapshotDate,
@@ -7408,11 +7935,11 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7408
7935
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
7409
7936
  }).run();
7410
7937
  const status = errors > 0 && inspected > 0 ? "partial" : errors === urls.length ? "failed" : "completed";
7411
- db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq17(runs.id, runId)).run();
7938
+ db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq18(runs.id, runId)).run();
7412
7939
  console.log(`[Inspect Sitemap] Done. ${inspected} inspected, ${errors} errors out of ${urls.length} URLs. Coverage: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
7413
7940
  } catch (err) {
7414
7941
  const errorMsg = err instanceof Error ? err.message : String(err);
7415
- db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq17(runs.id, runId)).run();
7942
+ db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq18(runs.id, runId)).run();
7416
7943
  console.error(`[Inspect Sitemap] Failed:`, errorMsg);
7417
7944
  throw err;
7418
7945
  }
@@ -7471,7 +7998,7 @@ var ProviderRegistry = class {
7471
7998
 
7472
7999
  // src/scheduler.ts
7473
8000
  import cron from "node-cron";
7474
- import { eq as eq18 } from "drizzle-orm";
8001
+ import { eq as eq19 } from "drizzle-orm";
7475
8002
  var Scheduler = class {
7476
8003
  db;
7477
8004
  callbacks;
@@ -7482,7 +8009,7 @@ var Scheduler = class {
7482
8009
  }
7483
8010
  /** Load all enabled schedules from DB and register cron jobs. */
7484
8011
  start() {
7485
- const allSchedules = this.db.select().from(schedules).where(eq18(schedules.enabled, 1)).all();
8012
+ const allSchedules = this.db.select().from(schedules).where(eq19(schedules.enabled, 1)).all();
7486
8013
  for (const schedule of allSchedules) {
7487
8014
  const missedRunAt = schedule.nextRunAt;
7488
8015
  this.registerCronTask(schedule);
@@ -7507,7 +8034,7 @@ var Scheduler = class {
7507
8034
  this.stopTask(projectId, existing, "Stopped");
7508
8035
  this.tasks.delete(projectId);
7509
8036
  }
7510
- const schedule = this.db.select().from(schedules).where(eq18(schedules.projectId, projectId)).get();
8037
+ const schedule = this.db.select().from(schedules).where(eq19(schedules.projectId, projectId)).get();
7511
8038
  if (schedule && schedule.enabled === 1) {
7512
8039
  this.registerCronTask(schedule);
7513
8040
  }
@@ -7540,13 +8067,13 @@ var Scheduler = class {
7540
8067
  this.db.update(schedules).set({
7541
8068
  nextRunAt: task.getNextRun()?.toISOString() ?? null,
7542
8069
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7543
- }).where(eq18(schedules.id, scheduleId)).run();
8070
+ }).where(eq19(schedules.id, scheduleId)).run();
7544
8071
  const label = schedule.preset ?? cronExpr;
7545
8072
  console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
7546
8073
  }
7547
8074
  triggerRun(scheduleId, projectId) {
7548
8075
  const now = (/* @__PURE__ */ new Date()).toISOString();
7549
- const currentSchedule = this.db.select().from(schedules).where(eq18(schedules.id, scheduleId)).get();
8076
+ const currentSchedule = this.db.select().from(schedules).where(eq19(schedules.id, scheduleId)).get();
7550
8077
  if (!currentSchedule || currentSchedule.enabled !== 1) {
7551
8078
  console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
7552
8079
  this.remove(projectId);
@@ -7554,7 +8081,7 @@ var Scheduler = class {
7554
8081
  }
7555
8082
  const task = this.tasks.get(projectId);
7556
8083
  const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
7557
- const project = this.db.select().from(projects).where(eq18(projects.id, projectId)).get();
8084
+ const project = this.db.select().from(projects).where(eq19(projects.id, projectId)).get();
7558
8085
  if (!project) {
7559
8086
  console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
7560
8087
  this.remove(projectId);
@@ -7571,7 +8098,7 @@ var Scheduler = class {
7571
8098
  this.db.update(schedules).set({
7572
8099
  nextRunAt,
7573
8100
  updatedAt: now
7574
- }).where(eq18(schedules.id, currentSchedule.id)).run();
8101
+ }).where(eq19(schedules.id, currentSchedule.id)).run();
7575
8102
  return;
7576
8103
  }
7577
8104
  const runId = queueResult.runId;
@@ -7579,7 +8106,7 @@ var Scheduler = class {
7579
8106
  lastRunAt: now,
7580
8107
  nextRunAt,
7581
8108
  updatedAt: now
7582
- }).where(eq18(schedules.id, currentSchedule.id)).run();
8109
+ }).where(eq19(schedules.id, currentSchedule.id)).run();
7583
8110
  const scheduleProviders = JSON.parse(currentSchedule.providers);
7584
8111
  const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
7585
8112
  console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
@@ -7588,8 +8115,8 @@ var Scheduler = class {
7588
8115
  };
7589
8116
 
7590
8117
  // src/notifier.ts
7591
- import { eq as eq19, desc as desc4, and as and7, or as or2 } from "drizzle-orm";
7592
- import crypto17 from "crypto";
8118
+ import { eq as eq20, desc as desc5, and as and8, or as or2 } from "drizzle-orm";
8119
+ import crypto18 from "crypto";
7593
8120
  var Notifier = class {
7594
8121
  db;
7595
8122
  serverUrl;
@@ -7600,18 +8127,18 @@ var Notifier = class {
7600
8127
  /** Called after a run completes (success, partial, or failed). */
7601
8128
  async onRunCompleted(runId, projectId) {
7602
8129
  console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
7603
- const notifs = this.db.select().from(notifications).where(eq19(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
8130
+ const notifs = this.db.select().from(notifications).where(eq20(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
7604
8131
  if (notifs.length === 0) {
7605
8132
  console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
7606
8133
  return;
7607
8134
  }
7608
8135
  console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
7609
- const run = this.db.select().from(runs).where(eq19(runs.id, runId)).get();
8136
+ const run = this.db.select().from(runs).where(eq20(runs.id, runId)).get();
7610
8137
  if (!run) {
7611
8138
  console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
7612
8139
  return;
7613
8140
  }
7614
- const project = this.db.select().from(projects).where(eq19(projects.id, projectId)).get();
8141
+ const project = this.db.select().from(projects).where(eq20(projects.id, projectId)).get();
7615
8142
  if (!project) {
7616
8143
  console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
7617
8144
  return;
@@ -7651,11 +8178,11 @@ var Notifier = class {
7651
8178
  }
7652
8179
  computeTransitions(runId, projectId) {
7653
8180
  const recentRuns = this.db.select().from(runs).where(
7654
- and7(
7655
- eq19(runs.projectId, projectId),
7656
- or2(eq19(runs.status, "completed"), eq19(runs.status, "partial"))
8181
+ and8(
8182
+ eq20(runs.projectId, projectId),
8183
+ or2(eq20(runs.status, "completed"), eq20(runs.status, "partial"))
7657
8184
  )
7658
- ).orderBy(desc4(runs.createdAt)).limit(2).all();
8185
+ ).orderBy(desc5(runs.createdAt)).limit(2).all();
7659
8186
  if (recentRuns.length < 2) return [];
7660
8187
  const currentRunId = recentRuns[0].id;
7661
8188
  const previousRunId = recentRuns[1].id;
@@ -7665,12 +8192,12 @@ var Notifier = class {
7665
8192
  keyword: keywords.keyword,
7666
8193
  provider: querySnapshots.provider,
7667
8194
  citationState: querySnapshots.citationState
7668
- }).from(querySnapshots).leftJoin(keywords, eq19(querySnapshots.keywordId, keywords.id)).where(eq19(querySnapshots.runId, currentRunId)).all();
8195
+ }).from(querySnapshots).leftJoin(keywords, eq20(querySnapshots.keywordId, keywords.id)).where(eq20(querySnapshots.runId, currentRunId)).all();
7669
8196
  const previousSnapshots = this.db.select({
7670
8197
  keywordId: querySnapshots.keywordId,
7671
8198
  provider: querySnapshots.provider,
7672
8199
  citationState: querySnapshots.citationState
7673
- }).from(querySnapshots).where(eq19(querySnapshots.runId, previousRunId)).all();
8200
+ }).from(querySnapshots).where(eq20(querySnapshots.runId, previousRunId)).all();
7674
8201
  const prevMap = /* @__PURE__ */ new Map();
7675
8202
  for (const s of previousSnapshots) {
7676
8203
  prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
@@ -7727,7 +8254,7 @@ var Notifier = class {
7727
8254
  }
7728
8255
  logDelivery(projectId, notificationId, event, status, error) {
7729
8256
  this.db.insert(auditLog).values({
7730
- id: crypto17.randomUUID(),
8257
+ id: crypto18.randomUUID(),
7731
8258
  projectId,
7732
8259
  actor: "scheduler",
7733
8260
  action: `notification.${status}`,
@@ -7959,8 +8486,43 @@ async function createServer(opts) {
7959
8486
  const googleSettingsSummary = {
7960
8487
  configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
7961
8488
  };
8489
+ const bingSettingsSummary = {
8490
+ configured: Boolean(opts.config.bing?.apiKey)
8491
+ };
8492
+ const bingConnectionStore = {
8493
+ getConnection: (domain) => {
8494
+ return opts.config.bing?.connections?.find((c) => c.domain === domain);
8495
+ },
8496
+ upsertConnection: (connection) => {
8497
+ if (!opts.config.bing) opts.config.bing = {};
8498
+ if (!opts.config.bing.connections) opts.config.bing.connections = [];
8499
+ const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
8500
+ if (idx >= 0) {
8501
+ opts.config.bing.connections[idx] = connection;
8502
+ } else {
8503
+ opts.config.bing.connections.push(connection);
8504
+ }
8505
+ saveConfig(opts.config);
8506
+ return connection;
8507
+ },
8508
+ updateConnection: (domain, patch) => {
8509
+ const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
8510
+ if (!conn) return void 0;
8511
+ Object.assign(conn, patch);
8512
+ saveConfig(opts.config);
8513
+ return conn;
8514
+ },
8515
+ deleteConnection: (domain) => {
8516
+ if (!opts.config.bing?.connections) return false;
8517
+ const idx = opts.config.bing.connections.findIndex((c) => c.domain === domain);
8518
+ if (idx < 0) return false;
8519
+ opts.config.bing.connections.splice(idx, 1);
8520
+ saveConfig(opts.config);
8521
+ return true;
8522
+ }
8523
+ };
7962
8524
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
7963
- const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto18.randomBytes(32).toString("hex");
8525
+ const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto19.randomBytes(32).toString("hex");
7964
8526
  const googleConnectionStore = {
7965
8527
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
7966
8528
  getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
@@ -7980,8 +8542,13 @@ async function createServer(opts) {
7980
8542
  return removed;
7981
8543
  }
7982
8544
  };
8545
+ const rawBasePath = process.env.CANONRY_BASE_PATH ?? opts.config.basePath;
8546
+ const normalizedBasePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
8547
+ const basePath = normalizedBasePath === "/" ? void 0 : normalizedBasePath;
8548
+ const apiPrefix = basePath ? `${basePath}api/v1` : "/api/v1";
7983
8549
  await app.register(apiRoutes, {
7984
8550
  db: opts.db,
8551
+ routePrefix: apiPrefix,
7985
8552
  skipAuth: false,
7986
8553
  getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
7987
8554
  googleConnectionStore,
@@ -8019,6 +8586,8 @@ async function createServer(opts) {
8019
8586
  },
8020
8587
  providerSummary,
8021
8588
  googleSettingsSummary,
8589
+ bingSettingsSummary,
8590
+ bingConnectionStore,
8022
8591
  onRunCreated: (runId, projectId, providers2, location) => {
8023
8592
  jobRunner.executeRun(runId, projectId, providers2, location).catch((err) => {
8024
8593
  app.log.error({ runId, err }, "Job runner failed");
@@ -8074,7 +8643,7 @@ async function createServer(opts) {
8074
8643
  const targetProjectIds = affectedProjectIds.length > 0 ? affectedProjectIds : [null];
8075
8644
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
8076
8645
  opts.db.insert(auditLog).values(targetProjectIds.map((projectId) => ({
8077
- id: crypto18.randomUUID(),
8646
+ id: crypto19.randomUUID(),
8078
8647
  projectId,
8079
8648
  actor: "api",
8080
8649
  action: existing ? "provider.updated" : "provider.created",
@@ -8102,6 +8671,18 @@ async function createServer(opts) {
8102
8671
  return null;
8103
8672
  }
8104
8673
  },
8674
+ onBingSettingsUpdate: (apiKey) => {
8675
+ try {
8676
+ if (!opts.config.bing) opts.config.bing = {};
8677
+ opts.config.bing.apiKey = apiKey;
8678
+ saveConfig(opts.config);
8679
+ bingSettingsSummary.configured = true;
8680
+ return { ...bingSettingsSummary };
8681
+ } catch (err) {
8682
+ app.log.error({ err }, "Failed to save Bing API key config");
8683
+ return null;
8684
+ }
8685
+ },
8105
8686
  onScheduleUpdated: (action, projectId) => {
8106
8687
  if (action === "upsert") scheduler.upsert(projectId);
8107
8688
  if (action === "delete") scheduler.remove(projectId);
@@ -8194,8 +8775,6 @@ async function createServer(opts) {
8194
8775
  const assetsDir = path6.join(dirname2, "..", "assets");
8195
8776
  if (fs5.existsSync(assetsDir)) {
8196
8777
  const indexPath = path6.join(assetsDir, "index.html");
8197
- const rawBasePath = process.env.CANONRY_BASE_PATH ?? opts.config.basePath;
8198
- const basePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
8199
8778
  const injectConfig = (html) => {
8200
8779
  const clientConfig = { apiKey: opts.config.apiKey };
8201
8780
  if (basePath) clientConfig.basePath = basePath;
@@ -8206,21 +8785,32 @@ async function createServer(opts) {
8206
8785
  const fastifyStatic = await import("@fastify/static");
8207
8786
  await app.register(fastifyStatic.default, {
8208
8787
  root: assetsDir,
8209
- prefix: "/",
8788
+ prefix: basePath ?? "/",
8210
8789
  wildcard: false,
8211
8790
  // Don't serve index.html automatically — we handle it with config injection
8212
8791
  serve: true,
8213
8792
  index: false
8214
8793
  });
8215
- app.get("/", (_request, reply) => {
8794
+ const serveIndex = (_request, reply) => {
8216
8795
  if (fs5.existsSync(indexPath)) {
8217
8796
  const html = fs5.readFileSync(indexPath, "utf-8");
8218
8797
  return reply.type("text/html").send(injectConfig(html));
8219
8798
  }
8220
8799
  return reply.status(404).send({ error: "Dashboard not built" });
8221
- });
8800
+ };
8801
+ const rootRouteTrailing = basePath ?? "/";
8802
+ app.get(rootRouteTrailing, serveIndex);
8803
+ if (basePath) {
8804
+ const rootRouteBare = basePath.replace(/\/$/, "");
8805
+ if (rootRouteBare) app.get(rootRouteBare, serveIndex);
8806
+ }
8222
8807
  app.setNotFoundHandler((request, reply) => {
8223
- if (request.url.startsWith("/api/")) {
8808
+ const url = request.url.split("?")[0];
8809
+ const isApiRoute = url.startsWith("/api/") || basePath !== void 0 && url.startsWith(`${basePath}api/`);
8810
+ if (isApiRoute) {
8811
+ return reply.status(404).send({ error: "Not found", path: request.url });
8812
+ }
8813
+ if (basePath && !url.startsWith(basePath)) {
8224
8814
  return reply.status(404).send({ error: "Not found", path: request.url });
8225
8815
  }
8226
8816
  if (fs5.existsSync(indexPath)) {
@@ -8230,11 +8820,11 @@ async function createServer(opts) {
8230
8820
  return reply.status(404).send({ error: "Not found" });
8231
8821
  });
8232
8822
  }
8233
- app.get("/health", async () => ({
8234
- status: "ok",
8235
- service: "canonry",
8236
- version: PKG_VERSION
8237
- }));
8823
+ const healthHandler = async () => ({ status: "ok", service: "canonry", version: PKG_VERSION });
8824
+ app.get("/health", healthHandler);
8825
+ if (basePath) {
8826
+ app.get(`${basePath}health`, healthHandler);
8827
+ }
8238
8828
  scheduler.start();
8239
8829
  app.addHook("onClose", async () => {
8240
8830
  scheduler.stop();