@ainyc/canonry 1.0.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.
@@ -0,0 +1,3706 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/config.ts
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import os from "os";
11
+ import { parse, stringify } from "yaml";
12
+ function getConfigDir() {
13
+ return path.join(os.homedir(), ".canonry");
14
+ }
15
+ function getConfigPath() {
16
+ return path.join(getConfigDir(), "config.yaml");
17
+ }
18
+ function loadConfig() {
19
+ const configPath = getConfigPath();
20
+ if (!fs.existsSync(configPath)) {
21
+ throw new Error(
22
+ `Config not found at ${configPath}. Run "canonry init" to create one.`
23
+ );
24
+ }
25
+ const raw = fs.readFileSync(configPath, "utf-8");
26
+ const parsed = parse(raw);
27
+ if (!parsed.apiUrl || !parsed.database || !parsed.apiKey) {
28
+ throw new Error(
29
+ `Invalid config at ${configPath}. Required fields: apiUrl, database, apiKey`
30
+ );
31
+ }
32
+ if (parsed.geminiApiKey && !parsed.providers?.gemini) {
33
+ parsed.providers = {
34
+ ...parsed.providers,
35
+ gemini: {
36
+ apiKey: parsed.geminiApiKey,
37
+ model: parsed.geminiModel,
38
+ quota: parsed.geminiQuota
39
+ }
40
+ };
41
+ }
42
+ const hasProvider = parsed.providers && (parsed.providers.gemini?.apiKey || parsed.providers.openai?.apiKey || parsed.providers.claude?.apiKey || parsed.providers.local?.baseUrl);
43
+ if (!hasProvider) {
44
+ throw new Error(
45
+ `No providers configured at ${configPath}. At least one provider is required.`
46
+ );
47
+ }
48
+ return parsed;
49
+ }
50
+ function saveConfig(config) {
51
+ const configDir = getConfigDir();
52
+ if (!fs.existsSync(configDir)) {
53
+ fs.mkdirSync(configDir, { recursive: true });
54
+ }
55
+ const yaml = stringify(config);
56
+ fs.writeFileSync(getConfigPath(), yaml, { encoding: "utf-8", mode: 384 });
57
+ }
58
+ function configExists() {
59
+ return fs.existsSync(getConfigPath());
60
+ }
61
+
62
+ // src/server.ts
63
+ import fs2 from "fs";
64
+ import path2 from "path";
65
+ import { fileURLToPath } from "url";
66
+ import Fastify from "fastify";
67
+
68
+ // ../api-routes/src/auth.ts
69
+ import crypto from "crypto";
70
+ import { eq } from "drizzle-orm";
71
+
72
+ // ../db/src/client.ts
73
+ import { mkdirSync } from "fs";
74
+ import { dirname } from "path";
75
+ import Database from "better-sqlite3";
76
+ import { drizzle } from "drizzle-orm/better-sqlite3";
77
+
78
+ // ../db/src/schema.ts
79
+ var schema_exports = {};
80
+ __export(schema_exports, {
81
+ apiKeys: () => apiKeys,
82
+ auditLog: () => auditLog,
83
+ competitors: () => competitors,
84
+ keywords: () => keywords,
85
+ notifications: () => notifications,
86
+ projects: () => projects,
87
+ querySnapshots: () => querySnapshots,
88
+ runs: () => runs,
89
+ schedules: () => schedules,
90
+ usageCounters: () => usageCounters
91
+ });
92
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
93
+ var projects = sqliteTable("projects", {
94
+ id: text("id").primaryKey(),
95
+ name: text("name").notNull().unique(),
96
+ displayName: text("display_name").notNull(),
97
+ canonicalDomain: text("canonical_domain").notNull(),
98
+ country: text("country").notNull(),
99
+ language: text("language").notNull(),
100
+ tags: text("tags").notNull().default("[]"),
101
+ labels: text("labels").notNull().default("{}"),
102
+ providers: text("providers").notNull().default("[]"),
103
+ configSource: text("config_source").notNull().default("cli"),
104
+ configRevision: integer("config_revision").notNull().default(1),
105
+ createdAt: text("created_at").notNull(),
106
+ updatedAt: text("updated_at").notNull()
107
+ });
108
+ var keywords = sqliteTable("keywords", {
109
+ id: text("id").primaryKey(),
110
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
111
+ keyword: text("keyword").notNull(),
112
+ createdAt: text("created_at").notNull()
113
+ }, (table) => [
114
+ index("idx_keywords_project").on(table.projectId),
115
+ uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
116
+ ]);
117
+ var competitors = sqliteTable("competitors", {
118
+ id: text("id").primaryKey(),
119
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
120
+ domain: text("domain").notNull(),
121
+ createdAt: text("created_at").notNull()
122
+ }, (table) => [
123
+ index("idx_competitors_project").on(table.projectId),
124
+ uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
125
+ ]);
126
+ var runs = sqliteTable("runs", {
127
+ id: text("id").primaryKey(),
128
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
129
+ kind: text("kind").notNull().default("answer-visibility"),
130
+ status: text("status").notNull().default("queued"),
131
+ trigger: text("trigger").notNull().default("manual"),
132
+ startedAt: text("started_at"),
133
+ finishedAt: text("finished_at"),
134
+ error: text("error"),
135
+ createdAt: text("created_at").notNull()
136
+ }, (table) => [
137
+ index("idx_runs_project").on(table.projectId),
138
+ index("idx_runs_status").on(table.status)
139
+ ]);
140
+ var querySnapshots = sqliteTable("query_snapshots", {
141
+ id: text("id").primaryKey(),
142
+ runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
143
+ keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
144
+ provider: text("provider").notNull().default("gemini"),
145
+ citationState: text("citation_state").notNull(),
146
+ answerText: text("answer_text"),
147
+ citedDomains: text("cited_domains").notNull().default("[]"),
148
+ competitorOverlap: text("competitor_overlap").notNull().default("[]"),
149
+ rawResponse: text("raw_response"),
150
+ createdAt: text("created_at").notNull()
151
+ }, (table) => [
152
+ index("idx_snapshots_run").on(table.runId),
153
+ index("idx_snapshots_keyword").on(table.keywordId)
154
+ ]);
155
+ var auditLog = sqliteTable("audit_log", {
156
+ id: text("id").primaryKey(),
157
+ projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
158
+ actor: text("actor").notNull(),
159
+ action: text("action").notNull(),
160
+ entityType: text("entity_type").notNull(),
161
+ entityId: text("entity_id"),
162
+ diff: text("diff"),
163
+ createdAt: text("created_at").notNull()
164
+ }, (table) => [
165
+ index("idx_audit_log_project").on(table.projectId),
166
+ index("idx_audit_log_created").on(table.createdAt)
167
+ ]);
168
+ var apiKeys = sqliteTable("api_keys", {
169
+ id: text("id").primaryKey(),
170
+ name: text("name").notNull(),
171
+ keyHash: text("key_hash").notNull().unique(),
172
+ keyPrefix: text("key_prefix").notNull(),
173
+ scopes: text("scopes").notNull().default('["*"]'),
174
+ createdAt: text("created_at").notNull(),
175
+ lastUsedAt: text("last_used_at"),
176
+ revokedAt: text("revoked_at")
177
+ }, (table) => [
178
+ index("idx_api_keys_prefix").on(table.keyPrefix)
179
+ ]);
180
+ var schedules = sqliteTable("schedules", {
181
+ id: text("id").primaryKey(),
182
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
183
+ cronExpr: text("cron_expr").notNull(),
184
+ preset: text("preset"),
185
+ timezone: text("timezone").notNull().default("UTC"),
186
+ enabled: integer("enabled").notNull().default(1),
187
+ providers: text("providers").notNull().default("[]"),
188
+ lastRunAt: text("last_run_at"),
189
+ nextRunAt: text("next_run_at"),
190
+ createdAt: text("created_at").notNull(),
191
+ updatedAt: text("updated_at").notNull()
192
+ }, (table) => [
193
+ uniqueIndex("idx_schedules_project").on(table.projectId)
194
+ ]);
195
+ var notifications = sqliteTable("notifications", {
196
+ id: text("id").primaryKey(),
197
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
198
+ channel: text("channel").notNull(),
199
+ config: text("config").notNull(),
200
+ webhookSecret: text("webhook_secret"),
201
+ enabled: integer("enabled").notNull().default(1),
202
+ createdAt: text("created_at").notNull(),
203
+ updatedAt: text("updated_at").notNull()
204
+ }, (table) => [
205
+ index("idx_notifications_project").on(table.projectId)
206
+ ]);
207
+ var usageCounters = sqliteTable("usage_counters", {
208
+ id: text("id").primaryKey(),
209
+ scope: text("scope").notNull(),
210
+ period: text("period").notNull(),
211
+ metric: text("metric").notNull(),
212
+ count: integer("count").notNull().default(0),
213
+ updatedAt: text("updated_at").notNull()
214
+ }, (table) => [
215
+ uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
216
+ index("idx_usage_scope_period").on(table.scope, table.period)
217
+ ]);
218
+
219
+ // ../db/src/client.ts
220
+ function createClient(databasePath) {
221
+ mkdirSync(dirname(databasePath), { recursive: true });
222
+ const sqlite = new Database(databasePath);
223
+ sqlite.pragma("journal_mode = WAL");
224
+ sqlite.pragma("foreign_keys = ON");
225
+ return drizzle(sqlite, { schema: schema_exports });
226
+ }
227
+
228
+ // ../db/src/migrate.ts
229
+ import { sql } from "drizzle-orm";
230
+ var MIGRATION_SQL = `
231
+ CREATE TABLE IF NOT EXISTS projects (
232
+ id TEXT PRIMARY KEY,
233
+ name TEXT NOT NULL UNIQUE,
234
+ display_name TEXT NOT NULL,
235
+ canonical_domain TEXT NOT NULL,
236
+ country TEXT NOT NULL,
237
+ language TEXT NOT NULL,
238
+ tags TEXT NOT NULL DEFAULT '[]',
239
+ labels TEXT NOT NULL DEFAULT '{}',
240
+ providers TEXT NOT NULL DEFAULT '[]',
241
+ config_source TEXT NOT NULL DEFAULT 'cli',
242
+ config_revision INTEGER NOT NULL DEFAULT 1,
243
+ created_at TEXT NOT NULL,
244
+ updated_at TEXT NOT NULL
245
+ );
246
+
247
+ CREATE TABLE IF NOT EXISTS keywords (
248
+ id TEXT PRIMARY KEY,
249
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
250
+ keyword TEXT NOT NULL,
251
+ created_at TEXT NOT NULL,
252
+ UNIQUE(project_id, keyword)
253
+ );
254
+
255
+ CREATE TABLE IF NOT EXISTS competitors (
256
+ id TEXT PRIMARY KEY,
257
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
258
+ domain TEXT NOT NULL,
259
+ created_at TEXT NOT NULL,
260
+ UNIQUE(project_id, domain)
261
+ );
262
+
263
+ CREATE TABLE IF NOT EXISTS runs (
264
+ id TEXT PRIMARY KEY,
265
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
266
+ kind TEXT NOT NULL DEFAULT 'answer-visibility',
267
+ status TEXT NOT NULL DEFAULT 'queued',
268
+ trigger TEXT NOT NULL DEFAULT 'manual',
269
+ started_at TEXT,
270
+ finished_at TEXT,
271
+ error TEXT,
272
+ created_at TEXT NOT NULL
273
+ );
274
+
275
+ CREATE TABLE IF NOT EXISTS query_snapshots (
276
+ id TEXT PRIMARY KEY,
277
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
278
+ keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
279
+ provider TEXT NOT NULL DEFAULT 'gemini',
280
+ citation_state TEXT NOT NULL,
281
+ answer_text TEXT,
282
+ cited_domains TEXT NOT NULL DEFAULT '[]',
283
+ competitor_overlap TEXT NOT NULL DEFAULT '[]',
284
+ raw_response TEXT,
285
+ created_at TEXT NOT NULL
286
+ );
287
+
288
+ CREATE TABLE IF NOT EXISTS audit_log (
289
+ id TEXT PRIMARY KEY,
290
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
291
+ actor TEXT NOT NULL,
292
+ action TEXT NOT NULL,
293
+ entity_type TEXT NOT NULL,
294
+ entity_id TEXT,
295
+ diff TEXT,
296
+ created_at TEXT NOT NULL
297
+ );
298
+
299
+ CREATE TABLE IF NOT EXISTS api_keys (
300
+ id TEXT PRIMARY KEY,
301
+ name TEXT NOT NULL,
302
+ key_hash TEXT NOT NULL UNIQUE,
303
+ key_prefix TEXT NOT NULL,
304
+ scopes TEXT NOT NULL DEFAULT '["*"]',
305
+ created_at TEXT NOT NULL,
306
+ last_used_at TEXT,
307
+ revoked_at TEXT
308
+ );
309
+
310
+ CREATE TABLE IF NOT EXISTS usage_counters (
311
+ id TEXT PRIMARY KEY,
312
+ scope TEXT NOT NULL,
313
+ period TEXT NOT NULL,
314
+ metric TEXT NOT NULL,
315
+ count INTEGER NOT NULL DEFAULT 0,
316
+ updated_at TEXT NOT NULL,
317
+ UNIQUE(scope, period, metric)
318
+ );
319
+
320
+ CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
321
+ CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
322
+ CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
323
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
324
+ CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
325
+ CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
326
+ CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
327
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
328
+ CREATE TABLE IF NOT EXISTS schedules (
329
+ id TEXT PRIMARY KEY,
330
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
331
+ cron_expr TEXT NOT NULL,
332
+ preset TEXT,
333
+ timezone TEXT NOT NULL DEFAULT 'UTC',
334
+ enabled INTEGER NOT NULL DEFAULT 1,
335
+ providers TEXT NOT NULL DEFAULT '[]',
336
+ last_run_at TEXT,
337
+ next_run_at TEXT,
338
+ created_at TEXT NOT NULL,
339
+ updated_at TEXT NOT NULL,
340
+ UNIQUE(project_id)
341
+ );
342
+
343
+ CREATE TABLE IF NOT EXISTS notifications (
344
+ id TEXT PRIMARY KEY,
345
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
346
+ channel TEXT NOT NULL,
347
+ config TEXT NOT NULL,
348
+ enabled INTEGER NOT NULL DEFAULT 1,
349
+ created_at TEXT NOT NULL,
350
+ updated_at TEXT NOT NULL
351
+ );
352
+
353
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
354
+ CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
355
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
356
+ CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
357
+ `;
358
+ var MIGRATIONS = [
359
+ // v2: Add providers column to projects for multi-provider support
360
+ `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
361
+ // v3: Add webhook_secret column to notifications for HMAC signing
362
+ `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`
363
+ ];
364
+ function migrate(db) {
365
+ const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
366
+ for (const statement of statements) {
367
+ db.run(sql.raw(statement));
368
+ }
369
+ for (const migration of MIGRATIONS) {
370
+ try {
371
+ db.run(sql.raw(migration));
372
+ } catch {
373
+ }
374
+ }
375
+ }
376
+
377
+ // ../contracts/src/config-schema.ts
378
+ import { z as z3 } from "zod";
379
+
380
+ // ../contracts/src/provider.ts
381
+ import { z } from "zod";
382
+ var providerQuotaPolicySchema = z.object({
383
+ maxConcurrency: z.number().int().positive(),
384
+ maxRequestsPerMinute: z.number().int().positive(),
385
+ maxRequestsPerDay: z.number().int().positive()
386
+ });
387
+ var providerNameSchema = z.enum(["gemini", "openai", "claude", "local"]);
388
+
389
+ // ../contracts/src/notification.ts
390
+ import { z as z2 } from "zod";
391
+ var notificationEventSchema = z2.enum([
392
+ "citation.lost",
393
+ "citation.gained",
394
+ "run.completed",
395
+ "run.failed"
396
+ ]);
397
+ var notificationDtoSchema = z2.object({
398
+ id: z2.string(),
399
+ projectId: z2.string(),
400
+ channel: z2.literal("webhook"),
401
+ url: z2.string().url(),
402
+ events: z2.array(notificationEventSchema),
403
+ enabled: z2.boolean().default(true),
404
+ webhookSecret: z2.string().optional(),
405
+ createdAt: z2.string(),
406
+ updatedAt: z2.string()
407
+ });
408
+
409
+ // ../contracts/src/config-schema.ts
410
+ var configMetadataSchema = z3.object({
411
+ name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
412
+ message: "Name must be a lowercase slug (letters, numbers, hyphens)"
413
+ }),
414
+ labels: z3.record(z3.string(), z3.string()).optional().default({})
415
+ });
416
+ var configScheduleSchema = z3.object({
417
+ preset: z3.string().optional(),
418
+ cron: z3.string().optional(),
419
+ timezone: z3.string().optional().default("UTC"),
420
+ providers: z3.array(providerNameSchema).optional().default([])
421
+ }).refine(
422
+ (data) => data.preset && !data.cron || !data.preset && data.cron,
423
+ { message: 'Exactly one of "preset" or "cron" must be provided' }
424
+ ).optional();
425
+ var configNotificationSchema = z3.object({
426
+ channel: z3.literal("webhook"),
427
+ url: z3.string().url(),
428
+ events: z3.array(notificationEventSchema).min(1)
429
+ });
430
+ var configSpecSchema = z3.object({
431
+ displayName: z3.string().min(1),
432
+ canonicalDomain: z3.string().min(1),
433
+ country: z3.string().length(2),
434
+ language: z3.string().min(2),
435
+ keywords: z3.array(z3.string().min(1)).optional().default([]),
436
+ competitors: z3.array(z3.string().min(1)).optional().default([]),
437
+ providers: z3.array(providerNameSchema).optional().default([]),
438
+ schedule: configScheduleSchema,
439
+ notifications: z3.array(configNotificationSchema).optional().default([])
440
+ });
441
+ var projectConfigSchema = z3.object({
442
+ apiVersion: z3.literal("canonry/v1"),
443
+ kind: z3.literal("Project"),
444
+ metadata: configMetadataSchema,
445
+ spec: configSpecSchema
446
+ });
447
+
448
+ // ../contracts/src/errors.ts
449
+ var AppError = class extends Error {
450
+ code;
451
+ statusCode;
452
+ details;
453
+ constructor(code, message, statusCode, details) {
454
+ super(message);
455
+ this.name = "AppError";
456
+ this.code = code;
457
+ this.statusCode = statusCode;
458
+ this.details = details;
459
+ }
460
+ toJSON() {
461
+ return {
462
+ error: {
463
+ code: this.code,
464
+ message: this.message,
465
+ ...this.details ? { details: this.details } : {}
466
+ }
467
+ };
468
+ }
469
+ };
470
+ function notFound(entity, id) {
471
+ return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
472
+ }
473
+ function validationError(message, details) {
474
+ return new AppError("VALIDATION_ERROR", message, 400, details);
475
+ }
476
+ function authRequired() {
477
+ return new AppError("AUTH_REQUIRED", "Authentication required", 401);
478
+ }
479
+ function authInvalid() {
480
+ return new AppError("AUTH_INVALID", "Invalid API key", 401);
481
+ }
482
+ function runInProgress(projectName) {
483
+ return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
484
+ }
485
+ function unsupportedKind(kind) {
486
+ return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
487
+ }
488
+
489
+ // ../contracts/src/project.ts
490
+ import { z as z4 } from "zod";
491
+ var configSourceSchema = z4.enum(["cli", "api", "config-file"]);
492
+ var projectDtoSchema = z4.object({
493
+ id: z4.string(),
494
+ name: z4.string(),
495
+ displayName: z4.string().optional(),
496
+ canonicalDomain: z4.string(),
497
+ country: z4.string().length(2),
498
+ language: z4.string().min(2),
499
+ tags: z4.array(z4.string()).default([]),
500
+ labels: z4.record(z4.string(), z4.string()).default({}),
501
+ configSource: configSourceSchema.default("cli"),
502
+ configRevision: z4.number().int().positive().default(1),
503
+ createdAt: z4.string().optional(),
504
+ updatedAt: z4.string().optional()
505
+ });
506
+
507
+ // ../contracts/src/run.ts
508
+ import { z as z5 } from "zod";
509
+ var runStatusSchema = z5.enum(["queued", "running", "completed", "partial", "failed"]);
510
+ var runKindSchema = z5.enum(["answer-visibility", "site-audit"]);
511
+ var runTriggerSchema = z5.enum(["manual", "scheduled", "config-apply"]);
512
+ var citationStateSchema = z5.enum(["cited", "not-cited"]);
513
+ var computedTransitionSchema = z5.enum(["new", "cited", "lost", "emerging", "not-cited"]);
514
+ var runDtoSchema = z5.object({
515
+ id: z5.string(),
516
+ projectId: z5.string(),
517
+ kind: runKindSchema,
518
+ status: runStatusSchema,
519
+ trigger: runTriggerSchema.default("manual"),
520
+ startedAt: z5.string().nullable().optional(),
521
+ finishedAt: z5.string().nullable().optional(),
522
+ error: z5.string().nullable().optional(),
523
+ createdAt: z5.string()
524
+ });
525
+ var groundingSourceSchema = z5.object({
526
+ uri: z5.string(),
527
+ title: z5.string()
528
+ });
529
+ var querySnapshotDtoSchema = z5.object({
530
+ id: z5.string(),
531
+ runId: z5.string(),
532
+ keywordId: z5.string(),
533
+ keyword: z5.string().optional(),
534
+ provider: providerNameSchema,
535
+ citationState: citationStateSchema,
536
+ transition: computedTransitionSchema.optional(),
537
+ answerText: z5.string().nullable().optional(),
538
+ citedDomains: z5.array(z5.string()).default([]),
539
+ competitorOverlap: z5.array(z5.string()).default([]),
540
+ groundingSources: z5.array(groundingSourceSchema).default([]),
541
+ searchQueries: z5.array(z5.string()).default([]),
542
+ model: z5.string().nullable().optional(),
543
+ createdAt: z5.string()
544
+ });
545
+ var auditLogEntrySchema = z5.object({
546
+ id: z5.string(),
547
+ projectId: z5.string().nullable().optional(),
548
+ actor: z5.string(),
549
+ action: z5.string(),
550
+ entityType: z5.string(),
551
+ entityId: z5.string().nullable().optional(),
552
+ diff: z5.unknown().optional(),
553
+ createdAt: z5.string()
554
+ });
555
+
556
+ // ../contracts/src/schedule.ts
557
+ import { z as z6 } from "zod";
558
+ var scheduleDtoSchema = z6.object({
559
+ id: z6.string(),
560
+ projectId: z6.string(),
561
+ cronExpr: z6.string(),
562
+ preset: z6.string().nullable().optional(),
563
+ timezone: z6.string().default("UTC"),
564
+ enabled: z6.boolean().default(true),
565
+ providers: z6.array(providerNameSchema).default([]),
566
+ lastRunAt: z6.string().nullable().optional(),
567
+ nextRunAt: z6.string().nullable().optional(),
568
+ createdAt: z6.string(),
569
+ updatedAt: z6.string()
570
+ });
571
+
572
+ // ../api-routes/src/auth.ts
573
+ function hashKey(key) {
574
+ return crypto.createHash("sha256").update(key).digest("hex");
575
+ }
576
+ var SKIP_PATHS = ["/health"];
577
+ function shouldSkipAuth(url) {
578
+ if (SKIP_PATHS.includes(url)) return true;
579
+ if (url.endsWith("/openapi.json")) return true;
580
+ return false;
581
+ }
582
+ async function authPlugin(app) {
583
+ app.addHook("onRequest", async (request, reply) => {
584
+ const url = request.url.split("?")[0];
585
+ if (shouldSkipAuth(url)) return;
586
+ const header = request.headers.authorization;
587
+ if (!header) {
588
+ const err = authRequired();
589
+ return reply.status(err.statusCode).send(err.toJSON());
590
+ }
591
+ const parts = header.split(" ");
592
+ if (parts.length !== 2 || parts[0] !== "Bearer") {
593
+ const err = authRequired();
594
+ return reply.status(err.statusCode).send(err.toJSON());
595
+ }
596
+ const token = parts[1];
597
+ const hash = hashKey(token);
598
+ const key = app.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)).get();
599
+ if (!key || key.revokedAt) {
600
+ const err = authInvalid();
601
+ return reply.status(err.statusCode).send(err.toJSON());
602
+ }
603
+ app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
604
+ });
605
+ }
606
+
607
+ // ../api-routes/src/projects.ts
608
+ import crypto3 from "crypto";
609
+ import { eq as eq3 } from "drizzle-orm";
610
+
611
+ // ../api-routes/src/helpers.ts
612
+ import crypto2 from "crypto";
613
+ import { eq as eq2, and } from "drizzle-orm";
614
+ function resolveProject(db, name) {
615
+ const project = db.select().from(projects).where(eq2(projects.name, name)).get();
616
+ if (!project) {
617
+ throw notFound("Project", name);
618
+ }
619
+ return project;
620
+ }
621
+ function writeAuditLog(db, entry) {
622
+ const now = (/* @__PURE__ */ new Date()).toISOString();
623
+ db.insert(auditLog).values({
624
+ id: crypto2.randomUUID(),
625
+ projectId: entry.projectId ?? null,
626
+ actor: entry.actor,
627
+ action: entry.action,
628
+ entityType: entry.entityType,
629
+ entityId: entry.entityId ?? null,
630
+ diff: entry.diff != null ? JSON.stringify(entry.diff) : null,
631
+ createdAt: now
632
+ }).run();
633
+ }
634
+
635
+ // ../api-routes/src/projects.ts
636
+ async function projectRoutes(app, opts) {
637
+ app.put("/projects/:name", async (request, reply) => {
638
+ const { name } = request.params;
639
+ const body = request.body;
640
+ if (!body || !body.displayName || !body.canonicalDomain || !body.country || !body.language) {
641
+ const err = validationError("Missing required fields: displayName, canonicalDomain, country, language");
642
+ return reply.status(err.statusCode).send(err.toJSON());
643
+ }
644
+ const now = (/* @__PURE__ */ new Date()).toISOString();
645
+ const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
646
+ if (existing) {
647
+ app.db.update(projects).set({
648
+ displayName: body.displayName,
649
+ canonicalDomain: body.canonicalDomain,
650
+ country: body.country,
651
+ language: body.language,
652
+ tags: JSON.stringify(body.tags ?? []),
653
+ labels: JSON.stringify(body.labels ?? {}),
654
+ providers: JSON.stringify(body.providers ?? []),
655
+ configSource: body.configSource ?? "api",
656
+ configRevision: existing.configRevision + 1,
657
+ updatedAt: now
658
+ }).where(eq3(projects.id, existing.id)).run();
659
+ writeAuditLog(app.db, {
660
+ projectId: existing.id,
661
+ actor: "api",
662
+ action: "project.updated",
663
+ entityType: "project",
664
+ entityId: existing.id
665
+ });
666
+ const updated = app.db.select().from(projects).where(eq3(projects.id, existing.id)).get();
667
+ return reply.status(200).send(formatProject(updated));
668
+ }
669
+ const id = crypto3.randomUUID();
670
+ app.db.insert(projects).values({
671
+ id,
672
+ name,
673
+ displayName: body.displayName,
674
+ canonicalDomain: body.canonicalDomain,
675
+ country: body.country,
676
+ language: body.language,
677
+ tags: JSON.stringify(body.tags ?? []),
678
+ labels: JSON.stringify(body.labels ?? {}),
679
+ providers: JSON.stringify(body.providers ?? []),
680
+ configSource: body.configSource ?? "api",
681
+ configRevision: 1,
682
+ createdAt: now,
683
+ updatedAt: now
684
+ }).run();
685
+ writeAuditLog(app.db, {
686
+ projectId: id,
687
+ actor: "api",
688
+ action: "project.created",
689
+ entityType: "project",
690
+ entityId: id
691
+ });
692
+ const created = app.db.select().from(projects).where(eq3(projects.id, id)).get();
693
+ return reply.status(201).send(formatProject(created));
694
+ });
695
+ app.get("/projects", async (_request, reply) => {
696
+ const rows = app.db.select().from(projects).all();
697
+ return reply.send(rows.map(formatProject));
698
+ });
699
+ app.get("/projects/:name", async (request, reply) => {
700
+ try {
701
+ const project = resolveProject(app.db, request.params.name);
702
+ return reply.send(formatProject(project));
703
+ } catch (e) {
704
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
705
+ const err = e;
706
+ return reply.status(err.statusCode).send(err.toJSON());
707
+ }
708
+ throw e;
709
+ }
710
+ });
711
+ app.delete("/projects/:name", async (request, reply) => {
712
+ let project;
713
+ try {
714
+ project = resolveProject(app.db, request.params.name);
715
+ } catch (e) {
716
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
717
+ const err = e;
718
+ return reply.status(err.statusCode).send(err.toJSON());
719
+ }
720
+ throw e;
721
+ }
722
+ writeAuditLog(app.db, {
723
+ projectId: project.id,
724
+ actor: "api",
725
+ action: "project.deleted",
726
+ entityType: "project",
727
+ entityId: project.id
728
+ });
729
+ app.db.delete(projects).where(eq3(projects.id, project.id)).run();
730
+ opts.onProjectDeleted?.(project.id);
731
+ return reply.status(204).send();
732
+ });
733
+ app.get("/projects/:name/export", async (request, reply) => {
734
+ let project;
735
+ try {
736
+ project = resolveProject(app.db, request.params.name);
737
+ } catch (e) {
738
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
739
+ const err = e;
740
+ return reply.status(err.statusCode).send(err.toJSON());
741
+ }
742
+ throw e;
743
+ }
744
+ const kws = app.db.select().from(keywords).where(eq3(keywords.projectId, project.id)).all();
745
+ const comps = app.db.select().from(competitors).where(eq3(competitors.projectId, project.id)).all();
746
+ const schedule = app.db.select().from(schedules).where(eq3(schedules.projectId, project.id)).get();
747
+ const notificationRows = app.db.select().from(notifications).where(eq3(notifications.projectId, project.id)).all();
748
+ const config = {
749
+ apiVersion: "canonry/v1",
750
+ kind: "Project",
751
+ metadata: {
752
+ name: project.name,
753
+ labels: JSON.parse(project.labels)
754
+ },
755
+ spec: {
756
+ displayName: project.displayName,
757
+ canonicalDomain: project.canonicalDomain,
758
+ country: project.country,
759
+ language: project.language,
760
+ keywords: kws.map((k) => k.keyword),
761
+ competitors: comps.map((c) => c.domain),
762
+ providers: JSON.parse(project.providers || "[]"),
763
+ notifications: notificationRows.map((row) => {
764
+ const cfg = JSON.parse(row.config);
765
+ return {
766
+ channel: row.channel,
767
+ url: cfg.url,
768
+ events: cfg.events
769
+ };
770
+ }),
771
+ ...schedule ? {
772
+ schedule: {
773
+ ...schedule.preset ? { preset: schedule.preset } : { cron: schedule.cronExpr },
774
+ timezone: schedule.timezone,
775
+ providers: JSON.parse(schedule.providers || "[]")
776
+ }
777
+ } : {}
778
+ }
779
+ };
780
+ return reply.send(config);
781
+ });
782
+ }
783
+ function formatProject(row) {
784
+ return {
785
+ id: row.id,
786
+ name: row.name,
787
+ displayName: row.displayName,
788
+ canonicalDomain: row.canonicalDomain,
789
+ country: row.country,
790
+ language: row.language,
791
+ tags: JSON.parse(row.tags),
792
+ labels: JSON.parse(row.labels),
793
+ providers: JSON.parse(row.providers || "[]"),
794
+ configSource: row.configSource,
795
+ configRevision: row.configRevision,
796
+ createdAt: row.createdAt,
797
+ updatedAt: row.updatedAt
798
+ };
799
+ }
800
+
801
+ // ../api-routes/src/keywords.ts
802
+ import crypto4 from "crypto";
803
+ import { eq as eq4 } from "drizzle-orm";
804
+ async function keywordRoutes(app) {
805
+ app.get("/projects/:name/keywords", async (request, reply) => {
806
+ const project = resolveProjectSafe(app, request.params.name, reply);
807
+ if (!project) return;
808
+ const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
809
+ return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
810
+ });
811
+ app.put("/projects/:name/keywords", async (request, reply) => {
812
+ const project = resolveProjectSafe(app, request.params.name, reply);
813
+ if (!project) return;
814
+ const body = request.body;
815
+ if (!body || !Array.isArray(body.keywords)) {
816
+ const err = validationError('Body must contain a "keywords" array');
817
+ return reply.status(err.statusCode).send(err.toJSON());
818
+ }
819
+ const now = (/* @__PURE__ */ new Date()).toISOString();
820
+ app.db.transaction((tx) => {
821
+ tx.delete(keywords).where(eq4(keywords.projectId, project.id)).run();
822
+ for (const kw of body.keywords) {
823
+ tx.insert(keywords).values({
824
+ id: crypto4.randomUUID(),
825
+ projectId: project.id,
826
+ keyword: kw,
827
+ createdAt: now
828
+ }).run();
829
+ }
830
+ writeAuditLog(tx, {
831
+ projectId: project.id,
832
+ actor: "api",
833
+ action: "keywords.replaced",
834
+ entityType: "keyword",
835
+ diff: { keywords: body.keywords }
836
+ });
837
+ });
838
+ const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
839
+ return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
840
+ });
841
+ app.post("/projects/:name/keywords", async (request, reply) => {
842
+ const project = resolveProjectSafe(app, request.params.name, reply);
843
+ if (!project) return;
844
+ const body = request.body;
845
+ if (!body || !Array.isArray(body.keywords)) {
846
+ const err = validationError('Body must contain a "keywords" array');
847
+ return reply.status(err.statusCode).send(err.toJSON());
848
+ }
849
+ const now = (/* @__PURE__ */ new Date()).toISOString();
850
+ const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
851
+ const existingSet = new Set(existing.map((k) => k.keyword));
852
+ const added = [];
853
+ for (const kw of body.keywords) {
854
+ if (!existingSet.has(kw)) {
855
+ app.db.insert(keywords).values({
856
+ id: crypto4.randomUUID(),
857
+ projectId: project.id,
858
+ keyword: kw,
859
+ createdAt: now
860
+ }).run();
861
+ added.push(kw);
862
+ existingSet.add(kw);
863
+ }
864
+ }
865
+ if (added.length > 0) {
866
+ writeAuditLog(app.db, {
867
+ projectId: project.id,
868
+ actor: "api",
869
+ action: "keywords.appended",
870
+ entityType: "keyword",
871
+ diff: { added }
872
+ });
873
+ }
874
+ const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
875
+ return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
876
+ });
877
+ }
878
+ function resolveProjectSafe(app, name, reply) {
879
+ try {
880
+ return resolveProject(app.db, name);
881
+ } catch (e) {
882
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
883
+ const err = e;
884
+ reply.status(err.statusCode).send(err.toJSON());
885
+ return null;
886
+ }
887
+ throw e;
888
+ }
889
+ }
890
+
891
+ // ../api-routes/src/competitors.ts
892
+ import crypto5 from "crypto";
893
+ import { eq as eq5 } from "drizzle-orm";
894
+ async function competitorRoutes(app) {
895
+ app.get("/projects/:name/competitors", async (request, reply) => {
896
+ const project = resolveProjectSafe2(app, request.params.name, reply);
897
+ if (!project) return;
898
+ const rows = app.db.select().from(competitors).where(eq5(competitors.projectId, project.id)).all();
899
+ return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
900
+ });
901
+ app.put("/projects/:name/competitors", async (request, reply) => {
902
+ const project = resolveProjectSafe2(app, request.params.name, reply);
903
+ if (!project) return;
904
+ const body = request.body;
905
+ if (!body || !Array.isArray(body.competitors)) {
906
+ const err = validationError('Body must contain a "competitors" array');
907
+ return reply.status(err.statusCode).send(err.toJSON());
908
+ }
909
+ const now = (/* @__PURE__ */ new Date()).toISOString();
910
+ app.db.transaction((tx) => {
911
+ tx.delete(competitors).where(eq5(competitors.projectId, project.id)).run();
912
+ for (const domain of body.competitors) {
913
+ tx.insert(competitors).values({
914
+ id: crypto5.randomUUID(),
915
+ projectId: project.id,
916
+ domain,
917
+ createdAt: now
918
+ }).run();
919
+ }
920
+ writeAuditLog(tx, {
921
+ projectId: project.id,
922
+ actor: "api",
923
+ action: "competitors.replaced",
924
+ entityType: "competitor",
925
+ diff: { competitors: body.competitors }
926
+ });
927
+ });
928
+ const rows = app.db.select().from(competitors).where(eq5(competitors.projectId, project.id)).all();
929
+ return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
930
+ });
931
+ }
932
+ function resolveProjectSafe2(app, name, reply) {
933
+ try {
934
+ return resolveProject(app.db, name);
935
+ } catch (e) {
936
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
937
+ const err = e;
938
+ reply.status(err.statusCode).send(err.toJSON());
939
+ return null;
940
+ }
941
+ throw e;
942
+ }
943
+ }
944
+
945
+ // ../api-routes/src/runs.ts
946
+ import { eq as eq7, asc } from "drizzle-orm";
947
+
948
+ // ../api-routes/src/run-queue.ts
949
+ import crypto6 from "crypto";
950
+ import { and as and2, eq as eq6, or } from "drizzle-orm";
951
+ function queueRunIfProjectIdle(db, params) {
952
+ const createdAt = params.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
953
+ const kind = params.kind ?? "answer-visibility";
954
+ const trigger = params.trigger ?? "manual";
955
+ const runId = crypto6.randomUUID();
956
+ return db.transaction((tx) => {
957
+ const activeRun = tx.select().from(runs).where(
958
+ and2(
959
+ eq6(runs.projectId, params.projectId),
960
+ or(eq6(runs.status, "queued"), eq6(runs.status, "running"))
961
+ )
962
+ ).get();
963
+ if (activeRun) {
964
+ return { conflict: true, activeRunId: activeRun.id };
965
+ }
966
+ tx.insert(runs).values({
967
+ id: runId,
968
+ projectId: params.projectId,
969
+ kind,
970
+ status: "queued",
971
+ trigger,
972
+ createdAt
973
+ }).run();
974
+ return { conflict: false, runId };
975
+ });
976
+ }
977
+
978
+ // ../api-routes/src/runs.ts
979
+ async function runRoutes(app, opts) {
980
+ app.post("/projects/:name/runs", async (request, reply) => {
981
+ const project = resolveProjectSafe3(app, request.params.name, reply);
982
+ if (!project) return;
983
+ const kind = request.body?.kind ?? "answer-visibility";
984
+ if (kind !== "answer-visibility") {
985
+ const err = unsupportedKind(kind);
986
+ return reply.status(err.statusCode).send(err.toJSON());
987
+ }
988
+ const now = (/* @__PURE__ */ new Date()).toISOString();
989
+ const trigger = request.body?.trigger ?? "manual";
990
+ const rawProviders = request.body?.providers;
991
+ const validProviders = ["gemini", "openai", "claude", "local"];
992
+ if (rawProviders?.length) {
993
+ const invalid = rawProviders.filter((p) => !validProviders.includes(p));
994
+ if (invalid.length) {
995
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validProviders.join(", ")}` } });
996
+ }
997
+ }
998
+ const providers = rawProviders?.length ? rawProviders : void 0;
999
+ const queueResult = queueRunIfProjectIdle(app.db, {
1000
+ createdAt: now,
1001
+ kind,
1002
+ projectId: project.id,
1003
+ trigger
1004
+ });
1005
+ if (queueResult.conflict) {
1006
+ const err = runInProgress(project.name);
1007
+ return reply.status(err.statusCode).send(err.toJSON());
1008
+ }
1009
+ const runId = queueResult.runId;
1010
+ writeAuditLog(app.db, {
1011
+ projectId: project.id,
1012
+ actor: "api",
1013
+ action: "run.created",
1014
+ entityType: "run",
1015
+ entityId: runId
1016
+ });
1017
+ const run = app.db.select().from(runs).where(eq7(runs.id, runId)).get();
1018
+ if (opts.onRunCreated) {
1019
+ opts.onRunCreated(runId, project.id, providers);
1020
+ }
1021
+ return reply.status(201).send(formatRun(run));
1022
+ });
1023
+ app.get("/projects/:name/runs", async (request, reply) => {
1024
+ const project = resolveProjectSafe3(app, request.params.name, reply);
1025
+ if (!project) return;
1026
+ const rows = app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all();
1027
+ return reply.send(rows.map(formatRun));
1028
+ });
1029
+ app.get("/runs", async (_request, reply) => {
1030
+ const rows = app.db.select().from(runs).all();
1031
+ return reply.send(rows.map(formatRun));
1032
+ });
1033
+ app.get("/runs/:id", async (request, reply) => {
1034
+ const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
1035
+ if (!run) {
1036
+ return reply.status(404).send({ error: { code: "NOT_FOUND", message: `Run '${request.params.id}' not found` } });
1037
+ }
1038
+ const snapshots = app.db.select({
1039
+ id: querySnapshots.id,
1040
+ runId: querySnapshots.runId,
1041
+ keywordId: querySnapshots.keywordId,
1042
+ keyword: keywords.keyword,
1043
+ provider: querySnapshots.provider,
1044
+ citationState: querySnapshots.citationState,
1045
+ answerText: querySnapshots.answerText,
1046
+ citedDomains: querySnapshots.citedDomains,
1047
+ competitorOverlap: querySnapshots.competitorOverlap,
1048
+ rawResponse: querySnapshots.rawResponse,
1049
+ createdAt: querySnapshots.createdAt
1050
+ }).from(querySnapshots).leftJoin(keywords, eq7(querySnapshots.keywordId, keywords.id)).where(eq7(querySnapshots.runId, run.id)).all();
1051
+ return reply.send({
1052
+ ...formatRun(run),
1053
+ snapshots: snapshots.map((s) => ({
1054
+ id: s.id,
1055
+ runId: s.runId,
1056
+ keywordId: s.keywordId,
1057
+ keyword: s.keyword,
1058
+ provider: s.provider,
1059
+ citationState: s.citationState,
1060
+ answerText: s.answerText,
1061
+ citedDomains: tryParseJson(s.citedDomains, []),
1062
+ competitorOverlap: tryParseJson(s.competitorOverlap, []),
1063
+ ...parseSnapshotRawResponse(s.rawResponse),
1064
+ createdAt: s.createdAt
1065
+ }))
1066
+ });
1067
+ });
1068
+ }
1069
+ function formatRun(row) {
1070
+ return {
1071
+ id: row.id,
1072
+ projectId: row.projectId,
1073
+ kind: row.kind,
1074
+ status: row.status,
1075
+ trigger: row.trigger,
1076
+ startedAt: row.startedAt,
1077
+ finishedAt: row.finishedAt,
1078
+ error: row.error,
1079
+ createdAt: row.createdAt
1080
+ };
1081
+ }
1082
+ function parseSnapshotRawResponse(raw) {
1083
+ const parsed = tryParseJson(raw ?? "{}", {});
1084
+ return {
1085
+ groundingSources: parsed.groundingSources ?? [],
1086
+ searchQueries: parsed.searchQueries ?? [],
1087
+ model: parsed.model ?? null
1088
+ };
1089
+ }
1090
+ function tryParseJson(value, fallback) {
1091
+ try {
1092
+ return JSON.parse(value);
1093
+ } catch {
1094
+ return fallback;
1095
+ }
1096
+ }
1097
+ function resolveProjectSafe3(app, name, reply) {
1098
+ try {
1099
+ return resolveProject(app.db, name);
1100
+ } catch (e) {
1101
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1102
+ const err = e;
1103
+ reply.status(err.statusCode).send(err.toJSON());
1104
+ return null;
1105
+ }
1106
+ throw e;
1107
+ }
1108
+ }
1109
+
1110
+ // ../api-routes/src/apply.ts
1111
+ import crypto8 from "crypto";
1112
+ import { eq as eq8 } from "drizzle-orm";
1113
+
1114
+ // ../api-routes/src/schedule-utils.ts
1115
+ var DAY_MAP = {
1116
+ sun: "0",
1117
+ mon: "1",
1118
+ tue: "2",
1119
+ wed: "3",
1120
+ thu: "4",
1121
+ fri: "5",
1122
+ sat: "6"
1123
+ };
1124
+ function resolvePreset(preset) {
1125
+ if (preset === "daily") return "0 6 * * *";
1126
+ if (preset === "weekly") return "0 6 * * 1";
1127
+ if (preset === "twice-daily") return "0 6,18 * * *";
1128
+ const dailyMatch = preset.match(/^daily@(\d{1,2})$/);
1129
+ if (dailyMatch) {
1130
+ const hour = parseInt(dailyMatch[1], 10);
1131
+ if (hour < 0 || hour > 23) throw new Error(`Invalid hour in preset: ${preset}`);
1132
+ return `0 ${hour} * * *`;
1133
+ }
1134
+ const weeklyDayMatch = preset.match(/^weekly@([a-z]{3})$/);
1135
+ if (weeklyDayMatch) {
1136
+ const day = DAY_MAP[weeklyDayMatch[1]];
1137
+ if (day === void 0) throw new Error(`Invalid day in preset: ${preset}`);
1138
+ return `0 6 * * ${day}`;
1139
+ }
1140
+ const weeklyDayHourMatch = preset.match(/^weekly@([a-z]{3})@(\d{1,2})$/);
1141
+ if (weeklyDayHourMatch) {
1142
+ const day = DAY_MAP[weeklyDayHourMatch[1]];
1143
+ const hour = parseInt(weeklyDayHourMatch[2], 10);
1144
+ if (day === void 0) throw new Error(`Invalid day in preset: ${preset}`);
1145
+ if (hour < 0 || hour > 23) throw new Error(`Invalid hour in preset: ${preset}`);
1146
+ return `0 ${hour} * * ${day}`;
1147
+ }
1148
+ throw new Error(`Unknown schedule preset: ${preset}`);
1149
+ }
1150
+ function validateCron(expr) {
1151
+ const parts = expr.trim().split(/\s+/);
1152
+ if (parts.length !== 5) return false;
1153
+ const ranges = [
1154
+ { min: 0, max: 59 },
1155
+ // minute
1156
+ { min: 0, max: 23 },
1157
+ // hour
1158
+ { min: 1, max: 31 },
1159
+ // day of month
1160
+ { min: 1, max: 12 },
1161
+ // month
1162
+ { min: 0, max: 7 }
1163
+ // day of week (0 and 7 = Sunday)
1164
+ ];
1165
+ for (let i = 0; i < 5; i++) {
1166
+ if (!validateCronField(parts[i], ranges[i].min, ranges[i].max)) {
1167
+ return false;
1168
+ }
1169
+ }
1170
+ return true;
1171
+ }
1172
+ function validateCronField(field, min, max) {
1173
+ if (field === "*") return true;
1174
+ const segments = field.split(",");
1175
+ for (const segment of segments) {
1176
+ const stepParts = segment.split("/");
1177
+ if (stepParts.length > 2) return false;
1178
+ if (stepParts.length === 2) {
1179
+ const step = parseInt(stepParts[1], 10);
1180
+ if (isNaN(step) || step < 1) return false;
1181
+ }
1182
+ const base = stepParts[0];
1183
+ if (base === "*") continue;
1184
+ const rangeParts = base.split("-");
1185
+ if (rangeParts.length > 2) return false;
1186
+ for (const part of rangeParts) {
1187
+ const num = parseInt(part, 10);
1188
+ if (isNaN(num) || num < min || num > max) return false;
1189
+ }
1190
+ }
1191
+ return true;
1192
+ }
1193
+ function isValidTimezone(tz) {
1194
+ try {
1195
+ Intl.DateTimeFormat(void 0, { timeZone: tz });
1196
+ return true;
1197
+ } catch {
1198
+ return false;
1199
+ }
1200
+ }
1201
+
1202
+ // ../api-routes/src/webhooks.ts
1203
+ import crypto7 from "crypto";
1204
+ import dns from "dns/promises";
1205
+ import http from "http";
1206
+ import https from "https";
1207
+ import net from "net";
1208
+ var REQUEST_TIMEOUT_MS = 1e4;
1209
+ async function resolveWebhookTarget(raw) {
1210
+ let parsed;
1211
+ try {
1212
+ parsed = new URL(raw);
1213
+ } catch {
1214
+ return { ok: false, message: '"url" must be a valid URL' };
1215
+ }
1216
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1217
+ return { ok: false, message: '"url" must use http or https scheme' };
1218
+ }
1219
+ if (parsed.username || parsed.password) {
1220
+ return { ok: false, message: '"url" must not include credentials' };
1221
+ }
1222
+ const lookupHost = stripIpv6Brackets(parsed.hostname);
1223
+ if (!lookupHost) {
1224
+ return { ok: false, message: '"url" must include a hostname' };
1225
+ }
1226
+ const addresses = await resolveHostAddresses(lookupHost);
1227
+ if (addresses.length === 0) {
1228
+ return { ok: false, message: '"url" hostname could not be resolved' };
1229
+ }
1230
+ const blocked = addresses.find((entry) => isBlockedAddress(entry.address));
1231
+ if (blocked) {
1232
+ return { ok: false, message: '"url" must not resolve to a private or loopback address' };
1233
+ }
1234
+ return {
1235
+ ok: true,
1236
+ target: {
1237
+ url: parsed,
1238
+ address: addresses[0].address,
1239
+ family: addresses[0].family
1240
+ }
1241
+ };
1242
+ }
1243
+ async function deliverWebhook(target, payload, webhookSecret) {
1244
+ const body = JSON.stringify(payload);
1245
+ const isHttps = target.url.protocol === "https:";
1246
+ const port = target.url.port ? Number(target.url.port) : isHttps ? 443 : 80;
1247
+ const path3 = `${target.url.pathname}${target.url.search}`;
1248
+ const headers = {
1249
+ "Content-Length": String(Buffer.byteLength(body)),
1250
+ "Content-Type": "application/json",
1251
+ "Host": target.url.host,
1252
+ "User-Agent": "Canonry/0.1.0"
1253
+ };
1254
+ if (webhookSecret) {
1255
+ headers["X-Canonry-Signature"] = "sha256=" + crypto7.createHmac("sha256", webhookSecret).update(body).digest("hex");
1256
+ }
1257
+ return await new Promise((resolve) => {
1258
+ const requestOptions = {
1259
+ family: target.family,
1260
+ headers,
1261
+ hostname: target.address,
1262
+ method: "POST",
1263
+ path: path3,
1264
+ port,
1265
+ timeout: REQUEST_TIMEOUT_MS
1266
+ };
1267
+ if (isHttps) {
1268
+ requestOptions.servername = stripIpv6Brackets(target.url.hostname);
1269
+ }
1270
+ const request = (isHttps ? https.request : http.request)(requestOptions, (response) => {
1271
+ response.resume();
1272
+ response.on("end", () => {
1273
+ resolve({ status: response.statusCode ?? 0, error: null });
1274
+ });
1275
+ });
1276
+ request.on("timeout", () => {
1277
+ request.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`));
1278
+ });
1279
+ request.on("error", (error) => {
1280
+ resolve({ status: 0, error: error.message });
1281
+ });
1282
+ request.end(body);
1283
+ });
1284
+ }
1285
+ async function resolveHostAddresses(hostname) {
1286
+ const family = net.isIP(hostname);
1287
+ if (family === 4 || family === 6) {
1288
+ return [{ address: hostname, family }];
1289
+ }
1290
+ try {
1291
+ const records = await dns.lookup(hostname, { all: true, verbatim: true });
1292
+ const unique = /* @__PURE__ */ new Map();
1293
+ for (const record of records) {
1294
+ if (record.family !== 4 && record.family !== 6) continue;
1295
+ unique.set(`${record.family}:${record.address}`, {
1296
+ address: record.address,
1297
+ family: record.family
1298
+ });
1299
+ }
1300
+ return [...unique.values()];
1301
+ } catch {
1302
+ return [];
1303
+ }
1304
+ }
1305
+ function isBlockedAddress(address) {
1306
+ const normalized = stripIpv6Brackets(address).toLowerCase();
1307
+ const family = net.isIP(normalized);
1308
+ if (family === 4) {
1309
+ return isBlockedIpv4(normalized);
1310
+ }
1311
+ if (family === 6) {
1312
+ const mappedIpv4 = extractMappedIpv4(normalized);
1313
+ if (mappedIpv4) {
1314
+ return isBlockedIpv4(mappedIpv4);
1315
+ }
1316
+ return isBlockedIpv6(normalized);
1317
+ }
1318
+ return true;
1319
+ }
1320
+ function isBlockedIpv4(address) {
1321
+ const octets = address.split(".").map((part) => Number.parseInt(part, 10));
1322
+ if (octets.length !== 4 || octets.some(Number.isNaN)) {
1323
+ return true;
1324
+ }
1325
+ const [first, second] = octets;
1326
+ return first === 0 || first === 10 || first === 127 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 198 && (second === 18 || second === 19);
1327
+ }
1328
+ function isBlockedIpv6(address) {
1329
+ const normalized = address.split("%")[0].toLowerCase();
1330
+ if (normalized === "::" || normalized === "::1") {
1331
+ return true;
1332
+ }
1333
+ const firstHextetText = normalized.split(":")[0] ?? "";
1334
+ const firstHextet = firstHextetText === "" ? 0 : Number.parseInt(firstHextetText, 16);
1335
+ if (Number.isNaN(firstHextet)) {
1336
+ return true;
1337
+ }
1338
+ return firstHextet >= 64512 && firstHextet <= 65023 || firstHextet >= 65152 && firstHextet <= 65215;
1339
+ }
1340
+ function extractMappedIpv4(address) {
1341
+ const normalized = address.toLowerCase();
1342
+ if (!normalized.startsWith("::ffff:")) {
1343
+ return null;
1344
+ }
1345
+ const remainder = normalized.slice("::ffff:".length);
1346
+ if (net.isIP(remainder) === 4) {
1347
+ return remainder;
1348
+ }
1349
+ const parts = remainder.split(":");
1350
+ if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
1351
+ return null;
1352
+ }
1353
+ const high = Number.parseInt(parts[0], 16);
1354
+ const low = Number.parseInt(parts[1], 16);
1355
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
1356
+ }
1357
+ function stripIpv6Brackets(value) {
1358
+ return value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
1359
+ }
1360
+
1361
+ // ../api-routes/src/apply.ts
1362
+ async function applyRoutes(app, opts) {
1363
+ app.post("/apply", async (request, reply) => {
1364
+ const parsed = projectConfigSchema.safeParse(request.body);
1365
+ if (!parsed.success) {
1366
+ const err = validationError("Invalid project config", {
1367
+ issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
1368
+ });
1369
+ return reply.status(err.statusCode).send(err.toJSON());
1370
+ }
1371
+ const config = parsed.data;
1372
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1373
+ const name = config.metadata.name;
1374
+ const existing = app.db.select().from(projects).where(eq8(projects.name, name)).get();
1375
+ let projectId;
1376
+ if (existing) {
1377
+ projectId = existing.id;
1378
+ app.db.update(projects).set({
1379
+ displayName: config.spec.displayName,
1380
+ canonicalDomain: config.spec.canonicalDomain,
1381
+ country: config.spec.country,
1382
+ language: config.spec.language,
1383
+ labels: JSON.stringify(config.metadata.labels),
1384
+ providers: JSON.stringify(config.spec.providers ?? []),
1385
+ configSource: "config-file",
1386
+ configRevision: existing.configRevision + 1,
1387
+ updatedAt: now
1388
+ }).where(eq8(projects.id, existing.id)).run();
1389
+ writeAuditLog(app.db, {
1390
+ projectId,
1391
+ actor: "api",
1392
+ action: "project.applied",
1393
+ entityType: "project",
1394
+ entityId: projectId
1395
+ });
1396
+ } else {
1397
+ projectId = crypto8.randomUUID();
1398
+ app.db.insert(projects).values({
1399
+ id: projectId,
1400
+ name,
1401
+ displayName: config.spec.displayName,
1402
+ canonicalDomain: config.spec.canonicalDomain,
1403
+ country: config.spec.country,
1404
+ language: config.spec.language,
1405
+ tags: "[]",
1406
+ labels: JSON.stringify(config.metadata.labels),
1407
+ providers: JSON.stringify(config.spec.providers ?? []),
1408
+ configSource: "config-file",
1409
+ configRevision: 1,
1410
+ createdAt: now,
1411
+ updatedAt: now
1412
+ }).run();
1413
+ writeAuditLog(app.db, {
1414
+ projectId,
1415
+ actor: "api",
1416
+ action: "project.created",
1417
+ entityType: "project",
1418
+ entityId: projectId
1419
+ });
1420
+ }
1421
+ app.db.transaction((tx) => {
1422
+ tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
1423
+ for (const kw of config.spec.keywords) {
1424
+ tx.insert(keywords).values({
1425
+ id: crypto8.randomUUID(),
1426
+ projectId,
1427
+ keyword: kw,
1428
+ createdAt: now
1429
+ }).run();
1430
+ }
1431
+ writeAuditLog(tx, {
1432
+ projectId,
1433
+ actor: "api",
1434
+ action: "keywords.replaced",
1435
+ entityType: "keyword",
1436
+ diff: { keywords: config.spec.keywords }
1437
+ });
1438
+ tx.delete(competitors).where(eq8(competitors.projectId, projectId)).run();
1439
+ for (const domain of config.spec.competitors) {
1440
+ tx.insert(competitors).values({
1441
+ id: crypto8.randomUUID(),
1442
+ projectId,
1443
+ domain,
1444
+ createdAt: now
1445
+ }).run();
1446
+ }
1447
+ writeAuditLog(tx, {
1448
+ projectId,
1449
+ actor: "api",
1450
+ action: "competitors.replaced",
1451
+ entityType: "competitor",
1452
+ diff: { competitors: config.spec.competitors }
1453
+ });
1454
+ });
1455
+ if (config.spec.schedule) {
1456
+ const schedSpec = config.spec.schedule;
1457
+ let cronExpr;
1458
+ let preset = null;
1459
+ if (schedSpec.preset) {
1460
+ preset = schedSpec.preset;
1461
+ try {
1462
+ cronExpr = resolvePreset(schedSpec.preset);
1463
+ } catch (err) {
1464
+ const msg = err instanceof Error ? err.message : String(err);
1465
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: msg } });
1466
+ }
1467
+ } else if (schedSpec.cron) {
1468
+ cronExpr = schedSpec.cron;
1469
+ if (!validateCron(cronExpr)) {
1470
+ return reply.status(400).send({
1471
+ error: { code: "VALIDATION_ERROR", message: `Invalid cron expression in schedule: ${cronExpr}` }
1472
+ });
1473
+ }
1474
+ } else {
1475
+ return reply.status(400).send({
1476
+ error: { code: "VALIDATION_ERROR", message: 'Schedule requires either "preset" or "cron"' }
1477
+ });
1478
+ }
1479
+ const timezone = schedSpec.timezone ?? "UTC";
1480
+ if (!isValidTimezone(timezone)) {
1481
+ return reply.status(400).send({
1482
+ error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
1483
+ });
1484
+ }
1485
+ const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
1486
+ if (existingSched) {
1487
+ app.db.update(schedules).set({
1488
+ cronExpr,
1489
+ preset,
1490
+ timezone,
1491
+ providers: JSON.stringify(schedSpec.providers ?? []),
1492
+ enabled: 1,
1493
+ updatedAt: now
1494
+ }).where(eq8(schedules.id, existingSched.id)).run();
1495
+ } else {
1496
+ app.db.insert(schedules).values({
1497
+ id: crypto8.randomUUID(),
1498
+ projectId,
1499
+ cronExpr,
1500
+ preset,
1501
+ timezone,
1502
+ enabled: 1,
1503
+ providers: JSON.stringify(schedSpec.providers ?? []),
1504
+ createdAt: now,
1505
+ updatedAt: now
1506
+ }).run();
1507
+ }
1508
+ opts?.onScheduleUpdated?.("upsert", projectId);
1509
+ } else {
1510
+ const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
1511
+ if (existingSched) {
1512
+ app.db.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
1513
+ opts?.onScheduleUpdated?.("delete", projectId);
1514
+ }
1515
+ }
1516
+ const rawSpec = request.body?.spec ?? {};
1517
+ if ("notifications" in rawSpec) {
1518
+ for (const notif of config.spec.notifications) {
1519
+ const urlCheck = await resolveWebhookTarget(notif.url ?? "");
1520
+ if (!urlCheck.ok) {
1521
+ return reply.status(400).send({
1522
+ error: { code: "VALIDATION_ERROR", message: `Notification URL invalid: ${urlCheck.message}` }
1523
+ });
1524
+ }
1525
+ }
1526
+ app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
1527
+ for (const notif of config.spec.notifications) {
1528
+ app.db.insert(notifications).values({
1529
+ id: crypto8.randomUUID(),
1530
+ projectId,
1531
+ channel: notif.channel,
1532
+ config: JSON.stringify({ url: notif.url, events: notif.events }),
1533
+ webhookSecret: crypto8.randomBytes(32).toString("hex"),
1534
+ enabled: 1,
1535
+ createdAt: now,
1536
+ updatedAt: now
1537
+ }).run();
1538
+ }
1539
+ writeAuditLog(app.db, {
1540
+ projectId,
1541
+ actor: "api",
1542
+ action: "notifications.replaced",
1543
+ entityType: "notification",
1544
+ diff: { notifications: config.spec.notifications }
1545
+ });
1546
+ }
1547
+ const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
1548
+ return reply.status(200).send({
1549
+ id: project.id,
1550
+ name: project.name,
1551
+ displayName: project.displayName,
1552
+ canonicalDomain: project.canonicalDomain,
1553
+ country: project.country,
1554
+ language: project.language,
1555
+ tags: JSON.parse(project.tags),
1556
+ labels: JSON.parse(project.labels),
1557
+ providers: JSON.parse(project.providers || "[]"),
1558
+ configSource: project.configSource,
1559
+ configRevision: project.configRevision,
1560
+ createdAt: project.createdAt,
1561
+ updatedAt: project.updatedAt
1562
+ });
1563
+ });
1564
+ }
1565
+
1566
+ // ../api-routes/src/history.ts
1567
+ import { eq as eq9, desc, inArray } from "drizzle-orm";
1568
+ async function historyRoutes(app) {
1569
+ app.get("/projects/:name/history", async (request, reply) => {
1570
+ const project = resolveProjectSafe4(app, request.params.name, reply);
1571
+ if (!project) return;
1572
+ const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc(auditLog.createdAt)).all();
1573
+ return reply.send(rows.map(formatAuditEntry));
1574
+ });
1575
+ app.get("/history", async (_request, reply) => {
1576
+ const rows = app.db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).all();
1577
+ return reply.send(rows.map(formatAuditEntry));
1578
+ });
1579
+ app.get("/projects/:name/snapshots", async (request, reply) => {
1580
+ const project = resolveProjectSafe4(app, request.params.name, reply);
1581
+ if (!project) return;
1582
+ const limit = parseInt(request.query.limit ?? "50", 10);
1583
+ const offset = parseInt(request.query.offset ?? "0", 10);
1584
+ const projectRuns = app.db.select({ id: runs.id }).from(runs).where(eq9(runs.projectId, project.id)).all();
1585
+ if (projectRuns.length === 0) {
1586
+ return reply.send({ snapshots: [], total: 0 });
1587
+ }
1588
+ const allSnapshots = app.db.select({
1589
+ id: querySnapshots.id,
1590
+ runId: querySnapshots.runId,
1591
+ keywordId: querySnapshots.keywordId,
1592
+ keyword: keywords.keyword,
1593
+ provider: querySnapshots.provider,
1594
+ citationState: querySnapshots.citationState,
1595
+ answerText: querySnapshots.answerText,
1596
+ citedDomains: querySnapshots.citedDomains,
1597
+ competitorOverlap: querySnapshots.competitorOverlap,
1598
+ createdAt: querySnapshots.createdAt
1599
+ }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc(querySnapshots.createdAt)).all();
1600
+ const total = allSnapshots.length;
1601
+ const paged = allSnapshots.slice(offset, offset + limit);
1602
+ return reply.send({
1603
+ snapshots: paged.map((s) => ({
1604
+ id: s.id,
1605
+ runId: s.runId,
1606
+ keywordId: s.keywordId,
1607
+ keyword: s.keyword,
1608
+ provider: s.provider,
1609
+ citationState: s.citationState,
1610
+ answerText: s.answerText,
1611
+ citedDomains: tryParseJson2(s.citedDomains, []),
1612
+ competitorOverlap: tryParseJson2(s.competitorOverlap, []),
1613
+ createdAt: s.createdAt
1614
+ })),
1615
+ total
1616
+ });
1617
+ });
1618
+ app.get("/projects/:name/timeline", async (request, reply) => {
1619
+ const project = resolveProjectSafe4(app, request.params.name, reply);
1620
+ if (!project) return;
1621
+ const projectKeywords = app.db.select().from(keywords).where(eq9(keywords.projectId, project.id)).all();
1622
+ const projectRuns = app.db.select().from(runs).where(eq9(runs.projectId, project.id)).orderBy(runs.createdAt).all();
1623
+ if (projectRuns.length === 0 || projectKeywords.length === 0) {
1624
+ return reply.send([]);
1625
+ }
1626
+ const runIds = new Set(projectRuns.map((r) => r.id));
1627
+ const allSnapshots = app.db.select().from(querySnapshots).where(inArray(querySnapshots.runId, [...runIds])).all();
1628
+ const deduped = /* @__PURE__ */ new Map();
1629
+ for (const snap of allSnapshots) {
1630
+ const key = `${snap.runId}:${snap.keywordId}`;
1631
+ const existing = deduped.get(key);
1632
+ if (!existing || snap.citationState === "cited") {
1633
+ deduped.set(key, snap);
1634
+ }
1635
+ }
1636
+ const dedupedSnapshots = [...deduped.values()];
1637
+ const timeline = projectKeywords.map((kw) => {
1638
+ const kwSnapshots = dedupedSnapshots.filter((s) => s.keywordId === kw.id).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1639
+ const runEntries = kwSnapshots.map((snap, idx) => {
1640
+ const run = projectRuns.find((r) => r.id === snap.runId);
1641
+ let transition = snap.citationState === "cited" ? "cited" : "not-cited";
1642
+ if (idx === 0) {
1643
+ transition = "new";
1644
+ } else {
1645
+ const prev = kwSnapshots[idx - 1];
1646
+ if (prev.citationState === "not-cited" && snap.citationState === "cited") {
1647
+ transition = "emerging";
1648
+ } else if (prev.citationState === "cited" && snap.citationState === "not-cited") {
1649
+ transition = "lost";
1650
+ }
1651
+ }
1652
+ return {
1653
+ runId: snap.runId,
1654
+ createdAt: run?.createdAt ?? snap.createdAt,
1655
+ citationState: snap.citationState,
1656
+ transition
1657
+ };
1658
+ });
1659
+ return {
1660
+ keyword: kw.keyword,
1661
+ runs: runEntries
1662
+ };
1663
+ });
1664
+ return reply.send(timeline);
1665
+ });
1666
+ app.get("/projects/:name/snapshots/diff", async (request, reply) => {
1667
+ const project = resolveProjectSafe4(app, request.params.name, reply);
1668
+ if (!project) return;
1669
+ const { run1, run2 } = request.query;
1670
+ if (!run1 || !run2) {
1671
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "Both run1 and run2 query params are required" } });
1672
+ }
1673
+ const snaps1 = app.db.select({
1674
+ keywordId: querySnapshots.keywordId,
1675
+ keyword: keywords.keyword,
1676
+ citationState: querySnapshots.citationState
1677
+ }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(eq9(querySnapshots.runId, run1)).all();
1678
+ const snaps2 = app.db.select({
1679
+ keywordId: querySnapshots.keywordId,
1680
+ keyword: keywords.keyword,
1681
+ citationState: querySnapshots.citationState
1682
+ }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(eq9(querySnapshots.runId, run2)).all();
1683
+ const map1 = /* @__PURE__ */ new Map();
1684
+ for (const s of snaps1) {
1685
+ const existing = map1.get(s.keywordId);
1686
+ if (!existing || s.citationState === "cited") map1.set(s.keywordId, s);
1687
+ }
1688
+ const map2 = /* @__PURE__ */ new Map();
1689
+ for (const s of snaps2) {
1690
+ const existing = map2.get(s.keywordId);
1691
+ if (!existing || s.citationState === "cited") map2.set(s.keywordId, s);
1692
+ }
1693
+ const allKeywordIds = /* @__PURE__ */ new Set([...map1.keys(), ...map2.keys()]);
1694
+ const diff = [...allKeywordIds].map((kwId) => {
1695
+ const s1 = map1.get(kwId);
1696
+ const s2 = map2.get(kwId);
1697
+ return {
1698
+ keywordId: kwId,
1699
+ keyword: s2?.keyword ?? s1?.keyword ?? null,
1700
+ run1State: s1?.citationState ?? null,
1701
+ run2State: s2?.citationState ?? null,
1702
+ changed: (s1?.citationState ?? null) !== (s2?.citationState ?? null)
1703
+ };
1704
+ });
1705
+ return reply.send({ run1, run2, diff });
1706
+ });
1707
+ }
1708
+ function formatAuditEntry(row) {
1709
+ return {
1710
+ id: row.id,
1711
+ projectId: row.projectId,
1712
+ actor: row.actor,
1713
+ action: row.action,
1714
+ entityType: row.entityType,
1715
+ entityId: row.entityId,
1716
+ diff: row.diff ? tryParseJson2(row.diff, null) : null,
1717
+ createdAt: row.createdAt
1718
+ };
1719
+ }
1720
+ function tryParseJson2(value, fallback) {
1721
+ try {
1722
+ return JSON.parse(value);
1723
+ } catch {
1724
+ return fallback;
1725
+ }
1726
+ }
1727
+ function resolveProjectSafe4(app, name, reply) {
1728
+ try {
1729
+ return resolveProject(app.db, name);
1730
+ } catch (e) {
1731
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1732
+ const err = e;
1733
+ reply.status(err.statusCode).send(err.toJSON());
1734
+ return null;
1735
+ }
1736
+ throw e;
1737
+ }
1738
+ }
1739
+
1740
+ // ../api-routes/src/settings.ts
1741
+ async function settingsRoutes(app, opts) {
1742
+ app.get("/settings", async () => ({
1743
+ providers: opts.providerSummary ?? []
1744
+ }));
1745
+ app.put("/settings/providers/:name", async (request, reply) => {
1746
+ const { name } = request.params;
1747
+ const { apiKey, baseUrl, model } = request.body ?? {};
1748
+ const validProviders = ["gemini", "openai", "claude", "local"];
1749
+ if (!validProviders.includes(name)) {
1750
+ return reply.status(400).send({ error: `Invalid provider: ${name}. Must be one of: ${validProviders.join(", ")}` });
1751
+ }
1752
+ if (name === "local") {
1753
+ if (!baseUrl || typeof baseUrl !== "string") {
1754
+ return reply.status(400).send({ error: "baseUrl is required for local provider" });
1755
+ }
1756
+ } else {
1757
+ if (!apiKey || typeof apiKey !== "string") {
1758
+ return reply.status(400).send({ error: "apiKey is required" });
1759
+ }
1760
+ }
1761
+ if (model !== void 0) {
1762
+ if (name === "gemini" && !model.startsWith("gemini-")) {
1763
+ return reply.status(400).send({
1764
+ error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "gemini" \u2014 model name must start with "gemini-" (e.g. gemini-2.5-flash)` }
1765
+ });
1766
+ }
1767
+ if (name === "openai" && !/^(gpt-|o\d)/.test(model)) {
1768
+ return reply.status(400).send({
1769
+ error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "openai" \u2014 expected a GPT or o-series model name (e.g. gpt-4o, o3)` }
1770
+ });
1771
+ }
1772
+ if (name === "claude" && !model.startsWith("claude-")) {
1773
+ return reply.status(400).send({
1774
+ error: { code: "VALIDATION_ERROR", message: `Invalid model "${model}" for provider "claude" \u2014 model name must start with "claude-" (e.g. claude-sonnet-4-6)` }
1775
+ });
1776
+ }
1777
+ }
1778
+ if (!opts.onProviderUpdate) {
1779
+ return reply.status(501).send({ error: "Provider configuration updates are not supported in this deployment" });
1780
+ }
1781
+ const result = opts.onProviderUpdate(name, apiKey ?? "", model, baseUrl);
1782
+ if (!result) {
1783
+ return reply.status(500).send({ error: "Failed to update provider configuration" });
1784
+ }
1785
+ return result;
1786
+ });
1787
+ }
1788
+
1789
+ // ../api-routes/src/schedules.ts
1790
+ import crypto9 from "crypto";
1791
+ import { eq as eq10 } from "drizzle-orm";
1792
+ async function scheduleRoutes(app, opts) {
1793
+ app.put("/projects/:name/schedule", async (request, reply) => {
1794
+ const project = resolveProjectSafe5(app, request.params.name, reply);
1795
+ if (!project) return;
1796
+ const { preset, cron: cron2, timezone = "UTC", providers = [], enabled = true } = request.body ?? {};
1797
+ if (!preset && !cron2 || preset && cron2) {
1798
+ return reply.status(400).send({
1799
+ error: { code: "VALIDATION_ERROR", message: 'Exactly one of "preset" or "cron" must be provided' }
1800
+ });
1801
+ }
1802
+ if (!isValidTimezone(timezone)) {
1803
+ return reply.status(400).send({
1804
+ error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
1805
+ });
1806
+ }
1807
+ let cronExpr;
1808
+ if (preset) {
1809
+ try {
1810
+ cronExpr = resolvePreset(preset);
1811
+ } catch (err) {
1812
+ const msg = err instanceof Error ? err.message : String(err);
1813
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: msg } });
1814
+ }
1815
+ } else {
1816
+ cronExpr = cron2;
1817
+ if (!validateCron(cronExpr)) {
1818
+ return reply.status(400).send({
1819
+ error: { code: "VALIDATION_ERROR", message: `Invalid cron expression: ${cronExpr}` }
1820
+ });
1821
+ }
1822
+ }
1823
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1824
+ const enabledInt = enabled === false ? 0 : 1;
1825
+ const existing = app.db.select().from(schedules).where(eq10(schedules.projectId, project.id)).get();
1826
+ if (existing) {
1827
+ app.db.update(schedules).set({
1828
+ cronExpr,
1829
+ preset: preset ?? null,
1830
+ timezone,
1831
+ providers: JSON.stringify(providers),
1832
+ enabled: enabledInt,
1833
+ updatedAt: now
1834
+ }).where(eq10(schedules.id, existing.id)).run();
1835
+ } else {
1836
+ app.db.insert(schedules).values({
1837
+ id: crypto9.randomUUID(),
1838
+ projectId: project.id,
1839
+ cronExpr,
1840
+ preset: preset ?? null,
1841
+ timezone,
1842
+ enabled: enabledInt,
1843
+ providers: JSON.stringify(providers),
1844
+ createdAt: now,
1845
+ updatedAt: now
1846
+ }).run();
1847
+ }
1848
+ writeAuditLog(app.db, {
1849
+ projectId: project.id,
1850
+ actor: "api",
1851
+ action: existing ? "schedule.updated" : "schedule.created",
1852
+ entityType: "schedule",
1853
+ diff: { cronExpr, preset, timezone, providers }
1854
+ });
1855
+ opts.onScheduleUpdated?.("upsert", project.id);
1856
+ const schedule = app.db.select().from(schedules).where(eq10(schedules.projectId, project.id)).get();
1857
+ return reply.status(existing ? 200 : 201).send(formatSchedule(schedule));
1858
+ });
1859
+ app.get("/projects/:name/schedule", async (request, reply) => {
1860
+ const project = resolveProjectSafe5(app, request.params.name, reply);
1861
+ if (!project) return;
1862
+ const schedule = app.db.select().from(schedules).where(eq10(schedules.projectId, project.id)).get();
1863
+ if (!schedule) {
1864
+ return reply.status(404).send({ error: { code: "NOT_FOUND", message: `No schedule for project '${request.params.name}'` } });
1865
+ }
1866
+ return reply.send(formatSchedule(schedule));
1867
+ });
1868
+ app.delete("/projects/:name/schedule", async (request, reply) => {
1869
+ const project = resolveProjectSafe5(app, request.params.name, reply);
1870
+ if (!project) return;
1871
+ const schedule = app.db.select().from(schedules).where(eq10(schedules.projectId, project.id)).get();
1872
+ if (!schedule) {
1873
+ return reply.status(404).send({ error: { code: "NOT_FOUND", message: `No schedule for project '${request.params.name}'` } });
1874
+ }
1875
+ app.db.delete(schedules).where(eq10(schedules.id, schedule.id)).run();
1876
+ writeAuditLog(app.db, {
1877
+ projectId: project.id,
1878
+ actor: "api",
1879
+ action: "schedule.deleted",
1880
+ entityType: "schedule",
1881
+ entityId: schedule.id
1882
+ });
1883
+ opts.onScheduleUpdated?.("delete", project.id);
1884
+ return reply.status(204).send();
1885
+ });
1886
+ }
1887
+ function formatSchedule(row) {
1888
+ return {
1889
+ id: row.id,
1890
+ projectId: row.projectId,
1891
+ cronExpr: row.cronExpr,
1892
+ preset: row.preset,
1893
+ timezone: row.timezone,
1894
+ enabled: row.enabled === 1,
1895
+ providers: JSON.parse(row.providers),
1896
+ lastRunAt: row.lastRunAt,
1897
+ nextRunAt: row.nextRunAt,
1898
+ createdAt: row.createdAt,
1899
+ updatedAt: row.updatedAt
1900
+ };
1901
+ }
1902
+ function resolveProjectSafe5(app, name, reply) {
1903
+ try {
1904
+ return resolveProject(app.db, name);
1905
+ } catch (e) {
1906
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1907
+ const err = e;
1908
+ reply.status(err.statusCode).send(err.toJSON());
1909
+ return null;
1910
+ }
1911
+ throw e;
1912
+ }
1913
+ }
1914
+
1915
+ // ../api-routes/src/notifications.ts
1916
+ import crypto10 from "crypto";
1917
+ import { eq as eq11 } from "drizzle-orm";
1918
+ var VALID_EVENTS = ["citation.lost", "citation.gained", "run.completed", "run.failed"];
1919
+ async function notificationRoutes(app) {
1920
+ app.post("/projects/:name/notifications", async (request, reply) => {
1921
+ const project = resolveProjectSafe6(app, request.params.name, reply);
1922
+ if (!project) return;
1923
+ const { channel, url, events } = request.body ?? {};
1924
+ if (channel !== "webhook") {
1925
+ return reply.status(400).send({
1926
+ error: { code: "VALIDATION_ERROR", message: 'Only "webhook" channel is supported' }
1927
+ });
1928
+ }
1929
+ const urlCheck = await resolveWebhookTarget(url ?? "");
1930
+ if (!urlCheck.ok) {
1931
+ return reply.status(400).send({
1932
+ error: { code: "VALIDATION_ERROR", message: urlCheck.message }
1933
+ });
1934
+ }
1935
+ if (!events?.length) {
1936
+ return reply.status(400).send({
1937
+ error: { code: "VALIDATION_ERROR", message: '"events" must be a non-empty array' }
1938
+ });
1939
+ }
1940
+ const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
1941
+ if (invalid.length) {
1942
+ return reply.status(400).send({
1943
+ error: { code: "VALIDATION_ERROR", message: `Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}` }
1944
+ });
1945
+ }
1946
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1947
+ const id = crypto10.randomUUID();
1948
+ const webhookSecret = crypto10.randomBytes(32).toString("hex");
1949
+ app.db.insert(notifications).values({
1950
+ id,
1951
+ projectId: project.id,
1952
+ channel: "webhook",
1953
+ config: JSON.stringify({ url, events }),
1954
+ webhookSecret,
1955
+ enabled: 1,
1956
+ createdAt: now,
1957
+ updatedAt: now
1958
+ }).run();
1959
+ writeAuditLog(app.db, {
1960
+ projectId: project.id,
1961
+ actor: "api",
1962
+ action: "notification.created",
1963
+ entityType: "notification",
1964
+ entityId: id,
1965
+ diff: { channel, url, events }
1966
+ });
1967
+ return reply.status(201).send({
1968
+ ...formatNotification(app.db.select().from(notifications).where(eq11(notifications.id, id)).get()),
1969
+ webhookSecret
1970
+ });
1971
+ });
1972
+ app.get("/projects/:name/notifications", async (request, reply) => {
1973
+ const project = resolveProjectSafe6(app, request.params.name, reply);
1974
+ if (!project) return;
1975
+ const rows = app.db.select().from(notifications).where(eq11(notifications.projectId, project.id)).all();
1976
+ return reply.send(rows.map(formatNotification));
1977
+ });
1978
+ app.delete("/projects/:name/notifications/:id", async (request, reply) => {
1979
+ const project = resolveProjectSafe6(app, request.params.name, reply);
1980
+ if (!project) return;
1981
+ const notification = app.db.select().from(notifications).where(eq11(notifications.id, request.params.id)).get();
1982
+ if (!notification || notification.projectId !== project.id) {
1983
+ return reply.status(404).send({
1984
+ error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
1985
+ });
1986
+ }
1987
+ app.db.delete(notifications).where(eq11(notifications.id, notification.id)).run();
1988
+ writeAuditLog(app.db, {
1989
+ projectId: project.id,
1990
+ actor: "api",
1991
+ action: "notification.deleted",
1992
+ entityType: "notification",
1993
+ entityId: notification.id
1994
+ });
1995
+ return reply.status(204).send();
1996
+ });
1997
+ app.post("/projects/:name/notifications/:id/test", async (request, reply) => {
1998
+ const project = resolveProjectSafe6(app, request.params.name, reply);
1999
+ if (!project) return;
2000
+ const notification = app.db.select().from(notifications).where(eq11(notifications.id, request.params.id)).get();
2001
+ if (!notification || notification.projectId !== project.id) {
2002
+ return reply.status(404).send({
2003
+ error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
2004
+ });
2005
+ }
2006
+ const config = JSON.parse(notification.config);
2007
+ const urlCheck = await resolveWebhookTarget(config.url);
2008
+ if (!urlCheck.ok) {
2009
+ return reply.status(400).send({
2010
+ error: { code: "VALIDATION_ERROR", message: `Stored webhook URL is invalid: ${urlCheck.message}` }
2011
+ });
2012
+ }
2013
+ const payload = {
2014
+ source: "canonry",
2015
+ event: "run.completed",
2016
+ project: { name: project.name, canonicalDomain: project.canonicalDomain },
2017
+ run: { id: "test-run-id", status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() },
2018
+ transitions: [
2019
+ { keyword: "test keyword", from: "not-cited", to: "cited", provider: "gemini" }
2020
+ ],
2021
+ dashboardUrl: `/projects/${project.name}`
2022
+ };
2023
+ request.log.info(`[Notification test] POST ${config.url}`);
2024
+ const { status, error } = await deliverWebhook(urlCheck.target, payload, notification.webhookSecret ?? null);
2025
+ request.log.info(`[Notification test] Response: HTTP ${status} from ${config.url}`);
2026
+ writeAuditLog(app.db, {
2027
+ projectId: project.id,
2028
+ actor: "api",
2029
+ action: "notification.tested",
2030
+ entityType: "notification",
2031
+ entityId: notification.id,
2032
+ diff: { status, error }
2033
+ });
2034
+ if (error) {
2035
+ return reply.status(502).send({ error: { code: "DELIVERY_FAILED", message: error } });
2036
+ }
2037
+ return reply.send({ status, ok: status >= 200 && status < 300 });
2038
+ });
2039
+ }
2040
+ function formatNotification(row) {
2041
+ const config = JSON.parse(row.config);
2042
+ return {
2043
+ id: row.id,
2044
+ projectId: row.projectId,
2045
+ channel: "webhook",
2046
+ url: config.url,
2047
+ events: config.events,
2048
+ enabled: row.enabled === 1,
2049
+ createdAt: row.createdAt,
2050
+ updatedAt: row.updatedAt
2051
+ };
2052
+ }
2053
+ function resolveProjectSafe6(app, name, reply) {
2054
+ try {
2055
+ return resolveProject(app.db, name);
2056
+ } catch (e) {
2057
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
2058
+ const err = e;
2059
+ reply.status(err.statusCode).send(err.toJSON());
2060
+ return null;
2061
+ }
2062
+ throw e;
2063
+ }
2064
+ }
2065
+
2066
+ // ../api-routes/src/index.ts
2067
+ async function apiRoutes(app, opts) {
2068
+ app.decorate("db", opts.db);
2069
+ if (!opts.skipAuth) {
2070
+ await app.register(authPlugin);
2071
+ }
2072
+ await app.register(async (api) => {
2073
+ await api.register(projectRoutes, {
2074
+ onProjectDeleted: opts.onProjectDeleted
2075
+ });
2076
+ await api.register(keywordRoutes);
2077
+ await api.register(competitorRoutes);
2078
+ await api.register(runRoutes, { onRunCreated: opts.onRunCreated });
2079
+ await api.register(applyRoutes, {
2080
+ onScheduleUpdated: opts.onScheduleUpdated
2081
+ });
2082
+ await api.register(historyRoutes);
2083
+ await api.register(settingsRoutes, {
2084
+ providerSummary: opts.providerSummary,
2085
+ onProviderUpdate: opts.onProviderUpdate
2086
+ });
2087
+ await api.register(scheduleRoutes, {
2088
+ onScheduleUpdated: opts.onScheduleUpdated
2089
+ });
2090
+ await api.register(notificationRoutes);
2091
+ }, { prefix: "/api/v1" });
2092
+ }
2093
+
2094
+ // ../provider-gemini/src/normalize.ts
2095
+ import { GoogleGenerativeAI } from "@google/generative-ai";
2096
+ var DEFAULT_MODEL = "gemini-2.5-flash";
2097
+ function resolveModel(config) {
2098
+ const m = config.model;
2099
+ if (!m) return DEFAULT_MODEL;
2100
+ if (m.startsWith("gemini-")) return m;
2101
+ console.warn(
2102
+ `[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}.`
2103
+ );
2104
+ return DEFAULT_MODEL;
2105
+ }
2106
+ function validateConfig(config) {
2107
+ if (!config.apiKey || config.apiKey.length === 0) {
2108
+ return { ok: false, provider: "gemini", message: "missing api key" };
2109
+ }
2110
+ const model = resolveModel(config);
2111
+ const warning = config.model && !config.model.startsWith("gemini-") ? ` (invalid model "${config.model}" replaced with default)` : "";
2112
+ return {
2113
+ ok: true,
2114
+ provider: "gemini",
2115
+ message: `config valid${warning}`,
2116
+ model
2117
+ };
2118
+ }
2119
+ async function healthcheck(config) {
2120
+ const validation = validateConfig(config);
2121
+ if (!validation.ok) return validation;
2122
+ try {
2123
+ const genAI = new GoogleGenerativeAI(config.apiKey);
2124
+ const model = genAI.getGenerativeModel({ model: resolveModel(config) });
2125
+ const result = await model.generateContent('Say "ok"');
2126
+ const text2 = result.response.text();
2127
+ return {
2128
+ ok: text2.length > 0,
2129
+ provider: "gemini",
2130
+ message: text2.length > 0 ? "gemini api key verified" : "empty response from gemini",
2131
+ model: config.model ?? DEFAULT_MODEL
2132
+ };
2133
+ } catch (err) {
2134
+ return {
2135
+ ok: false,
2136
+ provider: "gemini",
2137
+ message: err instanceof Error ? err.message : String(err),
2138
+ model: config.model ?? DEFAULT_MODEL
2139
+ };
2140
+ }
2141
+ }
2142
+ async function executeTrackedQuery(input) {
2143
+ const model = resolveModel(input.config);
2144
+ const genAI = new GoogleGenerativeAI(input.config.apiKey);
2145
+ const generativeModel = genAI.getGenerativeModel({
2146
+ model,
2147
+ tools: [{ googleSearch: {} }]
2148
+ });
2149
+ const prompt = buildPrompt(input.keyword);
2150
+ const result = await generativeModel.generateContent(prompt);
2151
+ const response = result.response;
2152
+ const groundingMetadata = extractGroundingMetadata(response);
2153
+ const searchQueries = extractSearchQueries(response);
2154
+ return {
2155
+ provider: "gemini",
2156
+ rawResponse: responseToRecord(response),
2157
+ model,
2158
+ groundingSources: groundingMetadata,
2159
+ searchQueries
2160
+ };
2161
+ }
2162
+ function normalizeResult(raw) {
2163
+ const answerText = extractAnswerText(raw.rawResponse);
2164
+ const citedDomains = extractCitedDomains(raw);
2165
+ return {
2166
+ provider: "gemini",
2167
+ answerText,
2168
+ citedDomains,
2169
+ groundingSources: raw.groundingSources,
2170
+ searchQueries: raw.searchQueries
2171
+ };
2172
+ }
2173
+ function buildPrompt(keyword) {
2174
+ return keyword;
2175
+ }
2176
+ function extractAnswerText(rawResponse) {
2177
+ try {
2178
+ const candidates = rawResponse.candidates;
2179
+ if (!candidates || candidates.length === 0) return "";
2180
+ const parts = candidates[0]?.content?.parts;
2181
+ if (!parts || parts.length === 0) return "";
2182
+ return parts.map((p) => p.text ?? "").join("");
2183
+ } catch {
2184
+ return "";
2185
+ }
2186
+ }
2187
+ function extractGroundingMetadata(response) {
2188
+ try {
2189
+ const candidate = response.candidates?.[0];
2190
+ if (!candidate) return [];
2191
+ const metadata = candidate.groundingMetadata;
2192
+ if (!metadata) return [];
2193
+ const chunks = metadata.groundingChunks;
2194
+ if (!chunks) return [];
2195
+ return chunks.filter((chunk) => chunk.web?.uri).map((chunk) => ({
2196
+ uri: chunk.web.uri,
2197
+ title: chunk.web?.title ?? ""
2198
+ }));
2199
+ } catch {
2200
+ return [];
2201
+ }
2202
+ }
2203
+ function extractSearchQueries(response) {
2204
+ try {
2205
+ const candidate = response.candidates?.[0];
2206
+ return candidate?.groundingMetadata?.webSearchQueries ?? [];
2207
+ } catch {
2208
+ return [];
2209
+ }
2210
+ }
2211
+ function extractCitedDomains(raw) {
2212
+ const domains = /* @__PURE__ */ new Set();
2213
+ for (const source of raw.groundingSources) {
2214
+ const domain = extractDomainFromUri(source.uri);
2215
+ if (domain) {
2216
+ domains.add(domain);
2217
+ continue;
2218
+ }
2219
+ if (source.title) {
2220
+ const titleDomain = extractDomainFromTitle(source.title);
2221
+ if (titleDomain) domains.add(titleDomain);
2222
+ }
2223
+ }
2224
+ return [...domains];
2225
+ }
2226
+ function extractDomainFromTitle(title) {
2227
+ const trimmed = title.trim().toLowerCase();
2228
+ if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$/.test(trimmed)) {
2229
+ return trimmed.replace(/^www\./, "");
2230
+ }
2231
+ return null;
2232
+ }
2233
+ function extractDomainFromUri(uri) {
2234
+ try {
2235
+ const url = new URL(uri);
2236
+ const hostname = url.hostname.replace(/^www\./, "");
2237
+ if (hostname === "vertexaisearch.cloud.google.com") {
2238
+ const redirectPath = url.pathname.replace(/^\/grounding-api-redirect\//, "");
2239
+ if (redirectPath && redirectPath !== url.pathname) {
2240
+ try {
2241
+ const decoded = decodeURIComponent(redirectPath);
2242
+ if (decoded.startsWith("http")) {
2243
+ const realUrl = new URL(decoded);
2244
+ return realUrl.hostname.replace(/^www\./, "");
2245
+ }
2246
+ } catch {
2247
+ }
2248
+ }
2249
+ return null;
2250
+ }
2251
+ return hostname;
2252
+ } catch {
2253
+ return null;
2254
+ }
2255
+ }
2256
+ function responseToRecord(response) {
2257
+ try {
2258
+ const candidates = response.candidates?.map((c) => ({
2259
+ content: c.content,
2260
+ finishReason: c.finishReason,
2261
+ groundingMetadata: c.groundingMetadata ? {
2262
+ webSearchQueries: c.groundingMetadata.webSearchQueries,
2263
+ groundingChunks: c.groundingMetadata.groundingChunks
2264
+ } : void 0
2265
+ }));
2266
+ return {
2267
+ candidates: candidates ?? [],
2268
+ usageMetadata: response.usageMetadata ?? null
2269
+ };
2270
+ } catch {
2271
+ return { error: "failed to serialize response" };
2272
+ }
2273
+ }
2274
+
2275
+ // ../provider-gemini/src/adapter.ts
2276
+ function toGeminiConfig(config) {
2277
+ return {
2278
+ apiKey: config.apiKey ?? "",
2279
+ model: config.model,
2280
+ quotaPolicy: config.quotaPolicy
2281
+ };
2282
+ }
2283
+ var geminiAdapter = {
2284
+ name: "gemini",
2285
+ validateConfig(config) {
2286
+ const result = validateConfig(toGeminiConfig(config));
2287
+ return {
2288
+ ok: result.ok,
2289
+ provider: "gemini",
2290
+ message: result.message,
2291
+ model: result.model
2292
+ };
2293
+ },
2294
+ async healthcheck(config) {
2295
+ const result = await healthcheck(toGeminiConfig(config));
2296
+ return {
2297
+ ok: result.ok,
2298
+ provider: "gemini",
2299
+ message: result.message,
2300
+ model: result.model
2301
+ };
2302
+ },
2303
+ async executeTrackedQuery(input, config) {
2304
+ const raw = await executeTrackedQuery({
2305
+ keyword: input.keyword,
2306
+ canonicalDomains: input.canonicalDomains,
2307
+ competitorDomains: input.competitorDomains,
2308
+ config: toGeminiConfig(config)
2309
+ });
2310
+ return {
2311
+ provider: "gemini",
2312
+ rawResponse: raw.rawResponse,
2313
+ model: raw.model,
2314
+ groundingSources: raw.groundingSources,
2315
+ searchQueries: raw.searchQueries
2316
+ };
2317
+ },
2318
+ normalizeResult(raw) {
2319
+ const geminiRaw = {
2320
+ provider: "gemini",
2321
+ rawResponse: raw.rawResponse,
2322
+ model: raw.model,
2323
+ groundingSources: raw.groundingSources,
2324
+ searchQueries: raw.searchQueries
2325
+ };
2326
+ const normalized = normalizeResult(geminiRaw);
2327
+ return {
2328
+ provider: "gemini",
2329
+ answerText: normalized.answerText,
2330
+ citedDomains: normalized.citedDomains,
2331
+ groundingSources: normalized.groundingSources,
2332
+ searchQueries: normalized.searchQueries
2333
+ };
2334
+ }
2335
+ };
2336
+
2337
+ // ../provider-openai/src/normalize.ts
2338
+ import OpenAI from "openai";
2339
+ var DEFAULT_MODEL2 = "gpt-4o";
2340
+ function validateConfig2(config) {
2341
+ if (!config.apiKey || config.apiKey.length === 0) {
2342
+ return { ok: false, provider: "openai", message: "missing api key" };
2343
+ }
2344
+ return {
2345
+ ok: true,
2346
+ provider: "openai",
2347
+ message: "config valid",
2348
+ model: config.model ?? DEFAULT_MODEL2
2349
+ };
2350
+ }
2351
+ async function healthcheck2(config) {
2352
+ const validation = validateConfig2(config);
2353
+ if (!validation.ok) return validation;
2354
+ try {
2355
+ const client = new OpenAI({ apiKey: config.apiKey });
2356
+ const response = await client.responses.create({
2357
+ model: config.model ?? DEFAULT_MODEL2,
2358
+ input: 'Say "ok"'
2359
+ });
2360
+ const text2 = extractResponseText(response);
2361
+ return {
2362
+ ok: text2.length > 0,
2363
+ provider: "openai",
2364
+ message: text2.length > 0 ? "openai api key verified" : "empty response from openai",
2365
+ model: config.model ?? DEFAULT_MODEL2
2366
+ };
2367
+ } catch (err) {
2368
+ return {
2369
+ ok: false,
2370
+ provider: "openai",
2371
+ message: err instanceof Error ? err.message : String(err),
2372
+ model: config.model ?? DEFAULT_MODEL2
2373
+ };
2374
+ }
2375
+ }
2376
+ async function executeTrackedQuery2(input) {
2377
+ const model = input.config.model ?? DEFAULT_MODEL2;
2378
+ const client = new OpenAI({ apiKey: input.config.apiKey });
2379
+ const response = await client.responses.create({
2380
+ model,
2381
+ tools: [{ type: "web_search_preview" }],
2382
+ tool_choice: "required",
2383
+ input: buildPrompt2(input.keyword)
2384
+ });
2385
+ const groundingSources = extractGroundingSources(response);
2386
+ const searchQueries = extractSearchQueries2(response);
2387
+ return {
2388
+ provider: "openai",
2389
+ rawResponse: responseToRecord2(response),
2390
+ model,
2391
+ groundingSources,
2392
+ searchQueries
2393
+ };
2394
+ }
2395
+ function normalizeResult2(raw) {
2396
+ const answerText = extractAnswerTextFromRaw(raw.rawResponse);
2397
+ const citedDomains = extractCitedDomains2(raw);
2398
+ return {
2399
+ provider: "openai",
2400
+ answerText,
2401
+ citedDomains,
2402
+ groundingSources: raw.groundingSources,
2403
+ searchQueries: raw.searchQueries
2404
+ };
2405
+ }
2406
+ function buildPrompt2(keyword) {
2407
+ return `Search the web for "${keyword}" and provide a comprehensive, factual answer. Include relevant sources.`;
2408
+ }
2409
+ function extractResponseText(response) {
2410
+ try {
2411
+ const parts = [];
2412
+ for (const item of response.output) {
2413
+ if (item.type === "message") {
2414
+ for (const content of item.content) {
2415
+ if (content.type === "output_text") {
2416
+ parts.push(content.text);
2417
+ }
2418
+ }
2419
+ }
2420
+ }
2421
+ return parts.join("");
2422
+ } catch {
2423
+ return "";
2424
+ }
2425
+ }
2426
+ function extractGroundingSources(response) {
2427
+ const sources = [];
2428
+ try {
2429
+ for (const item of response.output) {
2430
+ if (item.type === "message") {
2431
+ for (const content of item.content) {
2432
+ if (content.type === "output_text" && content.annotations) {
2433
+ for (const annotation of content.annotations) {
2434
+ if (annotation.type === "url_citation") {
2435
+ sources.push({
2436
+ uri: annotation.url,
2437
+ title: annotation.title ?? ""
2438
+ });
2439
+ }
2440
+ }
2441
+ }
2442
+ }
2443
+ }
2444
+ }
2445
+ } catch {
2446
+ }
2447
+ return sources;
2448
+ }
2449
+ function extractSearchQueries2(response) {
2450
+ const queries = [];
2451
+ try {
2452
+ for (const item of response.output) {
2453
+ if (item.type === "web_search_call" && "query" in item) {
2454
+ queries.push(item.query);
2455
+ }
2456
+ }
2457
+ } catch {
2458
+ }
2459
+ return queries;
2460
+ }
2461
+ function extractAnswerTextFromRaw(rawResponse) {
2462
+ try {
2463
+ const output = rawResponse.output;
2464
+ if (!output) return "";
2465
+ const parts = [];
2466
+ for (const item of output) {
2467
+ if (item.type === "message" && item.content) {
2468
+ for (const content of item.content) {
2469
+ if (content.type === "output_text" && content.text) {
2470
+ parts.push(content.text);
2471
+ }
2472
+ }
2473
+ }
2474
+ }
2475
+ return parts.join("");
2476
+ } catch {
2477
+ return "";
2478
+ }
2479
+ }
2480
+ function extractCitedDomains2(raw) {
2481
+ const domains = /* @__PURE__ */ new Set();
2482
+ for (const source of raw.groundingSources) {
2483
+ const domain = extractDomainFromUri2(source.uri);
2484
+ if (domain) domains.add(domain);
2485
+ }
2486
+ return [...domains];
2487
+ }
2488
+ function extractDomainFromUri2(uri) {
2489
+ try {
2490
+ const url = new URL(uri);
2491
+ return url.hostname.replace(/^www\./, "");
2492
+ } catch {
2493
+ return null;
2494
+ }
2495
+ }
2496
+ function responseToRecord2(response) {
2497
+ try {
2498
+ return JSON.parse(JSON.stringify(response));
2499
+ } catch {
2500
+ return { error: "failed to serialize response" };
2501
+ }
2502
+ }
2503
+
2504
+ // ../provider-openai/src/adapter.ts
2505
+ function toOpenAIConfig(config) {
2506
+ return {
2507
+ apiKey: config.apiKey ?? "",
2508
+ model: config.model,
2509
+ quotaPolicy: config.quotaPolicy
2510
+ };
2511
+ }
2512
+ var openaiAdapter = {
2513
+ name: "openai",
2514
+ validateConfig(config) {
2515
+ const result = validateConfig2(toOpenAIConfig(config));
2516
+ return {
2517
+ ok: result.ok,
2518
+ provider: "openai",
2519
+ message: result.message,
2520
+ model: result.model
2521
+ };
2522
+ },
2523
+ async healthcheck(config) {
2524
+ const result = await healthcheck2(toOpenAIConfig(config));
2525
+ return {
2526
+ ok: result.ok,
2527
+ provider: "openai",
2528
+ message: result.message,
2529
+ model: result.model
2530
+ };
2531
+ },
2532
+ async executeTrackedQuery(input, config) {
2533
+ const raw = await executeTrackedQuery2({
2534
+ keyword: input.keyword,
2535
+ canonicalDomains: input.canonicalDomains,
2536
+ competitorDomains: input.competitorDomains,
2537
+ config: toOpenAIConfig(config)
2538
+ });
2539
+ return {
2540
+ provider: "openai",
2541
+ rawResponse: raw.rawResponse,
2542
+ model: raw.model,
2543
+ groundingSources: raw.groundingSources,
2544
+ searchQueries: raw.searchQueries
2545
+ };
2546
+ },
2547
+ normalizeResult(raw) {
2548
+ const openaiRaw = {
2549
+ provider: "openai",
2550
+ rawResponse: raw.rawResponse,
2551
+ model: raw.model,
2552
+ groundingSources: raw.groundingSources,
2553
+ searchQueries: raw.searchQueries
2554
+ };
2555
+ const normalized = normalizeResult2(openaiRaw);
2556
+ return {
2557
+ provider: "openai",
2558
+ answerText: normalized.answerText,
2559
+ citedDomains: normalized.citedDomains,
2560
+ groundingSources: normalized.groundingSources,
2561
+ searchQueries: normalized.searchQueries
2562
+ };
2563
+ }
2564
+ };
2565
+
2566
+ // ../provider-claude/src/normalize.ts
2567
+ import Anthropic from "@anthropic-ai/sdk";
2568
+ var DEFAULT_MODEL3 = "claude-sonnet-4-6";
2569
+ function validateConfig3(config) {
2570
+ if (!config.apiKey || config.apiKey.length === 0) {
2571
+ return { ok: false, provider: "claude", message: "missing api key" };
2572
+ }
2573
+ return {
2574
+ ok: true,
2575
+ provider: "claude",
2576
+ message: "config valid",
2577
+ model: config.model ?? DEFAULT_MODEL3
2578
+ };
2579
+ }
2580
+ async function healthcheck3(config) {
2581
+ const validation = validateConfig3(config);
2582
+ if (!validation.ok) return validation;
2583
+ try {
2584
+ const client = new Anthropic({ apiKey: config.apiKey });
2585
+ const response = await client.messages.create({
2586
+ model: config.model ?? DEFAULT_MODEL3,
2587
+ max_tokens: 32,
2588
+ messages: [{ role: "user", content: 'Say "ok"' }]
2589
+ });
2590
+ const text2 = extractTextFromResponse(response);
2591
+ return {
2592
+ ok: text2.length > 0,
2593
+ provider: "claude",
2594
+ message: text2.length > 0 ? "claude api key verified" : "empty response from claude",
2595
+ model: config.model ?? DEFAULT_MODEL3
2596
+ };
2597
+ } catch (err) {
2598
+ return {
2599
+ ok: false,
2600
+ provider: "claude",
2601
+ message: err instanceof Error ? err.message : String(err),
2602
+ model: config.model ?? DEFAULT_MODEL3
2603
+ };
2604
+ }
2605
+ }
2606
+ async function executeTrackedQuery3(input) {
2607
+ const model = input.config.model ?? DEFAULT_MODEL3;
2608
+ const client = new Anthropic({ apiKey: input.config.apiKey });
2609
+ const response = await client.messages.create({
2610
+ model,
2611
+ max_tokens: 4096,
2612
+ tools: [
2613
+ {
2614
+ type: "web_search_20250305",
2615
+ name: "web_search",
2616
+ max_uses: 5
2617
+ }
2618
+ ],
2619
+ messages: [{ role: "user", content: input.keyword }]
2620
+ });
2621
+ const groundingSources = extractGroundingSources2(response);
2622
+ const searchQueries = extractSearchQueries3(response);
2623
+ return {
2624
+ provider: "claude",
2625
+ rawResponse: responseToRecord3(response),
2626
+ model,
2627
+ groundingSources,
2628
+ searchQueries
2629
+ };
2630
+ }
2631
+ function normalizeResult3(raw) {
2632
+ const answerText = extractAnswerTextFromRaw2(raw.rawResponse);
2633
+ const citedDomains = extractCitedDomains3(raw);
2634
+ return {
2635
+ provider: "claude",
2636
+ answerText,
2637
+ citedDomains,
2638
+ groundingSources: raw.groundingSources,
2639
+ searchQueries: raw.searchQueries
2640
+ };
2641
+ }
2642
+ function extractTextFromResponse(response) {
2643
+ try {
2644
+ const parts = [];
2645
+ for (const block of response.content) {
2646
+ if (block.type === "text") {
2647
+ parts.push(block.text);
2648
+ }
2649
+ }
2650
+ return parts.join("");
2651
+ } catch {
2652
+ return "";
2653
+ }
2654
+ }
2655
+ function extractGroundingSources2(response) {
2656
+ const sources = [];
2657
+ try {
2658
+ for (const block of response.content) {
2659
+ if (block.type === "web_search_tool_result" && Array.isArray(block.content)) {
2660
+ for (const result of block.content) {
2661
+ if (result.type === "web_search_result") {
2662
+ sources.push({
2663
+ uri: result.url,
2664
+ title: result.title ?? ""
2665
+ });
2666
+ }
2667
+ }
2668
+ }
2669
+ }
2670
+ } catch {
2671
+ }
2672
+ return sources;
2673
+ }
2674
+ function extractSearchQueries3(response) {
2675
+ const queries = [];
2676
+ try {
2677
+ for (const block of response.content) {
2678
+ if (block.type === "server_tool_use" && block.name === "web_search") {
2679
+ const input = block.input;
2680
+ if (input.query) {
2681
+ queries.push(input.query);
2682
+ }
2683
+ }
2684
+ }
2685
+ } catch {
2686
+ }
2687
+ return queries;
2688
+ }
2689
+ function extractAnswerTextFromRaw2(rawResponse) {
2690
+ try {
2691
+ const content = rawResponse.content;
2692
+ if (!content) return "";
2693
+ const parts = [];
2694
+ for (const block of content) {
2695
+ if (block.type === "text" && block.text) {
2696
+ parts.push(block.text);
2697
+ }
2698
+ }
2699
+ return parts.join("");
2700
+ } catch {
2701
+ return "";
2702
+ }
2703
+ }
2704
+ function extractCitedDomains3(raw) {
2705
+ const domains = /* @__PURE__ */ new Set();
2706
+ for (const source of raw.groundingSources) {
2707
+ const domain = extractDomainFromUri3(source.uri);
2708
+ if (domain) domains.add(domain);
2709
+ }
2710
+ return [...domains];
2711
+ }
2712
+ function extractDomainFromUri3(uri) {
2713
+ try {
2714
+ const url = new URL(uri);
2715
+ return url.hostname.replace(/^www\./, "");
2716
+ } catch {
2717
+ return null;
2718
+ }
2719
+ }
2720
+ function responseToRecord3(response) {
2721
+ try {
2722
+ return JSON.parse(JSON.stringify(response));
2723
+ } catch {
2724
+ return { error: "failed to serialize response" };
2725
+ }
2726
+ }
2727
+
2728
+ // ../provider-claude/src/adapter.ts
2729
+ function toClaudeConfig(config) {
2730
+ return {
2731
+ apiKey: config.apiKey ?? "",
2732
+ model: config.model,
2733
+ quotaPolicy: config.quotaPolicy
2734
+ };
2735
+ }
2736
+ var claudeAdapter = {
2737
+ name: "claude",
2738
+ validateConfig(config) {
2739
+ const result = validateConfig3(toClaudeConfig(config));
2740
+ return {
2741
+ ok: result.ok,
2742
+ provider: "claude",
2743
+ message: result.message,
2744
+ model: result.model
2745
+ };
2746
+ },
2747
+ async healthcheck(config) {
2748
+ const result = await healthcheck3(toClaudeConfig(config));
2749
+ return {
2750
+ ok: result.ok,
2751
+ provider: "claude",
2752
+ message: result.message,
2753
+ model: result.model
2754
+ };
2755
+ },
2756
+ async executeTrackedQuery(input, config) {
2757
+ const raw = await executeTrackedQuery3({
2758
+ keyword: input.keyword,
2759
+ canonicalDomains: input.canonicalDomains,
2760
+ competitorDomains: input.competitorDomains,
2761
+ config: toClaudeConfig(config)
2762
+ });
2763
+ return {
2764
+ provider: "claude",
2765
+ rawResponse: raw.rawResponse,
2766
+ model: raw.model,
2767
+ groundingSources: raw.groundingSources,
2768
+ searchQueries: raw.searchQueries
2769
+ };
2770
+ },
2771
+ normalizeResult(raw) {
2772
+ const claudeRaw = {
2773
+ provider: "claude",
2774
+ rawResponse: raw.rawResponse,
2775
+ model: raw.model,
2776
+ groundingSources: raw.groundingSources,
2777
+ searchQueries: raw.searchQueries
2778
+ };
2779
+ const normalized = normalizeResult3(claudeRaw);
2780
+ return {
2781
+ provider: "claude",
2782
+ answerText: normalized.answerText,
2783
+ citedDomains: normalized.citedDomains,
2784
+ groundingSources: normalized.groundingSources,
2785
+ searchQueries: normalized.searchQueries
2786
+ };
2787
+ }
2788
+ };
2789
+
2790
+ // ../provider-local/src/normalize.ts
2791
+ import OpenAI2 from "openai";
2792
+ var DEFAULT_MODEL4 = "llama3";
2793
+ function validateConfig4(config) {
2794
+ if (!config.baseUrl || config.baseUrl.length === 0) {
2795
+ return { ok: false, provider: "local", message: "missing base URL" };
2796
+ }
2797
+ return {
2798
+ ok: true,
2799
+ provider: "local",
2800
+ message: "config valid",
2801
+ model: config.model ?? DEFAULT_MODEL4
2802
+ };
2803
+ }
2804
+ async function healthcheck4(config) {
2805
+ const validation = validateConfig4(config);
2806
+ if (!validation.ok) return validation;
2807
+ try {
2808
+ const client = new OpenAI2({
2809
+ baseURL: config.baseUrl,
2810
+ apiKey: config.apiKey || "not-needed"
2811
+ });
2812
+ const models = await client.models.list();
2813
+ const modelList = [];
2814
+ for await (const m of models) {
2815
+ modelList.push(m.id);
2816
+ if (modelList.length >= 5) break;
2817
+ }
2818
+ return {
2819
+ ok: true,
2820
+ provider: "local",
2821
+ message: `connected, ${modelList.length} model(s) available`,
2822
+ model: config.model ?? DEFAULT_MODEL4
2823
+ };
2824
+ } catch (err) {
2825
+ return {
2826
+ ok: false,
2827
+ provider: "local",
2828
+ message: err instanceof Error ? err.message : String(err),
2829
+ model: config.model ?? DEFAULT_MODEL4
2830
+ };
2831
+ }
2832
+ }
2833
+ async function executeTrackedQuery4(input) {
2834
+ const model = input.config.model ?? DEFAULT_MODEL4;
2835
+ const client = new OpenAI2({
2836
+ baseURL: input.config.baseUrl,
2837
+ apiKey: input.config.apiKey || "not-needed"
2838
+ });
2839
+ const response = await client.chat.completions.create({
2840
+ model,
2841
+ messages: [
2842
+ {
2843
+ role: "system",
2844
+ content: "You are a helpful assistant. Provide comprehensive, factual answers. When mentioning websites or services, include their domain names."
2845
+ },
2846
+ {
2847
+ role: "user",
2848
+ content: buildPrompt3(input.keyword)
2849
+ }
2850
+ ]
2851
+ });
2852
+ return {
2853
+ provider: "local",
2854
+ rawResponse: JSON.parse(JSON.stringify(response)),
2855
+ model,
2856
+ groundingSources: [],
2857
+ searchQueries: []
2858
+ };
2859
+ }
2860
+ function normalizeResult4(raw) {
2861
+ const answerText = extractAnswerText2(raw.rawResponse);
2862
+ const citedDomains = extractDomainMentions(answerText);
2863
+ return {
2864
+ provider: "local",
2865
+ answerText,
2866
+ citedDomains,
2867
+ groundingSources: raw.groundingSources,
2868
+ searchQueries: raw.searchQueries
2869
+ };
2870
+ }
2871
+ function buildPrompt3(keyword) {
2872
+ return `Based on your training knowledge, what websites, services, or organizations are commonly associated with "${keyword}"? List the most relevant ones and include their domain names (e.g. example.com) where you know them.`;
2873
+ }
2874
+ function extractAnswerText2(rawResponse) {
2875
+ try {
2876
+ const choices = rawResponse.choices;
2877
+ if (!choices?.length) return "";
2878
+ return choices[0].message?.content ?? "";
2879
+ } catch {
2880
+ return "";
2881
+ }
2882
+ }
2883
+ function extractDomainMentions(text2) {
2884
+ const domains = /* @__PURE__ */ new Set();
2885
+ const urlPattern = /https?:\/\/([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)/g;
2886
+ let match;
2887
+ while ((match = urlPattern.exec(text2)) !== null) {
2888
+ domains.add(match[1].replace(/^www\./, "").toLowerCase());
2889
+ }
2890
+ const domainPattern = /(?:^|[\s(["'])((?:[a-zA-Z0-9][-a-zA-Z0-9]*\.)+(?:com|org|net|io|co|dev|ai|app|edu|gov|biz|info|tech|health|dental|legal|law|med|uk|us|ca|au|de|fr|es|it|nl|se|no|dk|fi|jp|cn|kr|br|mx|ru|in|sg|nz|za)(?:\.[a-zA-Z]{2})?)(?:[\s).,;/"']|$)/g;
2891
+ while ((match = domainPattern.exec(text2)) !== null) {
2892
+ domains.add(match[1].replace(/^www\./, "").toLowerCase());
2893
+ }
2894
+ return [...domains];
2895
+ }
2896
+
2897
+ // ../provider-local/src/adapter.ts
2898
+ function toLocalConfig(config) {
2899
+ return {
2900
+ baseUrl: config.baseUrl ?? "",
2901
+ apiKey: config.apiKey,
2902
+ model: config.model,
2903
+ quotaPolicy: config.quotaPolicy
2904
+ };
2905
+ }
2906
+ var localAdapter = {
2907
+ name: "local",
2908
+ validateConfig(config) {
2909
+ const result = validateConfig4(toLocalConfig(config));
2910
+ return {
2911
+ ok: result.ok,
2912
+ provider: "local",
2913
+ message: result.message,
2914
+ model: result.model
2915
+ };
2916
+ },
2917
+ async healthcheck(config) {
2918
+ const result = await healthcheck4(toLocalConfig(config));
2919
+ return {
2920
+ ok: result.ok,
2921
+ provider: "local",
2922
+ message: result.message,
2923
+ model: result.model
2924
+ };
2925
+ },
2926
+ async executeTrackedQuery(input, config) {
2927
+ const raw = await executeTrackedQuery4({
2928
+ keyword: input.keyword,
2929
+ canonicalDomains: input.canonicalDomains,
2930
+ competitorDomains: input.competitorDomains,
2931
+ config: toLocalConfig(config)
2932
+ });
2933
+ return {
2934
+ provider: "local",
2935
+ rawResponse: raw.rawResponse,
2936
+ model: raw.model,
2937
+ groundingSources: raw.groundingSources,
2938
+ searchQueries: raw.searchQueries
2939
+ };
2940
+ },
2941
+ normalizeResult(raw) {
2942
+ const localRaw = {
2943
+ provider: "local",
2944
+ rawResponse: raw.rawResponse,
2945
+ model: raw.model,
2946
+ groundingSources: raw.groundingSources,
2947
+ searchQueries: raw.searchQueries
2948
+ };
2949
+ const normalized = normalizeResult4(localRaw);
2950
+ return {
2951
+ provider: "local",
2952
+ answerText: normalized.answerText,
2953
+ citedDomains: normalized.citedDomains,
2954
+ groundingSources: normalized.groundingSources,
2955
+ searchQueries: normalized.searchQueries
2956
+ };
2957
+ }
2958
+ };
2959
+
2960
+ // src/job-runner.ts
2961
+ import crypto11 from "crypto";
2962
+ import { eq as eq12 } from "drizzle-orm";
2963
+ var JobRunner = class {
2964
+ db;
2965
+ registry;
2966
+ onRunCompleted;
2967
+ constructor(db, registry) {
2968
+ this.db = db;
2969
+ this.registry = registry;
2970
+ }
2971
+ async executeRun(runId, projectId, providerOverride) {
2972
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2973
+ try {
2974
+ this.db.update(runs).set({ status: "running", startedAt: now }).where(eq12(runs.id, runId)).run();
2975
+ const project = this.db.select().from(projects).where(eq12(projects.id, projectId)).get();
2976
+ if (!project) {
2977
+ throw new Error(`Project ${projectId} not found`);
2978
+ }
2979
+ const projectProviders = providerOverride ?? JSON.parse(project.providers || "[]");
2980
+ const activeProviders = this.registry.getForProject(projectProviders);
2981
+ if (activeProviders.length === 0) {
2982
+ throw new Error("No providers configured. Add at least one provider API key.");
2983
+ }
2984
+ console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
2985
+ const projectKeywords = this.db.select().from(keywords).where(eq12(keywords.projectId, projectId)).all();
2986
+ const projectCompetitors = this.db.select().from(competitors).where(eq12(competitors.projectId, projectId)).all();
2987
+ const competitorDomains = projectCompetitors.map((c) => c.domain);
2988
+ const queriesPerProvider = projectKeywords.length;
2989
+ const todayPeriod = getCurrentPeriod();
2990
+ for (const p of activeProviders) {
2991
+ const providerScope = `${projectId}:${p.adapter.name}`;
2992
+ const providerUsage = this.db.select().from(usageCounters).where(eq12(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
2993
+ const limit = p.config.quotaPolicy.maxRequestsPerDay;
2994
+ if (providerUsage + queriesPerProvider > limit) {
2995
+ throw new Error(
2996
+ `Daily quota exceeded for ${p.adapter.name}: ${providerUsage} queries used today, limit is ${limit}. This run needs ${queriesPerProvider} more.`
2997
+ );
2998
+ }
2999
+ }
3000
+ const minuteWindows = /* @__PURE__ */ new Map();
3001
+ for (const p of activeProviders) {
3002
+ minuteWindows.set(p.adapter.name, []);
3003
+ }
3004
+ const providerErrors = /* @__PURE__ */ new Map();
3005
+ let totalSnapshotsInserted = 0;
3006
+ for (const kw of projectKeywords) {
3007
+ const providerPromises = activeProviders.map(async (registeredProvider) => {
3008
+ const { adapter, config } = registeredProvider;
3009
+ const providerName = adapter.name;
3010
+ try {
3011
+ await this.waitForRateLimit(
3012
+ minuteWindows.get(providerName),
3013
+ config.quotaPolicy.maxRequestsPerMinute
3014
+ );
3015
+ const raw = await adapter.executeTrackedQuery(
3016
+ {
3017
+ keyword: kw.keyword,
3018
+ canonicalDomains: [project.canonicalDomain],
3019
+ competitorDomains
3020
+ },
3021
+ config
3022
+ );
3023
+ const normalized = adapter.normalizeResult(raw);
3024
+ console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, canonical="${project.canonicalDomain}"`);
3025
+ const citationState = determineCitationState(normalized, project.canonicalDomain);
3026
+ const overlap = computeCompetitorOverlap(normalized, competitorDomains);
3027
+ this.db.insert(querySnapshots).values({
3028
+ id: crypto11.randomUUID(),
3029
+ runId,
3030
+ keywordId: kw.id,
3031
+ provider: providerName,
3032
+ citationState,
3033
+ answerText: normalized.answerText,
3034
+ citedDomains: JSON.stringify(normalized.citedDomains),
3035
+ competitorOverlap: JSON.stringify(overlap),
3036
+ rawResponse: JSON.stringify({
3037
+ model: raw.model,
3038
+ groundingSources: normalized.groundingSources,
3039
+ searchQueries: normalized.searchQueries,
3040
+ apiResponse: raw.rawResponse
3041
+ }),
3042
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3043
+ }).run();
3044
+ totalSnapshotsInserted++;
3045
+ console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" \u2192 ${citationState}`);
3046
+ } catch (err) {
3047
+ const msg = err instanceof Error ? err.message : String(err);
3048
+ console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`);
3049
+ providerErrors.set(providerName, msg);
3050
+ }
3051
+ });
3052
+ await Promise.all(providerPromises);
3053
+ }
3054
+ const allFailed = totalSnapshotsInserted === 0 && providerErrors.size > 0;
3055
+ const someFailed = providerErrors.size > 0;
3056
+ if (allFailed) {
3057
+ const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
3058
+ this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq12(runs.id, runId)).run();
3059
+ } else if (someFailed) {
3060
+ const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
3061
+ this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq12(runs.id, runId)).run();
3062
+ } else {
3063
+ this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(runs.id, runId)).run();
3064
+ }
3065
+ for (const p of activeProviders) {
3066
+ this.incrementUsage(`${projectId}:${p.adapter.name}`, "queries", queriesPerProvider);
3067
+ }
3068
+ this.incrementUsage(projectId, "runs", 1);
3069
+ if (this.onRunCompleted) {
3070
+ this.onRunCompleted(runId, projectId).catch((err) => {
3071
+ console.error("[JobRunner] Notification callback failed:", err);
3072
+ });
3073
+ }
3074
+ } catch (err) {
3075
+ const errorMessage = err instanceof Error ? err.message : String(err);
3076
+ this.db.update(runs).set({
3077
+ status: "failed",
3078
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3079
+ error: errorMessage
3080
+ }).where(eq12(runs.id, runId)).run();
3081
+ if (this.onRunCompleted) {
3082
+ this.onRunCompleted(runId, projectId).catch((notifErr) => {
3083
+ console.error("[JobRunner] Notification callback failed:", notifErr);
3084
+ });
3085
+ }
3086
+ }
3087
+ }
3088
+ async waitForRateLimit(window, maxPerMinute) {
3089
+ const now = Date.now();
3090
+ const windowStart = now - 6e4;
3091
+ while (window.length > 0 && window[0] < windowStart) {
3092
+ window.shift();
3093
+ }
3094
+ if (window.length >= maxPerMinute) {
3095
+ const oldestInWindow = window[0];
3096
+ const waitMs = oldestInWindow + 6e4 - now + 50;
3097
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
3098
+ const nowAfterWait = Date.now();
3099
+ const newWindowStart = nowAfterWait - 6e4;
3100
+ while (window.length > 0 && window[0] < newWindowStart) {
3101
+ window.shift();
3102
+ }
3103
+ }
3104
+ window.push(Date.now());
3105
+ }
3106
+ incrementUsage(scope, metric, count) {
3107
+ const now = /* @__PURE__ */ new Date();
3108
+ const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
3109
+ const id = crypto11.randomUUID();
3110
+ const existing = this.db.select().from(usageCounters).where(eq12(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
3111
+ if (existing) {
3112
+ this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq12(usageCounters.id, existing.id)).run();
3113
+ } else {
3114
+ this.db.insert(usageCounters).values({
3115
+ id,
3116
+ scope,
3117
+ period,
3118
+ metric,
3119
+ count,
3120
+ updatedAt: now.toISOString()
3121
+ }).run();
3122
+ }
3123
+ }
3124
+ };
3125
+ function getCurrentPeriod() {
3126
+ const d = /* @__PURE__ */ new Date();
3127
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
3128
+ }
3129
+ function normalizeDomain(input) {
3130
+ let domain = input;
3131
+ try {
3132
+ if (domain.includes("://")) {
3133
+ domain = new URL(domain).hostname;
3134
+ }
3135
+ } catch {
3136
+ }
3137
+ return domain.replace(/^www\./, "");
3138
+ }
3139
+ function domainMatches(domain, canonicalDomain) {
3140
+ const normalized = normalizeDomain(canonicalDomain);
3141
+ const d = normalizeDomain(domain);
3142
+ return d === normalized || d.endsWith(`.${normalized}`);
3143
+ }
3144
+ function determineCitationState(normalized, canonicalDomain) {
3145
+ const bareDomain = normalizeDomain(canonicalDomain);
3146
+ if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
3147
+ return "cited";
3148
+ }
3149
+ const lowerDomain = bareDomain.toLowerCase();
3150
+ for (const source of normalized.groundingSources) {
3151
+ try {
3152
+ const uri = source.uri.toLowerCase();
3153
+ if (uri.includes(lowerDomain)) {
3154
+ return "cited";
3155
+ }
3156
+ } catch {
3157
+ }
3158
+ if (source.title) {
3159
+ const titleLower = source.title.toLowerCase().replace(/^www\./, "");
3160
+ if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
3161
+ return "cited";
3162
+ }
3163
+ }
3164
+ }
3165
+ return "not-cited";
3166
+ }
3167
+ function computeCompetitorOverlap(normalized, competitorDomains) {
3168
+ const overlapSet = /* @__PURE__ */ new Set();
3169
+ for (const d of normalized.citedDomains) {
3170
+ for (const cd of competitorDomains) {
3171
+ if (domainMatches(d, cd)) {
3172
+ overlapSet.add(cd);
3173
+ }
3174
+ }
3175
+ }
3176
+ for (const source of normalized.groundingSources) {
3177
+ const uri = source.uri.toLowerCase();
3178
+ for (const cd of competitorDomains) {
3179
+ if (uri.includes(cd.toLowerCase())) {
3180
+ overlapSet.add(cd);
3181
+ }
3182
+ }
3183
+ }
3184
+ if (normalized.answerText) {
3185
+ const lowerAnswer = normalized.answerText.toLowerCase();
3186
+ for (const cd of competitorDomains) {
3187
+ if (lowerAnswer.includes(cd.toLowerCase())) {
3188
+ overlapSet.add(cd);
3189
+ }
3190
+ const brand = cd.split(".")[0];
3191
+ if (brand && brand.length >= 4 && new RegExp(`\\b${brand}\\b`, "i").test(lowerAnswer)) {
3192
+ overlapSet.add(cd);
3193
+ }
3194
+ }
3195
+ }
3196
+ return [...overlapSet];
3197
+ }
3198
+
3199
+ // src/provider-registry.ts
3200
+ var ProviderRegistry = class {
3201
+ providers = /* @__PURE__ */ new Map();
3202
+ register(adapter, config) {
3203
+ this.providers.set(adapter.name, { adapter, config });
3204
+ }
3205
+ get(name) {
3206
+ return this.providers.get(name);
3207
+ }
3208
+ getAll() {
3209
+ return [...this.providers.values()];
3210
+ }
3211
+ getForProject(projectProviders) {
3212
+ if (projectProviders.length === 0) {
3213
+ return this.getAll();
3214
+ }
3215
+ const result = [];
3216
+ const seen = /* @__PURE__ */ new Set();
3217
+ for (const name of projectProviders) {
3218
+ if (seen.has(name)) continue;
3219
+ seen.add(name);
3220
+ const provider = this.providers.get(name);
3221
+ if (provider) {
3222
+ result.push(provider);
3223
+ }
3224
+ }
3225
+ return result;
3226
+ }
3227
+ get size() {
3228
+ return this.providers.size;
3229
+ }
3230
+ async healthcheckAll() {
3231
+ const results = /* @__PURE__ */ new Map();
3232
+ const entries = [...this.providers.entries()];
3233
+ const checks = entries.map(async ([name, { adapter, config }]) => {
3234
+ const result = await adapter.healthcheck(config);
3235
+ results.set(name, result);
3236
+ });
3237
+ await Promise.all(checks);
3238
+ return results;
3239
+ }
3240
+ };
3241
+
3242
+ // src/scheduler.ts
3243
+ import cron from "node-cron";
3244
+ import { eq as eq13 } from "drizzle-orm";
3245
+ var Scheduler = class {
3246
+ db;
3247
+ callbacks;
3248
+ tasks = /* @__PURE__ */ new Map();
3249
+ constructor(db, callbacks) {
3250
+ this.db = db;
3251
+ this.callbacks = callbacks;
3252
+ }
3253
+ /** Load all enabled schedules from DB and register cron jobs. */
3254
+ start() {
3255
+ const allSchedules = this.db.select().from(schedules).where(eq13(schedules.enabled, 1)).all();
3256
+ for (const schedule of allSchedules) {
3257
+ const missedRunAt = schedule.nextRunAt;
3258
+ this.registerCronTask(schedule);
3259
+ if (missedRunAt && new Date(missedRunAt) < /* @__PURE__ */ new Date()) {
3260
+ console.log(`[Scheduler] Catch-up run for project ${schedule.projectId} (missed ${missedRunAt})`);
3261
+ this.triggerRun(schedule.id, schedule.projectId);
3262
+ }
3263
+ }
3264
+ console.log(`[Scheduler] Started with ${allSchedules.length} schedule(s)`);
3265
+ }
3266
+ /** Stop all cron tasks for graceful shutdown. */
3267
+ stop() {
3268
+ for (const [projectId, task] of this.tasks) {
3269
+ task.stop();
3270
+ console.log(`[Scheduler] Stopped task for project ${projectId}`);
3271
+ }
3272
+ this.tasks.clear();
3273
+ }
3274
+ /** Add or update a cron registration at runtime (called when schedule API is used). */
3275
+ upsert(projectId) {
3276
+ const existing = this.tasks.get(projectId);
3277
+ if (existing) {
3278
+ existing.stop();
3279
+ this.tasks.delete(projectId);
3280
+ }
3281
+ const schedule = this.db.select().from(schedules).where(eq13(schedules.projectId, projectId)).get();
3282
+ if (schedule && schedule.enabled === 1) {
3283
+ this.registerCronTask(schedule);
3284
+ }
3285
+ }
3286
+ /** Remove a cron registration (called when schedule is deleted). */
3287
+ remove(projectId) {
3288
+ const existing = this.tasks.get(projectId);
3289
+ if (existing) {
3290
+ existing.stop();
3291
+ this.tasks.delete(projectId);
3292
+ console.log(`[Scheduler] Removed task for project ${projectId}`);
3293
+ }
3294
+ }
3295
+ registerCronTask(schedule) {
3296
+ const { id: scheduleId, projectId, cronExpr, timezone } = schedule;
3297
+ if (!cron.validate(cronExpr)) {
3298
+ console.error(`[Scheduler] Invalid cron expression for project ${projectId}: ${cronExpr}`);
3299
+ return;
3300
+ }
3301
+ const task = cron.schedule(cronExpr, () => {
3302
+ this.triggerRun(scheduleId, projectId);
3303
+ }, {
3304
+ timezone
3305
+ });
3306
+ this.tasks.set(projectId, task);
3307
+ this.db.update(schedules).set({
3308
+ nextRunAt: task.getNextRun()?.toISOString() ?? null,
3309
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3310
+ }).where(eq13(schedules.id, scheduleId)).run();
3311
+ const label = schedule.preset ?? cronExpr;
3312
+ console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
3313
+ }
3314
+ triggerRun(scheduleId, projectId) {
3315
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3316
+ const currentSchedule = this.db.select().from(schedules).where(eq13(schedules.id, scheduleId)).get();
3317
+ if (!currentSchedule || currentSchedule.enabled !== 1) {
3318
+ console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
3319
+ this.remove(projectId);
3320
+ return;
3321
+ }
3322
+ const task = this.tasks.get(projectId);
3323
+ const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
3324
+ const project = this.db.select().from(projects).where(eq13(projects.id, projectId)).get();
3325
+ if (!project) {
3326
+ console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
3327
+ this.remove(projectId);
3328
+ return;
3329
+ }
3330
+ const queueResult = queueRunIfProjectIdle(this.db, {
3331
+ createdAt: now,
3332
+ kind: "answer-visibility",
3333
+ projectId,
3334
+ trigger: "scheduled"
3335
+ });
3336
+ if (queueResult.conflict) {
3337
+ console.log(`[Scheduler] Skipping scheduled run for ${project.name} \u2014 run ${queueResult.activeRunId} already active`);
3338
+ this.db.update(schedules).set({
3339
+ nextRunAt,
3340
+ updatedAt: now
3341
+ }).where(eq13(schedules.id, currentSchedule.id)).run();
3342
+ return;
3343
+ }
3344
+ const runId = queueResult.runId;
3345
+ this.db.update(schedules).set({
3346
+ lastRunAt: now,
3347
+ nextRunAt,
3348
+ updatedAt: now
3349
+ }).where(eq13(schedules.id, currentSchedule.id)).run();
3350
+ const scheduleProviders = JSON.parse(currentSchedule.providers);
3351
+ const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
3352
+ console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
3353
+ this.callbacks.onRunCreated(runId, projectId, providers);
3354
+ }
3355
+ };
3356
+
3357
+ // src/notifier.ts
3358
+ import { eq as eq14, desc as desc2, and as and3, or as or2 } from "drizzle-orm";
3359
+ import crypto12 from "crypto";
3360
+ var Notifier = class {
3361
+ db;
3362
+ serverUrl;
3363
+ constructor(db, serverUrl) {
3364
+ this.db = db;
3365
+ this.serverUrl = serverUrl;
3366
+ }
3367
+ /** Called after a run completes (success, partial, or failed). */
3368
+ async onRunCompleted(runId, projectId) {
3369
+ console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
3370
+ const notifs = this.db.select().from(notifications).where(eq14(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
3371
+ if (notifs.length === 0) {
3372
+ console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
3373
+ return;
3374
+ }
3375
+ console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
3376
+ const run = this.db.select().from(runs).where(eq14(runs.id, runId)).get();
3377
+ if (!run) {
3378
+ console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
3379
+ return;
3380
+ }
3381
+ const project = this.db.select().from(projects).where(eq14(projects.id, projectId)).get();
3382
+ if (!project) {
3383
+ console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
3384
+ return;
3385
+ }
3386
+ const transitions = this.computeTransitions(runId, projectId);
3387
+ const events = [];
3388
+ console.log(`[Notifier] Run status: ${run.status}`);
3389
+ if (run.status === "completed" || run.status === "partial") {
3390
+ events.push("run.completed");
3391
+ }
3392
+ if (run.status === "failed") {
3393
+ events.push("run.failed");
3394
+ }
3395
+ const lostTransitions = transitions.filter((t) => t.to === "not-cited" && t.from === "cited");
3396
+ const gainedTransitions = transitions.filter((t) => t.to === "cited" && t.from === "not-cited");
3397
+ if (lostTransitions.length > 0) events.push("citation.lost");
3398
+ if (gainedTransitions.length > 0) events.push("citation.gained");
3399
+ for (const notif of notifs) {
3400
+ const config = JSON.parse(notif.config);
3401
+ const subscribedEvents = config.events;
3402
+ const matchingEvents = events.filter((e) => subscribedEvents.includes(e));
3403
+ console.log(`[Notifier] Notification ${notif.id}: subscribed=${JSON.stringify(subscribedEvents)} matched=${JSON.stringify(matchingEvents)}`);
3404
+ if (matchingEvents.length === 0) continue;
3405
+ for (const event of matchingEvents) {
3406
+ const relevantTransitions = event === "citation.lost" ? lostTransitions : event === "citation.gained" ? gainedTransitions : transitions;
3407
+ const payload = {
3408
+ source: "canonry",
3409
+ event,
3410
+ project: { name: project.name, canonicalDomain: project.canonicalDomain },
3411
+ run: { id: run.id, status: run.status, finishedAt: run.finishedAt },
3412
+ transitions: relevantTransitions,
3413
+ dashboardUrl: `${this.serverUrl}/projects/${project.name}`
3414
+ };
3415
+ await this.sendWebhook(config.url, payload, notif.id, projectId, notif.webhookSecret ?? null);
3416
+ }
3417
+ }
3418
+ }
3419
+ computeTransitions(runId, projectId) {
3420
+ const recentRuns = this.db.select().from(runs).where(
3421
+ and3(
3422
+ eq14(runs.projectId, projectId),
3423
+ or2(eq14(runs.status, "completed"), eq14(runs.status, "partial"))
3424
+ )
3425
+ ).orderBy(desc2(runs.createdAt)).limit(2).all();
3426
+ if (recentRuns.length < 2) return [];
3427
+ const currentRunId = recentRuns[0].id;
3428
+ const previousRunId = recentRuns[1].id;
3429
+ if (currentRunId !== runId) return [];
3430
+ const currentSnapshots = this.db.select({
3431
+ keywordId: querySnapshots.keywordId,
3432
+ keyword: keywords.keyword,
3433
+ provider: querySnapshots.provider,
3434
+ citationState: querySnapshots.citationState
3435
+ }).from(querySnapshots).leftJoin(keywords, eq14(querySnapshots.keywordId, keywords.id)).where(eq14(querySnapshots.runId, currentRunId)).all();
3436
+ const previousSnapshots = this.db.select({
3437
+ keywordId: querySnapshots.keywordId,
3438
+ provider: querySnapshots.provider,
3439
+ citationState: querySnapshots.citationState
3440
+ }).from(querySnapshots).where(eq14(querySnapshots.runId, previousRunId)).all();
3441
+ const prevMap = /* @__PURE__ */ new Map();
3442
+ for (const s of previousSnapshots) {
3443
+ prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
3444
+ }
3445
+ const transitions = [];
3446
+ for (const s of currentSnapshots) {
3447
+ const key = `${s.keywordId}:${s.provider}`;
3448
+ const prevState = prevMap.get(key);
3449
+ if (prevState && prevState !== s.citationState) {
3450
+ transitions.push({
3451
+ keyword: s.keyword ?? s.keywordId,
3452
+ from: prevState,
3453
+ to: s.citationState,
3454
+ provider: s.provider
3455
+ });
3456
+ }
3457
+ }
3458
+ return transitions;
3459
+ }
3460
+ async sendWebhook(url, payload, notificationId, projectId, webhookSecret) {
3461
+ const targetCheck = await resolveWebhookTarget(url);
3462
+ if (!targetCheck.ok) {
3463
+ console.error(`[Notifier] Webhook URL blocked by SSRF check: ${url}`);
3464
+ this.logDelivery(projectId, notificationId, payload.event, "failed", `SSRF: ${targetCheck.message}`);
3465
+ return;
3466
+ }
3467
+ console.log(`[Notifier] Sending webhook event="${payload.event}" to ${url}`);
3468
+ const maxRetries = 3;
3469
+ const delays = [1e3, 4e3, 16e3];
3470
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
3471
+ try {
3472
+ const response = await deliverWebhook(targetCheck.target, payload, webhookSecret);
3473
+ if (response.status >= 200 && response.status < 300) {
3474
+ console.log(`[Notifier] Webhook delivered: event="${payload.event}" status=${response.status}`);
3475
+ this.logDelivery(projectId, notificationId, payload.event, "sent", null);
3476
+ return;
3477
+ }
3478
+ const errorDetail = response.error ?? `HTTP ${response.status}`;
3479
+ console.warn(`[Notifier] Webhook attempt ${attempt + 1}/${maxRetries} failed: ${errorDetail}`);
3480
+ if (attempt === maxRetries - 1) {
3481
+ this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
3482
+ }
3483
+ } catch (err) {
3484
+ const errorDetail = err instanceof Error ? err.message : String(err);
3485
+ if (attempt === maxRetries - 1) {
3486
+ this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
3487
+ console.error(`[Notifier] Failed to deliver webhook after ${maxRetries} attempts: ${errorDetail}`);
3488
+ }
3489
+ }
3490
+ if (attempt < maxRetries - 1) {
3491
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
3492
+ }
3493
+ }
3494
+ }
3495
+ logDelivery(projectId, notificationId, event, status, error) {
3496
+ this.db.insert(auditLog).values({
3497
+ id: crypto12.randomUUID(),
3498
+ projectId,
3499
+ actor: "scheduler",
3500
+ action: `notification.${status}`,
3501
+ entityType: "notification",
3502
+ entityId: notificationId,
3503
+ diff: JSON.stringify({ event, error }),
3504
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3505
+ }).run();
3506
+ }
3507
+ };
3508
+
3509
+ // src/server.ts
3510
+ var DEFAULT_QUOTA = {
3511
+ maxConcurrency: 2,
3512
+ maxRequestsPerMinute: 10,
3513
+ maxRequestsPerDay: 1e3
3514
+ };
3515
+ async function createServer(opts) {
3516
+ const app = Fastify({
3517
+ logger: {
3518
+ transport: {
3519
+ target: "pino-pretty",
3520
+ options: {
3521
+ colorize: true,
3522
+ translateTime: "HH:MM:ss",
3523
+ ignore: "pid,hostname,reqId",
3524
+ messageFormat: "{msg} {req.method} {req.url}"
3525
+ }
3526
+ }
3527
+ }
3528
+ });
3529
+ const registry = new ProviderRegistry();
3530
+ const providers = opts.config.providers ?? {};
3531
+ if (opts.config.geminiApiKey && !providers.gemini) {
3532
+ providers.gemini = {
3533
+ apiKey: opts.config.geminiApiKey,
3534
+ model: opts.config.geminiModel,
3535
+ quota: opts.config.geminiQuota
3536
+ };
3537
+ }
3538
+ console.log("[Server] Configured providers:", Object.keys(providers).filter((k) => {
3539
+ const p = providers[k];
3540
+ return p?.apiKey || p?.baseUrl;
3541
+ }));
3542
+ if (providers.gemini?.apiKey) {
3543
+ registry.register(geminiAdapter, {
3544
+ provider: "gemini",
3545
+ apiKey: providers.gemini.apiKey,
3546
+ model: providers.gemini.model,
3547
+ quotaPolicy: providers.gemini.quota ?? DEFAULT_QUOTA
3548
+ });
3549
+ }
3550
+ if (providers.openai?.apiKey) {
3551
+ registry.register(openaiAdapter, {
3552
+ provider: "openai",
3553
+ apiKey: providers.openai.apiKey,
3554
+ model: providers.openai.model,
3555
+ quotaPolicy: providers.openai.quota ?? DEFAULT_QUOTA
3556
+ });
3557
+ }
3558
+ if (providers.claude?.apiKey) {
3559
+ registry.register(claudeAdapter, {
3560
+ provider: "claude",
3561
+ apiKey: providers.claude.apiKey,
3562
+ model: providers.claude.model,
3563
+ quotaPolicy: providers.claude.quota ?? DEFAULT_QUOTA
3564
+ });
3565
+ }
3566
+ if (providers.local?.baseUrl) {
3567
+ registry.register(localAdapter, {
3568
+ provider: "local",
3569
+ apiKey: providers.local.apiKey,
3570
+ baseUrl: providers.local.baseUrl,
3571
+ model: providers.local.model,
3572
+ quotaPolicy: providers.local.quota ?? DEFAULT_QUOTA
3573
+ });
3574
+ }
3575
+ const port = opts.config.port ?? 4100;
3576
+ const serverUrl = `http://localhost:${port}`;
3577
+ const jobRunner = new JobRunner(opts.db, registry);
3578
+ const notifier = new Notifier(opts.db, serverUrl);
3579
+ jobRunner.onRunCompleted = (runId, projectId) => notifier.onRunCompleted(runId, projectId);
3580
+ const scheduler = new Scheduler(opts.db, {
3581
+ onRunCreated: (runId, projectId, providers2) => {
3582
+ jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
3583
+ app.log.error({ runId, err }, "Scheduled job runner failed");
3584
+ });
3585
+ }
3586
+ });
3587
+ const providerSummary = ["gemini", "openai", "claude", "local"].map((name) => ({
3588
+ name,
3589
+ model: registry.get(name)?.config.model,
3590
+ configured: !!registry.get(name),
3591
+ quota: registry.get(name)?.config.quotaPolicy
3592
+ }));
3593
+ const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
3594
+ await app.register(apiRoutes, {
3595
+ db: opts.db,
3596
+ skipAuth: false,
3597
+ providerSummary,
3598
+ onRunCreated: (runId, projectId, providers2) => {
3599
+ jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
3600
+ app.log.error({ runId, err }, "Job runner failed");
3601
+ });
3602
+ },
3603
+ onProviderUpdate: (providerName, apiKey, model, baseUrl) => {
3604
+ const name = providerName;
3605
+ if (!(name in adapterMap)) return null;
3606
+ if (!opts.config.providers) opts.config.providers = {};
3607
+ const existing = opts.config.providers[name];
3608
+ opts.config.providers[name] = {
3609
+ apiKey: apiKey || existing?.apiKey,
3610
+ baseUrl: baseUrl || existing?.baseUrl,
3611
+ model: model || existing?.model,
3612
+ quota: existing?.quota
3613
+ };
3614
+ try {
3615
+ saveConfig(opts.config);
3616
+ } catch (err) {
3617
+ app.log.error({ err }, "Failed to save config");
3618
+ return null;
3619
+ }
3620
+ const quota = opts.config.providers[name].quota ?? DEFAULT_QUOTA;
3621
+ registry.register(adapterMap[name], {
3622
+ provider: name,
3623
+ apiKey: apiKey || existing?.apiKey,
3624
+ baseUrl: baseUrl || existing?.baseUrl,
3625
+ model: model || existing?.model,
3626
+ quotaPolicy: quota
3627
+ });
3628
+ const entry = providerSummary.find((p) => p.name === name);
3629
+ if (entry) {
3630
+ entry.configured = true;
3631
+ entry.model = model || registry.get(name)?.config.model;
3632
+ entry.quota = quota;
3633
+ }
3634
+ return {
3635
+ name,
3636
+ model: entry?.model,
3637
+ configured: true,
3638
+ quota
3639
+ };
3640
+ },
3641
+ onScheduleUpdated: (action, projectId) => {
3642
+ if (action === "upsert") scheduler.upsert(projectId);
3643
+ if (action === "delete") scheduler.remove(projectId);
3644
+ },
3645
+ onProjectDeleted: (projectId) => {
3646
+ scheduler.remove(projectId);
3647
+ }
3648
+ });
3649
+ const dirname2 = path2.dirname(fileURLToPath(import.meta.url));
3650
+ const assetsDir = path2.join(dirname2, "..", "assets");
3651
+ if (fs2.existsSync(assetsDir)) {
3652
+ const indexPath = path2.join(assetsDir, "index.html");
3653
+ const injectConfig = (html) => {
3654
+ const configScript = `<script>window.__CANONRY_CONFIG__=${JSON.stringify({ apiKey: opts.config.apiKey })}</script>`;
3655
+ return html.replace("</head>", `${configScript}</head>`);
3656
+ };
3657
+ const fastifyStatic = await import("@fastify/static");
3658
+ await app.register(fastifyStatic.default, {
3659
+ root: assetsDir,
3660
+ prefix: "/",
3661
+ wildcard: false,
3662
+ // Don't serve index.html automatically — we handle it with config injection
3663
+ serve: true,
3664
+ index: false
3665
+ });
3666
+ app.get("/", (_request, reply) => {
3667
+ if (fs2.existsSync(indexPath)) {
3668
+ const html = fs2.readFileSync(indexPath, "utf-8");
3669
+ return reply.type("text/html").send(injectConfig(html));
3670
+ }
3671
+ return reply.status(404).send({ error: "Dashboard not built" });
3672
+ });
3673
+ app.setNotFoundHandler((request, reply) => {
3674
+ if (request.url.startsWith("/api/")) {
3675
+ return reply.status(404).send({ error: "Not found", path: request.url });
3676
+ }
3677
+ if (fs2.existsSync(indexPath)) {
3678
+ const html = fs2.readFileSync(indexPath, "utf-8");
3679
+ return reply.type("text/html").send(injectConfig(html));
3680
+ }
3681
+ return reply.status(404).send({ error: "Not found" });
3682
+ });
3683
+ }
3684
+ app.get("/health", async () => ({
3685
+ status: "ok",
3686
+ service: "canonry",
3687
+ version: "0.1.0"
3688
+ }));
3689
+ scheduler.start();
3690
+ app.addHook("onClose", async () => {
3691
+ scheduler.stop();
3692
+ });
3693
+ return app;
3694
+ }
3695
+
3696
+ export {
3697
+ getConfigDir,
3698
+ getConfigPath,
3699
+ loadConfig,
3700
+ saveConfig,
3701
+ configExists,
3702
+ apiKeys,
3703
+ createClient,
3704
+ migrate,
3705
+ createServer
3706
+ };