@ainyc/canonry 1.20.0 → 1.21.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.
@@ -35,7 +35,7 @@ function loadConfig() {
35
35
  throw new Error(
36
36
  `Config not found at ${configPath}.
37
37
  Run "canonry init" to set up interactively, or "canonry init --gemini-key <key>" for non-interactive setup.
38
- For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET).`
38
+ For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, PERPLEXITY_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET).`
39
39
  );
40
40
  }
41
41
  const raw = fs.readFileSync(configPath, "utf-8");
@@ -72,6 +72,16 @@ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "ca
72
72
  } catch {
73
73
  }
74
74
  }
75
+ if (parsed.basePath) {
76
+ const normalizedBase = "/" + parsed.basePath.replace(/^\/|\/$/g, "");
77
+ try {
78
+ const url = new URL(parsed.apiUrl);
79
+ if (normalizedBase !== "/" && !url.pathname.startsWith(normalizedBase)) {
80
+ parsed.apiUrl = url.origin + normalizedBase;
81
+ }
82
+ } catch {
83
+ }
84
+ }
75
85
  return parsed;
76
86
  }
77
87
  function saveConfig(config) {
@@ -167,7 +177,7 @@ import { fileURLToPath } from "url";
167
177
  import Fastify from "fastify";
168
178
 
169
179
  // ../contracts/src/config-schema.ts
170
- import { z as z3 } from "zod";
180
+ import { z as z4 } from "zod";
171
181
 
172
182
  // ../contracts/src/provider.ts
173
183
  import { z } from "zod";
@@ -176,30 +186,18 @@ var providerQuotaPolicySchema = z.object({
176
186
  maxRequestsPerMinute: z.number().int().positive(),
177
187
  maxRequestsPerDay: z.number().int().positive()
178
188
  });
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
- };
189
+ var providerNameSchema = z.string().min(1);
190
+ var apiProviderNameSchema = z.string().min(1);
188
191
  function isBrowserProvider(name) {
189
- return PROVIDER_MODE[name] === "browser";
192
+ return name.startsWith("cdp:");
190
193
  }
191
194
  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
195
  function resolveProviderInput(input) {
197
196
  const lower = input.trim().toLowerCase();
198
197
  if (lower === "cdp") {
199
198
  return [...CDP_TARGETS];
200
199
  }
201
- const parsed = parseProviderName(lower);
202
- return parsed ? [parsed] : [];
200
+ return lower ? [lower] : [];
203
201
  }
204
202
  var locationContextSchema = z.object({
205
203
  label: z.string().min(1),
@@ -229,118 +227,148 @@ var notificationDtoSchema = z2.object({
229
227
  updatedAt: z2.string()
230
228
  });
231
229
 
230
+ // ../contracts/src/project.ts
231
+ import { z as z3 } from "zod";
232
+ var configSourceSchema = z3.enum(["cli", "api", "config-file"]);
233
+ function findDuplicateLocationLabels(locations) {
234
+ const seen = /* @__PURE__ */ new Set();
235
+ const duplicates = /* @__PURE__ */ new Set();
236
+ for (const location of locations) {
237
+ if (seen.has(location.label)) {
238
+ duplicates.add(location.label);
239
+ continue;
240
+ }
241
+ seen.add(location.label);
242
+ }
243
+ return [...duplicates];
244
+ }
245
+ function hasLocationLabel(locations, label) {
246
+ if (!label) return true;
247
+ return locations.some((location) => location.label === label);
248
+ }
249
+ var projectUpsertRequestSchema = z3.object({
250
+ displayName: z3.string().min(1),
251
+ canonicalDomain: z3.string().min(1),
252
+ ownedDomains: z3.array(z3.string().min(1)).optional(),
253
+ country: z3.string().length(2),
254
+ language: z3.string().min(2),
255
+ tags: z3.array(z3.string()).optional(),
256
+ labels: z3.record(z3.string(), z3.string()).optional(),
257
+ providers: z3.array(providerNameSchema).optional(),
258
+ locations: z3.array(locationContextSchema).optional(),
259
+ defaultLocation: z3.string().nullable().optional(),
260
+ configSource: configSourceSchema.optional()
261
+ });
262
+ var projectDtoSchema = z3.object({
263
+ id: z3.string(),
264
+ name: z3.string(),
265
+ displayName: z3.string().optional(),
266
+ canonicalDomain: z3.string(),
267
+ ownedDomains: z3.array(z3.string()).default([]),
268
+ country: z3.string().length(2),
269
+ language: z3.string().min(2),
270
+ tags: z3.array(z3.string()).default([]),
271
+ labels: z3.record(z3.string(), z3.string()).default({}),
272
+ locations: z3.array(locationContextSchema).default([]),
273
+ defaultLocation: z3.string().nullable().optional(),
274
+ configSource: configSourceSchema.default("cli"),
275
+ configRevision: z3.number().int().positive().default(1),
276
+ createdAt: z3.string().optional(),
277
+ updatedAt: z3.string().optional()
278
+ });
279
+ function normalizeProjectDomain(input) {
280
+ let domain = input.trim().toLowerCase();
281
+ try {
282
+ if (domain.includes("://")) {
283
+ domain = new URL(domain).hostname.toLowerCase();
284
+ }
285
+ } catch {
286
+ }
287
+ return domain.replace(/^www\./, "");
288
+ }
289
+ function effectiveDomains(project) {
290
+ const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
291
+ const seen = /* @__PURE__ */ new Set();
292
+ const result = [];
293
+ for (const d of all) {
294
+ const trimmed = d.trim();
295
+ if (!trimmed) continue;
296
+ const norm = normalizeProjectDomain(trimmed);
297
+ if (seen.has(norm)) continue;
298
+ seen.add(norm);
299
+ result.push(trimmed);
300
+ }
301
+ return result;
302
+ }
303
+
232
304
  // ../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])?$/, {
305
+ var configMetadataSchema = z4.object({
306
+ name: z4.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
235
307
  message: "Name must be a lowercase slug (letters, numbers, hyphens)"
236
308
  }),
237
- labels: z3.record(z3.string(), z3.string()).optional().default({})
309
+ labels: z4.record(z4.string(), z4.string()).optional().default({})
238
310
  });
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([])
311
+ var configScheduleSchema = z4.object({
312
+ preset: z4.string().optional(),
313
+ cron: z4.string().optional(),
314
+ timezone: z4.string().optional().default("UTC"),
315
+ providers: z4.array(providerNameSchema).optional().default([])
244
316
  }).refine(
245
317
  (data) => data.preset && !data.cron || !data.preset && data.cron,
246
318
  { message: 'Exactly one of "preset" or "cron" must be provided' }
247
319
  ).optional();
248
- var configNotificationSchema = z3.object({
249
- channel: z3.literal("webhook"),
250
- url: z3.string().url(),
251
- events: z3.array(notificationEventSchema).min(1)
320
+ var configNotificationSchema = z4.object({
321
+ channel: z4.literal("webhook"),
322
+ url: z4.string().url(),
323
+ events: z4.array(notificationEventSchema).min(1)
252
324
  });
253
- var configGoogleSchema = z3.object({
254
- gsc: z3.object({
255
- propertyUrl: z3.string()
325
+ var configGoogleSchema = z4.object({
326
+ gsc: z4.object({
327
+ propertyUrl: z4.string()
256
328
  }).optional(),
257
- syncSchedule: z3.object({
258
- preset: z3.string().optional(),
259
- cron: z3.string().optional()
329
+ syncSchedule: z4.object({
330
+ preset: z4.string().optional(),
331
+ cron: z4.string().optional()
260
332
  }).optional()
261
333
  }).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(),
334
+ var configSpecSchema = z4.object({
335
+ displayName: z4.string().min(1),
336
+ canonicalDomain: z4.string().min(1),
337
+ ownedDomains: z4.array(z4.string().min(1)).optional().default([]),
338
+ country: z4.string().length(2),
339
+ language: z4.string().min(2),
340
+ keywords: z4.array(z4.string().min(1)).optional().default([]),
341
+ competitors: z4.array(z4.string().min(1)).optional().default([]),
342
+ providers: z4.array(providerNameSchema).optional().default([]),
343
+ locations: z4.array(locationContextSchema).optional().default([]),
344
+ defaultLocation: z4.string().optional(),
273
345
  schedule: configScheduleSchema,
274
- notifications: z3.array(configNotificationSchema).optional().default([]),
346
+ notifications: z4.array(configNotificationSchema).optional().default([]),
275
347
  google: configGoogleSchema
348
+ }).superRefine((spec, ctx) => {
349
+ const duplicateLabels = findDuplicateLocationLabels(spec.locations);
350
+ if (duplicateLabels.length > 0) {
351
+ ctx.addIssue({
352
+ code: "custom",
353
+ message: `Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`,
354
+ path: ["locations"]
355
+ });
356
+ }
357
+ if (!hasLocationLabel(spec.locations, spec.defaultLocation)) {
358
+ ctx.addIssue({
359
+ code: "custom",
360
+ message: `defaultLocation "${spec.defaultLocation}" must match a configured location label`,
361
+ path: ["defaultLocation"]
362
+ });
363
+ }
276
364
  });
277
- var projectConfigSchema = z3.object({
278
- apiVersion: z3.literal("canonry/v1"),
279
- kind: z3.literal("Project"),
365
+ var projectConfigSchema = z4.object({
366
+ apiVersion: z4.literal("canonry/v1"),
367
+ kind: z4.literal("Project"),
280
368
  metadata: configMetadataSchema,
281
369
  spec: configSpecSchema
282
370
  });
283
371
 
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
- }
343
-
344
372
  // ../contracts/src/errors.ts
345
373
  var AppError = class extends Error {
346
374
  code;
@@ -378,184 +406,145 @@ function authInvalid() {
378
406
  function runInProgress(projectName) {
379
407
  return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
380
408
  }
409
+ function runNotCancellable(runId, status) {
410
+ return new AppError("RUN_NOT_CANCELLABLE", `Run '${runId}' is already in terminal state '${status}' and cannot be cancelled`, 409);
411
+ }
381
412
  function unsupportedKind(kind) {
382
413
  return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
383
414
  }
415
+ function notImplemented(message) {
416
+ return new AppError("NOT_IMPLEMENTED", message, 501);
417
+ }
384
418
 
385
419
  // ../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
- });
467
-
468
- // ../contracts/src/bing.ts
469
420
  import { z as z5 } from "zod";
470
- var bingConnectionDtoSchema = z5.object({
421
+ var googleConnectionTypeSchema = z5.enum(["gsc", "ga4"]);
422
+ var googleConnectionDtoSchema = z5.object({
471
423
  id: z5.string(),
472
424
  domain: z5.string(),
473
- siteUrl: z5.string().nullable().optional(),
425
+ connectionType: googleConnectionTypeSchema,
426
+ propertyId: z5.string().nullable().optional(),
427
+ sitemapUrl: z5.string().nullable().optional(),
428
+ scopes: z5.array(z5.string()).default([]),
474
429
  createdAt: z5.string(),
475
430
  updatedAt: z5.string()
476
431
  });
477
- var bingUrlInspectionDtoSchema = z5.object({
432
+ var gscSearchDataDtoSchema = z5.object({
433
+ date: z5.string(),
434
+ query: z5.string(),
435
+ page: z5.string(),
436
+ country: z5.string().nullable().optional(),
437
+ device: z5.string().nullable().optional(),
438
+ clicks: z5.number(),
439
+ impressions: z5.number(),
440
+ ctr: z5.number(),
441
+ position: z5.number()
442
+ });
443
+ var gscUrlInspectionDtoSchema = z5.object({
478
444
  id: z5.string(),
479
445
  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(),
446
+ indexingState: z5.string().nullable().optional(),
447
+ verdict: z5.string().nullable().optional(),
448
+ coverageState: z5.string().nullable().optional(),
449
+ pageFetchState: z5.string().nullable().optional(),
450
+ robotsTxtState: z5.string().nullable().optional(),
451
+ crawlTime: z5.string().nullable().optional(),
452
+ lastCrawlResult: z5.string().nullable().optional(),
453
+ isMobileFriendly: z5.boolean().nullable().optional(),
454
+ richResults: z5.array(z5.string()).default([]),
484
455
  inspectedAt: z5.string()
485
456
  });
486
- var bingCoverageSummaryDtoSchema = z5.object({
457
+ var indexTransitionSchema = z5.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
458
+ var gscDeindexedRowSchema = z5.object({
459
+ url: z5.string(),
460
+ previousState: z5.string().nullable(),
461
+ currentState: z5.string().nullable(),
462
+ transitionDate: z5.string()
463
+ });
464
+ var gscReasonGroupSchema = z5.object({
465
+ reason: z5.string(),
466
+ count: z5.number(),
467
+ urls: z5.array(gscUrlInspectionDtoSchema).default([])
468
+ });
469
+ var gscCoverageSummaryDtoSchema = z5.object({
487
470
  summary: z5.object({
488
471
  total: z5.number(),
489
472
  indexed: z5.number(),
490
473
  notIndexed: z5.number(),
474
+ deindexed: z5.number(),
491
475
  percentage: z5.number()
492
476
  }),
493
477
  lastInspectedAt: z5.string().nullable(),
494
- indexed: z5.array(bingUrlInspectionDtoSchema).default([]),
495
- notIndexed: z5.array(bingUrlInspectionDtoSchema).default([])
478
+ indexed: z5.array(gscUrlInspectionDtoSchema).default([]),
479
+ notIndexed: z5.array(gscUrlInspectionDtoSchema).default([]),
480
+ deindexed: z5.array(gscDeindexedRowSchema).default([]),
481
+ reasonGroups: z5.array(gscReasonGroupSchema).default([])
496
482
  });
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()
483
+ var indexingNotificationDtoSchema = z5.object({
484
+ url: z5.string(),
485
+ type: z5.enum(["URL_UPDATED", "URL_DELETED"]),
486
+ notifiedAt: z5.string()
503
487
  });
504
- var bingSubmitResultDtoSchema = z5.object({
488
+ var indexingRequestResultDtoSchema = z5.object({
505
489
  url: z5.string(),
490
+ type: z5.enum(["URL_UPDATED", "URL_DELETED"]),
491
+ notifiedAt: z5.string(),
506
492
  status: z5.enum(["success", "error"]),
507
- submittedAt: z5.string(),
508
493
  error: z5.string().optional()
509
494
  });
495
+ var gscCoverageSnapshotDtoSchema = z5.object({
496
+ date: z5.string(),
497
+ indexed: z5.number(),
498
+ notIndexed: z5.number(),
499
+ reasonBreakdown: z5.record(z5.string(), z5.number()).default({})
500
+ });
510
501
 
511
- // ../contracts/src/project.ts
502
+ // ../contracts/src/bing.ts
512
503
  import { z as z6 } from "zod";
513
- var configSourceSchema = z6.enum(["cli", "api", "config-file"]);
514
- var projectDtoSchema = z6.object({
504
+ var bingConnectionDtoSchema = z6.object({
515
505
  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()
506
+ domain: z6.string(),
507
+ siteUrl: z6.string().nullable().optional(),
508
+ createdAt: z6.string(),
509
+ updatedAt: z6.string()
510
+ });
511
+ var bingUrlInspectionDtoSchema = z6.object({
512
+ id: z6.string(),
513
+ url: z6.string(),
514
+ httpCode: z6.number().nullable().optional(),
515
+ inIndex: z6.boolean().nullable().optional(),
516
+ lastCrawledDate: z6.string().nullable().optional(),
517
+ inIndexDate: z6.string().nullable().optional(),
518
+ inspectedAt: z6.string()
519
+ });
520
+ var bingCoverageSummaryDtoSchema = z6.object({
521
+ summary: z6.object({
522
+ total: z6.number(),
523
+ indexed: z6.number(),
524
+ notIndexed: z6.number(),
525
+ percentage: z6.number()
526
+ }),
527
+ lastInspectedAt: z6.string().nullable(),
528
+ indexed: z6.array(bingUrlInspectionDtoSchema).default([]),
529
+ notIndexed: z6.array(bingUrlInspectionDtoSchema).default([])
530
+ });
531
+ var bingKeywordStatsDtoSchema = z6.object({
532
+ query: z6.string(),
533
+ impressions: z6.number(),
534
+ clicks: z6.number(),
535
+ ctr: z6.number(),
536
+ averagePosition: z6.number()
537
+ });
538
+ var bingSubmitResultDtoSchema = z6.object({
539
+ url: z6.string(),
540
+ status: z6.enum(["success", "error"]),
541
+ submittedAt: z6.string(),
542
+ error: z6.string().optional()
530
543
  });
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
- }
555
544
 
556
545
  // ../contracts/src/run.ts
557
546
  import { z as z7 } from "zod";
558
- var runStatusSchema = z7.enum(["queued", "running", "completed", "partial", "failed"]);
547
+ var runStatusSchema = z7.enum(["queued", "running", "completed", "partial", "failed", "cancelled"]);
559
548
  var runKindSchema = z7.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
560
549
  var runTriggerSchema = z7.enum(["manual", "scheduled", "config-apply"]);
561
550
  var citationStateSchema = z7.enum(["cited", "not-cited"]);
@@ -619,6 +608,16 @@ var scheduleDtoSchema = z8.object({
619
608
  createdAt: z8.string(),
620
609
  updatedAt: z8.string()
621
610
  });
611
+ var scheduleUpsertRequestSchema = z8.object({
612
+ preset: z8.string().optional(),
613
+ cron: z8.string().optional(),
614
+ timezone: z8.string().optional().default("UTC"),
615
+ enabled: z8.boolean().optional().default(true),
616
+ providers: z8.array(providerNameSchema).optional().default([])
617
+ }).refine(
618
+ (data) => data.preset && !data.cron || !data.preset && data.cron,
619
+ { message: 'Exactly one of "preset" or "cron" must be provided' }
620
+ );
622
621
 
623
622
  // ../contracts/src/source-categories.ts
624
623
  var SOURCE_CATEGORY_RULES = [
@@ -1211,7 +1210,44 @@ var MIGRATIONS = [
1211
1210
  // v10: Add sitemapUrl to google_connections for persistent sitemap storage
1212
1211
  `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
1213
1212
  // v11: CDP browser provider — screenshot path for captured evidence
1214
- `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
1213
+ `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`,
1214
+ // v12: Bing Webmaster Tools — bing_connections table
1215
+ `CREATE TABLE IF NOT EXISTS bing_connections (
1216
+ id TEXT PRIMARY KEY,
1217
+ domain TEXT NOT NULL,
1218
+ site_url TEXT,
1219
+ created_at TEXT NOT NULL,
1220
+ updated_at TEXT NOT NULL
1221
+ )`,
1222
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bing_conn_domain ON bing_connections(domain)`,
1223
+ // v12: Bing Webmaster Tools — bing_url_inspections table
1224
+ `CREATE TABLE IF NOT EXISTS bing_url_inspections (
1225
+ id TEXT PRIMARY KEY,
1226
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1227
+ url TEXT NOT NULL,
1228
+ http_code INTEGER,
1229
+ in_index INTEGER,
1230
+ last_crawled_date TEXT,
1231
+ in_index_date TEXT,
1232
+ inspected_at TEXT NOT NULL,
1233
+ created_at TEXT NOT NULL
1234
+ )`,
1235
+ `CREATE INDEX IF NOT EXISTS idx_bing_inspect_project_url ON bing_url_inspections(project_id, url)`,
1236
+ `CREATE INDEX IF NOT EXISTS idx_bing_inspect_url_time ON bing_url_inspections(url, inspected_at)`,
1237
+ // v12: Bing Webmaster Tools — bing_keyword_stats table
1238
+ `CREATE TABLE IF NOT EXISTS bing_keyword_stats (
1239
+ id TEXT PRIMARY KEY,
1240
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1241
+ query TEXT NOT NULL,
1242
+ impressions INTEGER NOT NULL DEFAULT 0,
1243
+ clicks INTEGER NOT NULL DEFAULT 0,
1244
+ ctr TEXT NOT NULL DEFAULT '0',
1245
+ average_position TEXT NOT NULL DEFAULT '0',
1246
+ synced_at TEXT NOT NULL,
1247
+ created_at TEXT NOT NULL
1248
+ )`,
1249
+ `CREATE INDEX IF NOT EXISTS idx_bing_keyword_project ON bing_keyword_stats(project_id)`,
1250
+ `CREATE INDEX IF NOT EXISTS idx_bing_keyword_query ON bing_keyword_stats(query)`
1215
1251
  ];
