@ainyc/canonry 1.19.2 → 1.19.4

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.
@@ -166,981 +166,981 @@ import path6 from "path";
166
166
  import { fileURLToPath } from "url";
167
167
  import Fastify from "fastify";
168
168
 
169
- // ../api-routes/src/auth.ts
170
- import crypto2 from "crypto";
171
- import { eq } from "drizzle-orm";
172
-
173
- // ../db/src/client.ts
174
- import { mkdirSync } from "fs";
175
- import { dirname } from "path";
176
- import Database from "better-sqlite3";
177
- import { drizzle } from "drizzle-orm/better-sqlite3";
169
+ // ../contracts/src/config-schema.ts
170
+ import { z as z3 } from "zod";
178
171
 
179
- // ../db/src/schema.ts
180
- var schema_exports = {};
181
- __export(schema_exports, {
182
- apiKeys: () => apiKeys,
183
- auditLog: () => auditLog,
184
- competitors: () => competitors,
185
- googleConnections: () => googleConnections,
186
- gscCoverageSnapshots: () => gscCoverageSnapshots,
187
- gscSearchData: () => gscSearchData,
188
- gscUrlInspections: () => gscUrlInspections,
189
- keywords: () => keywords,
190
- notifications: () => notifications,
191
- projects: () => projects,
192
- querySnapshots: () => querySnapshots,
193
- runs: () => runs,
194
- schedules: () => schedules,
195
- usageCounters: () => usageCounters
172
+ // ../contracts/src/provider.ts
173
+ import { z } from "zod";
174
+ var providerQuotaPolicySchema = z.object({
175
+ maxConcurrency: z.number().int().positive(),
176
+ maxRequestsPerMinute: z.number().int().positive(),
177
+ maxRequestsPerDay: z.number().int().positive()
196
178
  });
197
- import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
198
- var projects = sqliteTable("projects", {
199
- id: text("id").primaryKey(),
200
- name: text("name").notNull().unique(),
201
- displayName: text("display_name").notNull(),
202
- canonicalDomain: text("canonical_domain").notNull(),
203
- ownedDomains: text("owned_domains").notNull().default("[]"),
204
- country: text("country").notNull(),
205
- language: text("language").notNull(),
206
- tags: text("tags").notNull().default("[]"),
207
- labels: text("labels").notNull().default("{}"),
208
- providers: text("providers").notNull().default("[]"),
209
- locations: text("locations").notNull().default("[]"),
210
- defaultLocation: text("default_location"),
211
- configSource: text("config_source").notNull().default("cli"),
212
- configRevision: integer("config_revision").notNull().default(1),
213
- createdAt: text("created_at").notNull(),
214
- updatedAt: text("updated_at").notNull()
179
+ var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
180
+ var providerNameSchema = z.enum(PROVIDER_NAMES);
181
+ var PROVIDER_MODE = {
182
+ gemini: "api",
183
+ openai: "api",
184
+ claude: "api",
185
+ local: "api",
186
+ "cdp:chatgpt": "browser"
187
+ };
188
+ function isBrowserProvider(name) {
189
+ return PROVIDER_MODE[name] === "browser";
190
+ }
191
+ var CDP_TARGETS = ["cdp:chatgpt"];
192
+ function parseProviderName(input) {
193
+ const lower = input.trim().toLowerCase();
194
+ return PROVIDER_NAMES.includes(lower) ? lower : void 0;
195
+ }
196
+ function resolveProviderInput(input) {
197
+ const lower = input.trim().toLowerCase();
198
+ if (lower === "cdp") {
199
+ return [...CDP_TARGETS];
200
+ }
201
+ const parsed = parseProviderName(lower);
202
+ return parsed ? [parsed] : [];
203
+ }
204
+ var locationContextSchema = z.object({
205
+ label: z.string().min(1),
206
+ city: z.string().min(1),
207
+ region: z.string().min(1),
208
+ country: z.string().length(2),
209
+ timezone: z.string().optional()
215
210
  });
216
- var keywords = sqliteTable("keywords", {
217
- id: text("id").primaryKey(),
218
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
219
- keyword: text("keyword").notNull(),
220
- createdAt: text("created_at").notNull()
221
- }, (table) => [
222
- index("idx_keywords_project").on(table.projectId),
223
- uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
224
- ]);
225
- var competitors = sqliteTable("competitors", {
226
- id: text("id").primaryKey(),
227
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
228
- domain: text("domain").notNull(),
229
- createdAt: text("created_at").notNull()
230
- }, (table) => [
231
- index("idx_competitors_project").on(table.projectId),
232
- uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
233
- ]);
234
- var runs = sqliteTable("runs", {
235
- id: text("id").primaryKey(),
236
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
237
- kind: text("kind").notNull().default("answer-visibility"),
238
- status: text("status").notNull().default("queued"),
239
- trigger: text("trigger").notNull().default("manual"),
240
- location: text("location"),
241
- startedAt: text("started_at"),
242
- finishedAt: text("finished_at"),
243
- error: text("error"),
244
- createdAt: text("created_at").notNull()
245
- }, (table) => [
246
- index("idx_runs_project").on(table.projectId),
247
- index("idx_runs_status").on(table.status)
248
- ]);
249
- var querySnapshots = sqliteTable("query_snapshots", {
250
- id: text("id").primaryKey(),
251
- runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
252
- keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
253
- provider: text("provider").notNull().default("gemini"),
254
- model: text("model"),
255
- citationState: text("citation_state").notNull(),
256
- answerText: text("answer_text"),
257
- citedDomains: text("cited_domains").notNull().default("[]"),
258
- competitorOverlap: text("competitor_overlap").notNull().default("[]"),
259
- location: text("location"),
260
- screenshotPath: text("screenshot_path"),
261
- rawResponse: text("raw_response"),
262
- createdAt: text("created_at").notNull()
263
- }, (table) => [
264
- index("idx_snapshots_run").on(table.runId),
265
- index("idx_snapshots_keyword").on(table.keywordId)
266
- ]);
267
- var auditLog = sqliteTable("audit_log", {
268
- id: text("id").primaryKey(),
269
- projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
270
- actor: text("actor").notNull(),
271
- action: text("action").notNull(),
272
- entityType: text("entity_type").notNull(),
273
- entityId: text("entity_id"),
274
- diff: text("diff"),
275
- createdAt: text("created_at").notNull()
276
- }, (table) => [
277
- index("idx_audit_log_project").on(table.projectId),
278
- index("idx_audit_log_created").on(table.createdAt)
279
- ]);
280
- var apiKeys = sqliteTable("api_keys", {
281
- id: text("id").primaryKey(),
282
- name: text("name").notNull(),
283
- keyHash: text("key_hash").notNull().unique(),
284
- keyPrefix: text("key_prefix").notNull(),
285
- scopes: text("scopes").notNull().default('["*"]'),
286
- createdAt: text("created_at").notNull(),
287
- lastUsedAt: text("last_used_at"),
288
- revokedAt: text("revoked_at")
289
- }, (table) => [
290
- index("idx_api_keys_prefix").on(table.keyPrefix)
291
- ]);
292
- var schedules = sqliteTable("schedules", {
293
- id: text("id").primaryKey(),
294
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
295
- cronExpr: text("cron_expr").notNull(),
296
- preset: text("preset"),
297
- timezone: text("timezone").notNull().default("UTC"),
298
- enabled: integer("enabled").notNull().default(1),
299
- providers: text("providers").notNull().default("[]"),
300
- lastRunAt: text("last_run_at"),
301
- nextRunAt: text("next_run_at"),
302
- createdAt: text("created_at").notNull(),
303
- updatedAt: text("updated_at").notNull()
304
- }, (table) => [
305
- uniqueIndex("idx_schedules_project").on(table.projectId)
306
- ]);
307
- var notifications = sqliteTable("notifications", {
308
- id: text("id").primaryKey(),
309
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
310
- channel: text("channel").notNull(),
311
- config: text("config").notNull(),
312
- webhookSecret: text("webhook_secret"),
313
- enabled: integer("enabled").notNull().default(1),
314
- createdAt: text("created_at").notNull(),
315
- updatedAt: text("updated_at").notNull()
316
- }, (table) => [
317
- index("idx_notifications_project").on(table.projectId)
318
- ]);
319
- var googleConnections = sqliteTable("google_connections", {
320
- id: text("id").primaryKey(),
321
- domain: text("domain").notNull(),
322
- connectionType: text("connection_type").notNull(),
323
- propertyId: text("property_id"),
324
- sitemapUrl: text("sitemap_url"),
325
- accessToken: text("access_token"),
326
- refreshToken: text("refresh_token"),
327
- tokenExpiresAt: text("token_expires_at"),
328
- scopes: text("scopes").notNull().default("[]"),
329
- createdAt: text("created_at").notNull(),
330
- updatedAt: text("updated_at").notNull()
331
- }, (table) => [
332
- uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
333
- ]);
334
- var gscSearchData = sqliteTable("gsc_search_data", {
335
- id: text("id").primaryKey(),
336
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
337
- syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
338
- date: text("date").notNull(),
339
- query: text("query").notNull(),
340
- page: text("page").notNull(),
341
- country: text("country"),
342
- device: text("device"),
343
- clicks: integer("clicks").notNull().default(0),
344
- impressions: integer("impressions").notNull().default(0),
345
- ctr: text("ctr").notNull().default("0"),
346
- position: text("position").notNull().default("0"),
347
- createdAt: text("created_at").notNull()
348
- }, (table) => [
349
- index("idx_gsc_search_project_date").on(table.projectId, table.date),
350
- index("idx_gsc_search_query").on(table.query),
351
- index("idx_gsc_search_run").on(table.syncRunId)
352
- ]);
353
- var gscUrlInspections = sqliteTable("gsc_url_inspections", {
354
- id: text("id").primaryKey(),
355
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
356
- syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
357
- url: text("url").notNull(),
358
- indexingState: text("indexing_state"),
359
- verdict: text("verdict"),
360
- coverageState: text("coverage_state"),
361
- pageFetchState: text("page_fetch_state"),
362
- robotsTxtState: text("robots_txt_state"),
363
- crawlTime: text("crawl_time"),
364
- lastCrawlResult: text("last_crawl_result"),
365
- isMobileFriendly: integer("is_mobile_friendly"),
366
- richResults: text("rich_results").notNull().default("[]"),
367
- referringUrls: text("referring_urls").notNull().default("[]"),
368
- inspectedAt: text("inspected_at").notNull(),
369
- createdAt: text("created_at").notNull()
370
- }, (table) => [
371
- index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
372
- index("idx_gsc_inspect_run").on(table.syncRunId),
373
- index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
211
+
212
+ // ../contracts/src/notification.ts
213
+ import { z as z2 } from "zod";
214
+ var notificationEventSchema = z2.enum([
215
+ "citation.lost",
216
+ "citation.gained",
217
+ "run.completed",
218
+ "run.failed"
374
219
  ]);
375
- var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
376
- id: text("id").primaryKey(),
377
- projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
378
- syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
379
- date: text("date").notNull(),
380
- indexed: integer("indexed").notNull().default(0),
381
- notIndexed: integer("not_indexed").notNull().default(0),
382
- reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
383
- createdAt: text("created_at").notNull()
384
- }, (table) => [
385
- index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
386
- index("idx_gsc_coverage_snap_run").on(table.syncRunId)
387
- ]);
388
- var usageCounters = sqliteTable("usage_counters", {
389
- id: text("id").primaryKey(),
390
- scope: text("scope").notNull(),
391
- period: text("period").notNull(),
392
- metric: text("metric").notNull(),
393
- count: integer("count").notNull().default(0),
394
- updatedAt: text("updated_at").notNull()
395
- }, (table) => [
396
- uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
397
- index("idx_usage_scope_period").on(table.scope, table.period)
398
- ]);
399
-
400
- // ../db/src/client.ts
401
- function createClient(databasePath) {
402
- mkdirSync(dirname(databasePath), { recursive: true });
403
- const sqlite = new Database(databasePath);
404
- sqlite.pragma("journal_mode = WAL");
405
- sqlite.pragma("foreign_keys = ON");
406
- return drizzle(sqlite, { schema: schema_exports });
407
- }
408
-
409
- // ../db/src/migrate.ts
410
- import { sql } from "drizzle-orm";
411
- var MIGRATION_SQL = `
412
- CREATE TABLE IF NOT EXISTS projects (
413
- id TEXT PRIMARY KEY,
414
- name TEXT NOT NULL UNIQUE,
415
- display_name TEXT NOT NULL,
416
- canonical_domain TEXT NOT NULL,
417
- owned_domains TEXT NOT NULL DEFAULT '[]',
418
- country TEXT NOT NULL,
419
- language TEXT NOT NULL,
420
- tags TEXT NOT NULL DEFAULT '[]',
421
- labels TEXT NOT NULL DEFAULT '{}',
422
- providers TEXT NOT NULL DEFAULT '[]',
423
- config_source TEXT NOT NULL DEFAULT 'cli',
424
- config_revision INTEGER NOT NULL DEFAULT 1,
425
- created_at TEXT NOT NULL,
426
- updated_at TEXT NOT NULL
427
- );
428
-
429
- CREATE TABLE IF NOT EXISTS keywords (
430
- id TEXT PRIMARY KEY,
431
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
432
- keyword TEXT NOT NULL,
433
- created_at TEXT NOT NULL,
434
- UNIQUE(project_id, keyword)
435
- );
436
-
437
- CREATE TABLE IF NOT EXISTS competitors (
438
- id TEXT PRIMARY KEY,
439
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
440
- domain TEXT NOT NULL,
441
- created_at TEXT NOT NULL,
442
- UNIQUE(project_id, domain)
443
- );
220
+ var notificationDtoSchema = z2.object({
221
+ id: z2.string(),
222
+ projectId: z2.string(),
223
+ channel: z2.literal("webhook"),
224
+ url: z2.string().url(),
225
+ events: z2.array(notificationEventSchema),
226
+ enabled: z2.boolean().default(true),
227
+ webhookSecret: z2.string().optional(),
228
+ createdAt: z2.string(),
229
+ updatedAt: z2.string()
230
+ });
444
231
 
445
- CREATE TABLE IF NOT EXISTS runs (
446
- id TEXT PRIMARY KEY,
447
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
448
- kind TEXT NOT NULL DEFAULT 'answer-visibility',
449
- status TEXT NOT NULL DEFAULT 'queued',
450
- trigger TEXT NOT NULL DEFAULT 'manual',
451
- started_at TEXT,
452
- finished_at TEXT,
453
- error TEXT,
454
- created_at TEXT NOT NULL
455
- );
232
+ // ../contracts/src/config-schema.ts
233
+ var configMetadataSchema = z3.object({
234
+ name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
235
+ message: "Name must be a lowercase slug (letters, numbers, hyphens)"
236
+ }),
237
+ labels: z3.record(z3.string(), z3.string()).optional().default({})
238
+ });
239
+ var configScheduleSchema = z3.object({
240
+ preset: z3.string().optional(),
241
+ cron: z3.string().optional(),
242
+ timezone: z3.string().optional().default("UTC"),
243
+ providers: z3.array(providerNameSchema).optional().default([])
244
+ }).refine(
245
+ (data) => data.preset && !data.cron || !data.preset && data.cron,
246
+ { message: 'Exactly one of "preset" or "cron" must be provided' }
247
+ ).optional();
248
+ var configNotificationSchema = z3.object({
249
+ channel: z3.literal("webhook"),
250
+ url: z3.string().url(),
251
+ events: z3.array(notificationEventSchema).min(1)
252
+ });
253
+ var configGoogleSchema = z3.object({
254
+ gsc: z3.object({
255
+ propertyUrl: z3.string()
256
+ }).optional(),
257
+ syncSchedule: z3.object({
258
+ preset: z3.string().optional(),
259
+ cron: z3.string().optional()
260
+ }).optional()
261
+ }).optional();
262
+ var configSpecSchema = z3.object({
263
+ displayName: z3.string().min(1),
264
+ canonicalDomain: z3.string().min(1),
265
+ ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
266
+ country: z3.string().length(2),
267
+ language: z3.string().min(2),
268
+ keywords: z3.array(z3.string().min(1)).optional().default([]),
269
+ competitors: z3.array(z3.string().min(1)).optional().default([]),
270
+ providers: z3.array(providerNameSchema).optional().default([]),
271
+ locations: z3.array(locationContextSchema).optional().default([]),
272
+ defaultLocation: z3.string().optional(),
273
+ schedule: configScheduleSchema,
274
+ notifications: z3.array(configNotificationSchema).optional().default([]),
275
+ google: configGoogleSchema
276
+ });
277
+ var projectConfigSchema = z3.object({
278
+ apiVersion: z3.literal("canonry/v1"),
279
+ kind: z3.literal("Project"),
280
+ metadata: configMetadataSchema,
281
+ spec: configSpecSchema
282
+ });
456
283
 
457
- CREATE TABLE IF NOT EXISTS query_snapshots (
458
- id TEXT PRIMARY KEY,
459
- run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
460
- keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
461
- provider TEXT NOT NULL DEFAULT 'gemini',
462
- citation_state TEXT NOT NULL,
463
- answer_text TEXT,
464
- cited_domains TEXT NOT NULL DEFAULT '[]',
465
- competitor_overlap TEXT NOT NULL DEFAULT '[]',
466
- raw_response TEXT,
467
- created_at TEXT NOT NULL
468
- );
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
+ }
469
343
 
470
- CREATE TABLE IF NOT EXISTS audit_log (
471
- id TEXT PRIMARY KEY,
472
- project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
473
- actor TEXT NOT NULL,
474
- action TEXT NOT NULL,
475
- entity_type TEXT NOT NULL,
476
- entity_id TEXT,
477
- diff TEXT,
478
- created_at TEXT NOT NULL
479
- );
344
+ // ../contracts/src/errors.ts
345
+ var AppError = class extends Error {
346
+ code;
347
+ statusCode;
348
+ details;
349
+ constructor(code, message, statusCode, details) {
350
+ super(message);
351
+ this.name = "AppError";
352
+ this.code = code;
353
+ this.statusCode = statusCode;
354
+ this.details = details;
355
+ }
356
+ toJSON() {
357
+ return {
358
+ error: {
359
+ code: this.code,
360
+ message: this.message,
361
+ ...this.details ? { details: this.details } : {}
362
+ }
363
+ };
364
+ }
365
+ };
366
+ function notFound(entity, id) {
367
+ return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
368
+ }
369
+ function validationError(message, details) {
370
+ return new AppError("VALIDATION_ERROR", message, 400, details);
371
+ }
372
+ function authRequired() {
373
+ return new AppError("AUTH_REQUIRED", "Authentication required", 401);
374
+ }
375
+ function authInvalid() {
376
+ return new AppError("AUTH_INVALID", "Invalid API key", 401);
377
+ }
378
+ function runInProgress(projectName) {
379
+ return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
380
+ }
381
+ function unsupportedKind(kind) {
382
+ return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
383
+ }
480
384
 
481
- CREATE TABLE IF NOT EXISTS api_keys (
482
- id TEXT PRIMARY KEY,
483
- name TEXT NOT NULL,
484
- key_hash TEXT NOT NULL UNIQUE,
485
- key_prefix TEXT NOT NULL,
486
- scopes TEXT NOT NULL DEFAULT '["*"]',
487
- created_at TEXT NOT NULL,
488
- last_used_at TEXT,
489
- revoked_at TEXT
490
- );
385
+ // ../contracts/src/google.ts
386
+ import { z as z4 } from "zod";
387
+ var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
388
+ var googleConnectionDtoSchema = z4.object({
389
+ id: z4.string(),
390
+ domain: z4.string(),
391
+ connectionType: googleConnectionTypeSchema,
392
+ propertyId: z4.string().nullable().optional(),
393
+ sitemapUrl: z4.string().nullable().optional(),
394
+ scopes: z4.array(z4.string()).default([]),
395
+ createdAt: z4.string(),
396
+ updatedAt: z4.string()
397
+ });
398
+ var gscSearchDataDtoSchema = z4.object({
399
+ date: z4.string(),
400
+ query: z4.string(),
401
+ page: z4.string(),
402
+ country: z4.string().nullable().optional(),
403
+ device: z4.string().nullable().optional(),
404
+ clicks: z4.number(),
405
+ impressions: z4.number(),
406
+ ctr: z4.number(),
407
+ position: z4.number()
408
+ });
409
+ var gscUrlInspectionDtoSchema = z4.object({
410
+ id: z4.string(),
411
+ url: z4.string(),
412
+ indexingState: z4.string().nullable().optional(),
413
+ verdict: z4.string().nullable().optional(),
414
+ coverageState: z4.string().nullable().optional(),
415
+ pageFetchState: z4.string().nullable().optional(),
416
+ robotsTxtState: z4.string().nullable().optional(),
417
+ crawlTime: z4.string().nullable().optional(),
418
+ lastCrawlResult: z4.string().nullable().optional(),
419
+ isMobileFriendly: z4.boolean().nullable().optional(),
420
+ richResults: z4.array(z4.string()).default([]),
421
+ inspectedAt: z4.string()
422
+ });
423
+ var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
424
+ var gscDeindexedRowSchema = z4.object({
425
+ url: z4.string(),
426
+ previousState: z4.string().nullable(),
427
+ currentState: z4.string().nullable(),
428
+ transitionDate: z4.string()
429
+ });
430
+ var gscReasonGroupSchema = z4.object({
431
+ reason: z4.string(),
432
+ count: z4.number(),
433
+ urls: z4.array(gscUrlInspectionDtoSchema).default([])
434
+ });
435
+ var gscCoverageSummaryDtoSchema = z4.object({
436
+ summary: z4.object({
437
+ total: z4.number(),
438
+ indexed: z4.number(),
439
+ notIndexed: z4.number(),
440
+ deindexed: z4.number(),
441
+ percentage: z4.number()
442
+ }),
443
+ lastInspectedAt: z4.string().nullable(),
444
+ indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
445
+ notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
446
+ deindexed: z4.array(gscDeindexedRowSchema).default([]),
447
+ reasonGroups: z4.array(gscReasonGroupSchema).default([])
448
+ });
449
+ var indexingNotificationDtoSchema = z4.object({
450
+ url: z4.string(),
451
+ type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
452
+ notifiedAt: z4.string()
453
+ });
454
+ var indexingRequestResultDtoSchema = z4.object({
455
+ url: z4.string(),
456
+ type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
457
+ notifiedAt: z4.string(),
458
+ status: z4.enum(["success", "error"]),
459
+ error: z4.string().optional()
460
+ });
461
+ var gscCoverageSnapshotDtoSchema = z4.object({
462
+ date: z4.string(),
463
+ indexed: z4.number(),
464
+ notIndexed: z4.number(),
465
+ reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
466
+ });
491
467
 
492
- CREATE TABLE IF NOT EXISTS usage_counters (
493
- id TEXT PRIMARY KEY,
494
- scope TEXT NOT NULL,
495
- period TEXT NOT NULL,
496
- metric TEXT NOT NULL,
497
- count INTEGER NOT NULL DEFAULT 0,
498
- updated_at TEXT NOT NULL,
499
- UNIQUE(scope, period, metric)
500
- );
468
+ // ../contracts/src/project.ts
469
+ import { z as z5 } from "zod";
470
+ var configSourceSchema = z5.enum(["cli", "api", "config-file"]);
471
+ var projectDtoSchema = z5.object({
472
+ id: z5.string(),
473
+ name: z5.string(),
474
+ displayName: z5.string().optional(),
475
+ canonicalDomain: z5.string(),
476
+ ownedDomains: z5.array(z5.string()).default([]),
477
+ country: z5.string().length(2),
478
+ language: z5.string().min(2),
479
+ tags: z5.array(z5.string()).default([]),
480
+ labels: z5.record(z5.string(), z5.string()).default({}),
481
+ locations: z5.array(locationContextSchema).default([]),
482
+ defaultLocation: z5.string().nullable().optional(),
483
+ configSource: configSourceSchema.default("cli"),
484
+ configRevision: z5.number().int().positive().default(1),
485
+ createdAt: z5.string().optional(),
486
+ updatedAt: z5.string().optional()
487
+ });
488
+ function normalizeProjectDomain(input) {
489
+ let domain = input.trim().toLowerCase();
490
+ try {
491
+ if (domain.includes("://")) {
492
+ domain = new URL(domain).hostname.toLowerCase();
493
+ }
494
+ } catch {
495
+ }
496
+ return domain.replace(/^www\./, "");
497
+ }
498
+ function effectiveDomains(project) {
499
+ const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
500
+ const seen = /* @__PURE__ */ new Set();
501
+ const result = [];
502
+ for (const d of all) {
503
+ const trimmed = d.trim();
504
+ if (!trimmed) continue;
505
+ const norm = normalizeProjectDomain(trimmed);
506
+ if (seen.has(norm)) continue;
507
+ seen.add(norm);
508
+ result.push(trimmed);
509
+ }
510
+ return result;
511
+ }
501
512
 
502
- CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
503
- CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
504
- CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
505
- CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
506
- CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
507
- CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
508
- CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
509
- CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
510
- CREATE TABLE IF NOT EXISTS schedules (
511
- id TEXT PRIMARY KEY,
512
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
513
- cron_expr TEXT NOT NULL,
514
- preset TEXT,
515
- timezone TEXT NOT NULL DEFAULT 'UTC',
516
- enabled INTEGER NOT NULL DEFAULT 1,
517
- providers TEXT NOT NULL DEFAULT '[]',
518
- last_run_at TEXT,
519
- next_run_at TEXT,
520
- created_at TEXT NOT NULL,
521
- updated_at TEXT NOT NULL,
522
- UNIQUE(project_id)
523
- );
513
+ // ../contracts/src/run.ts
514
+ import { z as z6 } from "zod";
515
+ var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
516
+ var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
517
+ var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
518
+ var citationStateSchema = z6.enum(["cited", "not-cited"]);
519
+ var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
520
+ var runDtoSchema = z6.object({
521
+ id: z6.string(),
522
+ projectId: z6.string(),
523
+ kind: runKindSchema,
524
+ status: runStatusSchema,
525
+ trigger: runTriggerSchema.default("manual"),
526
+ location: z6.string().nullable().optional(),
527
+ startedAt: z6.string().nullable().optional(),
528
+ finishedAt: z6.string().nullable().optional(),
529
+ error: z6.string().nullable().optional(),
530
+ createdAt: z6.string()
531
+ });
532
+ var groundingSourceSchema = z6.object({
533
+ uri: z6.string(),
534
+ title: z6.string()
535
+ });
536
+ var querySnapshotDtoSchema = z6.object({
537
+ id: z6.string(),
538
+ runId: z6.string(),
539
+ keywordId: z6.string(),
540
+ keyword: z6.string().optional(),
541
+ provider: providerNameSchema,
542
+ citationState: citationStateSchema,
543
+ transition: computedTransitionSchema.optional(),
544
+ answerText: z6.string().nullable().optional(),
545
+ citedDomains: z6.array(z6.string()).default([]),
546
+ competitorOverlap: z6.array(z6.string()).default([]),
547
+ groundingSources: z6.array(groundingSourceSchema).default([]),
548
+ searchQueries: z6.array(z6.string()).default([]),
549
+ model: z6.string().nullable().optional(),
550
+ location: z6.string().nullable().optional(),
551
+ createdAt: z6.string()
552
+ });
553
+ var auditLogEntrySchema = z6.object({
554
+ id: z6.string(),
555
+ projectId: z6.string().nullable().optional(),
556
+ actor: z6.string(),
557
+ action: z6.string(),
558
+ entityType: z6.string(),
559
+ entityId: z6.string().nullable().optional(),
560
+ diff: z6.unknown().optional(),
561
+ createdAt: z6.string()
562
+ });
524
563
 
525
- CREATE TABLE IF NOT EXISTS notifications (
526
- id TEXT PRIMARY KEY,
527
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
528
- channel TEXT NOT NULL,
529
- config TEXT NOT NULL,
530
- enabled INTEGER NOT NULL DEFAULT 1,
531
- created_at TEXT NOT NULL,
532
- updated_at TEXT NOT NULL
533
- );
564
+ // ../contracts/src/schedule.ts
565
+ import { z as z7 } from "zod";
566
+ var scheduleDtoSchema = z7.object({
567
+ id: z7.string(),
568
+ projectId: z7.string(),
569
+ cronExpr: z7.string(),
570
+ preset: z7.string().nullable().optional(),
571
+ timezone: z7.string().default("UTC"),
572
+ enabled: z7.boolean().default(true),
573
+ providers: z7.array(providerNameSchema).default([]),
574
+ lastRunAt: z7.string().nullable().optional(),
575
+ nextRunAt: z7.string().nullable().optional(),
576
+ createdAt: z7.string(),
577
+ updatedAt: z7.string()
578
+ });
534
579
 
535
- CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
536
- CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
537
- CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
538
- CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
539
- `;
540
- var MIGRATIONS = [
541
- // v2: Add providers column to projects for multi-provider support
542
- `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
543
- // v3: Add webhook_secret column to notifications for HMAC signing
544
- `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
545
- // v4: Add owned_domains column to projects for multi-domain citation matching
546
- `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
547
- // v5: Add model column to query_snapshots for per-model scoring
548
- `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
549
- // v5b: Backfill model from rawResponse JSON for existing snapshots
550
- `UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`,
551
- // v6: Google Search Console integration google_connections table (domain-scoped)
552
- `CREATE TABLE IF NOT EXISTS google_connections (
553
- id TEXT PRIMARY KEY,
554
- domain TEXT NOT NULL,
555
- connection_type TEXT NOT NULL,
556
- property_id TEXT,
557
- access_token TEXT,
558
- refresh_token TEXT,
559
- token_expires_at TEXT,
560
- scopes TEXT NOT NULL DEFAULT '[]',
561
- created_at TEXT NOT NULL,
562
- updated_at TEXT NOT NULL
563
- )`,
564
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
565
- // v6: Google Search Console integration gsc_search_data table
566
- `CREATE TABLE IF NOT EXISTS gsc_search_data (
567
- id TEXT PRIMARY KEY,
568
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
569
- sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
570
- date TEXT NOT NULL,
571
- query TEXT NOT NULL,
572
- page TEXT NOT NULL,
573
- country TEXT,
574
- device TEXT,
575
- clicks INTEGER NOT NULL DEFAULT 0,
576
- impressions INTEGER NOT NULL DEFAULT 0,
577
- ctr TEXT NOT NULL DEFAULT '0',
578
- position TEXT NOT NULL DEFAULT '0',
579
- created_at TEXT NOT NULL
580
- )`,
581
- `CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
582
- `CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
583
- `CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
584
- // v6: Google Search Console integration — gsc_url_inspections table
585
- `CREATE TABLE IF NOT EXISTS gsc_url_inspections (
586
- id TEXT PRIMARY KEY,
587
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
588
- sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
589
- url TEXT NOT NULL,
590
- indexing_state TEXT,
591
- verdict TEXT,
592
- coverage_state TEXT,
593
- page_fetch_state TEXT,
594
- robots_txt_state TEXT,
595
- crawl_time TEXT,
596
- last_crawl_result TEXT,
597
- is_mobile_friendly INTEGER,
598
- rich_results TEXT NOT NULL DEFAULT '[]',
599
- referring_urls TEXT NOT NULL DEFAULT '[]',
600
- inspected_at TEXT NOT NULL,
601
- created_at TEXT NOT NULL
602
- )`,
603
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
604
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
605
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
606
- // v7: GSC coverage snapshots for historical tracking
607
- `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
608
- id TEXT PRIMARY KEY,
609
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
610
- sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
611
- date TEXT NOT NULL,
612
- indexed INTEGER NOT NULL DEFAULT 0,
613
- not_indexed INTEGER NOT NULL DEFAULT 0,
614
- reason_breakdown TEXT NOT NULL DEFAULT '{}',
615
- created_at TEXT NOT NULL
616
- )`,
617
- `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
618
- `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
619
- // v8: Location-aware sweeps — project locations + snapshot location tag
620
- `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
621
- `ALTER TABLE projects ADD COLUMN default_location TEXT`,
622
- `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
623
- // v9: Add location column to runs for per-location run tracking
624
- `ALTER TABLE runs ADD COLUMN location TEXT`,
625
- // v10: Add sitemapUrl to google_connections for persistent sitemap storage
626
- `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
627
- // v11: CDP browser provider — screenshot path for captured evidence
628
- `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
580
+ // ../contracts/src/source-categories.ts
581
+ var SOURCE_CATEGORY_RULES = [
582
+ // Forums
583
+ { pattern: "reddit.com", category: "forum", label: "Reddit" },
584
+ { pattern: "quora.com", category: "forum", label: "Quora" },
585
+ { pattern: "stackexchange.com", category: "forum", label: "Stack Exchange" },
586
+ { pattern: "stackoverflow.com", category: "forum", label: "Stack Overflow" },
587
+ { pattern: "discourse.org", category: "forum", label: "Discourse" },
588
+ // Social
589
+ { pattern: "linkedin.com", category: "social", label: "LinkedIn" },
590
+ { pattern: "twitter.com", category: "social", label: "X (Twitter)" },
591
+ { pattern: "x.com", category: "social", label: "X (Twitter)" },
592
+ { pattern: "facebook.com", category: "social", label: "Facebook" },
593
+ { pattern: "instagram.com", category: "social", label: "Instagram" },
594
+ { pattern: "threads.net", category: "social", label: "Threads" },
595
+ { pattern: "pinterest.com", category: "social", label: "Pinterest" },
596
+ { pattern: "tiktok.com", category: "social", label: "TikTok" },
597
+ // Video
598
+ { pattern: "youtube.com", category: "video", label: "YouTube" },
599
+ { pattern: "youtu.be", category: "video", label: "YouTube" },
600
+ { pattern: "vimeo.com", category: "video", label: "Vimeo" },
601
+ // News
602
+ { pattern: "nytimes.com", category: "news", label: "NY Times" },
603
+ { pattern: "bbc.com", category: "news", label: "BBC" },
604
+ { pattern: "bbc.co.uk", category: "news", label: "BBC" },
605
+ { pattern: "cnn.com", category: "news", label: "CNN" },
606
+ { pattern: "reuters.com", category: "news", label: "Reuters" },
607
+ { pattern: "apnews.com", category: "news", label: "AP News" },
608
+ { pattern: "theguardian.com", category: "news", label: "The Guardian" },
609
+ { pattern: "washingtonpost.com", category: "news", label: "Washington Post" },
610
+ { pattern: "wsj.com", category: "news", label: "WSJ" },
611
+ { pattern: "forbes.com", category: "news", label: "Forbes" },
612
+ { pattern: "techcrunch.com", category: "news", label: "TechCrunch" },
613
+ { pattern: "theverge.com", category: "news", label: "The Verge" },
614
+ { pattern: "wired.com", category: "news", label: "Wired" },
615
+ { pattern: "arstechnica.com", category: "news", label: "Ars Technica" },
616
+ // Reference
617
+ { pattern: "wikipedia.org", category: "reference", label: "Wikipedia" },
618
+ { pattern: "wikimedia.org", category: "reference", label: "Wikimedia" },
619
+ { pattern: "britannica.com", category: "reference", label: "Britannica" },
620
+ { pattern: "merriam-webster.com", category: "reference", label: "Merriam-Webster" },
621
+ // Blog / Content platforms
622
+ { pattern: "medium.com", category: "blog", label: "Medium" },
623
+ { pattern: "substack.com", category: "blog", label: "Substack" },
624
+ { pattern: "dev.to", category: "blog", label: "DEV Community" },
625
+ { pattern: "hashnode.dev", category: "blog", label: "Hashnode" },
626
+ { pattern: "wordpress.com", category: "blog", label: "WordPress" },
627
+ { pattern: "blogger.com", category: "blog", label: "Blogger" },
628
+ { pattern: "hubspot.com", category: "blog", label: "HubSpot" },
629
+ // E-commerce
630
+ { pattern: "amazon.com", category: "ecommerce", label: "Amazon" },
631
+ { pattern: "amazon.co.uk", category: "ecommerce", label: "Amazon UK" },
632
+ { pattern: "shopify.com", category: "ecommerce", label: "Shopify" },
633
+ { pattern: "ebay.com", category: "ecommerce", label: "eBay" },
634
+ // Academic
635
+ { pattern: "scholar.google.com", category: "academic", label: "Google Scholar" },
636
+ { pattern: "arxiv.org", category: "academic", label: "arXiv" },
637
+ { pattern: "pubmed.ncbi.nlm.nih.gov", category: "academic", label: "PubMed" },
638
+ { pattern: "researchgate.net", category: "academic", label: "ResearchGate" },
639
+ { pattern: ".edu", category: "academic", label: "Academic (.edu)" }
629
640
  ];
630
- function migrate(db) {
631
- const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
632
- for (const statement of statements) {
633
- db.run(sql.raw(statement));
641
+ var CATEGORY_LABELS = {
642
+ social: "Social Media",
643
+ forum: "Forums & Q&A",
644
+ news: "News & Media",
645
+ reference: "Reference",
646
+ blog: "Blogs & Content",
647
+ ecommerce: "E-commerce",
648
+ video: "Video",
649
+ academic: "Academic",
650
+ other: "Other"
651
+ };
652
+ function categorizeSource(uri) {
653
+ let domain;
654
+ try {
655
+ const url = new URL(uri.startsWith("http") ? uri : `https://${uri}`);
656
+ domain = url.hostname.replace(/^www\./, "");
657
+ } catch {
658
+ domain = uri.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0] ?? uri;
634
659
  }
635
- for (const migration of MIGRATIONS) {
636
- try {
637
- db.run(sql.raw(migration));
638
- } catch {
660
+ const domainLower = domain.toLowerCase();
661
+ for (const rule of SOURCE_CATEGORY_RULES) {
662
+ if (domainLower === rule.pattern || domainLower.endsWith(`.${rule.pattern}`) || rule.pattern.startsWith(".") && domainLower.endsWith(rule.pattern)) {
663
+ return { category: rule.category, label: rule.label, domain };
639
664
  }
640
665
  }
666
+ return { category: "other", label: CATEGORY_LABELS.other, domain };
667
+ }
668
+ function categoryLabel(category) {
669
+ return CATEGORY_LABELS[category];
641
670
  }
642
671
 
643
- // ../contracts/src/config-schema.ts
644
- import { z as z3 } from "zod";
672
+ // ../api-routes/src/auth.ts
673
+ import crypto2 from "crypto";
674
+ import { eq } from "drizzle-orm";
675
+
676
+ // ../db/src/client.ts
677
+ import { mkdirSync } from "fs";
678
+ import { dirname } from "path";
679
+ import Database from "better-sqlite3";
680
+ import { drizzle } from "drizzle-orm/better-sqlite3";
681
+
682
+ // ../db/src/schema.ts
683
+ var schema_exports = {};
684
+ __export(schema_exports, {
685
+ apiKeys: () => apiKeys,
686
+ auditLog: () => auditLog,
687
+ competitors: () => competitors,
688
+ googleConnections: () => googleConnections,
689
+ gscCoverageSnapshots: () => gscCoverageSnapshots,
690
+ gscSearchData: () => gscSearchData,
691
+ gscUrlInspections: () => gscUrlInspections,
692
+ keywords: () => keywords,
693
+ notifications: () => notifications,
694
+ projects: () => projects,
695
+ querySnapshots: () => querySnapshots,
696
+ runs: () => runs,
697
+ schedules: () => schedules,
698
+ usageCounters: () => usageCounters
699
+ });
700
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
701
+ var projects = sqliteTable("projects", {
702
+ id: text("id").primaryKey(),
703
+ name: text("name").notNull().unique(),
704
+ displayName: text("display_name").notNull(),
705
+ canonicalDomain: text("canonical_domain").notNull(),
706
+ ownedDomains: text("owned_domains").notNull().default("[]"),
707
+ country: text("country").notNull(),
708
+ language: text("language").notNull(),
709
+ tags: text("tags").notNull().default("[]"),
710
+ labels: text("labels").notNull().default("{}"),
711
+ providers: text("providers").notNull().default("[]"),
712
+ locations: text("locations").notNull().default("[]"),
713
+ defaultLocation: text("default_location"),
714
+ configSource: text("config_source").notNull().default("cli"),
715
+ configRevision: integer("config_revision").notNull().default(1),
716
+ createdAt: text("created_at").notNull(),
717
+ updatedAt: text("updated_at").notNull()
718
+ });
719
+ var keywords = sqliteTable("keywords", {
720
+ id: text("id").primaryKey(),
721
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
722
+ keyword: text("keyword").notNull(),
723
+ createdAt: text("created_at").notNull()
724
+ }, (table) => [
725
+ index("idx_keywords_project").on(table.projectId),
726
+ uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
727
+ ]);
728
+ var competitors = sqliteTable("competitors", {
729
+ id: text("id").primaryKey(),
730
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
731
+ domain: text("domain").notNull(),
732
+ createdAt: text("created_at").notNull()
733
+ }, (table) => [
734
+ index("idx_competitors_project").on(table.projectId),
735
+ uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
736
+ ]);
737
+ var runs = sqliteTable("runs", {
738
+ id: text("id").primaryKey(),
739
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
740
+ kind: text("kind").notNull().default("answer-visibility"),
741
+ status: text("status").notNull().default("queued"),
742
+ trigger: text("trigger").notNull().default("manual"),
743
+ location: text("location"),
744
+ startedAt: text("started_at"),
745
+ finishedAt: text("finished_at"),
746
+ error: text("error"),
747
+ createdAt: text("created_at").notNull()
748
+ }, (table) => [
749
+ index("idx_runs_project").on(table.projectId),
750
+ index("idx_runs_status").on(table.status)
751
+ ]);
752
+ var querySnapshots = sqliteTable("query_snapshots", {
753
+ id: text("id").primaryKey(),
754
+ runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
755
+ keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
756
+ provider: text("provider").notNull().default("gemini"),
757
+ model: text("model"),
758
+ citationState: text("citation_state").notNull(),
759
+ answerText: text("answer_text"),
760
+ citedDomains: text("cited_domains").notNull().default("[]"),
761
+ competitorOverlap: text("competitor_overlap").notNull().default("[]"),
762
+ location: text("location"),
763
+ screenshotPath: text("screenshot_path"),
764
+ rawResponse: text("raw_response"),
765
+ createdAt: text("created_at").notNull()
766
+ }, (table) => [
767
+ index("idx_snapshots_run").on(table.runId),
768
+ index("idx_snapshots_keyword").on(table.keywordId)
769
+ ]);
770
+ var auditLog = sqliteTable("audit_log", {
771
+ id: text("id").primaryKey(),
772
+ projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
773
+ actor: text("actor").notNull(),
774
+ action: text("action").notNull(),
775
+ entityType: text("entity_type").notNull(),
776
+ entityId: text("entity_id"),
777
+ diff: text("diff"),
778
+ createdAt: text("created_at").notNull()
779
+ }, (table) => [
780
+ index("idx_audit_log_project").on(table.projectId),
781
+ index("idx_audit_log_created").on(table.createdAt)
782
+ ]);
783
+ var apiKeys = sqliteTable("api_keys", {
784
+ id: text("id").primaryKey(),
785
+ name: text("name").notNull(),
786
+ keyHash: text("key_hash").notNull().unique(),
787
+ keyPrefix: text("key_prefix").notNull(),
788
+ scopes: text("scopes").notNull().default('["*"]'),
789
+ createdAt: text("created_at").notNull(),
790
+ lastUsedAt: text("last_used_at"),
791
+ revokedAt: text("revoked_at")
792
+ }, (table) => [
793
+ index("idx_api_keys_prefix").on(table.keyPrefix)
794
+ ]);
795
+ var schedules = sqliteTable("schedules", {
796
+ id: text("id").primaryKey(),
797
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
798
+ cronExpr: text("cron_expr").notNull(),
799
+ preset: text("preset"),
800
+ timezone: text("timezone").notNull().default("UTC"),
801
+ enabled: integer("enabled").notNull().default(1),
802
+ providers: text("providers").notNull().default("[]"),
803
+ lastRunAt: text("last_run_at"),
804
+ nextRunAt: text("next_run_at"),
805
+ createdAt: text("created_at").notNull(),
806
+ updatedAt: text("updated_at").notNull()
807
+ }, (table) => [
808
+ uniqueIndex("idx_schedules_project").on(table.projectId)
809
+ ]);
810
+ var notifications = sqliteTable("notifications", {
811
+ id: text("id").primaryKey(),
812
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
813
+ channel: text("channel").notNull(),
814
+ config: text("config").notNull(),
815
+ webhookSecret: text("webhook_secret"),
816
+ enabled: integer("enabled").notNull().default(1),
817
+ createdAt: text("created_at").notNull(),
818
+ updatedAt: text("updated_at").notNull()
819
+ }, (table) => [
820
+ index("idx_notifications_project").on(table.projectId)
821
+ ]);
822
+ var googleConnections = sqliteTable("google_connections", {
823
+ id: text("id").primaryKey(),
824
+ domain: text("domain").notNull(),
825
+ connectionType: text("connection_type").notNull(),
826
+ propertyId: text("property_id"),
827
+ sitemapUrl: text("sitemap_url"),
828
+ accessToken: text("access_token"),
829
+ refreshToken: text("refresh_token"),
830
+ tokenExpiresAt: text("token_expires_at"),
831
+ scopes: text("scopes").notNull().default("[]"),
832
+ createdAt: text("created_at").notNull(),
833
+ updatedAt: text("updated_at").notNull()
834
+ }, (table) => [
835
+ uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
836
+ ]);
837
+ var gscSearchData = sqliteTable("gsc_search_data", {
838
+ id: text("id").primaryKey(),
839
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
840
+ syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
841
+ date: text("date").notNull(),
842
+ query: text("query").notNull(),
843
+ page: text("page").notNull(),
844
+ country: text("country"),
845
+ device: text("device"),
846
+ clicks: integer("clicks").notNull().default(0),
847
+ impressions: integer("impressions").notNull().default(0),
848
+ ctr: text("ctr").notNull().default("0"),
849
+ position: text("position").notNull().default("0"),
850
+ createdAt: text("created_at").notNull()
851
+ }, (table) => [
852
+ index("idx_gsc_search_project_date").on(table.projectId, table.date),
853
+ index("idx_gsc_search_query").on(table.query),
854
+ index("idx_gsc_search_run").on(table.syncRunId)
855
+ ]);
856
+ var gscUrlInspections = sqliteTable("gsc_url_inspections", {
857
+ id: text("id").primaryKey(),
858
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
859
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
860
+ url: text("url").notNull(),
861
+ indexingState: text("indexing_state"),
862
+ verdict: text("verdict"),
863
+ coverageState: text("coverage_state"),
864
+ pageFetchState: text("page_fetch_state"),
865
+ robotsTxtState: text("robots_txt_state"),
866
+ crawlTime: text("crawl_time"),
867
+ lastCrawlResult: text("last_crawl_result"),
868
+ isMobileFriendly: integer("is_mobile_friendly"),
869
+ richResults: text("rich_results").notNull().default("[]"),
870
+ referringUrls: text("referring_urls").notNull().default("[]"),
871
+ inspectedAt: text("inspected_at").notNull(),
872
+ createdAt: text("created_at").notNull()
873
+ }, (table) => [
874
+ index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
875
+ index("idx_gsc_inspect_run").on(table.syncRunId),
876
+ index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
877
+ ]);
878
+ var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
879
+ id: text("id").primaryKey(),
880
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
881
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
882
+ date: text("date").notNull(),
883
+ indexed: integer("indexed").notNull().default(0),
884
+ notIndexed: integer("not_indexed").notNull().default(0),
885
+ reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
886
+ createdAt: text("created_at").notNull()
887
+ }, (table) => [
888
+ index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
889
+ index("idx_gsc_coverage_snap_run").on(table.syncRunId)
890
+ ]);
891
+ var usageCounters = sqliteTable("usage_counters", {
892
+ id: text("id").primaryKey(),
893
+ scope: text("scope").notNull(),
894
+ period: text("period").notNull(),
895
+ metric: text("metric").notNull(),
896
+ count: integer("count").notNull().default(0),
897
+ updatedAt: text("updated_at").notNull()
898
+ }, (table) => [
899
+ uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
900
+ index("idx_usage_scope_period").on(table.scope, table.period)
901
+ ]);
645
902
 
646
- // ../contracts/src/provider.ts
647
- import { z } from "zod";
648
- var providerQuotaPolicySchema = z.object({
649
- maxConcurrency: z.number().int().positive(),
650
- maxRequestsPerMinute: z.number().int().positive(),
651
- maxRequestsPerDay: z.number().int().positive()
652
- });
653
- var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
654
- var providerNameSchema = z.enum(PROVIDER_NAMES);
655
- var PROVIDER_MODE = {
656
- gemini: "api",
657
- openai: "api",
658
- claude: "api",
659
- local: "api",
660
- "cdp:chatgpt": "browser"
661
- };
662
- function isBrowserProvider(name) {
663
- return PROVIDER_MODE[name] === "browser";
664
- }
665
- var CDP_TARGETS = ["cdp:chatgpt"];
666
- function parseProviderName(input) {
667
- const lower = input.trim().toLowerCase();
668
- return PROVIDER_NAMES.includes(lower) ? lower : void 0;
669
- }
670
- function resolveProviderInput(input) {
671
- const lower = input.trim().toLowerCase();
672
- if (lower === "cdp") {
673
- return [...CDP_TARGETS];
674
- }
675
- const parsed = parseProviderName(lower);
676
- return parsed ? [parsed] : [];
903
+ // ../db/src/client.ts
904
+ function createClient(databasePath) {
905
+ mkdirSync(dirname(databasePath), { recursive: true });
906
+ const sqlite = new Database(databasePath);
907
+ sqlite.pragma("journal_mode = WAL");
908
+ sqlite.pragma("foreign_keys = ON");
909
+ return drizzle(sqlite, { schema: schema_exports });
677
910
  }
678
- var locationContextSchema = z.object({
679
- label: z.string().min(1),
680
- city: z.string().min(1),
681
- region: z.string().min(1),
682
- country: z.string().length(2),
683
- timezone: z.string().optional()
684
- });
685
911
 
686
- // ../contracts/src/notification.ts
687
- import { z as z2 } from "zod";
688
- var notificationEventSchema = z2.enum([
689
- "citation.lost",
690
- "citation.gained",
691
- "run.completed",
692
- "run.failed"
693
- ]);
694
- var notificationDtoSchema = z2.object({
695
- id: z2.string(),
696
- projectId: z2.string(),
697
- channel: z2.literal("webhook"),
698
- url: z2.string().url(),
699
- events: z2.array(notificationEventSchema),
700
- enabled: z2.boolean().default(true),
701
- webhookSecret: z2.string().optional(),
702
- createdAt: z2.string(),
703
- updatedAt: z2.string()
704
- });
912
+ // ../db/src/migrate.ts
913
+ import { sql } from "drizzle-orm";
914
+ var MIGRATION_SQL = `
915
+ CREATE TABLE IF NOT EXISTS projects (
916
+ id TEXT PRIMARY KEY,
917
+ name TEXT NOT NULL UNIQUE,
918
+ display_name TEXT NOT NULL,
919
+ canonical_domain TEXT NOT NULL,
920
+ owned_domains TEXT NOT NULL DEFAULT '[]',
921
+ country TEXT NOT NULL,
922
+ language TEXT NOT NULL,
923
+ tags TEXT NOT NULL DEFAULT '[]',
924
+ labels TEXT NOT NULL DEFAULT '{}',
925
+ providers TEXT NOT NULL DEFAULT '[]',
926
+ config_source TEXT NOT NULL DEFAULT 'cli',
927
+ config_revision INTEGER NOT NULL DEFAULT 1,
928
+ created_at TEXT NOT NULL,
929
+ updated_at TEXT NOT NULL
930
+ );
705
931
 
706
- // ../contracts/src/config-schema.ts
707
- var configMetadataSchema = z3.object({
708
- name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
709
- message: "Name must be a lowercase slug (letters, numbers, hyphens)"
710
- }),
711
- labels: z3.record(z3.string(), z3.string()).optional().default({})
712
- });
713
- var configScheduleSchema = z3.object({
714
- preset: z3.string().optional(),
715
- cron: z3.string().optional(),
716
- timezone: z3.string().optional().default("UTC"),
717
- providers: z3.array(providerNameSchema).optional().default([])
718
- }).refine(
719
- (data) => data.preset && !data.cron || !data.preset && data.cron,
720
- { message: 'Exactly one of "preset" or "cron" must be provided' }
721
- ).optional();
722
- var configNotificationSchema = z3.object({
723
- channel: z3.literal("webhook"),
724
- url: z3.string().url(),
725
- events: z3.array(notificationEventSchema).min(1)
726
- });
727
- var configGoogleSchema = z3.object({
728
- gsc: z3.object({
729
- propertyUrl: z3.string()
730
- }).optional(),
731
- syncSchedule: z3.object({
732
- preset: z3.string().optional(),
733
- cron: z3.string().optional()
734
- }).optional()
735
- }).optional();
736
- var configSpecSchema = z3.object({
737
- displayName: z3.string().min(1),
738
- canonicalDomain: z3.string().min(1),
739
- ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
740
- country: z3.string().length(2),
741
- language: z3.string().min(2),
742
- keywords: z3.array(z3.string().min(1)).optional().default([]),
743
- competitors: z3.array(z3.string().min(1)).optional().default([]),
744
- providers: z3.array(providerNameSchema).optional().default([]),
745
- locations: z3.array(locationContextSchema).optional().default([]),
746
- defaultLocation: z3.string().optional(),
747
- schedule: configScheduleSchema,
748
- notifications: z3.array(configNotificationSchema).optional().default([]),
749
- google: configGoogleSchema
750
- });
751
- var projectConfigSchema = z3.object({
752
- apiVersion: z3.literal("canonry/v1"),
753
- kind: z3.literal("Project"),
754
- metadata: configMetadataSchema,
755
- spec: configSpecSchema
756
- });
932
+ CREATE TABLE IF NOT EXISTS keywords (
933
+ id TEXT PRIMARY KEY,
934
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
935
+ keyword TEXT NOT NULL,
936
+ created_at TEXT NOT NULL,
937
+ UNIQUE(project_id, keyword)
938
+ );
757
939
 
758
- // ../contracts/src/models.ts
759
- var MODEL_REGISTRY = {
760
- gemini: {
761
- defaultModel: "gemini-3-flash",
762
- validationPattern: /^gemini-/,
763
- validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
764
- knownModels: [
765
- { id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
766
- { id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
767
- { id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
768
- { id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
769
- ]
770
- },
771
- openai: {
772
- defaultModel: "gpt-5.4",
773
- validationPattern: /^(gpt-|o\d)/,
774
- validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
775
- knownModels: [
776
- { id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
777
- { id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
778
- { id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
779
- { id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
780
- { id: "gpt-5", displayName: "GPT-5", tier: "standard" },
781
- { id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
782
- ]
783
- },
784
- claude: {
785
- defaultModel: "claude-sonnet-4-6",
786
- validationPattern: /^claude-/,
787
- validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
788
- knownModels: [
789
- { id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
790
- { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
791
- { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
792
- ]
793
- },
794
- local: {
795
- defaultModel: "llama3",
796
- validationPattern: /./,
797
- validationHint: "any model name accepted",
798
- knownModels: [
799
- { id: "llama3", displayName: "Llama 3", tier: "standard" }
800
- ]
801
- },
802
- "cdp:chatgpt": {
803
- defaultModel: "chatgpt-web",
804
- validationPattern: /./,
805
- validationHint: "model is detected from the ChatGPT web UI",
806
- knownModels: [
807
- { id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
808
- ]
809
- }
810
- };
811
- function getDefaultModel(provider) {
812
- return MODEL_REGISTRY[provider].defaultModel;
813
- }
814
- function isValidModelName(provider, model) {
815
- return MODEL_REGISTRY[provider].validationPattern.test(model);
816
- }
940
+ CREATE TABLE IF NOT EXISTS competitors (
941
+ id TEXT PRIMARY KEY,
942
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
943
+ domain TEXT NOT NULL,
944
+ created_at TEXT NOT NULL,
945
+ UNIQUE(project_id, domain)
946
+ );
947
+
948
+ CREATE TABLE IF NOT EXISTS runs (
949
+ id TEXT PRIMARY KEY,
950
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
951
+ kind TEXT NOT NULL DEFAULT 'answer-visibility',
952
+ status TEXT NOT NULL DEFAULT 'queued',
953
+ trigger TEXT NOT NULL DEFAULT 'manual',
954
+ started_at TEXT,
955
+ finished_at TEXT,
956
+ error TEXT,
957
+ created_at TEXT NOT NULL
958
+ );
817
959
 
818
- // ../contracts/src/errors.ts
819
- var AppError = class extends Error {
820
- code;
821
- statusCode;
822
- details;
823
- constructor(code, message, statusCode, details) {
824
- super(message);
825
- this.name = "AppError";
826
- this.code = code;
827
- this.statusCode = statusCode;
828
- this.details = details;
829
- }
830
- toJSON() {
831
- return {
832
- error: {
833
- code: this.code,
834
- message: this.message,
835
- ...this.details ? { details: this.details } : {}
836
- }
837
- };
838
- }
839
- };
840
- function notFound(entity, id) {
841
- return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
842
- }
843
- function validationError(message, details) {
844
- return new AppError("VALIDATION_ERROR", message, 400, details);
845
- }
846
- function authRequired() {
847
- return new AppError("AUTH_REQUIRED", "Authentication required", 401);
848
- }
849
- function authInvalid() {
850
- return new AppError("AUTH_INVALID", "Invalid API key", 401);
851
- }
852
- function runInProgress(projectName) {
853
- return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
854
- }
855
- function unsupportedKind(kind) {
856
- return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
857
- }
960
+ CREATE TABLE IF NOT EXISTS query_snapshots (
961
+ id TEXT PRIMARY KEY,
962
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
963
+ keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
964
+ provider TEXT NOT NULL DEFAULT 'gemini',
965
+ citation_state TEXT NOT NULL,
966
+ answer_text TEXT,
967
+ cited_domains TEXT NOT NULL DEFAULT '[]',
968
+ competitor_overlap TEXT NOT NULL DEFAULT '[]',
969
+ raw_response TEXT,
970
+ created_at TEXT NOT NULL
971
+ );
858
972
 
859
- // ../contracts/src/google.ts
860
- import { z as z4 } from "zod";
861
- var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
862
- var googleConnectionDtoSchema = z4.object({
863
- id: z4.string(),
864
- domain: z4.string(),
865
- connectionType: googleConnectionTypeSchema,
866
- propertyId: z4.string().nullable().optional(),
867
- sitemapUrl: z4.string().nullable().optional(),
868
- scopes: z4.array(z4.string()).default([]),
869
- createdAt: z4.string(),
870
- updatedAt: z4.string()
871
- });
872
- var gscSearchDataDtoSchema = z4.object({
873
- date: z4.string(),
874
- query: z4.string(),
875
- page: z4.string(),
876
- country: z4.string().nullable().optional(),
877
- device: z4.string().nullable().optional(),
878
- clicks: z4.number(),
879
- impressions: z4.number(),
880
- ctr: z4.number(),
881
- position: z4.number()
882
- });
883
- var gscUrlInspectionDtoSchema = z4.object({
884
- id: z4.string(),
885
- url: z4.string(),
886
- indexingState: z4.string().nullable().optional(),
887
- verdict: z4.string().nullable().optional(),
888
- coverageState: z4.string().nullable().optional(),
889
- pageFetchState: z4.string().nullable().optional(),
890
- robotsTxtState: z4.string().nullable().optional(),
891
- crawlTime: z4.string().nullable().optional(),
892
- lastCrawlResult: z4.string().nullable().optional(),
893
- isMobileFriendly: z4.boolean().nullable().optional(),
894
- richResults: z4.array(z4.string()).default([]),
895
- inspectedAt: z4.string()
896
- });
897
- var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
898
- var gscDeindexedRowSchema = z4.object({
899
- url: z4.string(),
900
- previousState: z4.string().nullable(),
901
- currentState: z4.string().nullable(),
902
- transitionDate: z4.string()
903
- });
904
- var gscReasonGroupSchema = z4.object({
905
- reason: z4.string(),
906
- count: z4.number(),
907
- urls: z4.array(gscUrlInspectionDtoSchema).default([])
908
- });
909
- var gscCoverageSummaryDtoSchema = z4.object({
910
- summary: z4.object({
911
- total: z4.number(),
912
- indexed: z4.number(),
913
- notIndexed: z4.number(),
914
- deindexed: z4.number(),
915
- percentage: z4.number()
916
- }),
917
- lastInspectedAt: z4.string().nullable(),
918
- indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
919
- notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
920
- deindexed: z4.array(gscDeindexedRowSchema).default([]),
921
- reasonGroups: z4.array(gscReasonGroupSchema).default([])
922
- });
923
- var indexingNotificationDtoSchema = z4.object({
924
- url: z4.string(),
925
- type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
926
- notifiedAt: z4.string()
927
- });
928
- var indexingRequestResultDtoSchema = z4.object({
929
- url: z4.string(),
930
- type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
931
- notifiedAt: z4.string(),
932
- status: z4.enum(["success", "error"]),
933
- error: z4.string().optional()
934
- });
935
- var gscCoverageSnapshotDtoSchema = z4.object({
936
- date: z4.string(),
937
- indexed: z4.number(),
938
- notIndexed: z4.number(),
939
- reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
940
- });
973
+ CREATE TABLE IF NOT EXISTS audit_log (
974
+ id TEXT PRIMARY KEY,
975
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
976
+ actor TEXT NOT NULL,
977
+ action TEXT NOT NULL,
978
+ entity_type TEXT NOT NULL,
979
+ entity_id TEXT,
980
+ diff TEXT,
981
+ created_at TEXT NOT NULL
982
+ );
941
983
 
942
- // ../contracts/src/project.ts
943
- import { z as z5 } from "zod";
944
- var configSourceSchema = z5.enum(["cli", "api", "config-file"]);
945
- var projectDtoSchema = z5.object({
946
- id: z5.string(),
947
- name: z5.string(),
948
- displayName: z5.string().optional(),
949
- canonicalDomain: z5.string(),
950
- ownedDomains: z5.array(z5.string()).default([]),
951
- country: z5.string().length(2),
952
- language: z5.string().min(2),
953
- tags: z5.array(z5.string()).default([]),
954
- labels: z5.record(z5.string(), z5.string()).default({}),
955
- locations: z5.array(locationContextSchema).default([]),
956
- defaultLocation: z5.string().nullable().optional(),
957
- configSource: configSourceSchema.default("cli"),
958
- configRevision: z5.number().int().positive().default(1),
959
- createdAt: z5.string().optional(),
960
- updatedAt: z5.string().optional()
961
- });
962
- function normalizeProjectDomain(input) {
963
- let domain = input.trim().toLowerCase();
964
- try {
965
- if (domain.includes("://")) {
966
- domain = new URL(domain).hostname.toLowerCase();
967
- }
968
- } catch {
969
- }
970
- return domain.replace(/^www\./, "");
971
- }
972
- function effectiveDomains(project) {
973
- const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
974
- const seen = /* @__PURE__ */ new Set();
975
- const result = [];
976
- for (const d of all) {
977
- const trimmed = d.trim();
978
- if (!trimmed) continue;
979
- const norm = normalizeProjectDomain(trimmed);
980
- if (seen.has(norm)) continue;
981
- seen.add(norm);
982
- result.push(trimmed);
983
- }
984
- return result;
985
- }
984
+ CREATE TABLE IF NOT EXISTS api_keys (
985
+ id TEXT PRIMARY KEY,
986
+ name TEXT NOT NULL,
987
+ key_hash TEXT NOT NULL UNIQUE,
988
+ key_prefix TEXT NOT NULL,
989
+ scopes TEXT NOT NULL DEFAULT '["*"]',
990
+ created_at TEXT NOT NULL,
991
+ last_used_at TEXT,
992
+ revoked_at TEXT
993
+ );
986
994
 
987
- // ../contracts/src/run.ts
988
- import { z as z6 } from "zod";
989
- var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
990
- var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
991
- var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
992
- var citationStateSchema = z6.enum(["cited", "not-cited"]);
993
- var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
994
- var runDtoSchema = z6.object({
995
- id: z6.string(),
996
- projectId: z6.string(),
997
- kind: runKindSchema,
998
- status: runStatusSchema,
999
- trigger: runTriggerSchema.default("manual"),
1000
- location: z6.string().nullable().optional(),
1001
- startedAt: z6.string().nullable().optional(),
1002
- finishedAt: z6.string().nullable().optional(),
1003
- error: z6.string().nullable().optional(),
1004
- createdAt: z6.string()
1005
- });
1006
- var groundingSourceSchema = z6.object({
1007
- uri: z6.string(),
1008
- title: z6.string()
1009
- });
1010
- var querySnapshotDtoSchema = z6.object({
1011
- id: z6.string(),
1012
- runId: z6.string(),
1013
- keywordId: z6.string(),
1014
- keyword: z6.string().optional(),
1015
- provider: providerNameSchema,
1016
- citationState: citationStateSchema,
1017
- transition: computedTransitionSchema.optional(),
1018
- answerText: z6.string().nullable().optional(),
1019
- citedDomains: z6.array(z6.string()).default([]),
1020
- competitorOverlap: z6.array(z6.string()).default([]),
1021
- groundingSources: z6.array(groundingSourceSchema).default([]),
1022
- searchQueries: z6.array(z6.string()).default([]),
1023
- model: z6.string().nullable().optional(),
1024
- location: z6.string().nullable().optional(),
1025
- createdAt: z6.string()
1026
- });
1027
- var auditLogEntrySchema = z6.object({
1028
- id: z6.string(),
1029
- projectId: z6.string().nullable().optional(),
1030
- actor: z6.string(),
1031
- action: z6.string(),
1032
- entityType: z6.string(),
1033
- entityId: z6.string().nullable().optional(),
1034
- diff: z6.unknown().optional(),
1035
- createdAt: z6.string()
1036
- });
995
+ CREATE TABLE IF NOT EXISTS usage_counters (
996
+ id TEXT PRIMARY KEY,
997
+ scope TEXT NOT NULL,
998
+ period TEXT NOT NULL,
999
+ metric TEXT NOT NULL,
1000
+ count INTEGER NOT NULL DEFAULT 0,
1001
+ updated_at TEXT NOT NULL,
1002
+ UNIQUE(scope, period, metric)
1003
+ );
1037
1004
 
1038
- // ../contracts/src/schedule.ts
1039
- import { z as z7 } from "zod";
1040
- var scheduleDtoSchema = z7.object({
1041
- id: z7.string(),
1042
- projectId: z7.string(),
1043
- cronExpr: z7.string(),
1044
- preset: z7.string().nullable().optional(),
1045
- timezone: z7.string().default("UTC"),
1046
- enabled: z7.boolean().default(true),
1047
- providers: z7.array(providerNameSchema).default([]),
1048
- lastRunAt: z7.string().nullable().optional(),
1049
- nextRunAt: z7.string().nullable().optional(),
1050
- createdAt: z7.string(),
1051
- updatedAt: z7.string()
1052
- });
1005
+ CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
1006
+ CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
1007
+ CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
1008
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
1009
+ CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
1010
+ CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
1011
+ CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
1012
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
1013
+ CREATE TABLE IF NOT EXISTS schedules (
1014
+ id TEXT PRIMARY KEY,
1015
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1016
+ cron_expr TEXT NOT NULL,
1017
+ preset TEXT,
1018
+ timezone TEXT NOT NULL DEFAULT 'UTC',
1019
+ enabled INTEGER NOT NULL DEFAULT 1,
1020
+ providers TEXT NOT NULL DEFAULT '[]',
1021
+ last_run_at TEXT,
1022
+ next_run_at TEXT,
1023
+ created_at TEXT NOT NULL,
1024
+ updated_at TEXT NOT NULL,
1025
+ UNIQUE(project_id)
1026
+ );
1053
1027
 
1054
- // ../contracts/src/source-categories.ts
1055
- var SOURCE_CATEGORY_RULES = [
1056
- // Forums
1057
- { pattern: "reddit.com", category: "forum", label: "Reddit" },
1058
- { pattern: "quora.com", category: "forum", label: "Quora" },
1059
- { pattern: "stackexchange.com", category: "forum", label: "Stack Exchange" },
1060
- { pattern: "stackoverflow.com", category: "forum", label: "Stack Overflow" },
1061
- { pattern: "discourse.org", category: "forum", label: "Discourse" },
1062
- // Social
1063
- { pattern: "linkedin.com", category: "social", label: "LinkedIn" },
1064
- { pattern: "twitter.com", category: "social", label: "X (Twitter)" },
1065
- { pattern: "x.com", category: "social", label: "X (Twitter)" },
1066
- { pattern: "facebook.com", category: "social", label: "Facebook" },
1067
- { pattern: "instagram.com", category: "social", label: "Instagram" },
1068
- { pattern: "threads.net", category: "social", label: "Threads" },
1069
- { pattern: "pinterest.com", category: "social", label: "Pinterest" },
1070
- { pattern: "tiktok.com", category: "social", label: "TikTok" },
1071
- // Video
1072
- { pattern: "youtube.com", category: "video", label: "YouTube" },
1073
- { pattern: "youtu.be", category: "video", label: "YouTube" },
1074
- { pattern: "vimeo.com", category: "video", label: "Vimeo" },
1075
- // News
1076
- { pattern: "nytimes.com", category: "news", label: "NY Times" },
1077
- { pattern: "bbc.com", category: "news", label: "BBC" },
1078
- { pattern: "bbc.co.uk", category: "news", label: "BBC" },
1079
- { pattern: "cnn.com", category: "news", label: "CNN" },
1080
- { pattern: "reuters.com", category: "news", label: "Reuters" },
1081
- { pattern: "apnews.com", category: "news", label: "AP News" },
1082
- { pattern: "theguardian.com", category: "news", label: "The Guardian" },
1083
- { pattern: "washingtonpost.com", category: "news", label: "Washington Post" },
1084
- { pattern: "wsj.com", category: "news", label: "WSJ" },
1085
- { pattern: "forbes.com", category: "news", label: "Forbes" },
1086
- { pattern: "techcrunch.com", category: "news", label: "TechCrunch" },
1087
- { pattern: "theverge.com", category: "news", label: "The Verge" },
1088
- { pattern: "wired.com", category: "news", label: "Wired" },
1089
- { pattern: "arstechnica.com", category: "news", label: "Ars Technica" },
1090
- // Reference
1091
- { pattern: "wikipedia.org", category: "reference", label: "Wikipedia" },
1092
- { pattern: "wikimedia.org", category: "reference", label: "Wikimedia" },
1093
- { pattern: "britannica.com", category: "reference", label: "Britannica" },
1094
- { pattern: "merriam-webster.com", category: "reference", label: "Merriam-Webster" },
1095
- // Blog / Content platforms
1096
- { pattern: "medium.com", category: "blog", label: "Medium" },
1097
- { pattern: "substack.com", category: "blog", label: "Substack" },
1098
- { pattern: "dev.to", category: "blog", label: "DEV Community" },
1099
- { pattern: "hashnode.dev", category: "blog", label: "Hashnode" },
1100
- { pattern: "wordpress.com", category: "blog", label: "WordPress" },
1101
- { pattern: "blogger.com", category: "blog", label: "Blogger" },
1102
- { pattern: "hubspot.com", category: "blog", label: "HubSpot" },
1103
- // E-commerce
1104
- { pattern: "amazon.com", category: "ecommerce", label: "Amazon" },
1105
- { pattern: "amazon.co.uk", category: "ecommerce", label: "Amazon UK" },
1106
- { pattern: "shopify.com", category: "ecommerce", label: "Shopify" },
1107
- { pattern: "ebay.com", category: "ecommerce", label: "eBay" },
1108
- // Academic
1109
- { pattern: "scholar.google.com", category: "academic", label: "Google Scholar" },
1110
- { pattern: "arxiv.org", category: "academic", label: "arXiv" },
1111
- { pattern: "pubmed.ncbi.nlm.nih.gov", category: "academic", label: "PubMed" },
1112
- { pattern: "researchgate.net", category: "academic", label: "ResearchGate" },
1113
- { pattern: ".edu", category: "academic", label: "Academic (.edu)" }
1028
+ CREATE TABLE IF NOT EXISTS notifications (
1029
+ id TEXT PRIMARY KEY,
1030
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1031
+ channel TEXT NOT NULL,
1032
+ config TEXT NOT NULL,
1033
+ enabled INTEGER NOT NULL DEFAULT 1,
1034
+ created_at TEXT NOT NULL,
1035
+ updated_at TEXT NOT NULL
1036
+ );
1037
+
1038
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
1039
+ CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
1040
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
1041
+ CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
1042
+ `;
1043
+ var MIGRATIONS = [
1044
+ // v2: Add providers column to projects for multi-provider support
1045
+ `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
1046
+ // v3: Add webhook_secret column to notifications for HMAC signing
1047
+ `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
1048
+ // v4: Add owned_domains column to projects for multi-domain citation matching
1049
+ `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
1050
+ // v5: Add model column to query_snapshots for per-model scoring
1051
+ `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
1052
+ // v5b: Backfill model from rawResponse JSON for existing snapshots
1053
+ `UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`,
1054
+ // v6: Google Search Console integration google_connections table (domain-scoped)
1055
+ `CREATE TABLE IF NOT EXISTS google_connections (
1056
+ id TEXT PRIMARY KEY,
1057
+ domain TEXT NOT NULL,
1058
+ connection_type TEXT NOT NULL,
1059
+ property_id TEXT,
1060
+ access_token TEXT,
1061
+ refresh_token TEXT,
1062
+ token_expires_at TEXT,
1063
+ scopes TEXT NOT NULL DEFAULT '[]',
1064
+ created_at TEXT NOT NULL,
1065
+ updated_at TEXT NOT NULL
1066
+ )`,
1067
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
1068
+ // v6: Google Search Console integration gsc_search_data table
1069
+ `CREATE TABLE IF NOT EXISTS gsc_search_data (
1070
+ id TEXT PRIMARY KEY,
1071
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1072
+ sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1073
+ date TEXT NOT NULL,
1074
+ query TEXT NOT NULL,
1075
+ page TEXT NOT NULL,
1076
+ country TEXT,
1077
+ device TEXT,
1078
+ clicks INTEGER NOT NULL DEFAULT 0,
1079
+ impressions INTEGER NOT NULL DEFAULT 0,
1080
+ ctr TEXT NOT NULL DEFAULT '0',
1081
+ position TEXT NOT NULL DEFAULT '0',
1082
+ created_at TEXT NOT NULL
1083
+ )`,
1084
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
1085
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
1086
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
1087
+ // v6: Google Search Console integration gsc_url_inspections table
1088
+ `CREATE TABLE IF NOT EXISTS gsc_url_inspections (
1089
+ id TEXT PRIMARY KEY,
1090
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1091
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
1092
+ url TEXT NOT NULL,
1093
+ indexing_state TEXT,
1094
+ verdict TEXT,
1095
+ coverage_state TEXT,
1096
+ page_fetch_state TEXT,
1097
+ robots_txt_state TEXT,
1098
+ crawl_time TEXT,
1099
+ last_crawl_result TEXT,
1100
+ is_mobile_friendly INTEGER,
1101
+ rich_results TEXT NOT NULL DEFAULT '[]',
1102
+ referring_urls TEXT NOT NULL DEFAULT '[]',
1103
+ inspected_at TEXT NOT NULL,
1104
+ created_at TEXT NOT NULL
1105
+ )`,
1106
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
1107
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
1108
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
1109
+ // v7: GSC coverage snapshots for historical tracking
1110
+ `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
1111
+ id TEXT PRIMARY KEY,
1112
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1113
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
1114
+ date TEXT NOT NULL,
1115
+ indexed INTEGER NOT NULL DEFAULT 0,
1116
+ not_indexed INTEGER NOT NULL DEFAULT 0,
1117
+ reason_breakdown TEXT NOT NULL DEFAULT '{}',
1118
+ created_at TEXT NOT NULL
1119
+ )`,
1120
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
1121
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
1122
+ // v8: Location-aware sweeps — project locations + snapshot location tag
1123
+ `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
1124
+ `ALTER TABLE projects ADD COLUMN default_location TEXT`,
1125
+ `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
1126
+ // v9: Add location column to runs for per-location run tracking
1127
+ `ALTER TABLE runs ADD COLUMN location TEXT`,
1128
+ // v10: Add sitemapUrl to google_connections for persistent sitemap storage
1129
+ `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
1130
+ // v11: CDP browser provider — screenshot path for captured evidence
1131
+ `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
1114
1132
  ];
1115
- var CATEGORY_LABELS = {
1116
- social: "Social Media",
1117
- forum: "Forums & Q&A",
1118
- news: "News & Media",
1119
- reference: "Reference",
1120
- blog: "Blogs & Content",
1121
- ecommerce: "E-commerce",
1122
- video: "Video",
1123
- academic: "Academic",
1124
- other: "Other"
1125
- };
1126
- function categorizeSource(uri) {
1127
- let domain;
1128
- try {
1129
- const url = new URL(uri.startsWith("http") ? uri : `https://${uri}`);
1130
- domain = url.hostname.replace(/^www\./, "");
1131
- } catch {
1132
- domain = uri.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0] ?? uri;
1133
+ function migrate(db) {
1134
+ const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
1135
+ for (const statement of statements) {
1136
+ db.run(sql.raw(statement));
1133
1137
  }
1134
- const domainLower = domain.toLowerCase();
1135
- for (const rule of SOURCE_CATEGORY_RULES) {
1136
- if (domainLower === rule.pattern || domainLower.endsWith(`.${rule.pattern}`) || rule.pattern.startsWith(".") && domainLower.endsWith(rule.pattern)) {
1137
- return { category: rule.category, label: rule.label, domain };
1138
+ for (const migration of MIGRATIONS) {
1139
+ try {
1140
+ db.run(sql.raw(migration));
1141
+ } catch {
1138
1142
  }
1139
1143
  }
1140
- return { category: "other", label: CATEGORY_LABELS.other, domain };
1141
- }
1142
- function categoryLabel(category) {
1143
- return CATEGORY_LABELS[category];
1144
1144
  }
1145
1145
 
1146
1146
  // ../api-routes/src/auth.ts
@@ -5067,6 +5067,27 @@ async function cdpRoutes(app, opts) {
5067
5067
  // ../api-routes/src/index.ts
5068
5068
  async function apiRoutes(app, opts) {
5069
5069
  app.decorate("db", opts.db);
5070
+ app.setErrorHandler((error, _request, reply) => {
5071
+ if (error instanceof AppError) {
5072
+ return reply.status(error.statusCode).send(error.toJSON());
5073
+ }
5074
+ const httpStatus = error.statusCode ?? error.status ?? 500;
5075
+ if (httpStatus >= 400 && httpStatus < 500) {
5076
+ return reply.status(httpStatus).send({
5077
+ error: {
5078
+ code: httpStatus === 401 ? "AUTH_INVALID" : httpStatus === 403 ? "FORBIDDEN" : httpStatus === 404 ? "NOT_FOUND" : httpStatus === 429 ? "QUOTA_EXCEEDED" : "VALIDATION_ERROR",
5079
+ message: error.message
5080
+ }
5081
+ });
5082
+ }
5083
+ app.log.error(error);
5084
+ return reply.status(500).send({
5085
+ error: {
5086
+ code: "INTERNAL_ERROR",
5087
+ message: "An unexpected error occurred"
5088
+ }
5089
+ });
5090
+ });
5070
5091
  if (!opts.skipAuth) {
5071
5092
  await app.register(authPlugin);
5072
5093
  }
@@ -5118,7 +5139,7 @@ async function apiRoutes(app, opts) {
5118
5139
  onCdpScreenshot: opts.onCdpScreenshot,
5119
5140
  onCdpConfigure: opts.onCdpConfigure
5120
5141
  });
5121
- }, { prefix: "/api/v1" });
5142
+ }, { prefix: opts.routePrefix ?? "/api/v1" });
5122
5143
  }
5123
5144
 
5124
5145
  // ../provider-gemini/src/normalize.ts
@@ -7330,7 +7351,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
7330
7351
  });
