@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.
- package/LICENSE +661 -0
- package/assets/assets/index-CkNSldWM.css +1 -0
- package/assets/assets/index-DHoyZdlF.js +63 -0
- package/assets/index.html +17 -0
- package/bin/canonry.mjs +2 -0
- package/dist/chunk-ONZDY6Q4.js +3706 -0
- package/dist/cli.js +1101 -0
- package/dist/index.js +8 -0
- package/package.json +58 -0
- package/src/cli.ts +470 -0
- package/src/client.ts +152 -0
- package/src/commands/apply.ts +25 -0
- package/src/commands/competitor.ts +36 -0
- package/src/commands/evidence.ts +41 -0
- package/src/commands/export-cmd.ts +40 -0
- package/src/commands/history.ts +41 -0
- package/src/commands/init.ts +122 -0
- package/src/commands/keyword.ts +54 -0
- package/src/commands/notify.ts +70 -0
- package/src/commands/project.ts +89 -0
- package/src/commands/run.ts +54 -0
- package/src/commands/schedule.ts +90 -0
- package/src/commands/serve.ts +24 -0
- package/src/commands/settings.ts +45 -0
- package/src/commands/status.ts +52 -0
- package/src/config.ts +90 -0
- package/src/index.ts +2 -0
- package/src/job-runner.ts +368 -0
- package/src/notifier.ts +227 -0
- package/src/provider-registry.ts +55 -0
- package/src/scheduler.ts +161 -0
- package/src/server.ts +249 -0
|
@@ -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
|
+
};
|