1216
1252
  function migrate(db) {
1217
1253
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -1294,17 +1330,46 @@ function writeAuditLog(db, entry) {
1294
1330
  async function projectRoutes(app, opts) {
1295
1331
  app.put("/projects/:name", async (request, reply) => {
1296
1332
  const { name } = request.params;
1297
- const body = request.body;
1298
- if (!body || !body.displayName || !body.canonicalDomain || !body.country || !body.language) {
1299
- const err = validationError("Missing required fields: displayName, canonicalDomain, country, language");
1333
+ const parsedBody = projectUpsertRequestSchema.safeParse(request.body);
1334
+ if (!parsedBody.success) {
1335
+ const err = validationError("Invalid project payload", {
1336
+ issues: parsedBody.error.issues.map((issue) => ({
1337
+ path: issue.path.join("."),
1338
+ message: issue.message
1339
+ }))
1340
+ });
1300
1341
  return reply.status(err.statusCode).send(err.toJSON());
1301
1342
  }
1302
- if (body.ownedDomains !== void 0 && (!Array.isArray(body.ownedDomains) || body.ownedDomains.some((d) => typeof d !== "string" || d.trim() === ""))) {
1303
- const err = validationError("ownedDomains must be an array of non-empty strings");
1304
- return reply.status(err.statusCode).send(err.toJSON());
1343
+ const body = parsedBody.data;
1344
+ const validNames = opts.validProviderNames ?? [];
1345
+ if (validNames.length && body.providers?.length) {
1346
+ const invalid = body.providers.filter((p) => !validNames.includes(p));
1347
+ if (invalid.length) {
1348
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1349
+ invalidProviders: invalid,
1350
+ validProviders: validNames
1351
+ });
1352
+ return reply.status(err.statusCode).send(err.toJSON());
1353
+ }
1305
1354
  }
1306
1355
  const now = (/* @__PURE__ */ new Date()).toISOString();
1307
1356
  const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
1357
+ const existingLocations = existing ? JSON.parse(existing.locations || "[]") : [];
1358
+ const nextLocations = body.locations ?? existingLocations;
1359
+ const duplicateLabels = findDuplicateLocationLabels(nextLocations);
1360
+ if (duplicateLabels.length > 0) {
1361
+ const err = validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
1362
+ duplicateLabels
1363
+ });
1364
+ return reply.status(err.statusCode).send(err.toJSON());
1365
+ }
1366
+ const nextDefaultLocation = body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing?.defaultLocation ?? null;
1367
+ if (!hasLocationLabel(nextLocations, nextDefaultLocation)) {
1368
+ const err = validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
1369
+ defaultLocation: nextDefaultLocation
1370
+ });
1371
+ return reply.status(err.statusCode).send(err.toJSON());
1372
+ }
1308
1373
  if (existing) {
1309
1374
  app.db.update(projects).set({
1310
1375
  displayName: body.displayName,
@@ -1315,8 +1380,8 @@ async function projectRoutes(app, opts) {
1315
1380
  tags: JSON.stringify(body.tags ?? []),
1316
1381
  labels: JSON.stringify(body.labels ?? {}),
1317
1382
  providers: JSON.stringify(body.providers ?? []),
1318
- locations: JSON.stringify(body.locations ?? JSON.parse(existing.locations || "[]")),
1319
- defaultLocation: body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing.defaultLocation,
1383
+ locations: JSON.stringify(nextLocations),
1384
+ defaultLocation: nextDefaultLocation,
1320
1385
  configSource: body.configSource ?? "api",
1321
1386
  configRevision: existing.configRevision + 1,
1322
1387
  updatedAt: now
@@ -1343,8 +1408,8 @@ async function projectRoutes(app, opts) {
1343
1408
  tags: JSON.stringify(body.tags ?? []),
1344
1409
  labels: JSON.stringify(body.labels ?? {}),
1345
1410
  providers: JSON.stringify(body.providers ?? []),
1346
- locations: JSON.stringify(body.locations ?? []),
1347
- defaultLocation: body.defaultLocation ?? null,
1411
+ locations: JSON.stringify(nextLocations),
1412
+ defaultLocation: nextDefaultLocation,
1348
1413
  configSource: body.configSource ?? "api",
1349
1414
  configRevision: 1,
1350
1415
  createdAt: now,
@@ -1709,9 +1774,13 @@ async function keywordRoutes(app, opts) {
1709
1774
  const err = validationError('Body must contain a "provider" string');
1710
1775
  return reply.status(err.statusCode).send(err.toJSON());
1711
1776
  }
1712
- const provider = parseProviderName(body.provider);
1713
- if (!provider) {
1714
- const err = validationError(`Unknown provider "${body.provider}". Valid providers: gemini, openai, claude, local`);
1777
+ const provider = body.provider.trim().toLowerCase();
1778
+ const validNames = opts.validProviderNames ?? [];
1779
+ if (validNames.length && !validNames.includes(provider)) {
1780
+ const err = validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
1781
+ provider: body.provider,
1782
+ validProviders: validNames
1783
+ });
1715
1784
  return reply.status(err.statusCode).send(err.toJSON());
1716
1785
  }
1717
1786
  if (body.count !== void 0 && (typeof body.count !== "number" || !Number.isFinite(body.count) || !Number.isInteger(body.count))) {
@@ -1720,7 +1789,8 @@ async function keywordRoutes(app, opts) {
1720
1789
  }
1721
1790
  const count = Math.min(Math.max(body.count ?? 5, 1), 20);
1722
1791
  if (!opts.onGenerateKeywords) {
1723
- return reply.status(501).send({ error: "Key phrase generation is not supported in this deployment" });
1792
+ const err = notImplemented("Key phrase generation is not supported in this deployment");
1793
+ return reply.status(err.statusCode).send(err.toJSON());
1724
1794
  }
1725
1795
  const existingRows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1726
1796
  const existingKeywords = existingRows.map((r) => r.keyword);
@@ -1736,7 +1806,10 @@ async function keywordRoutes(app, opts) {
1736
1806
  } catch (err) {
1737
1807
  request.log.error({ err }, "Key phrase generation failed");
1738
1808
  return reply.status(500).send({
1739
- error: err instanceof Error ? err.message : "Failed to generate key phrases"
1809
+ error: {
1810
+ code: "INTERNAL_ERROR",
1811
+ message: err instanceof Error ? err.message : "Failed to generate key phrases"
1812
+ }
1740
1813
  });
1741
1814
  }
1742
1815
  });
@@ -1810,7 +1883,7 @@ function resolveProjectSafe2(app, name, reply) {
1810
1883
 
1811
1884
  // ../api-routes/src/runs.ts
1812
1885
  import crypto8 from "crypto";
1813
- import { eq as eq7, asc } from "drizzle-orm";
1886
+ import { eq as eq7, asc, desc } from "drizzle-orm";
1814
1887
 
1815
1888
  // ../api-routes/src/run-queue.ts
1816
1889
  import crypto7 from "crypto";
@@ -1857,12 +1930,19 @@ async function runRoutes(app, opts) {
1857
1930
  const trigger = request.body?.trigger ?? "manual";
1858
1931
  const rawProviders = request.body?.providers;
1859
1932
  if (rawProviders?.length) {
1860
- const parsed = rawProviders.map((p) => parseProviderName(p));
1861
- const invalid = rawProviders.filter((_, i) => !parsed[i]);
1862
- if (invalid.length) {
1863
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: gemini, openai, claude, local` } });
1933
+ const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
1934
+ const validNames = opts.validProviderNames ?? [];
1935
+ if (validNames.length) {
1936
+ const invalid = normalized.filter((p) => !validNames.includes(p));
1937
+ if (invalid.length) {
1938
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
1939
+ invalidProviders: invalid,
1940
+ validProviders: validNames
1941
+ });
1942
+ return reply.status(err.statusCode).send(err.toJSON());
1943
+ }
1864
1944
  }
1865
- rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
1945
+ rawProviders.splice(0, rawProviders.length, ...normalized);
1866
1946
  }
1867
1947
  const providers = rawProviders?.length ? rawProviders : void 0;
1868
1948
  let resolvedLocation;
@@ -1941,7 +2021,9 @@ async function runRoutes(app, opts) {
1941
2021
  app.get("/projects/:name/runs", async (request, reply) => {
1942
2022
  const project = resolveProjectSafe3(app, request.params.name, reply);
1943
2023
  if (!project) return;
1944
- const rows = app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all();
2024
+ const parsedLimit = parseInt(request.query.limit ?? "", 10);
2025
+ const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
2026
+ const rows = limit == null ? app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
1945
2027
  return reply.send(rows.map(formatRun));
1946
2028
  });
1947
2029
  app.get("/runs", async (_request, reply) => {
@@ -1960,12 +2042,19 @@ async function runRoutes(app, opts) {
1960
2042
  }
1961
2043
  const rawProviders = request.body?.providers;
1962
2044
  if (rawProviders?.length) {
1963
- const parsed = rawProviders.map((p) => parseProviderName(p));
1964
- const invalid = rawProviders.filter((_, i) => !parsed[i]);
1965
- if (invalid.length) {
1966
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: gemini, openai, claude, local` } });
2045
+ const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
2046
+ const validNames = opts.validProviderNames ?? [];
2047
+ if (validNames.length) {
2048
+ const invalid = normalized.filter((p) => !validNames.includes(p));
2049
+ if (invalid.length) {
2050
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2051
+ invalidProviders: invalid,
2052
+ validProviders: validNames
2053
+ });
2054
+ return reply.status(err.statusCode).send(err.toJSON());
2055
+ }
1967
2056
  }
1968
- rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
2057
+ rawProviders.splice(0, rawProviders.length, ...normalized);
1969
2058
  }
1970
2059
  const providers = rawProviders?.length ? rawProviders : void 0;
1971
2060
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1997,6 +2086,29 @@ async function runRoutes(app, opts) {
1997
2086
  }
1998
2087
  return reply.status(207).send(results);
1999
2088
  });
2089
+ app.post("/runs/:id/cancel", async (request, reply) => {
2090
+ const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
2091
+ if (!run) {
2092
+ const err = notFound("Run", request.params.id);
2093
+ return reply.status(err.statusCode).send(err.toJSON());
2094
+ }
2095
+ const terminalStatuses = /* @__PURE__ */ new Set(["completed", "partial", "failed", "cancelled"]);
2096
+ if (terminalStatuses.has(run.status)) {
2097
+ const err = runNotCancellable(run.id, run.status);
2098
+ return reply.status(err.statusCode).send(err.toJSON());
2099
+ }
2100
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2101
+ app.db.update(runs).set({ status: "cancelled", finishedAt: now, error: "Cancelled by user" }).where(eq7(runs.id, run.id)).run();
2102
+ writeAuditLog(app.db, {
2103
+ projectId: run.projectId,
2104
+ actor: "api",
2105
+ action: "run.cancelled",
2106
+ entityType: "run",
2107
+ entityId: run.id
2108
+ });
2109
+ const updated = app.db.select().from(runs).where(eq7(runs.id, run.id)).get();
2110
+ return reply.send(formatRun(updated));
2111
+ });
2000
2112
  app.get("/runs/:id", async (request, reply) => {
2001
2113
  const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
2002
2114
  if (!run) {
@@ -2345,6 +2457,23 @@ async function applyRoutes(app, opts) {
2345
2457
  return reply.status(err.statusCode).send(err.toJSON());
2346
2458
  }
2347
2459
  const config = parsed.data;
2460
+ const validNames = opts?.validProviderNames ?? [];
2461
+ if (validNames.length) {
2462
+ const allProviders = [
2463
+ ...config.spec.providers ?? [],
2464
+ ...config.spec.schedule?.providers ?? []
2465
+ ];
2466
+ if (allProviders.length) {
2467
+ const invalid = allProviders.filter((p) => !validNames.includes(p));
2468
+ if (invalid.length) {
2469
+ const err = validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
2470
+ invalidProviders: [...new Set(invalid)],
2471
+ validProviders: validNames
2472
+ });
2473
+ return reply.status(err.statusCode).send(err.toJSON());
2474
+ }
2475
+ }
2476
+ }
2348
2477
  const now = (/* @__PURE__ */ new Date()).toISOString();
2349
2478
  const name = config.metadata.name;
2350
2479
  const existing = app.db.select().from(projects).where(eq8(projects.name, name)).get();
@@ -2552,16 +2681,16 @@ async function applyRoutes(app, opts) {
2552
2681
  }
2553
2682
 
2554
2683
  // ../api-routes/src/history.ts
2555
- import { eq as eq9, desc, inArray } from "drizzle-orm";
2684
+ import { eq as eq9, desc as desc2, inArray } from "drizzle-orm";
2556
2685
  async function historyRoutes(app) {
2557
2686
  app.get("/projects/:name/history", async (request, reply) => {
2558
2687
  const project = resolveProjectSafe4(app, request.params.name, reply);
2559
2688
  if (!project) return;
2560
- const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc(auditLog.createdAt)).all();
2689
+ const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc2(auditLog.createdAt)).all();
2561
2690
  return reply.send(rows.map(formatAuditEntry));
2562
2691
  });
2563
2692
  app.get("/history", async (_request, reply) => {
2564
- const rows = app.db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).all();
2693
+ const rows = app.db.select().from(auditLog).orderBy(desc2(auditLog.createdAt)).all();
2565
2694
  return reply.send(rows.map(formatAuditEntry));
2566
2695
  });
2567
2696
  app.get("/projects/:name/snapshots", async (request, reply) => {
@@ -2586,7 +2715,7 @@ async function historyRoutes(app) {
2586
2715
  competitorOverlap: querySnapshots.competitorOverlap,
2587
2716
  location: querySnapshots.location,
2588
2717
  createdAt: querySnapshots.createdAt
2589
- }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc(querySnapshots.createdAt)).all();
2718
+ }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc2(querySnapshots.createdAt)).all();
2590
2719
  const locationFilter = request.query.location;
2591
2720
  const filtered = locationFilter !== void 0 ? allSnapshots.filter((s) => s.location === (locationFilter || null)) : allSnapshots;
2592
2721
  const total = filtered.length;
@@ -2767,7 +2896,7 @@ function resolveProjectSafe4(app, name, reply) {
2767
2896
  }
2768
2897
 
2769
2898
  // ../api-routes/src/analytics.ts