7331
7352
  saveConfig(opts.config);
7332
7353
  }
7333
- const sitemapUrl = opts.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
7354
+ const sitemapUrl = opts.sitemapUrl || conn.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
7334
7355
  console.log(`[Inspect Sitemap] Fetching sitemap from ${sitemapUrl}`);
7335
7356
  const urls = await fetchAndParseSitemap(sitemapUrl);
7336
7357
  console.log(`[Inspect Sitemap] Found ${urls.length} URLs in sitemap`);
@@ -7980,8 +8001,13 @@ async function createServer(opts) {
7980
8001
  return removed;
7981
8002
  }
7982
8003
  };
8004
+ const rawBasePath = process.env.CANONRY_BASE_PATH ?? opts.config.basePath;
8005
+ const normalizedBasePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
8006
+ const basePath = normalizedBasePath === "/" ? void 0 : normalizedBasePath;
8007
+ const apiPrefix = basePath ? `${basePath}api/v1` : "/api/v1";
7983
8008
  await app.register(apiRoutes, {
7984
8009
  db: opts.db,
8010
+ routePrefix: apiPrefix,
7985
8011
  skipAuth: false,
7986
8012
  getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
7987
8013
  googleConnectionStore,
@@ -8194,8 +8220,6 @@ async function createServer(opts) {
8194
8220
  const assetsDir = path6.join(dirname2, "..", "assets");
8195
8221
  if (fs5.existsSync(assetsDir)) {
8196
8222
  const indexPath = path6.join(assetsDir, "index.html");
8197
- const rawBasePath = process.env.CANONRY_BASE_PATH ?? opts.config.basePath;
8198
- const basePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
8199
8223
  const injectConfig = (html) => {
8200
8224
  const clientConfig = { apiKey: opts.config.apiKey };
8201
8225
  if (basePath) clientConfig.basePath = basePath;
@@ -8206,21 +8230,32 @@ async function createServer(opts) {
8206
8230
  const fastifyStatic = await import("@fastify/static");
8207
8231
  await app.register(fastifyStatic.default, {
8208
8232
  root: assetsDir,
8209
- prefix: "/",
8233
+ prefix: basePath ?? "/",
8210
8234
  wildcard: false,
8211
8235
  // Don't serve index.html automatically — we handle it with config injection
8212
8236
  serve: true,
8213
8237
  index: false
8214
8238
  });
8215
- app.get("/", (_request, reply) => {
8239
+ const serveIndex = (_request, reply) => {
8216
8240
  if (fs5.existsSync(indexPath)) {
8217
8241
  const html = fs5.readFileSync(indexPath, "utf-8");
8218
8242
  return reply.type("text/html").send(injectConfig(html));
8219
8243
  }
8220
8244
  return reply.status(404).send({ error: "Dashboard not built" });
8221
- });
8245
+ };
8246
+ const rootRouteTrailing = basePath ?? "/";
8247
+ app.get(rootRouteTrailing, serveIndex);
8248
+ if (basePath) {
8249
+ const rootRouteBare = basePath.replace(/\/$/, "");
8250
+ if (rootRouteBare) app.get(rootRouteBare, serveIndex);
8251
+ }
8222
8252
  app.setNotFoundHandler((request, reply) => {
8223
- if (request.url.startsWith("/api/")) {
8253
+ const url = request.url.split("?")[0];
8254
+ const isApiRoute = url.startsWith("/api/") || basePath !== void 0 && url.startsWith(`${basePath}api/`);
8255
+ if (isApiRoute) {
8256
+ return reply.status(404).send({ error: "Not found", path: request.url });
8257
+ }
8258
+ if (basePath && !url.startsWith(basePath)) {
8224
8259
  return reply.status(404).send({ error: "Not found", path: request.url });
8225
8260
  }
8226
8261
  if (fs5.existsSync(indexPath)) {
@@ -8230,11 +8265,11 @@ async function createServer(opts) {
8230
8265
  return reply.status(404).send({ error: "Not found" });
8231
8266
  });
8232
8267
  }
8233
- app.get("/health", async () => ({
8234
- status: "ok",
8235
- service: "canonry",
8236
- version: PKG_VERSION
8237
- }));
8268
+ const healthHandler = async () => ({ status: "ok", service: "canonry", version: PKG_VERSION });
8269
+ app.get("/health", healthHandler);
8270
+ if (basePath) {
8271
+ app.get(`${basePath}health`, healthHandler);
8272
+ }
8238
8273
  scheduler.start();
8239
8274
  app.addHook("onClose", async () => {
8240
8275
  scheduler.stop();