2770
- import { eq as eq10, desc as desc2, inArray as inArray2 } from "drizzle-orm";
2899
+ import { eq as eq10, desc as desc3, inArray as inArray2 } from "drizzle-orm";
2771
2900
  async function analyticsRoutes(app) {
2772
2901
  app.get("/projects/:name/analytics/metrics", async (request, reply) => {
2773
2902
  const project = resolveProjectSafe5(app, request.params.name, reply);
@@ -2811,7 +2940,7 @@ async function analyticsRoutes(app) {
2811
2940
  if (!project) return;
2812
2941
  const window = parseWindow(request.query.window);
2813
2942
  const cutoff = windowCutoff(window);
2814
- const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc2(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
2943
+ const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
2815
2944
  if (!latestRun) {
2816
2945
  return reply.send({ cited: [], gap: [], uncited: [], runId: "", window });
2817
2946
  }
@@ -2893,7 +3022,7 @@ async function analyticsRoutes(app) {
2893
3022
  if (!project) return;
2894
3023
  const window = parseWindow(request.query.window);
2895
3024
  const cutoff = windowCutoff(window);
2896
- const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc2(runs.createdAt)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
3025
+ const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
2897
3026
  if (windowRuns.length === 0) {
2898
3027
  return reply.send({ overall: [], byKeyword: {}, runId: "", window });
2899
3028
  }
@@ -3066,6 +3195,18 @@ var booleanSchema = { type: "boolean" };
3066
3195
  var integerSchema = { type: "integer" };
3067
3196
  var objectSchema = { type: "object", additionalProperties: true };
3068
3197
  var stringArraySchema = { type: "array", items: stringSchema };
3198
+ var googleConnectionTypeSchema2 = { type: "string", enum: ["gsc", "ga4"] };
3199
+ var locationSchema = {
3200
+ type: "object",
3201
+ required: ["label", "city", "region", "country"],
3202
+ properties: {
3203
+ label: stringSchema,
3204
+ city: stringSchema,
3205
+ region: stringSchema,
3206
+ country: stringSchema,
3207
+ timezone: stringSchema
3208
+ }
3209
+ };
3069
3210
  var nameParameter = {
3070
3211
  name: "name",
3071
3212
  in: "path",
@@ -3092,7 +3233,59 @@ var providerNameParameter = {
3092
3233
  in: "path",
3093
3234
  required: true,
3094
3235
  description: "Provider name.",
3095
- schema: { type: "string", enum: ["gemini", "openai", "claude", "local"] }
3236
+ schema: { type: "string", enum: ["gemini", "openai", "claude", "perplexity", "local"] }
3237
+ };
3238
+ var locationLabelParameter = {
3239
+ name: "label",
3240
+ in: "path",
3241
+ required: true,
3242
+ description: "Location label.",
3243
+ schema: stringSchema
3244
+ };
3245
+ var googleTypeParameter = {
3246
+ name: "type",
3247
+ in: "path",
3248
+ required: true,
3249
+ description: "Google connection type.",
3250
+ schema: googleConnectionTypeSchema2
3251
+ };
3252
+ var projectRunIdParameter = {
3253
+ name: "runId",
3254
+ in: "path",
3255
+ required: true,
3256
+ description: "Run ID for a project run.",
3257
+ schema: stringSchema
3258
+ };
3259
+ var snapshotIdParameter = {
3260
+ name: "snapshotId",
3261
+ in: "path",
3262
+ required: true,
3263
+ description: "Snapshot ID.",
3264
+ schema: stringSchema
3265
+ };
3266
+ var limitQueryParameter = {
3267
+ name: "limit",
3268
+ in: "query",
3269
+ description: "Maximum number of records to return.",
3270
+ schema: integerSchema
3271
+ };
3272
+ var offsetQueryParameter = {
3273
+ name: "offset",
3274
+ in: "query",
3275
+ description: "Number of records to skip.",
3276
+ schema: integerSchema
3277
+ };
3278
+ var locationQueryParameter = {
3279
+ name: "location",
3280
+ in: "query",
3281
+ description: "Filter by location label. Use an empty value to request locationless results.",
3282
+ schema: stringSchema
3283
+ };
3284
+ var analyticsWindowParameter = {
3285
+ name: "window",
3286
+ in: "query",
3287
+ description: "Time window for analytics queries.",
3288
+ schema: { type: "string", enum: ["7d", "30d", "90d", "all"] }
3096
3289
  };
3097
3290
  var routeCatalog = [
3098
3291
  {
@@ -3122,11 +3315,14 @@ var routeCatalog = [
3122
3315
  properties: {
3123
3316
  displayName: stringSchema,
3124
3317
  canonicalDomain: stringSchema,
3318
+ ownedDomains: stringArraySchema,
3125
3319
  country: stringSchema,
3126
3320
  language: stringSchema,
3127
3321
  tags: stringArraySchema,
3128
3322
  labels: objectSchema,
3129
3323
  providers: stringArraySchema,
3324
+ locations: { type: "array", items: locationSchema },
3325
+ defaultLocation: stringSchema,
3130
3326
  configSource: stringSchema
3131
3327
  }
3132
3328
  }
@@ -3170,16 +3366,85 @@ var routeCatalog = [
3170
3366
  }
3171
3367
  },
3172
3368
  {
3173
- method: "get",
3174
- path: "/api/v1/projects/{name}/export",
3175
- summary: "Export a project as config",
3369
+ method: "post",
3370
+ path: "/api/v1/projects/{name}/locations",
3371
+ summary: "Add a project location",
3176
3372
  tags: ["projects"],
3177
3373
  parameters: [nameParameter],
3178
- responses: {
3179
- 200: { description: "Project configuration returned." },
3180
- 404: { description: "Project not found." }
3181
- }
3182
- },
3374
+ requestBody: {
3375
+ required: true,
3376
+ content: {
3377
+ "application/json": {
3378
+ schema: locationSchema
3379
+ }
3380
+ }
3381
+ },
3382
+ responses: {
3383
+ 201: { description: "Location created." },
3384
+ 400: { description: "Invalid location." },
3385
+ 404: { description: "Project not found." }
3386
+ }
3387
+ },
3388
+ {
3389
+ method: "get",
3390
+ path: "/api/v1/projects/{name}/locations",
3391
+ summary: "List project locations",
3392
+ tags: ["projects"],
3393
+ parameters: [nameParameter],
3394
+ responses: {
3395
+ 200: { description: "Locations returned." },
3396
+ 404: { description: "Project not found." }
3397
+ }
3398
+ },
3399
+ {
3400
+ method: "delete",
3401
+ path: "/api/v1/projects/{name}/locations/{label}",
3402
+ summary: "Remove a project location",
3403
+ tags: ["projects"],
3404
+ parameters: [nameParameter, locationLabelParameter],
3405
+ responses: {
3406
+ 204: { description: "Location removed." },
3407
+ 400: { description: "Invalid location." },
3408
+ 404: { description: "Project or location not found." }
3409
+ }
3410
+ },
3411
+ {
3412
+ method: "put",
3413
+ path: "/api/v1/projects/{name}/locations/default",
3414
+ summary: "Set the default project location",
3415
+ tags: ["projects"],
3416
+ parameters: [nameParameter],
3417
+ requestBody: {
3418
+ required: true,
3419
+ content: {
3420
+ "application/json": {
3421
+ schema: {
3422
+ type: "object",
3423
+ required: ["label"],
3424
+ properties: {
3425
+ label: stringSchema
3426
+ }
3427
+ }
3428
+ }
3429
+ }
3430
+ },
3431
+ responses: {
3432
+ 200: { description: "Default location updated." },
3433
+ 400: { description: "Invalid location." },
3434
+ 404: { description: "Project not found." }
3435
+ }
3436
+ },
3437
+ {
3438
+ method: "get",
3439
+ path: "/api/v1/projects/{name}/export",
3440
+ summary: "Export a project as config",
3441
+ tags: ["projects"],
3442
+ parameters: [nameParameter],
3443
+ responses: {
3444
+ 200: { description: "Project configuration returned." },
3445
+ 404: { description: "Project not found." }
3446
+ }
3447
+ },
3183
3448
  {
3184
3449
  method: "get",
3185
3450
  path: "/api/v1/projects/{name}/keywords",
@@ -3214,6 +3479,31 @@ var routeCatalog = [
3214
3479
  200: { description: "Keywords replaced." }
3215
3480
  }
3216
3481
  },
3482
+ {
3483
+ method: "delete",
3484
+ path: "/api/v1/projects/{name}/keywords",
3485
+ summary: "Delete specific keywords",
3486
+ tags: ["keywords"],
3487
+ parameters: [nameParameter],
3488
+ requestBody: {
3489
+ required: true,
3490
+ content: {
3491
+ "application/json": {
3492
+ schema: {
3493
+ type: "object",
3494
+ required: ["keywords"],
3495
+ properties: {
3496
+ keywords: stringArraySchema
3497
+ }
3498
+ }
3499
+ }
3500
+ }
3501
+ },
3502
+ responses: {
3503
+ 200: { description: "Remaining keywords returned." },
3504
+ 400: { description: "Invalid keyword delete request." }
3505
+ }
3506
+ },
3217
3507
  {
3218
3508
  method: "post",
3219
3509
  path: "/api/v1/projects/{name}/keywords",
@@ -3252,7 +3542,7 @@ var routeCatalog = [
3252
3542
  type: "object",
3253
3543
  required: ["provider"],
3254
3544
  properties: {
3255
- provider: { type: "string", enum: ["gemini", "openai", "claude", "local"] },
3545
+ provider: { type: "string", enum: ["gemini", "openai", "claude", "perplexity", "local"] },
3256
3546
  count: integerSchema
3257
3547
  }
3258
3548
  }
@@ -3312,7 +3602,10 @@ var routeCatalog = [
3312
3602
  properties: {
3313
3603
  kind: stringSchema,
3314
3604
  trigger: stringSchema,
3315
- providers: stringArraySchema
3605
+ providers: stringArraySchema,
3606
+ location: stringSchema,
3607
+ allLocations: booleanSchema,
3608
+ noLocation: booleanSchema
3316
3609
  }
3317
3610
  }
3318
3611
  }
@@ -3328,7 +3621,7 @@ var routeCatalog = [
3328
3621
  path: "/api/v1/projects/{name}/runs",
3329
3622
  summary: "List project runs",
3330
3623
  tags: ["runs"],
3331
- parameters: [nameParameter],
3624
+ parameters: [nameParameter, limitQueryParameter],
3332
3625
  responses: {
3333
3626
  200: { description: "Runs returned." }
3334
3627
  }
@@ -3375,6 +3668,18 @@ var routeCatalog = [
3375
3668
  404: { description: "Run not found." }
3376
3669
  }
3377
3670
  },
3671
+ {
3672
+ method: "post",
3673
+ path: "/api/v1/runs/{id}/cancel",
3674
+ summary: "Cancel a queued or running run",
3675
+ tags: ["runs"],
3676
+ parameters: [runIdParameter],
3677
+ responses: {
3678
+ 200: { description: "Run cancelled." },
3679
+ 404: { description: "Run not found." },
3680
+ 409: { description: "Run is not cancellable." }
3681
+ }
3682
+ },
3378
3683
  {
3379
3684
  method: "post",
3380
3685
  path: "/api/v1/apply",
@@ -3420,18 +3725,9 @@ var routeCatalog = [
3420
3725
  tags: ["history"],
3421
3726
  parameters: [
3422
3727
  nameParameter,
3423
- {
3424
- name: "limit",
3425
- in: "query",
3426
- description: "Maximum number of snapshots to return.",
3427
- schema: integerSchema
3428
- },
3429
- {
3430
- name: "offset",
3431
- in: "query",
3432
- description: "Number of snapshots to skip.",
3433
- schema: integerSchema
3434
- }
3728
+ limitQueryParameter,
3729
+ offsetQueryParameter,
3730
+ locationQueryParameter
3435
3731
  ],
3436
3732
  responses: {
3437
3733
  200: { description: "Snapshots returned." }
@@ -3442,11 +3738,44 @@ var routeCatalog = [
3442
3738
  path: "/api/v1/projects/{name}/timeline",
3443
3739
  summary: "Get keyword timeline",
3444
3740
  tags: ["history"],
3445
- parameters: [nameParameter],
3741
+ parameters: [nameParameter, locationQueryParameter],
3446
3742
  responses: {
3447
3743
  200: { description: "Timeline returned." }
3448
3744
  }
3449
3745
  },
3746
+ {
3747
+ method: "get",
3748
+ path: "/api/v1/projects/{name}/analytics/metrics",
3749
+ summary: "Get citation trend analytics",
3750
+ tags: ["analytics"],
3751
+ parameters: [nameParameter, analyticsWindowParameter],
3752
+ responses: {
3753
+ 200: { description: "Citation metrics returned." },
3754
+ 404: { description: "Project not found." }
3755
+ }
3756
+ },
3757
+ {
3758
+ method: "get",
3759
+ path: "/api/v1/projects/{name}/analytics/gaps",
3760
+ summary: "Get brand gap analysis",
3761
+ tags: ["analytics"],
3762
+ parameters: [nameParameter, analyticsWindowParameter],
3763
+ responses: {
3764
+ 200: { description: "Gap analysis returned." },
3765
+ 404: { description: "Project not found." }
3766
+ }
3767
+ },
3768
+ {
3769
+ method: "get",
3770
+ path: "/api/v1/projects/{name}/analytics/sources",
3771
+ summary: "Get source origin analytics",
3772
+ tags: ["analytics"],
3773
+ parameters: [nameParameter, analyticsWindowParameter],
3774
+ responses: {
3775
+ 200: { description: "Source breakdown returned." },
3776
+ 404: { description: "Project not found." }
3777
+ }
3778
+ },
3450
3779
  {
3451
3780
  method: "get",
3452
3781
  path: "/api/v1/projects/{name}/snapshots/diff",
@@ -3511,6 +3840,83 @@ var routeCatalog = [
3511
3840
  501: { description: "Provider updates are not supported." }
3512
3841
  }
3513
3842
  },
3843
+ {
3844
+ method: "put",
3845
+ path: "/api/v1/settings/google",
3846
+ summary: "Update Google OAuth settings",
3847
+ tags: ["settings"],
3848
+ requestBody: {
3849
+ required: true,
3850
+ content: {
3851
+ "application/json": {
3852
+ schema: {
3853
+ type: "object",
3854
+ required: ["clientId", "clientSecret"],
3855
+ properties: {
3856
+ clientId: stringSchema,
3857
+ clientSecret: stringSchema
3858
+ }
3859
+ }
3860
+ }
3861
+ }
3862
+ },
3863
+ responses: {
3864
+ 200: { description: "Google settings updated." },
3865
+ 400: { description: "Invalid Google settings." },
3866
+ 501: { description: "Google settings updates are not supported." }
3867
+ }
3868
+ },
3869
+ {
3870
+ method: "put",
3871
+ path: "/api/v1/settings/bing",
3872
+ summary: "Update Bing settings",
3873
+ tags: ["settings"],
3874
+ requestBody: {
3875
+ required: true,
3876
+ content: {
3877
+ "application/json": {
3878
+ schema: {
3879
+ type: "object",
3880
+ required: ["apiKey"],
3881
+ properties: {
3882
+ apiKey: stringSchema
3883
+ }
3884
+ }
3885
+ }
3886
+ }
3887
+ },
3888
+ responses: {
3889
+ 200: { description: "Bing settings updated." },
3890
+ 400: { description: "Invalid Bing settings." },
3891
+ 501: { description: "Bing settings updates are not supported." }
3892
+ }
3893
+ },
3894
+ {
3895
+ method: "put",
3896
+ path: "/api/v1/settings/cdp",
3897
+ summary: "Update CDP endpoint settings",
3898
+ tags: ["settings", "cdp"],
3899
+ requestBody: {
3900
+ required: true,
3901
+ content: {
3902
+ "application/json": {
3903
+ schema: {
3904
+ type: "object",
3905
+ required: ["host"],
3906
+ properties: {
3907
+ host: stringSchema,
3908
+ port: integerSchema
3909
+ }
3910
+ }
3911
+ }
3912
+ }
3913
+ },
3914
+ responses: {
3915
+ 200: { description: "CDP endpoint updated." },
3916
+ 400: { description: "Invalid CDP settings." },
3917
+ 501: { description: "CDP updates are not supported." }
3918
+ }
3919
+ },
3514
3920
  {
3515
3921
  method: "put",
3516
3922
  path: "/api/v1/projects/{name}/schedule",
@@ -3636,33 +4042,600 @@ var routeCatalog = [
3636
4042
  summary: "Get telemetry status",
3637
4043
  tags: ["telemetry"],
3638
4044
  responses: {
3639
- 200: { description: "Telemetry status returned." },
3640
- 501: { description: "Telemetry status is not available." }
4045
+ 200: { description: "Telemetry status returned." },
4046
+ 501: { description: "Telemetry status is not available." }
4047
+ }
4048
+ },
4049
+ {
4050
+ method: "put",
4051
+ path: "/api/v1/telemetry",
4052
+ summary: "Update telemetry status",
4053
+ tags: ["telemetry"],
4054
+ requestBody: {
4055
+ required: true,
4056
+ content: {
4057
+ "application/json": {
4058
+ schema: {
4059
+ type: "object",
4060
+ required: ["enabled"],
4061
+ properties: {
4062
+ enabled: booleanSchema
4063
+ }
4064
+ }
4065
+ }
4066
+ }
4067
+ },
4068
+ responses: {
4069
+ 200: { description: "Telemetry updated." },
4070
+ 400: { description: "Invalid telemetry request." },
4071
+ 501: { description: "Telemetry configuration is not available." }
4072
+ }
4073
+ },
4074
+ {
4075
+ method: "get",
4076
+ path: "/api/v1/screenshots/{snapshotId}",
4077
+ summary: "Fetch a stored browser screenshot",
4078
+ tags: ["cdp"],
4079
+ parameters: [snapshotIdParameter],
4080
+ responses: {
4081
+ 200: { description: "Screenshot returned." },
4082
+ 404: { description: "Screenshot not found." }
4083
+ }
4084
+ },
4085
+ {
4086
+ method: "get",
4087
+ path: "/api/v1/cdp/status",
4088
+ summary: "Get CDP connection status",
4089
+ tags: ["cdp"],
4090
+ responses: {
4091
+ 200: { description: "CDP status returned." },
4092
+ 501: { description: "CDP is not configured." }
4093
+ }
4094
+ },
4095
+ {
4096
+ method: "post",
4097
+ path: "/api/v1/cdp/screenshot",
4098
+ summary: "Run a one-off browser query and capture screenshots",
4099
+ tags: ["cdp"],
4100
+ requestBody: {
4101
+ required: true,
4102
+ content: {
4103
+ "application/json": {
4104
+ schema: {
4105
+ type: "object",
4106
+ required: ["query"],
4107
+ properties: {
4108
+ query: stringSchema,
4109
+ targets: stringArraySchema
4110
+ }
4111
+ }
4112
+ }
4113
+ }
4114
+ },
4115
+ responses: {
4116
+ 200: { description: "CDP screenshot results returned." },
4117
+ 400: { description: "Invalid CDP screenshot request." },
4118
+ 501: { description: "CDP screenshot support is not available." }
4119
+ }
4120
+ },
4121
+ {
4122
+ method: "get",
4123
+ path: "/api/v1/projects/{name}/runs/{runId}/browser-diff",
4124
+ summary: "Compare API and browser provider results for a run",
4125
+ tags: ["cdp", "runs"],
4126
+ parameters: [nameParameter, projectRunIdParameter],
4127
+ responses: {
4128
+ 200: { description: "Browser diff returned." },
4129
+ 404: { description: "Project or run not found." }
4130
+ }
4131
+ },
4132
+ {
4133
+ method: "get",
4134
+ path: "/api/v1/google/callback",
4135
+ summary: "Handle the shared Google OAuth callback",
4136
+ tags: ["google"],
4137
+ auth: false,
4138
+ parameters: [
4139
+ { name: "code", in: "query", description: "OAuth authorization code.", schema: stringSchema },
4140
+ { name: "state", in: "query", description: "Signed OAuth state payload.", schema: stringSchema },
4141
+ { name: "error", in: "query", description: "OAuth error code.", schema: stringSchema }
4142
+ ],
4143
+ responses: {
4144
+ 200: { description: "OAuth callback handled." },
4145
+ 400: { description: "Invalid callback request." },
4146
+ 500: { description: "OAuth configuration is incomplete." }
4147
+ }
4148
+ },
4149
+ {
4150
+ method: "get",
4151
+ path: "/api/v1/projects/{name}/google/callback",
4152
+ summary: "Handle the legacy project-scoped Google OAuth callback",
4153
+ tags: ["google"],
4154
+ auth: false,
4155
+ parameters: [
4156
+ nameParameter,
4157
+ { name: "code", in: "query", description: "OAuth authorization code.", schema: stringSchema },
4158
+ { name: "state", in: "query", description: "Signed OAuth state payload.", schema: stringSchema },
4159
+ { name: "error", in: "query", description: "OAuth error code.", schema: stringSchema }
4160
+ ],
4161
+ responses: {
4162
+ 200: { description: "OAuth callback handled." },
4163
+ 400: { description: "Invalid callback request." },
4164
+ 500: { description: "OAuth configuration is incomplete." }
4165
+ }
4166
+ },
4167
+ {
4168
+ method: "get",
4169
+ path: "/api/v1/projects/{name}/google/connections",
4170
+ summary: "List Google connections for a project",
4171
+ tags: ["google"],
4172
+ parameters: [nameParameter],
4173
+ responses: {
4174
+ 200: { description: "Google connections returned." },
4175
+ 404: { description: "Project not found." }
4176
+ }
4177
+ },
4178
+ {
4179
+ method: "post",
4180
+ path: "/api/v1/projects/{name}/google/connect",
4181
+ summary: "Start a Google OAuth connection flow",
4182
+ tags: ["google"],
4183
+ parameters: [nameParameter],
4184
+ requestBody: {
4185
+ required: true,
4186
+ content: {
4187
+ "application/json": {
4188
+ schema: {
4189
+ type: "object",
4190
+ required: ["type"],
4191
+ properties: {
4192
+ type: googleConnectionTypeSchema2,
4193
+ propertyId: stringSchema,
4194
+ publicUrl: stringSchema
4195
+ }
4196
+ }
4197
+ }
4198
+ }
4199
+ },
4200
+ responses: {
4201
+ 200: { description: "Google auth URL returned." },
4202
+ 400: { description: "Invalid Google connection request." }
4203
+ }
4204
+ },
4205
+ {
4206
+ method: "delete",
4207
+ path: "/api/v1/projects/{name}/google/connections/{type}",
4208
+ summary: "Delete a Google connection",
4209
+ tags: ["google"],
4210
+ parameters: [nameParameter, googleTypeParameter],
4211
+ responses: {
4212
+ 204: { description: "Google connection deleted." },
4213
+ 404: { description: "Project or connection not found." }
4214
+ }
4215
+ },
4216
+ {
4217
+ method: "get",
4218
+ path: "/api/v1/projects/{name}/google/properties",
4219
+ summary: "List available Google Search Console properties",
4220
+ tags: ["google"],
4221
+ parameters: [nameParameter],
4222
+ responses: {
4223
+ 200: { description: "Google properties returned." },
4224
+ 400: { description: "Google OAuth is not configured." },
4225
+ 404: { description: "Project not found." }
4226
+ }
4227
+ },
4228
+ {
4229
+ method: "put",
4230
+ path: "/api/v1/projects/{name}/google/connections/{type}/property",
4231
+ summary: "Set the property for a Google connection",
4232
+ tags: ["google"],
4233
+ parameters: [nameParameter, googleTypeParameter],
4234
+ requestBody: {
4235
+ required: true,
4236
+ content: {
4237
+ "application/json": {
4238
+ schema: {
4239
+ type: "object",
4240
+ required: ["propertyId"],
4241
+ properties: {
4242
+ propertyId: stringSchema
4243
+ }
4244
+ }
4245
+ }
4246
+ }
4247
+ },
4248
+ responses: {
4249
+ 200: { description: "Google property updated." },
4250
+ 400: { description: "Invalid property request." },
4251
+ 404: { description: "Project or connection not found." }
4252
+ }
4253
+ },
4254
+ {
4255
+ method: "put",
4256
+ path: "/api/v1/projects/{name}/google/connections/{type}/sitemap",
4257
+ summary: "Set the sitemap URL for a Google connection",
4258
+ tags: ["google"],
4259
+ parameters: [nameParameter, googleTypeParameter],
4260
+ requestBody: {
4261
+ required: true,
4262
+ content: {
4263
+ "application/json": {
4264
+ schema: {
4265
+ type: "object",
4266
+ required: ["sitemapUrl"],
4267
+ properties: {
4268
+ sitemapUrl: stringSchema
4269
+ }
4270
+ }
4271
+ }
4272
+ }
4273
+ },
4274
+ responses: {
4275
+ 200: { description: "Google sitemap updated." },
4276
+ 400: { description: "Invalid sitemap request." },
4277
+ 404: { description: "Project or connection not found." }
4278
+ }
4279
+ },
4280
+ {
4281
+ method: "post",
4282
+ path: "/api/v1/projects/{name}/google/gsc/sync",
4283
+ summary: "Queue a GSC sync run",
4284
+ tags: ["google"],
4285
+ parameters: [nameParameter],
4286
+ requestBody: {
4287
+ content: {
4288
+ "application/json": {
4289
+ schema: {
4290
+ type: "object",
4291
+ properties: {
4292
+ days: integerSchema,
4293
+ full: booleanSchema
4294
+ }
4295
+ }
4296
+ }
4297
+ }
4298
+ },
4299
+ responses: {
4300
+ 200: { description: "GSC sync run returned." },
4301
+ 400: { description: "Invalid GSC sync request." },
4302
+ 404: { description: "Project or connection not found." }
4303
+ }
4304
+ },
4305
+ {
4306
+ method: "get",
4307
+ path: "/api/v1/projects/{name}/google/gsc/performance",
4308
+ summary: "Get GSC search performance data",
4309
+ tags: ["google"],
4310
+ parameters: [
4311
+ nameParameter,
4312
+ { name: "startDate", in: "query", description: "Filter by start date.", schema: stringSchema },
4313
+ { name: "endDate", in: "query", description: "Filter by end date.", schema: stringSchema },
4314
+ { name: "query", in: "query", description: "Filter by search query.", schema: stringSchema },
4315
+ { name: "page", in: "query", description: "Filter by page URL.", schema: stringSchema },
4316
+ limitQueryParameter
4317
+ ],
4318
+ responses: {
4319
+ 200: { description: "GSC performance rows returned." },
4320
+ 404: { description: "Project not found." }
4321
+ }
4322
+ },
4323
+ {
4324
+ method: "post",
4325
+ path: "/api/v1/projects/{name}/google/gsc/inspect",
4326
+ summary: "Inspect a URL through Google Search Console",
4327
+ tags: ["google"],
4328
+ parameters: [nameParameter],
4329
+ requestBody: {
4330
+ required: true,
4331
+ content: {
4332
+ "application/json": {
4333
+ schema: {
4334
+ type: "object",
4335
+ required: ["url"],
4336
+ properties: {
4337
+ url: stringSchema
4338
+ }
4339
+ }
4340
+ }
4341
+ }
4342
+ },
4343
+ responses: {
4344
+ 200: { description: "GSC inspection result returned." },
4345
+ 400: { description: "Invalid inspection request." },
4346
+ 404: { description: "Project or connection not found." }
4347
+ }
4348
+ },
4349
+ {
4350
+ method: "get",
4351
+ path: "/api/v1/projects/{name}/google/gsc/inspections",
4352
+ summary: "List GSC URL inspections",
4353
+ tags: ["google"],
4354
+ parameters: [nameParameter, { name: "url", in: "query", description: "Filter by URL.", schema: stringSchema }, limitQueryParameter],
4355
+ responses: {
4356
+ 200: { description: "GSC inspections returned." },
4357
+ 404: { description: "Project not found." }
4358
+ }
4359
+ },
4360
+ {
4361
+ method: "get",
4362
+ path: "/api/v1/projects/{name}/google/gsc/deindexed",
4363
+ summary: "List GSC deindexed pages",
4364
+ tags: ["google"],
4365
+ parameters: [nameParameter],
4366
+ responses: {
4367
+ 200: { description: "Deindexed pages returned." },
4368
+ 404: { description: "Project not found." }
4369
+ }
4370
+ },
4371
+ {
4372
+ method: "get",
4373
+ path: "/api/v1/projects/{name}/google/gsc/coverage",
4374
+ summary: "Get GSC coverage summary",
4375
+ tags: ["google"],
4376
+ parameters: [nameParameter],
4377
+ responses: {
4378
+ 200: { description: "GSC coverage returned." },
4379
+ 404: { description: "Project not found." }
4380
+ }
4381
+ },
4382
+ {
4383
+ method: "get",
4384
+ path: "/api/v1/projects/{name}/google/gsc/coverage/history",
4385
+ summary: "Get GSC coverage history",
4386
+ tags: ["google"],
4387
+ parameters: [nameParameter, limitQueryParameter],
4388
+ responses: {
4389
+ 200: { description: "GSC coverage history returned." },
4390
+ 404: { description: "Project not found." }
4391
+ }
4392
+ },
4393
+ {
4394
+ method: "get",
4395
+ path: "/api/v1/projects/{name}/google/gsc/sitemaps",
4396
+ summary: "List GSC sitemaps",
4397
+ tags: ["google"],
4398
+ parameters: [nameParameter],
4399
+ responses: {
4400
+ 200: { description: "GSC sitemaps returned." },
4401
+ 400: { description: "Invalid sitemap request." },
4402
+ 404: { description: "Project or connection not found." }
4403
+ }
4404
+ },
4405
+ {
4406
+ method: "post",
4407
+ path: "/api/v1/projects/{name}/google/gsc/discover-sitemaps",
4408
+ summary: "Discover sitemaps and queue sitemap inspection",
4409
+ tags: ["google"],
4410
+ parameters: [nameParameter],
4411
+ responses: {
4412
+ 200: { description: "Discovered sitemaps and queued run returned." },
4413
+ 400: { description: "Invalid sitemap discovery request." },
4414
+ 404: { description: "Project or connection not found." }
4415
+ }
4416
+ },
4417
+ {
4418
+ method: "post",
4419
+ path: "/api/v1/projects/{name}/google/gsc/inspect-sitemap",
4420
+ summary: "Queue a sitemap inspection run",
4421
+ tags: ["google"],
4422
+ parameters: [nameParameter],
4423
+ requestBody: {
4424
+ content: {
4425
+ "application/json": {
4426
+ schema: {
4427
+ type: "object",
4428
+ properties: {
4429
+ sitemapUrl: stringSchema
4430
+ }
4431
+ }
4432
+ }
4433
+ }
4434
+ },
4435
+ responses: {
4436
+ 200: { description: "Sitemap inspection run returned." },
4437
+ 400: { description: "Invalid sitemap inspection request." },
4438
+ 404: { description: "Project or connection not found." }
4439
+ }
4440
+ },
4441
+ {
4442
+ method: "post",
4443
+ path: "/api/v1/projects/{name}/google/indexing/request",
4444
+ summary: "Request Google indexing notifications",
4445
+ tags: ["google"],
4446
+ parameters: [nameParameter],
4447
+ requestBody: {
4448
+ required: true,
4449
+ content: {
4450
+ "application/json": {
4451
+ schema: {
4452
+ type: "object",
4453
+ properties: {
4454
+ urls: stringArraySchema,
4455
+ allUnindexed: booleanSchema
4456
+ }
4457
+ }
4458
+ }
4459
+ }
4460
+ },
4461
+ responses: {
4462
+ 200: { description: "Indexing request results returned." },
4463
+ 400: { description: "Invalid indexing request." },
4464
+ 404: { description: "Project or connection not found." }
4465
+ }
4466
+ },
4467
+ {
4468
+ method: "post",
4469
+ path: "/api/v1/projects/{name}/bing/connect",
4470
+ summary: "Connect Bing Webmaster Tools",
4471
+ tags: ["bing"],
4472
+ parameters: [nameParameter],
4473
+ requestBody: {
4474
+ required: true,
4475
+ content: {
4476
+ "application/json": {
4477
+ schema: {
4478
+ type: "object",
4479
+ required: ["apiKey"],
4480
+ properties: {
4481
+ apiKey: stringSchema
4482
+ }
4483
+ }
4484
+ }
4485
+ }
4486
+ },
4487
+ responses: {
4488
+ 200: { description: "Bing connection returned." },
4489
+ 400: { description: "Invalid Bing connection request." },
4490
+ 404: { description: "Project not found." }
4491
+ }
4492
+ },
4493
+ {
4494
+ method: "delete",
4495
+ path: "/api/v1/projects/{name}/bing/disconnect",
4496
+ summary: "Disconnect Bing Webmaster Tools",
4497
+ tags: ["bing"],
4498
+ parameters: [nameParameter],
4499
+ responses: {
4500
+ 204: { description: "Bing connection deleted." },
4501
+ 404: { description: "Project or connection not found." }
4502
+ }
4503
+ },
4504
+ {
4505
+ method: "get",
4506
+ path: "/api/v1/projects/{name}/bing/status",
4507
+ summary: "Get Bing connection status",
4508
+ tags: ["bing"],
4509
+ parameters: [nameParameter],
4510
+ responses: {
4511
+ 200: { description: "Bing status returned." },
4512
+ 404: { description: "Project not found." }
4513
+ }
4514
+ },
4515
+ {
4516
+ method: "get",
4517
+ path: "/api/v1/projects/{name}/bing/sites",
4518
+ summary: "List Bing sites for the current connection",
4519
+ tags: ["bing"],
4520
+ parameters: [nameParameter],
4521
+ responses: {
4522
+ 200: { description: "Bing sites returned." },
4523
+ 400: { description: "Bing is not configured for this project." },
4524
+ 404: { description: "Project not found." }
4525
+ }
4526
+ },
4527
+ {
4528
+ method: "post",
4529
+ path: "/api/v1/projects/{name}/bing/set-site",
4530
+ summary: "Set the active Bing site",
4531
+ tags: ["bing"],
4532
+ parameters: [nameParameter],
4533
+ requestBody: {
4534
+ required: true,
4535
+ content: {
4536
+ "application/json": {
4537
+ schema: {
4538
+ type: "object",
4539
+ required: ["siteUrl"],
4540
+ properties: {
4541
+ siteUrl: stringSchema
4542
+ }
4543
+ }
4544
+ }
4545
+ }
4546
+ },
4547
+ responses: {
4548
+ 200: { description: "Active Bing site updated." },
4549
+ 400: { description: "Invalid Bing site request." },
4550
+ 404: { description: "Project or connection not found." }
4551
+ }
4552
+ },
4553
+ {
4554
+ method: "get",
4555
+ path: "/api/v1/projects/{name}/bing/coverage",
4556
+ summary: "Get Bing index coverage",
4557
+ tags: ["bing"],
4558
+ parameters: [nameParameter],
4559
+ responses: {
4560
+ 200: { description: "Bing coverage returned." },
4561
+ 400: { description: "Bing is not configured for this project." },
4562
+ 404: { description: "Project not found." }
4563
+ }
4564
+ },
4565
+ {
4566
+ method: "get",
4567
+ path: "/api/v1/projects/{name}/bing/inspections",
4568
+ summary: "List Bing URL inspections",
4569
+ tags: ["bing"],
4570
+ parameters: [nameParameter, { name: "url", in: "query", description: "Filter by URL.", schema: stringSchema }, limitQueryParameter],
4571
+ responses: {
4572
+ 200: { description: "Bing inspections returned." },
4573
+ 400: { description: "Bing is not configured for this project." },
4574
+ 404: { description: "Project not found." }
4575
+ }
4576
+ },
4577
+ {
4578
+ method: "post",
4579
+ path: "/api/v1/projects/{name}/bing/inspect-url",
4580
+ summary: "Inspect a URL through Bing Webmaster Tools",
4581
+ tags: ["bing"],
4582
+ parameters: [nameParameter],
4583
+ requestBody: {
4584
+ required: true,
4585
+ content: {
4586
+ "application/json": {
4587
+ schema: {
4588
+ type: "object",
4589
+ required: ["url"],
4590
+ properties: {
4591
+ url: stringSchema
4592
+ }
4593
+ }
4594
+ }
4595
+ }
4596
+ },
4597
+ responses: {
4598
+ 200: { description: "Bing inspection result returned." },
4599
+ 400: { description: "Invalid inspection request." },
4600
+ 404: { description: "Project or connection not found." }
3641
4601
  }
3642
4602
  },
3643
4603
  {
3644
- method: "put",
3645
- path: "/api/v1/telemetry",
3646
- summary: "Update telemetry status",
3647
- tags: ["telemetry"],
4604
+ method: "post",
4605
+ path: "/api/v1/projects/{name}/bing/request-indexing",
4606
+ summary: "Submit URLs to Bing for indexing",
4607
+ tags: ["bing"],
4608
+ parameters: [nameParameter],
3648
4609
  requestBody: {
3649
4610
  required: true,
3650
4611
  content: {
3651
4612
  "application/json": {
3652
4613
  schema: {
3653
4614
  type: "object",
3654
- required: ["enabled"],
3655
4615
  properties: {
3656
- enabled: booleanSchema
4616
+ urls: stringArraySchema,
4617
+ allUnindexed: booleanSchema
3657
4618
  }
3658
4619
  }
3659
4620
  }
3660
4621
  }
3661
4622
  },
3662
4623
  responses: {
3663
- 200: { description: "Telemetry updated." },
3664
- 400: { description: "Invalid telemetry request." },
3665
- 501: { description: "Telemetry configuration is not available." }
4624
+ 200: { description: "Bing indexing request results returned." },
4625
+ 400: { description: "Invalid indexing request." },
4626
+ 404: { description: "Project or connection not found." }
4627
+ }
4628
+ },
4629
+ {
4630
+ method: "get",
4631
+ path: "/api/v1/projects/{name}/bing/performance",
4632
+ summary: "Get Bing keyword performance",
4633
+ tags: ["bing"],
4634
+ parameters: [nameParameter, limitQueryParameter],
4635
+ responses: {
4636
+ 200: { description: "Bing performance returned." },
4637
+ 400: { description: "Bing is not configured for this project." },
4638
+ 404: { description: "Project not found." }
3666
4639
  }
3667
4640
  }
3668
4641
  ];
@@ -3688,7 +4661,7 @@ function buildOpenApiDocument(info = {}) {
3688
4661
  info: {
3689
4662
  title: info.title ?? "Canonry API",
3690
4663
  version: info.version ?? "0.0.0",
3691
- description: info.description ?? "REST API for Canonry projects, runs, schedules, and notifications."
4664
+ description: info.description ?? "REST API for Canonry projects, runs, analytics, integrations, and operator workflows."
3692
4665
  },
3693
4666
  servers: [
3694
4667
  {
@@ -3731,31 +4704,40 @@ async function settingsRoutes(app, opts) {
3731
4704
  bing: opts.bing ?? { configured: false }
3732
4705
  }));
3733
4706
  app.put("/settings/providers/:name", async (request, reply) => {
3734
- const providerName = parseProviderName(request.params.name);
3735
4707
  const { apiKey, baseUrl, model, quota } = request.body ?? {};
3736
- if (!providerName) {
3737
- return reply.status(400).send({ error: `Invalid provider: ${request.params.name}. Must be one of: gemini, openai, claude, local` });
4708
+ const name = request.params.name;
4709
+ const adapters = opts.providerAdapters ?? [];
4710
+ const apiAdapters = adapters.filter((a) => a.mode === "api");
4711
+ const adapterInfo = apiAdapters.find((a) => a.name === name);
4712
+ if (!adapterInfo) {
4713
+ const validNames = apiAdapters.map((a) => a.name);
4714
+ const err = validationError(`Invalid provider: ${name}. Must be one of: ${validNames.join(", ")}`, {
4715
+ provider: name,
4716
+ validProviders: validNames
4717
+ });
4718
+ return reply.status(err.statusCode).send(err.toJSON());
3738
4719
  }
3739
- const name = providerName;
3740
4720
  if (name === "local") {
3741
4721
  if (!baseUrl || typeof baseUrl !== "string") {
3742
- return reply.status(400).send({ error: "baseUrl is required for local provider" });
4722
+ const err = validationError("baseUrl is required for local provider");
4723
+ return reply.status(err.statusCode).send(err.toJSON());
3743
4724
  }
3744
4725
  } else {
3745
4726
  if (!apiKey || typeof apiKey !== "string") {
3746
- return reply.status(400).send({ error: "apiKey is required" });
4727
+ const err = validationError("apiKey is required");
4728
+ return reply.status(err.statusCode).send(err.toJSON());
3747
4729
  }
3748
4730
  }
3749
4731
  if (model !== void 0) {
3750
- const registry = MODEL_REGISTRY[name];
3751
- if (!registry.validationPattern.test(model)) {
4732
+ if (!adapterInfo.modelValidationPattern.test(model)) {
3752
4733
  return reply.status(400).send({
3753
- error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "${name}" \u2014 ${registry.validationHint}` }
4734
+ error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "${name}" \u2014 ${adapterInfo.modelValidationHint}` }
3754
4735
  });
3755
4736
  }
3756
4737
  }
3757
4738
  if (!opts.onProviderUpdate) {
3758
- return reply.status(501).send({ error: "Provider configuration updates are not supported in this deployment" });
4739
+ const err = notImplemented("Provider configuration updates are not supported in this deployment");
4740
+ return reply.status(err.statusCode).send(err.toJSON());
3759
4741
  }
3760
4742
  if (quota !== void 0) {
3761
4743
  if (typeof quota !== "object" || quota === null) {
@@ -3772,7 +4754,12 @@ async function settingsRoutes(app, opts) {
3772
4754
  }
3773
4755
  const result = opts.onProviderUpdate(name, apiKey ?? "", model, baseUrl, quota);
3774
4756
  if (!result) {
3775
- return reply.status(500).send({ error: "Failed to update provider configuration" });
4757
+ return reply.status(500).send({
4758
+ error: {
4759
+ code: "INTERNAL_ERROR",
4760
+ message: "Failed to update provider configuration"
4761
+ }
4762
+ });
3776
4763
  }
3777
4764
  return result;
3778
4765
  });
@@ -3784,11 +4771,17 @@ async function settingsRoutes(app, opts) {
3784
4771
  });
3785
4772
  }
3786
4773
  if (!opts.onGoogleUpdate) {
3787
- return reply.status(501).send({ error: "Google OAuth configuration updates are not supported in this deployment" });
4774
+ const err = notImplemented("Google OAuth configuration updates are not supported in this deployment");
4775
+ return reply.status(err.statusCode).send(err.toJSON());
3788
4776
  }
3789
4777
  const result = opts.onGoogleUpdate(clientId, clientSecret);
3790
4778
  if (!result) {
3791
- return reply.status(500).send({ error: "Failed to update Google OAuth configuration" });
4779
+ return reply.status(500).send({
4780
+ error: {
4781
+ code: "INTERNAL_ERROR",
4782
+ message: "Failed to update Google OAuth configuration"
4783
+ }
4784
+ });
3792
4785
  }
3793
4786
  return result;
3794
4787
  });
@@ -3800,11 +4793,17 @@ async function settingsRoutes(app, opts) {
3800
4793
  });
3801
4794
  }
3802
4795
  if (!opts.onBingUpdate) {
3803
- return reply.status(501).send({ error: "Bing configuration updates are not supported in this deployment" });
4796
+ const err = notImplemented("Bing configuration updates are not supported in this deployment");
4797
+ return reply.status(err.statusCode).send(err.toJSON());
3804
4798
  }
3805
4799
  const result = opts.onBingUpdate(apiKey);
3806
4800
  if (!result) {
3807
- return reply.status(500).send({ error: "Failed to update Bing configuration" });
4801
+ return reply.status(500).send({
4802
+ error: {
4803
+ code: "INTERNAL_ERROR",
4804
+ message: "Failed to update Bing configuration"
4805
+ }
4806
+ });
3808
4807
  }
3809
4808
  return result;
3810
4809
  });
@@ -3814,7 +4813,8 @@ async function settingsRoutes(app, opts) {
3814
4813
  async function telemetryRoutes(app, opts) {
3815
4814
  app.get("/telemetry", async (_request, reply) => {
3816
4815
  if (!opts.getTelemetryStatus) {
3817
- return reply.status(501).send({ error: "Telemetry status is not available in this deployment" });
4816
+ const err = notImplemented("Telemetry status is not available in this deployment");
4817
+ return reply.status(err.statusCode).send(err.toJSON());
3818
4818
  }
3819
4819
  const status = opts.getTelemetryStatus();
3820
4820
  return {
@@ -3824,11 +4824,13 @@ async function telemetryRoutes(app, opts) {
3824
4824
  });
3825
4825
  app.put("/telemetry", async (request, reply) => {
3826
4826
  if (!opts.setTelemetryEnabled) {
3827
- return reply.status(501).send({ error: "Telemetry configuration is not available in this deployment" });
4827
+ const err = notImplemented("Telemetry configuration is not available in this deployment");
4828
+ return reply.status(err.statusCode).send(err.toJSON());
3828
4829
  }
3829
4830
  const { enabled } = request.body ?? {};
3830
4831
  if (typeof enabled !== "boolean") {
3831
- return reply.status(400).send({ error: "enabled (boolean) is required" });
4832
+ const err = validationError("enabled (boolean) is required");
4833
+ return reply.status(err.statusCode).send(err.toJSON());
3832
4834
  }
3833
4835
  opts.setTelemetryEnabled(enabled);
3834
4836
  const status = opts.getTelemetryStatus?.();
@@ -3846,11 +4848,27 @@ async function scheduleRoutes(app, opts) {
3846
4848
  app.put("/projects/:name/schedule", async (request, reply) => {
3847
4849
  const project = resolveProjectSafe6(app, request.params.name, reply);
3848
4850
  if (!project) return;
3849
- const { preset, cron: cron2, timezone = "UTC", providers = [], enabled = true } = request.body ?? {};
3850
- if (!preset && !cron2 || preset && cron2) {
3851
- return reply.status(400).send({
3852
- error: { code: "VALIDATION_ERROR", message: 'Exactly one of "preset" or "cron" must be provided' }
4851
+ const parsedBody = scheduleUpsertRequestSchema.safeParse(request.body);
4852
+ if (!parsedBody.success) {
4853
+ const err = validationError("Invalid schedule payload", {
4854
+ issues: parsedBody.error.issues.map((issue) => ({
4855
+ path: issue.path.join("."),
4856
+ message: issue.message
4857
+ }))
3853
4858
  });
4859
+ return reply.status(err.statusCode).send(err.toJSON());
4860
+ }
4861
+ const { preset, cron: cron2, timezone, providers, enabled } = parsedBody.data;
4862
+ const validNames = opts.validProviderNames ?? [];
4863
+ if (validNames.length && providers?.length) {
4864
+ const invalid = providers.filter((p) => !validNames.includes(p));
4865
+ if (invalid.length) {
4866
+ const err = validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
4867
+ invalidProviders: invalid,
4868
+ validProviders: validNames
4869
+ });
4870
+ return reply.status(err.statusCode).send(err.toJSON());
4871
+ }
3854
4872
  }
3855
4873
  if (!isValidTimezone(timezone)) {
3856
4874
  return reply.status(400).send({
@@ -4121,7 +5139,7 @@ function resolveProjectSafe7(app, name, reply) {
4121
5139
 
4122
5140
  // ../api-routes/src/google.ts
4123
5141
  import crypto13 from "crypto";
4124
- import { eq as eq13, and as and3, desc as desc3, sql as sql2 } from "drizzle-orm";
5142
+ import { eq as eq13, and as and3, desc as desc4, sql as sql2 } from "drizzle-orm";
4125
5143
 
4126
5144
  // ../integration-google/src/constants.ts
4127
5145
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -4562,7 +5580,7 @@ async function googleRoutes(app, opts) {
4562
5580
  if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
4563
5581
  if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
4564
5582
  if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
4565
- const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc3(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
5583
+ const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
4566
5584
  return rows.map((r) => ({
4567
5585
  date: r.date,
4568
5586
  query: r.query,
@@ -4640,7 +5658,7 @@ async function googleRoutes(app, opts) {
4640
5658
  const { url, limit } = request.query;
4641
5659
  const conditions = [eq13(gscUrlInspections.projectId, project.id)];
4642
5660
  if (url) conditions.push(eq13(gscUrlInspections.url, url));
4643
- const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc3(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
5661
+ const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
4644
5662
  return rows.map((r) => ({
4645
5663
  id: r.id,
4646
5664
  url: r.url,
@@ -4659,7 +5677,7 @@ async function googleRoutes(app, opts) {
4659
5677
  });
4660
5678
  app.get("/projects/:name/google/gsc/deindexed", async (request) => {
4661
5679
  const project = resolveProject(app.db, request.params.name);
4662
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5680
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc4(gscUrlInspections.inspectedAt)).all();
4663
5681
  const byUrl = /* @__PURE__ */ new Map();
4664
5682
  for (const row of allInspections) {
4665
5683
  const existing = byUrl.get(row.url);
@@ -4687,7 +5705,7 @@ async function googleRoutes(app, opts) {
4687
5705
  });
4688
5706
  app.get("/projects/:name/google/gsc/coverage", async (request) => {
4689
5707
  const project = resolveProject(app.db, request.params.name);
4690
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5708
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc4(gscUrlInspections.inspectedAt)).all();
4691
5709
  const latestByUrl = /* @__PURE__ */ new Map();
4692
5710
  const historyByUrl = /* @__PURE__ */ new Map();
4693
5711
  for (const row of allInspections) {
@@ -4779,7 +5797,7 @@ async function googleRoutes(app, opts) {
4779
5797
  const project = resolveProject(app.db, request.params.name);
4780
5798
  const parsed = parseInt(request.query.limit ?? "90", 10);
4781
5799
  const limit = Number.isNaN(parsed) || parsed <= 0 ? 90 : parsed;
4782
- const rows = app.db.select().from(gscCoverageSnapshots).where(eq13(gscCoverageSnapshots.projectId, project.id)).orderBy(desc3(gscCoverageSnapshots.date)).limit(limit).all();
5800
+ const rows = app.db.select().from(gscCoverageSnapshots).where(eq13(gscCoverageSnapshots.projectId, project.id)).orderBy(desc4(gscCoverageSnapshots.date)).limit(limit).all();
4783
5801
  return rows.map((r) => ({
4784
5802
  date: r.date,
4785
5803
  indexed: r.indexed,
@@ -4932,7 +5950,7 @@ async function googleRoutes(app, opts) {
4932
5950
  const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
4933
5951
  let urlsToNotify = request.body?.urls ?? [];
4934
5952
  if (request.body?.allUnindexed) {
4935
- const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
5953
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc4(gscUrlInspections.inspectedAt)).all();
4936
5954
  const latestByUrl = /* @__PURE__ */ new Map();
4937
5955
  for (const row of allInspections) {
4938
5956
  if (!latestByUrl.has(row.url)) {
@@ -5007,7 +6025,7 @@ async function googleRoutes(app, opts) {
5007
6025
 
5008
6026
  // ../api-routes/src/bing.ts
5009
6027
  import crypto14 from "crypto";
5010
- import { eq as eq14, and as and4, desc as desc4 } from "drizzle-orm";
6028
+ import { eq as eq14, and as and4, desc as desc5 } from "drizzle-orm";
5011
6029
 
5012
6030
  // ../integration-bing/src/constants.ts
5013
6031
  var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
@@ -5211,7 +6229,7 @@ async function bingRoutes(app, opts) {
5211
6229
  const project = resolveProject(app.db, request.params.name);
5212
6230
  const conn = requireConnection(store, project.canonicalDomain, reply);
5213
6231
  if (!conn) return;
5214
- const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
6232
+ const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc5(bingUrlInspections.inspectedAt)).all();
5215
6233
  const latestByUrl = /* @__PURE__ */ new Map();
5216
6234
  for (const row of allInspections) {
5217
6235
  if (!latestByUrl.has(row.url)) {
@@ -5261,7 +6279,7 @@ async function bingRoutes(app, opts) {
5261
6279
  const project = resolveProject(app.db, request.params.name);
5262
6280
  const { url, limit } = request.query;
5263
6281
  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();
6282
+ const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc5(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
5265
6283
  return filtered.map((r) => ({
5266
6284
  id: r.id,
5267
6285
  url: r.url,
@@ -5323,7 +6341,7 @@ async function bingRoutes(app, opts) {
5323
6341
  }
5324
6342
  let urlsToSubmit = request.body?.urls ?? [];
5325
6343
  if (request.body?.allUnindexed) {
5326
- const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
6344
+ const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc5(bingUrlInspections.inspectedAt)).all();
5327
6345
  const latestByUrl = /* @__PURE__ */ new Map();
5328
6346
  for (const row of allInspections) {
5329
6347
  if (!latestByUrl.has(row.url)) {
@@ -5419,51 +6437,61 @@ async function cdpRoutes(app, opts) {
5419
6437
  const { snapshotId } = request.params;
5420
6438
  const snapshot = app.db.select({ screenshotPath: querySnapshots.screenshotPath }).from(querySnapshots).where(eq15(querySnapshots.id, snapshotId)).get();
5421
6439
  if (!snapshot?.screenshotPath) {
5422
- return reply.code(404).send({ error: "Screenshot not found" });
6440
+ const err = notFound("Screenshot", snapshotId);
6441
+ return reply.code(err.statusCode).send(err.toJSON());
5423
6442
  }
5424
6443
  const base = path2.resolve(getScreenshotDir());
5425
6444
  const fullPath = path2.resolve(path2.join(base, snapshot.screenshotPath));
5426
6445
  if (!fullPath.startsWith(base + path2.sep) && fullPath !== base) {
5427
- return reply.code(404).send({ error: "Screenshot not found" });
6446
+ const err = notFound("Screenshot", snapshotId);
6447
+ return reply.code(err.statusCode).send(err.toJSON());
5428
6448
  }
5429
6449
  if (!fs2.existsSync(fullPath)) {
5430
- return reply.code(404).send({ error: "Screenshot file not found on disk" });
6450
+ const err = notFound("Screenshot file", snapshotId);
6451
+ return reply.code(err.statusCode).send(err.toJSON());
5431
6452
  }
5432
6453
  const stream = fs2.createReadStream(fullPath);
5433
6454
  return reply.type("image/png").send(stream);
5434
6455
  });
5435
6456
  app.put("/settings/cdp", async (request, reply) => {
5436
6457
  if (!opts.onCdpConfigure) {
5437
- return reply.code(501).send({ error: "CDP configuration not supported in this deployment" });
6458
+ const err = notImplemented("CDP configuration not supported in this deployment");
6459
+ return reply.code(err.statusCode).send(err.toJSON());
5438
6460
  }
5439
6461
  const { host, port = 9222 } = request.body;
5440
6462
  if (!host || typeof host !== "string") {
5441
- return reply.code(400).send({ error: "host is required" });
6463
+ const err = validationError("host is required");
6464
+ return reply.code(err.statusCode).send(err.toJSON());
5442
6465
  }
5443
6466
  const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "::1"];
5444
6467
  if (!ALLOWED_HOSTS.includes(host)) {
5445
- return reply.code(400).send({ error: "host must be localhost, 127.0.0.1, or ::1" });
6468
+ const err = validationError("host must be localhost, 127.0.0.1, or ::1");
6469
+ return reply.code(err.statusCode).send(err.toJSON());
5446
6470
  }
5447
6471
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
5448
- return reply.code(400).send({ error: "port must be an integer between 1 and 65535" });
6472
+ const err = validationError("port must be an integer between 1 and 65535");
6473
+ return reply.code(err.statusCode).send(err.toJSON());
5449
6474
  }
5450
6475
  await opts.onCdpConfigure(host, port);
5451
6476
  return reply.code(200).send({ endpoint: `ws://${host}:${port}` });
5452
6477
  });
5453
6478
  app.get("/cdp/status", async (_request, reply) => {
5454
6479
  if (!opts.getCdpStatus) {
5455
- return reply.code(501).send({ error: "CDP not configured" });
6480
+ const err = notImplemented("CDP not configured");
6481
+ return reply.code(err.statusCode).send(err.toJSON());
5456
6482
  }
5457
6483
  const status = await opts.getCdpStatus();
5458
6484
  return reply.send(status);
5459
6485
  });
5460
6486
  app.post("/cdp/screenshot", async (request, reply) => {
5461
6487
  if (!opts.onCdpScreenshot) {
5462
- return reply.code(501).send({ error: "CDP not configured" });
6488
+ const err = notImplemented("CDP not configured");
6489
+ return reply.code(err.statusCode).send(err.toJSON());
5463
6490
  }
5464
6491
  const { query, targets } = request.body;
5465
6492
  if (!query || typeof query !== "string") {
5466
- return reply.code(400).send({ error: "query is required" });
6493
+ const err = validationError("query is required");
6494
+ return reply.code(err.statusCode).send(err.toJSON());
5467
6495
  }
5468
6496
  const results = await opts.onCdpScreenshot(query, targets);
5469
6497
  return reply.code(200).send({ results });
@@ -5474,7 +6502,10 @@ async function cdpRoutes(app, opts) {
5474
6502
  const project = resolveProject(app.db, request.params.name);
5475
6503
  const { runId } = request.params;
5476
6504
  const run = app.db.select().from(runs).where(and5(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
5477
- if (!run) return reply.code(404).send({ error: "Run not found" });
6505
+ if (!run) {
6506
+ const err = notFound("Run", runId);
6507
+ return reply.code(err.statusCode).send(err.toJSON());
6508
+ }
5478
6509
  const snapshots = app.db.select({
5479
6510
  id: querySnapshots.id,
5480
6511
  keywordId: querySnapshots.keywordId,
@@ -5595,15 +6626,21 @@ async function apiRoutes(app, opts) {
5595
6626
  await app.register(async (api) => {
5596
6627
  await api.register(openApiRoutes, opts.openApiInfo ?? {});
5597
6628
  await api.register(projectRoutes, {
5598
- onProjectDeleted: opts.onProjectDeleted
6629
+ onProjectDeleted: opts.onProjectDeleted,
6630
+ validProviderNames: opts.providerAdapters?.map((a) => a.name)
5599
6631
  });
5600
6632
  await api.register(keywordRoutes, {
5601
- onGenerateKeywords: opts.onGenerateKeywords
6633
+ onGenerateKeywords: opts.onGenerateKeywords,
6634
+ validProviderNames: opts.providerAdapters?.filter((a) => a.mode === "api").map((a) => a.name)
5602
6635
  });
5603
6636
  await api.register(competitorRoutes);
5604
- await api.register(runRoutes, { onRunCreated: opts.onRunCreated });
6637
+ await api.register(runRoutes, {
6638
+ onRunCreated: opts.onRunCreated,
6639
+ validProviderNames: opts.providerAdapters?.map((a) => a.name)
6640
+ });
5605
6641
  await api.register(applyRoutes, {
5606
6642
  onScheduleUpdated: opts.onScheduleUpdated,
6643
+ validProviderNames: opts.providerAdapters?.map((a) => a.name),
5607
6644
  onGoogleConnectionPropertyUpdated: (domain, connectionType, propertyId) => {
5608
6645
  opts.googleConnectionStore?.updateConnection(domain, connectionType, {
5609
6646
  propertyId,
@@ -5615,6 +6652,7 @@ async function apiRoutes(app, opts) {
5615
6652
  await api.register(analyticsRoutes);
5616
6653
  await api.register(settingsRoutes, {
5617
6654
  providerSummary: opts.providerSummary,
6655
+ providerAdapters: opts.providerAdapters,
5618
6656
  onProviderUpdate: opts.onProviderUpdate,
5619
6657
  google: opts.googleSettingsSummary,
5620
6658
  onGoogleUpdate: opts.onGoogleSettingsUpdate,
@@ -5622,7 +6660,8 @@ async function apiRoutes(app, opts) {
5622
6660
  onBingUpdate: opts.onBingSettingsUpdate
5623
6661
  });
5624
6662
  await api.register(scheduleRoutes, {
5625
- onScheduleUpdated: opts.onScheduleUpdated
6663
+ onScheduleUpdated: opts.onScheduleUpdated,
6664
+ validProviderNames: opts.providerAdapters?.map((a) => a.name)
5626
6665
  });
5627
6666
  await api.register(notificationRoutes);
5628
6667
  await api.register(telemetryRoutes, {
@@ -5650,11 +6689,12 @@ async function apiRoutes(app, opts) {
5650
6689
 
5651
6690
  // ../provider-gemini/src/normalize.ts
5652
6691
  import { GoogleGenerativeAI } from "@google/generative-ai";
5653
- var DEFAULT_MODEL = getDefaultModel("gemini");
6692
+ var DEFAULT_MODEL = "gemini-3-flash";
6693
+ var VALIDATION_PATTERN = /^gemini-/;
5654
6694
  function resolveModel(config) {
5655
6695
  const m = config.model;
5656
6696
  if (!m) return DEFAULT_MODEL;
5657
- if (isValidModelName("gemini", m)) return m;
6697
+ if (VALIDATION_PATTERN.test(m)) return m;
5658
6698
  console.warn(
5659
6699
  `[provider-gemini] Invalid model name "${m}" \u2014 this provider uses the Gemini AI Studio API (generativelanguage.googleapis.com) which only accepts "gemini-*" model names. Falling back to ${DEFAULT_MODEL}.`
5660
6700
  );
@@ -5665,7 +6705,7 @@ function validateConfig(config) {
5665
6705
  return { ok: false, provider: "gemini", message: "missing api key" };
5666
6706
  }
5667
6707
  const model = resolveModel(config);
5668
- const warning = config.model && !isValidModelName("gemini", config.model) ? ` (invalid model "${config.model}" replaced with default)` : "";
6708
+ const warning = config.model && !VALIDATION_PATTERN.test(config.model) ? ` (invalid model "${config.model}" replaced with default)` : "";
5669
6709
  return {
5670
6710
  ok: true,
5671
6711
  provider: "gemini",
@@ -5849,6 +6889,20 @@ function toGeminiConfig(config) {
5849
6889
  }
5850
6890
  var geminiAdapter = {
5851
6891
  name: "gemini",
6892
+ displayName: "Gemini",
6893
+ mode: "api",
6894
+ keyUrl: "https://aistudio.google.com/apikey",
6895
+ modelRegistry: {
6896
+ defaultModel: "gemini-3-flash",
6897
+ validationPattern: /^gemini-/,
6898
+ validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
6899
+ knownModels: [
6900
+ { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
6901
+ { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
6902
+ { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
6903
+ { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
6904
+ ]
6905
+ },
5852
6906
  validateConfig(config) {
5853
6907
  const result = validateConfig(toGeminiConfig(config));
5854
6908
  return {
@@ -5907,7 +6961,7 @@ var geminiAdapter = {
5907
6961
 
5908
6962
  // ../provider-openai/src/normalize.ts
5909
6963
  import OpenAI from "openai";
5910
- var DEFAULT_MODEL2 = getDefaultModel("openai");
6964
+ var DEFAULT_MODEL2 = "gpt-5.4";
5911
6965
  function validateConfig2(config) {
5912
6966
  if (!config.apiKey || config.apiKey.length === 0) {
5913
6967
  return { ok: false, provider: "openai", message: "missing api key" };
@@ -6101,6 +7155,22 @@ function toOpenAIConfig(config) {
6101
7155
  }
6102
7156
  var openaiAdapter = {
6103
7157
  name: "openai",
7158
+ displayName: "OpenAI",
7159
+ mode: "api",
7160
+ keyUrl: "https://platform.openai.com/api-keys",
7161
+ modelRegistry: {
7162
+ defaultModel: "gpt-5.4",
7163
+ validationPattern: /^(gpt-|o\d)/,
7164
+ validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
7165
+ knownModels: [
7166
+ { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
7167
+ { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
7168
+ { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
7169
+ { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
7170
+ { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
7171
+ { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
7172
+ ]
7173
+ },
6104
7174
  validateConfig(config) {
6105
7175
  const result = validateConfig2(toOpenAIConfig(config));
6106
7176
  return {
@@ -6159,7 +7229,7 @@ var openaiAdapter = {
6159
7229
 
6160
7230
  // ../provider-claude/src/normalize.ts
6161
7231
  import Anthropic from "@anthropic-ai/sdk";
6162
- var DEFAULT_MODEL3 = getDefaultModel("claude");
7232
+ var DEFAULT_MODEL3 = "claude-sonnet-4-6";
6163
7233
  function validateConfig3(config) {
6164
7234
  if (!config.apiKey || config.apiKey.length === 0) {
6165
7235
  return { ok: false, provider: "claude", message: "missing api key" };
@@ -6347,6 +7417,19 @@ function toClaudeConfig(config) {
6347
7417
  }
6348
7418
  var claudeAdapter = {
6349
7419
  name: "claude",
7420
+ displayName: "Claude",
7421
+ mode: "api",
7422
+ keyUrl: "https://platform.claude.com/settings/keys",
7423
+ modelRegistry: {
7424
+ defaultModel: "claude-sonnet-4-6",
7425
+ validationPattern: /^claude-/,
7426
+ validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
7427
+ knownModels: [
7428
+ { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
7429
+ { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
7430
+ { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
7431
+ ]
7432
+ },
6350
7433
  validateConfig(config) {
6351
7434
  const result = validateConfig3(toClaudeConfig(config));
6352
7435
  return {
@@ -6405,7 +7488,7 @@ var claudeAdapter = {
6405
7488
 
6406
7489
  // ../provider-local/src/normalize.ts
6407
7490
  import OpenAI2 from "openai";
6408
- var DEFAULT_MODEL4 = getDefaultModel("local");
7491
+ var DEFAULT_MODEL4 = "llama3";
6409
7492
  function validateConfig4(config) {
6410
7493
  if (!config.baseUrl || config.baseUrl.length === 0) {
6411
7494
  return { ok: false, provider: "local", message: "missing base URL" };
@@ -6534,6 +7617,16 @@ function toLocalConfig(config) {
6534
7617
  }
6535
7618
  var localAdapter = {
6536
7619
  name: "local",
7620
+ displayName: "Local",
7621
+ mode: "api",
7622
+ modelRegistry: {
7623
+ defaultModel: "llama3",
7624
+ validationPattern: /./,
7625
+ validationHint: "any model name accepted",
7626
+ knownModels: [
7627
+ { id: "llama3", displayName: "Llama 3", tier: "standard" }
7628
+ ]
7629
+ },
6537
7630
  validateConfig(config) {
6538
7631
  const result = validateConfig4(toLocalConfig(config));
6539
7632
  return {
@@ -7050,6 +8143,16 @@ function getScreenshotDir2() {
7050
8143
  }
7051
8144
  var cdpChatgptAdapter = {
7052
8145
  name: "cdp:chatgpt",
8146
+ displayName: "ChatGPT (Browser)",
8147
+ mode: "browser",
8148
+ modelRegistry: {
8149
+ defaultModel: "chatgpt-web",
8150
+ validationPattern: /./,
8151
+ validationHint: "model is detected from the ChatGPT web UI",
8152
+ knownModels: [
8153
+ { id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
8154
+ ]
8155
+ },
7053
8156
  validateConfig(config) {
7054
8157
  if (!config.cdpEndpoint) {
7055
8158
  return {
@@ -7059,74 +8162,275 @@ var cdpChatgptAdapter = {
7059
8162
  };
7060
8163
  }
7061
8164
  return {
7062
- ok: true,
7063
- provider: "cdp:chatgpt",
7064
- message: `CDP endpoint: ${config.cdpEndpoint}`
8165
+ ok: true,
8166
+ provider: "cdp:chatgpt",
8167
+ message: `CDP endpoint: ${config.cdpEndpoint}`
8168
+ };
8169
+ },
8170
+ async healthcheck(config) {
8171
+ try {
8172
+ const conn = getConnection(config);
8173
+ const health = await conn.healthcheck();
8174
+ if (!health.connected) {
8175
+ return {
8176
+ ok: false,
8177
+ provider: "cdp:chatgpt",
8178
+ message: `Chrome not reachable at ${config.cdpEndpoint}`
8179
+ };
8180
+ }
8181
+ return {
8182
+ ok: true,
8183
+ provider: "cdp:chatgpt",
8184
+ message: `Connected to ${health.browserVersion ?? "Chrome"} via CDP`,
8185
+ model: "chatgpt-web"
8186
+ };
8187
+ } catch (err) {
8188
+ return {
8189
+ ok: false,
8190
+ provider: "cdp:chatgpt",
8191
+ message: err instanceof Error ? err.message : String(err)
8192
+ };
8193
+ }
8194
+ },
8195
+ async executeTrackedQuery(input, config) {
8196
+ const conn = getConnection(config);
8197
+ const target = chatgptTarget;
8198
+ const client = await conn.prepareForQuery(target);
8199
+ await target.submitQuery(client, input.keyword);
8200
+ await target.waitForResponse(client);
8201
+ const answerText = await target.extractAnswer(client);
8202
+ const groundingSources = await target.extractCitations(client);
8203
+ const screenshotId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
8204
+ const screenshotPath = path4.join(getScreenshotDir2(), `${screenshotId}.png`);
8205
+ let capturedScreenshotPath;
8206
+ try {
8207
+ capturedScreenshotPath = await captureElementScreenshot(
8208
+ client,
8209
+ target.responseSelector,
8210
+ screenshotPath
8211
+ );
8212
+ } catch {
8213
+ }
8214
+ return {
8215
+ provider: "cdp:chatgpt",
8216
+ rawResponse: {
8217
+ answerText,
8218
+ groundingSources,
8219
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
8220
+ targetUrl: target.newConversationUrl
8221
+ },
8222
+ model: "chatgpt-web",
8223
+ groundingSources,
8224
+ searchQueries: [input.keyword],
8225
+ screenshotPath: capturedScreenshotPath
8226
+ };
8227
+ },
8228
+ normalizeResult(raw) {
8229
+ return normalizeResult5(raw);
8230
+ },
8231
+ async generateText() {
8232
+ throw new Error("generateText is not supported for browser-based CDP providers");
8233
+ }
8234
+ };
8235
+
8236
+ // ../provider-perplexity/src/normalize.ts
8237
+ import OpenAI3 from "openai";
8238
+ var DEFAULT_MODEL5 = "sonar";
8239
+ var BASE_URL = "https://api.perplexity.ai";
8240
+ function validateConfig5(config) {
8241
+ if (!config.apiKey || config.apiKey.length === 0) {
8242
+ return { ok: false, provider: "perplexity", message: "missing api key" };
8243
+ }
8244
+ return {
8245
+ ok: true,
8246
+ provider: "perplexity",
8247
+ message: "config valid",
8248
+ model: config.model ?? DEFAULT_MODEL5
8249
+ };
8250
+ }
8251
+ async function healthcheck5(config) {
8252
+ const validation = validateConfig5(config);
8253
+ if (!validation.ok) return validation;
8254
+ try {
8255
+ const client = new OpenAI3({ apiKey: config.apiKey, baseURL: BASE_URL });
8256
+ const response = await client.chat.completions.create({
8257
+ model: config.model ?? DEFAULT_MODEL5,
8258
+ messages: [{ role: "user", content: 'Say "ok"' }]
8259
+ });
8260
+ const text2 = response.choices[0]?.message?.content ?? "";
8261
+ return {
8262
+ ok: text2.length > 0,
8263
+ provider: "perplexity",
8264
+ message: text2.length > 0 ? "perplexity api key verified" : "empty response from perplexity",
8265
+ model: config.model ?? DEFAULT_MODEL5
8266
+ };
8267
+ } catch (err) {
8268
+ return {
8269
+ ok: false,
8270
+ provider: "perplexity",
8271
+ message: err instanceof Error ? err.message : String(err),
8272
+ model: config.model ?? DEFAULT_MODEL5
8273
+ };
8274
+ }
8275
+ }
8276
+ async function executeTrackedQuery5(input) {
8277
+ const model = input.config.model ?? DEFAULT_MODEL5;
8278
+ const client = new OpenAI3({ apiKey: input.config.apiKey, baseURL: BASE_URL });
8279
+ const response = await client.chat.completions.create({
8280
+ model,
8281
+ messages: [
8282
+ { role: "user", content: input.keyword }
8283
+ ]
8284
+ });
8285
+ const rawResponse = responseToRecord4(response);
8286
+ const citations = extractCitations(rawResponse);
8287
+ const groundingSources = citations.map((url) => ({
8288
+ uri: url,
8289
+ title: ""
8290
+ }));
8291
+ return {
8292
+ provider: "perplexity",
8293
+ rawResponse,
8294
+ model,
8295
+ groundingSources,
8296
+ searchQueries: [input.keyword]
8297
+ };
8298
+ }
8299
+ function normalizeResult6(raw) {
8300
+ const answerText = extractAnswerText3(raw.rawResponse);
8301
+ const citedDomains = extractCitedDomains5(raw.groundingSources);
8302
+ return {
8303
+ provider: "perplexity",
8304
+ answerText,
8305
+ citedDomains,
8306
+ groundingSources: raw.groundingSources,
8307
+ searchQueries: raw.searchQueries
8308
+ };
8309
+ }
8310
+ function extractCitations(rawResponse) {
8311
+ const citations = rawResponse.citations;
8312
+ if (!Array.isArray(citations)) return [];
8313
+ return citations.filter((c) => typeof c === "string");
8314
+ }
8315
+ function extractAnswerText3(rawResponse) {
8316
+ try {
8317
+ const choices = rawResponse.choices;
8318
+ if (!choices?.length) return "";
8319
+ return choices[0].message?.content ?? "";
8320
+ } catch {
8321
+ return "";
8322
+ }
8323
+ }
8324
+ function extractCitedDomains5(groundingSources) {
8325
+ const domains = /* @__PURE__ */ new Set();
8326
+ for (const source of groundingSources) {
8327
+ const domain = extractDomainFromUri4(source.uri);
8328
+ if (domain) domains.add(domain);
8329
+ }
8330
+ return [...domains];
8331
+ }
8332
+ function extractDomainFromUri4(uri) {
8333
+ try {
8334
+ const url = new URL(uri);
8335
+ return url.hostname.replace(/^www\./, "");
8336
+ } catch {
8337
+ return null;
8338
+ }
8339
+ }
8340
+ async function generateText5(prompt, config) {
8341
+ const model = config.model ?? DEFAULT_MODEL5;
8342
+ const client = new OpenAI3({ apiKey: config.apiKey, baseURL: BASE_URL });
8343
+ const response = await client.chat.completions.create({
8344
+ model,
8345
+ messages: [{ role: "user", content: prompt }]
8346
+ });
8347
+ return response.choices[0]?.message?.content ?? "";
8348
+ }
8349
+ function responseToRecord4(response) {
8350
+ try {
8351
+ return JSON.parse(JSON.stringify(response));
8352
+ } catch {
8353
+ return { error: "failed to serialize response" };
8354
+ }
8355
+ }
8356
+
8357
+ // ../provider-perplexity/src/adapter.ts
8358
+ function toPerplexityConfig(config) {
8359
+ return {
8360
+ apiKey: config.apiKey ?? "",
8361
+ model: config.model,
8362
+ quotaPolicy: config.quotaPolicy
8363
+ };
8364
+ }
8365
+ var perplexityAdapter = {
8366
+ name: "perplexity",
8367
+ displayName: "Perplexity",
8368
+ mode: "api",
8369
+ keyUrl: "https://www.perplexity.ai/settings/api",
8370
+ modelRegistry: {
8371
+ defaultModel: "sonar",
8372
+ validationPattern: /^sonar/,
8373
+ validationHint: "expected a sonar model (e.g. sonar, sonar-pro, sonar-reasoning)",
8374
+ knownModels: [
8375
+ { id: "sonar", displayName: "Sonar", tier: "standard" },
8376
+ { id: "sonar-pro", displayName: "Sonar Pro", tier: "flagship" },
8377
+ { id: "sonar-reasoning", displayName: "Sonar Reasoning", tier: "flagship" },
8378
+ { id: "sonar-reasoning-pro", displayName: "Sonar Reasoning Pro", tier: "flagship" }
8379
+ ]
8380
+ },
8381
+ validateConfig(config) {
8382
+ const result = validateConfig5(toPerplexityConfig(config));
8383
+ return {
8384
+ ok: result.ok,
8385
+ provider: "perplexity",
8386
+ message: result.message,
8387
+ model: result.model
7065
8388
  };
7066
8389
  },
7067
8390
  async healthcheck(config) {
7068
- try {
7069
- const conn = getConnection(config);
7070
- const health = await conn.healthcheck();
7071
- if (!health.connected) {
7072
- return {
7073
- ok: false,
7074
- provider: "cdp:chatgpt",
7075
- message: `Chrome not reachable at ${config.cdpEndpoint}`
7076
- };
7077
- }
7078
- return {
7079
- ok: true,
7080
- provider: "cdp:chatgpt",
7081
- message: `Connected to ${health.browserVersion ?? "Chrome"} via CDP`,
7082
- model: "chatgpt-web"
7083
- };
7084
- } catch (err) {
7085
- return {
7086
- ok: false,
7087
- provider: "cdp:chatgpt",
7088
- message: err instanceof Error ? err.message : String(err)
7089
- };
7090
- }
8391
+ const result = await healthcheck5(toPerplexityConfig(config));
8392
+ return {
8393
+ ok: result.ok,
8394
+ provider: "perplexity",
8395
+ message: result.message,
8396
+ model: result.model
8397
+ };
7091
8398
  },
7092
8399
  async executeTrackedQuery(input, config) {
7093
- const conn = getConnection(config);
7094
- const target = chatgptTarget;
7095
- const client = await conn.prepareForQuery(target);
7096
- await target.submitQuery(client, input.keyword);
7097
- await target.waitForResponse(client);
7098
- const answerText = await target.extractAnswer(client);
7099
- const groundingSources = await target.extractCitations(client);
7100
- const screenshotId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
7101
- const screenshotPath = path4.join(getScreenshotDir2(), `${screenshotId}.png`);
7102
- let capturedScreenshotPath;
7103
- try {
7104
- capturedScreenshotPath = await captureElementScreenshot(
7105
- client,
7106
- target.responseSelector,
7107
- screenshotPath
7108
- );
7109
- } catch {
7110
- }
8400
+ const raw = await executeTrackedQuery5({
8401
+ keyword: input.keyword,
8402
+ canonicalDomains: input.canonicalDomains,
8403
+ competitorDomains: input.competitorDomains,
8404
+ config: toPerplexityConfig(config),
8405
+ location: input.location
8406
+ });
7111
8407
  return {
7112
- provider: "cdp:chatgpt",
7113
- rawResponse: {
7114
- answerText,
7115
- groundingSources,
7116
- extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
7117
- targetUrl: target.newConversationUrl
7118
- },
7119
- model: "chatgpt-web",
7120
- groundingSources,
7121
- searchQueries: [input.keyword],
7122
- screenshotPath: capturedScreenshotPath
8408
+ provider: "perplexity",
8409
+ rawResponse: raw.rawResponse,
8410
+ model: raw.model,
8411
+ groundingSources: raw.groundingSources,
8412
+ searchQueries: raw.searchQueries
7123
8413
  };
7124
8414
  },
7125
8415
  normalizeResult(raw) {
7126
- return normalizeResult5(raw);
8416
+ const perplexityRaw = {
8417
+ provider: "perplexity",
8418
+ rawResponse: raw.rawResponse,
8419
+ model: raw.model,
8420
+ groundingSources: raw.groundingSources,
8421
+ searchQueries: raw.searchQueries
8422
+ };
8423
+ const normalized = normalizeResult6(perplexityRaw);
8424
+ return {
8425
+ provider: "perplexity",
8426
+ answerText: normalized.answerText,
8427
+ citedDomains: normalized.citedDomains,
8428
+ groundingSources: normalized.groundingSources,
8429
+ searchQueries: normalized.searchQueries
8430
+ };
7127
8431
  },
7128
- async generateText() {
7129
- throw new Error("generateText is not supported for browser-based CDP providers");
8432
+ async generateText(prompt, config) {
8433
+ return generateText5(prompt, toPerplexityConfig(config));
7130
8434
  }
7131
8435
  };
7132
8436
 
@@ -7208,7 +8512,75 @@ import crypto15 from "crypto";
7208
8512
  import fs4 from "fs";
7209
8513
  import path5 from "path";
7210
8514
  import os4 from "os";
7211
- import { eq as eq16, inArray as inArray3 } from "drizzle-orm";
8515
+ import { and as and6, eq as eq16, inArray as inArray3 } from "drizzle-orm";
8516
+ var RunCancelledError = class extends Error {
8517
+ constructor(runId) {
8518
+ super(`Run ${runId} was cancelled`);
8519
+ this.name = "RunCancelledError";
8520
+ }
8521
+ };
8522
+ var ProviderExecutionGate = class {
8523
+ constructor(maxConcurrency, maxPerMinute) {
8524
+ this.maxConcurrency = maxConcurrency;
8525
+ this.maxPerMinute = maxPerMinute;
8526
+ }
8527
+ window = [];
8528
+ waiters = [];
8529
+ rateLimitChain = Promise.resolve();
8530
+ inFlight = 0;
8531
+ async run(task) {
8532
+ await this.acquire();
8533
+ try {
8534
+ await this.waitForRateLimit();
8535
+ return await task();
8536
+ } finally {
8537
+ this.release();
8538
+ }
8539
+ }
8540
+ async acquire() {
8541
+ if (this.inFlight < Math.max(1, this.maxConcurrency)) {
8542
+ this.inFlight++;
8543
+ return;
8544
+ }
8545
+ await new Promise((resolve) => {
8546
+ this.waiters.push(resolve);
8547
+ });
8548
+ this.inFlight++;
8549
+ }
8550
+ release() {
8551
+ this.inFlight = Math.max(0, this.inFlight - 1);
8552
+ const next = this.waiters.shift();
8553
+ next?.();
8554
+ }
8555
+ async waitForRateLimit() {
8556
+ let releaseChain;
8557
+ const previousChain = this.rateLimitChain;
8558
+ this.rateLimitChain = new Promise((resolve) => {
8559
+ releaseChain = resolve;
8560
+ });
8561
+ await previousChain;
8562
+ try {
8563
+ const now = Date.now();
8564
+ const windowStart = now - 6e4;
8565
+ while (this.window.length > 0 && this.window[0] < windowStart) {
8566
+ this.window.shift();
8567
+ }
8568
+ if (this.window.length >= this.maxPerMinute) {
8569
+ const oldestInWindow = this.window[0];
8570
+ const waitMs = oldestInWindow + 6e4 - now + 50;
8571
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
8572
+ const nowAfterWait = Date.now();
8573
+ const newWindowStart = nowAfterWait - 6e4;
8574
+ while (this.window.length > 0 && this.window[0] < newWindowStart) {
8575
+ this.window.shift();
8576
+ }
8577
+ }
8578
+ this.window.push(Date.now());
8579
+ } finally {
8580
+ releaseChain?.();
8581
+ }
8582
+ }
8583
+ };
7212
8584
  var JobRunner = class {
7213
8585
  db;
7214
8586
  registry;
@@ -7229,13 +8601,34 @@ var JobRunner = class {
7229
8601
  async executeRun(runId, projectId, providerOverride, locationOverride) {
7230
8602
  const now = (/* @__PURE__ */ new Date()).toISOString();
7231
8603
  const startTime = Date.now();
8604
+ let runLocation;
8605
+ let activeProviders = [];
8606
+ let projectKeywords = [];
8607
+ const providerDispatchCounts = /* @__PURE__ */ new Map();
7232
8608
  try {
7233
- this.db.update(runs).set({ status: "running", startedAt: now }).where(eq16(runs.id, runId)).run();
8609
+ const existingRun = this.getRunState(runId);
8610
+ if (!existingRun) {
8611
+ throw new Error(`Run ${runId} not found`);
8612
+ }
8613
+ if (existingRun.status === "cancelled") {
8614
+ this.handleCancelledRun(runId, projectId, startTime, {
8615
+ providerCount: 0,
8616
+ providers: [],
8617
+ keywordCount: 0
8618
+ });
8619
+ return;
8620
+ }
8621
+ if (existingRun.status !== "queued" && existingRun.status !== "running") {
8622
+ throw new Error(`Run ${runId} is not executable from status '${existingRun.status}'`);
8623
+ }
8624
+ if (existingRun.status === "queued") {
8625
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(and6(eq16(runs.id, runId), eq16(runs.status, "queued"))).run();
8626
+ }
8627
+ this.throwIfRunCancelled(runId);
7234
8628
  const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
7235
8629
  if (!project) {
7236
8630
  throw new Error(`Project ${projectId} not found`);
7237
8631
  }
7238
- let runLocation;
7239
8632
  if (locationOverride === null) {
7240
8633
  runLocation = void 0;
7241
8634
  } else if (locationOverride) {
@@ -7247,16 +8640,26 @@ var JobRunner = class {
7247
8640
  }
7248
8641
  }
7249
8642
  const projectProviders = providerOverride ?? JSON.parse(project.providers || "[]");
7250
- const activeProviders = this.registry.getForProject(projectProviders);
8643
+ activeProviders = this.registry.getForProject(projectProviders);
7251
8644
  if (activeProviders.length === 0) {
7252
8645
  throw new Error("No providers configured. Add at least one provider API key.");
7253
8646
  }
7254
8647
  console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
7255
- const projectKeywords = this.db.select().from(keywords).where(eq16(keywords.projectId, projectId)).all();
8648
+ projectKeywords = this.db.select().from(keywords).where(eq16(keywords.projectId, projectId)).all();
7256
8649
  const projectCompetitors = this.db.select().from(competitors).where(eq16(competitors.projectId, projectId)).all();
7257
8650
  const competitorDomains = projectCompetitors.map((c) => c.domain);
8651
+ const allDomains = effectiveDomains({
8652
+ canonicalDomain: project.canonicalDomain,
8653
+ ownedDomains: JSON.parse(project.ownedDomains || "[]")
8654
+ });
8655
+ const executionContext = {
8656
+ providerCount: activeProviders.length,
8657
+ providers: activeProviders.map((provider) => provider.adapter.name),
8658
+ keywordCount: projectKeywords.length,
8659
+ ...runLocation ? { location: runLocation.label } : {}
8660
+ };
7258
8661
  const queriesPerProvider = projectKeywords.length;
7259
- const todayPeriod = getCurrentPeriod();
8662
+ const todayPeriod = getCurrentUsageDay();
7260
8663
  for (const p of activeProviders) {
7261
8664
  const providerScope = `${projectId}:${p.adapter.name}`;
7262
8665
  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);
@@ -7267,27 +8670,31 @@ var JobRunner = class {
7267
8670
  );
7268
8671
  }
7269
8672
  }
7270
- const minuteWindows = /* @__PURE__ */ new Map();
7271
- for (const p of activeProviders) {
7272
- minuteWindows.set(p.adapter.name, []);
8673
+ const executionGates = /* @__PURE__ */ new Map();
8674
+ for (const provider of activeProviders) {
8675
+ executionGates.set(
8676
+ provider.adapter.name,
8677
+ new ProviderExecutionGate(
8678
+ provider.config.quotaPolicy.maxConcurrency,
8679
+ provider.config.quotaPolicy.maxRequestsPerMinute
8680
+ )
8681
+ );
7273
8682
  }
7274
8683
  const providerErrors = /* @__PURE__ */ new Map();
7275
8684
  let totalSnapshotsInserted = 0;
7276
8685
  const apiProviders = activeProviders.filter((p) => !isBrowserProvider(p.adapter.name));
7277
8686
  const browserProviders = activeProviders.filter((p) => isBrowserProvider(p.adapter.name));
7278
- for (const kw of projectKeywords) {
7279
- const providerPromises = apiProviders.map(async (registeredProvider) => {
7280
- const { adapter, config } = registeredProvider;
7281
- const providerName = adapter.name;
7282
- try {
7283
- await this.waitForRateLimit(
7284
- minuteWindows.get(providerName),
7285
- config.quotaPolicy.maxRequestsPerMinute
7286
- );
7287
- const allDomains = effectiveDomains({
7288
- canonicalDomain: project.canonicalDomain,
7289
- ownedDomains: JSON.parse(project.ownedDomains || "[]")
7290
- });
8687
+ const processKeywordForProvider = async (registeredProvider, kw) => {
8688
+ const { adapter, config } = registeredProvider;
8689
+ const providerName = adapter.name;
8690
+ const gate = executionGates.get(providerName);
8691
+ if (!gate) {
8692
+ throw new Error(`Missing execution gate for provider ${providerName}`);
8693
+ }
8694
+ try {
8695
+ await gate.run(async () => {
8696
+ this.throwIfRunCancelled(runId);
8697
+ providerDispatchCounts.set(providerName, (providerDispatchCounts.get(providerName) ?? 0) + 1);
7291
8698
  const raw = await adapter.executeTrackedQuery(
7292
8699
  {
7293
8700
  keyword: kw.keyword,
@@ -7297,6 +8704,7 @@ var JobRunner = class {
7297
8704
  },
7298
8705
  config
7299
8706
  );
8707
+ this.throwIfRunCancelled(runId);
7300
8708
  const normalized = adapter.normalizeResult(raw);
7301
8709
  console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, domains=${JSON.stringify(allDomains)}`);
7302
8710
  const citationState = determineCitationState(normalized, allDomains);
@@ -7352,96 +8760,29 @@ var JobRunner = class {
7352
8760
  }
7353
8761
  totalSnapshotsInserted++;
7354
8762
  console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" \u2192 ${citationState}`);
7355
- } catch (err) {
7356
- const msg = err instanceof Error ? err.message : String(err);
7357
- console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
7358
- providerErrors.set(providerName, msg);
8763
+ });
8764
+ } catch (err) {
8765
+ if (err instanceof RunCancelledError) {
8766
+ throw err;
7359
8767
  }
7360
- });
7361
- await Promise.all(providerPromises);
7362
- for (const registeredProvider of browserProviders) {
7363
- const { adapter, config } = registeredProvider;
7364
- const providerName = adapter.name;
7365
- try {
7366
- await this.waitForRateLimit(
7367
- minuteWindows.get(providerName),
7368
- config.quotaPolicy.maxRequestsPerMinute
7369
- );
7370
- const allDomains = effectiveDomains({
7371
- canonicalDomain: project.canonicalDomain,
7372
- ownedDomains: JSON.parse(project.ownedDomains || "[]")
7373
- });
7374
- const raw = await adapter.executeTrackedQuery(
7375
- {
7376
- keyword: kw.keyword,
7377
- canonicalDomains: allDomains,
7378
- competitorDomains,
7379
- location: runLocation
7380
- },
7381
- config
7382
- );
7383
- const normalized = adapter.normalizeResult(raw);
7384
- console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, domains=${JSON.stringify(allDomains)}`);
7385
- const citationState = determineCitationState(normalized, allDomains);
7386
- const overlap = computeCompetitorOverlap(normalized, competitorDomains);
7387
- let screenshotRelPath = null;
7388
- if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
7389
- const snapshotId = crypto15.randomUUID();
7390
- const screenshotDir = path5.join(os4.homedir(), ".canonry", "screenshots", runId);
7391
- if (!fs4.existsSync(screenshotDir)) fs4.mkdirSync(screenshotDir, { recursive: true });
7392
- const destPath = path5.join(screenshotDir, `${snapshotId}.png`);
7393
- fs4.renameSync(raw.screenshotPath, destPath);
7394
- screenshotRelPath = `${runId}/${snapshotId}.png`;
7395
- this.db.insert(querySnapshots).values({
7396
- id: snapshotId,
7397
- runId,
7398
- keywordId: kw.id,
7399
- provider: providerName,
7400
- model: raw.model,
7401
- citationState,
7402
- answerText: normalized.answerText,
7403
- citedDomains: JSON.stringify(normalized.citedDomains),
7404
- competitorOverlap: JSON.stringify(overlap),
7405
- location: runLocation?.label ?? null,
7406
- screenshotPath: screenshotRelPath,
7407
- rawResponse: JSON.stringify({
7408
- model: raw.model,
7409
- groundingSources: normalized.groundingSources,
7410
- searchQueries: normalized.searchQueries,
7411
- apiResponse: raw.rawResponse
7412
- }),
7413
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
7414
- }).run();
7415
- } else {
7416
- this.db.insert(querySnapshots).values({
7417
- id: crypto15.randomUUID(),
7418
- runId,
7419
- keywordId: kw.id,
7420
- provider: providerName,
7421
- model: raw.model,
7422
- citationState,
7423
- answerText: normalized.answerText,
7424
- citedDomains: JSON.stringify(normalized.citedDomains),
7425
- competitorOverlap: JSON.stringify(overlap),
7426
- location: runLocation?.label ?? null,
7427
- rawResponse: JSON.stringify({
7428
- model: raw.model,
7429
- groundingSources: normalized.groundingSources,
7430
- searchQueries: normalized.searchQueries,
7431
- apiResponse: raw.rawResponse
7432
- }),
7433
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
7434
- }).run();
7435
- }
7436
- totalSnapshotsInserted++;
7437
- console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" \u2192 ${citationState}`);
7438
- } catch (err) {
7439
- const msg = err instanceof Error ? err.message : String(err);
7440
- console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
8768
+ const msg = err instanceof Error ? err.message : String(err);
8769
+ console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
8770
+ if (!providerErrors.has(providerName)) {
7441
8771
  providerErrors.set(providerName, msg);
7442
8772
  }
7443
8773
  }
8774
+ };
8775
+ await Promise.all(apiProviders.map(async (registeredProvider) => {
8776
+ await Promise.all(projectKeywords.map(async (kw) => {
8777
+ await processKeywordForProvider(registeredProvider, kw);
8778
+ }));
8779
+ }));
8780
+ for (const registeredProvider of browserProviders) {
8781
+ for (const kw of projectKeywords) {
8782
+ await processKeywordForProvider(registeredProvider, kw);
8783
+ }
7444
8784
  }
8785
+ this.throwIfRunCancelled(runId);
7445
8786
  const allFailed = totalSnapshotsInserted === 0 && providerErrors.size > 0;
7446
8787
  const someFailed = providerErrors.size > 0;
7447
8788
  if (allFailed) {
@@ -7453,18 +8794,16 @@ var JobRunner = class {
7453
8794
  } else {
7454
8795
  this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq16(runs.id, runId)).run();
7455
8796
  }
8797
+ this.flushProviderUsage(projectId, providerDispatchCounts);
7456
8798
  const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
7457
8799
  trackEvent("run.completed", {
7458
8800
  status: finalStatus,
7459
- providerCount: activeProviders.length,
7460
- providers: activeProviders.map((p) => p.adapter.name),
7461
- keywordCount: projectKeywords.length,
8801
+ providerCount: executionContext.providerCount,
8802
+ providers: executionContext.providers,
8803
+ keywordCount: executionContext.keywordCount,
7462
8804
  durationMs: Date.now() - startTime,
7463
- ...runLocation ? { location: runLocation.label } : {}
8805
+ ...executionContext.location ? { location: executionContext.location } : {}
7464
8806
  });
7465
- for (const p of activeProviders) {
7466
- this.incrementUsage(`${projectId}:${p.adapter.name}`, "queries", queriesPerProvider);
7467
- }
7468
8807
  this.incrementUsage(projectId, "runs", 1);
7469
8808
  if (this.onRunCompleted) {
7470
8809
  this.onRunCompleted(runId, projectId).catch((err) => {
@@ -7472,18 +8811,31 @@ var JobRunner = class {
7472
8811
  });
7473
8812
  }
7474
8813
  } catch (err) {
8814
+ const executionContext = {
8815
+ providerCount: activeProviders.length,
8816
+ providers: activeProviders.map((provider) => provider.adapter.name),
8817
+ keywordCount: projectKeywords.length,
8818
+ ...runLocation ? { location: runLocation.label } : {}
8819
+ };
8820
+ if (err instanceof RunCancelledError || this.isRunCancelled(runId)) {
8821
+ this.flushProviderUsage(projectId, providerDispatchCounts);
8822
+ this.handleCancelledRun(runId, projectId, startTime, executionContext);
8823
+ return;
8824
+ }
7475
8825
  const errorMessage = err instanceof Error ? err.message : String(err);
7476
8826
  this.db.update(runs).set({
7477
8827
  status: "failed",
7478
8828
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
7479
8829
  error: errorMessage
7480
8830
  }).where(eq16(runs.id, runId)).run();
8831
+ this.flushProviderUsage(projectId, providerDispatchCounts);
7481
8832
  trackEvent("run.completed", {
7482
8833
  status: "failed",
7483
- providerCount: 0,
7484
- providers: [],
7485
- keywordCount: 0,
7486
- durationMs: Date.now() - startTime
8834
+ providerCount: executionContext.providerCount,
8835
+ providers: executionContext.providers,
8836
+ keywordCount: executionContext.keywordCount,
8837
+ durationMs: Date.now() - startTime,
8838
+ ...executionContext.location ? { location: executionContext.location } : {}
7487
8839
  });
7488
8840
  if (this.onRunCompleted) {
7489
8841
  this.onRunCompleted(runId, projectId).catch((notifErr) => {
@@ -7492,27 +8844,9 @@ var JobRunner = class {
7492
8844
  }
7493
8845
  }
7494
8846
  }
7495
- async waitForRateLimit(window, maxPerMinute) {
7496
- const now = Date.now();
7497
- const windowStart = now - 6e4;
7498
- while (window.length > 0 && window[0] < windowStart) {
7499
- window.shift();
7500
- }
7501
- if (window.length >= maxPerMinute) {
7502
- const oldestInWindow = window[0];
7503
- const waitMs = oldestInWindow + 6e4 - now + 50;
7504
- await new Promise((resolve) => setTimeout(resolve, waitMs));
7505
- const nowAfterWait = Date.now();
7506
- const newWindowStart = nowAfterWait - 6e4;
7507
- while (window.length > 0 && window[0] < newWindowStart) {
7508
- window.shift();
7509
- }
7510
- }
7511
- window.push(Date.now());
7512
- }
7513
8847
  incrementUsage(scope, metric, count) {
7514
8848
  const now = /* @__PURE__ */ new Date();
7515
- const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
8849
+ const period = now.toISOString().slice(0, 10);
7516
8850
  const id = crypto15.randomUUID();
7517
8851
  const existing = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
7518
8852
  if (existing) {
@@ -7528,10 +8862,52 @@ var JobRunner = class {
7528
8862
  }).run();
7529
8863
  }
7530
8864
  }
8865
+ flushProviderUsage(projectId, providerDispatchCounts) {
8866
+ for (const [providerName, count] of providerDispatchCounts.entries()) {
8867
+ if (count <= 0) continue;
8868
+ this.incrementUsage(`${projectId}:${providerName}`, "queries", count);
8869
+ }
8870
+ }
8871
+ getRunState(runId) {
8872
+ return this.db.select({
8873
+ status: runs.status,
8874
+ finishedAt: runs.finishedAt,
8875
+ error: runs.error
8876
+ }).from(runs).where(eq16(runs.id, runId)).get();
8877
+ }
8878
+ isRunCancelled(runId) {
8879
+ return this.getRunState(runId)?.status === "cancelled";
8880
+ }
8881
+ throwIfRunCancelled(runId) {
8882
+ if (this.isRunCancelled(runId)) {
8883
+ throw new RunCancelledError(runId);
8884
+ }
8885
+ }
8886
+ handleCancelledRun(runId, projectId, startTime, context) {
8887
+ const currentRun = this.getRunState(runId);
8888
+ if (currentRun && !currentRun.finishedAt) {
8889
+ this.db.update(runs).set({
8890
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
8891
+ error: currentRun.error ?? "Cancelled by user"
8892
+ }).where(eq16(runs.id, runId)).run();
8893
+ }
8894
+ trackEvent("run.completed", {
8895
+ status: "cancelled",
8896
+ providerCount: context.providerCount,
8897
+ providers: context.providers,
8898
+ keywordCount: context.keywordCount,
8899
+ durationMs: Date.now() - startTime,
8900
+ ...context.location ? { location: context.location } : {}
8901
+ });
8902
+ if (this.onRunCompleted) {
8903
+ this.onRunCompleted(runId, projectId).catch((err) => {
8904
+ console.error("[JobRunner] Notification callback failed:", err);
8905
+ });
8906
+ }
8907
+ }
7531
8908
  };
7532
- function getCurrentPeriod() {
7533
- const d = /* @__PURE__ */ new Date();
7534
- return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
8909
+ function getCurrentUsageDay() {
8910
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
7535
8911
  }
7536
8912
  function domainMatches(domain, canonicalDomain) {
7537
8913
  const normalized = normalizeProjectDomain(canonicalDomain);
@@ -7597,7 +8973,7 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
7597
8973
 
7598
8974
  // src/gsc-sync.ts
7599
8975
  import crypto16 from "crypto";
7600
- import { eq as eq17, and as and6, sql as sql3 } from "drizzle-orm";
8976
+ import { eq as eq17, and as and7, sql as sql3 } from "drizzle-orm";
7601
8977
  function formatDate(d) {
7602
8978
  return d.toISOString().split("T")[0];
7603
8979
  }
@@ -7648,7 +9024,7 @@ async function executeGscSync(db, runId, projectId, opts) {
7648
9024
  });
7649
9025
  console.log(`[GSC Sync] Received ${rows.length} rows`);
7650
9026
  db.delete(gscSearchData).where(
7651
- and6(
9027
+ and7(
7652
9028
  eq17(gscSearchData.projectId, projectId),
7653
9029
  sql3`${gscSearchData.date} >= ${startDate}`,
7654
9030
  sql3`${gscSearchData.date} <= ${endDate}`
@@ -7737,7 +9113,7 @@ async function executeGscSync(db, runId, projectId, opts) {
7737
9113
  }
7738
9114
  }
7739
9115
  const snapshotDate = formatDate(/* @__PURE__ */ new Date());
7740
- db.delete(gscCoverageSnapshots).where(and6(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
9116
+ db.delete(gscCoverageSnapshots).where(and7(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
7741
9117
  db.insert(gscCoverageSnapshots).values({
7742
9118
  id: crypto16.randomUUID(),
7743
9119
  projectId,
@@ -7760,7 +9136,7 @@ async function executeGscSync(db, runId, projectId, opts) {
7760
9136
 
7761
9137
  // src/gsc-inspect-sitemap.ts
7762
9138
  import crypto17 from "crypto";
7763
- import { eq as eq18, and as and7 } from "drizzle-orm";
9139
+ import { eq as eq18, and as and8 } from "drizzle-orm";
7764
9140
 
7765
9141
  // src/sitemap-parser.ts
7766
9142
  var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
@@ -7923,7 +9299,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7923
9299
  }
7924
9300
  }
7925
9301
  const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
7926
- db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
9302
+ db.delete(gscCoverageSnapshots).where(and8(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
7927
9303
  db.insert(gscCoverageSnapshots).values({
7928
9304
  id: crypto17.randomUUID(),
7929
9305
  projectId,
@@ -8115,7 +9491,7 @@ var Scheduler = class {
8115
9491
  };
8116
9492
 
8117
9493
  // src/notifier.ts
8118
- import { eq as eq20, desc as desc5, and as and8, or as or2 } from "drizzle-orm";
9494
+ import { eq as eq20, desc as desc6, and as and9, or as or2 } from "drizzle-orm";
8119
9495
  import crypto18 from "crypto";
8120
9496
  var Notifier = class {
8121
9497
  db;
@@ -8178,11 +9554,11 @@ var Notifier = class {
8178
9554
  }
8179
9555
  computeTransitions(runId, projectId) {
8180
9556
  const recentRuns = this.db.select().from(runs).where(
8181
- and8(
9557
+ and9(
8182
9558
  eq20(runs.projectId, projectId),
8183
9559
  or2(eq20(runs.status, "completed"), eq20(runs.status, "partial"))
8184
9560
  )
8185
- ).orderBy(desc5(runs.createdAt)).limit(2).all();
9561
+ ).orderBy(desc6(runs.createdAt)).limit(2).all();
8186
9562
  if (recentRuns.length < 2) return [];
8187
9563
  const currentRunId = recentRuns[0].id;
8188
9564
  const previousRunId = recentRuns[1].id;
@@ -8385,6 +9761,20 @@ var DEFAULT_QUOTA = {
8385
9761
  maxRequestsPerMinute: 10,
8386
9762
  maxRequestsPerDay: 1e3
8387
9763
  };
9764
+ var API_ADAPTERS = [
9765
+ geminiAdapter,
9766
+ openaiAdapter,
9767
+ claudeAdapter,
9768
+ localAdapter,
9769
+ perplexityAdapter
9770
+ ];
9771
+ var BROWSER_ADAPTERS = [
9772
+ cdpChatgptAdapter
9773
+ ];
9774
+ var ALL_ADAPTERS = [...API_ADAPTERS, ...BROWSER_ADAPTERS];
9775
+ var adapterMap = Object.fromEntries(
9776
+ API_ADAPTERS.map((a) => [a.name, a])
9777
+ );
8388
9778
  function summarizeProviderConfig(provider, config) {
8389
9779
  return {
8390
9780
  configured: Boolean(config?.apiKey || config?.baseUrl),
@@ -8421,38 +9811,19 @@ async function createServer(opts) {
8421
9811
  const p = providers[k];
8422
9812
  return p?.apiKey || p?.baseUrl;
8423
9813
  }));
8424
- if (providers.gemini?.apiKey) {
8425
- registry.register(geminiAdapter, {
8426
- provider: "gemini",
8427
- apiKey: providers.gemini.apiKey,
8428
- model: providers.gemini.model,
8429
- quotaPolicy: providers.gemini.quota ?? DEFAULT_QUOTA
8430
- });
8431
- }
8432
- if (providers.openai?.apiKey) {
8433
- registry.register(openaiAdapter, {
8434
- provider: "openai",
8435
- apiKey: providers.openai.apiKey,
8436
- model: providers.openai.model,
8437
- quotaPolicy: providers.openai.quota ?? DEFAULT_QUOTA
8438
- });
8439
- }
8440
- if (providers.claude?.apiKey) {
8441
- registry.register(claudeAdapter, {
8442
- provider: "claude",
8443
- apiKey: providers.claude.apiKey,
8444
- model: providers.claude.model,
8445
- quotaPolicy: providers.claude.quota ?? DEFAULT_QUOTA
8446
- });
8447
- }
8448
- if (providers.local?.baseUrl) {
8449
- registry.register(localAdapter, {
8450
- provider: "local",
8451
- apiKey: providers.local.apiKey,
8452
- baseUrl: providers.local.baseUrl,
8453
- model: providers.local.model,
8454
- quotaPolicy: providers.local.quota ?? DEFAULT_QUOTA
8455
- });
9814
+ for (const adapter of API_ADAPTERS) {
9815
+ const entry = providers[adapter.name];
9816
+ if (!entry) continue;
9817
+ const isConfigured = adapter.name === "local" ? !!entry.baseUrl : !!entry.apiKey;
9818
+ if (isConfigured) {
9819
+ registry.register(adapter, {
9820
+ provider: adapter.name,
9821
+ apiKey: entry.apiKey,
9822
+ baseUrl: entry.baseUrl,
9823
+ model: entry.model,
9824
+ quotaPolicy: entry.quota ?? DEFAULT_QUOTA
9825
+ });
9826
+ }
8456
9827
  }
8457
9828
  const cdpConfig = opts.config.cdp;
8458
9829
  if (cdpConfig?.host || cdpConfig?.port) {
@@ -8477,11 +9848,14 @@ async function createServer(opts) {
8477
9848
  });
8478
9849
  }
8479
9850
  });
8480
- const providerSummary = ["gemini", "openai", "claude", "local"].map((name) => ({
8481
- name,
8482
- model: registry.get(name)?.config.model,
8483
- configured: !!registry.get(name),
8484
- quota: registry.get(name)?.config.quotaPolicy
9851
+ const providerSummary = API_ADAPTERS.map((adapter) => ({
9852
+ name: adapter.name,
9853
+ displayName: adapter.displayName,
9854
+ keyUrl: adapter.keyUrl,
9855
+ modelHint: `e.g. ${adapter.modelRegistry.defaultModel}`,
9856
+ model: registry.get(adapter.name)?.config.model,
9857
+ configured: !!registry.get(adapter.name),
9858
+ quota: registry.get(adapter.name)?.config.quotaPolicy
8485
9859
  }));
8486
9860
  const googleSettingsSummary = {
8487
9861
  configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
@@ -8521,7 +9895,6 @@ async function createServer(opts) {
8521
9895
  return true;
8522
9896
  }
8523
9897
  };
8524
- const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
8525
9898
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto19.randomBytes(32).toString("hex");
8526
9899
  const googleConnectionStore = {
8527
9900
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
@@ -8585,6 +9958,13 @@ async function createServer(opts) {
8585
9958
  version: PKG_VERSION
8586
9959
  },
8587
9960
  providerSummary,
9961
+ providerAdapters: [...API_ADAPTERS, ...BROWSER_ADAPTERS].map((a) => ({
9962
+ name: a.name,
9963
+ displayName: a.displayName,
9964
+ mode: a.mode,
9965
+ modelValidationPattern: a.modelRegistry.validationPattern,
9966
+ modelValidationHint: a.modelRegistry.validationHint
9967
+ })),
8588
9968
  googleSettingsSummary,
8589
9969
  bingSettingsSummary,
8590
9970
  bingConnectionStore,
@@ -8595,7 +9975,7 @@ async function createServer(opts) {
8595
9975
  },
8596
9976
  onProviderUpdate: (providerName, apiKey, model, baseUrl, incomingQuota) => {
8597
9977
  const name = providerName;
8598
- if (!(name in adapterMap)) return null;
9978
+ if (!adapterMap[name]) return null;
8599
9979
  if (!opts.config.providers) opts.config.providers = {};
8600
9980
  const existing = opts.config.providers[name];
8601
9981
  const beforeConfig = summarizeProviderConfig(name, existing);
@@ -8878,14 +10258,6 @@ function parseKeywordResponse(raw, count) {
8878
10258
  }
8879
10259
 
8880
10260
  export {
8881
- providerQuotaPolicySchema,
8882
- resolveProviderInput,
8883
- notificationEventSchema,
8884
- getDefaultModel,
8885
- effectiveDomains,
8886
- apiKeys,
8887
- createClient,
8888
- migrate,
8889
10261
  getConfigDir,
8890
10262
  getConfigPath,
8891
10263
  loadConfig,
@@ -8896,6 +10268,13 @@ export {
8896
10268
  isFirstRun,
8897
10269
  showFirstRunNotice,
8898
10270
  trackEvent,
10271
+ providerQuotaPolicySchema,
10272
+ resolveProviderInput,
10273
+ notificationEventSchema,
10274
+ effectiveDomains,
8899
10275
  setGoogleAuthConfig,
10276
+ apiKeys,
10277
+ createClient,
10278
+ migrate,
8900
10279
  createServer
8901
10280
  };